From 80ae7b1521a475f6b87922dab279d41033b61188 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Wed, 21 Oct 2026 15:31:21 -0300 Subject: [PATCH 001/297] Zarr: a variable's time series at a point, as a chart (#555) (#602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Zarr: a variable that follows the time slider (#554) layer.addZarr takes "time": true. The variable's time axis is read from its CF coordinate (model calendars included) and the layer shows the last of its steps in the slider's window, or nothing when none falls in it; the slider turns on over the axis when it is off, and "start"/"end" take a period of a longer axis. Moving, playing or exporting the slider reads that step's slice, only its chunks, keeping the last eight; a web store's next two steps are read ahead in the background. The stretch is fixed at the first slice's range, so a colour means one value at every step. The project keeps the layer by its store and selection (ZarrTime) and reads the slider's step again when it opens. The Zarr dialog has a Follow the time slider box (desktop). Co-Authored-By: Claude Opus 5.5 * Zarr: a variable's time series at a point, as a chart (#555) zarr.series reads a variable along its time dimension at the cell holding a point, from only the chunks that hold that cell: through a Zarr layer (one following the time slider gives its period, a slice the whole axis) or a store's folder or address, with start/end, the other dimensions' steps, any longitude convention, and a projected grid's coordinate system. A store chunked a step at a time is capped at MAX_CHUNKS chunks, with the count in the error. The data crate gains locate, plan_series and assemble_series (scaled, no data as NaN), map_coordinate_keys, is_lonlat_grid and grid_crs. View › Time Series: pick a Zarr layer and a point on the map; up to six lines on one chart, the time slider's step marked, a click on the chart moves the slider there, and Export CSV. Co-Authored-By: Claude Opus 5.5 * Zarr: dates stay one per step; read-ahead fetches each slice once, on two threads at most (#601 review) - An axis out of order or with a date twice is refused by layer.addZarr's time, and repair clears dates it can't match to steps instead of sorting them onto the wrong ones. - Read-ahead skips slices kept or already being read, runs on at most two threads, and the step shown waits for a read ahead of it rather than fetching it again. - Tests: the Zarr store helper sits above the doc comment it had taken from zarr_stores_in_folders_and_handed_over, which gets its doc and cfg back; the new test is desktop-only like its helper. Co-Authored-By: Claude Opus 5.5 * Zarr: dates stay one per step; read-ahead fetches each slice once, on two threads at most (#601 review) - An axis out of order or with a date twice is refused by layer.addZarr's time, and repair clears dates it can't match to steps instead of sorting them onto the wrong ones. - Read-ahead skips slices kept or already being read, runs on at most two threads, and the step shown waits for a read ahead of it rather than fetching it again. - Tests: the Zarr store helper sits above the doc comment it had taken from zarr_stores_in_folders_and_handed_over, which gets its doc and cfg back; the new test is desktop-only like its helper. Co-Authored-By: Claude Opus 5.5 * Zarr: the series test is desktop-only, like the store it builds (#601 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a series reads hostile chunk shapes without overflow; the CSV helper is desktop-only (#602 review) assemble_series takes chunk sizes through chunk(), multiplies and adds saturating, and bounds its allocation; series_csv is only used by the desktop pane, so the web build leaves it out. Co-Authored-By: Claude Opus 5.5 * Zarr: read-ahead gives its keys back even on a panic; dates before 1 CE read (#601 review) - A read-ahead thread releases its keys and its place among the threads in a drop guard, so a panic on a hostile store can't stall later steps or stop read-ahead; a step waits for one being read ahead 15 s at most, then reads it itself. - A CF date's leading minus is the year's sign, not a separator (date_parts), in model calendars. Co-Authored-By: Claude Opus 5.5 * Zarr: read-ahead gives its keys back even on a panic; dates before 1 CE read (#601 review) - A read-ahead thread releases its keys and its place among the threads in a drop guard, so a panic on a hostile store can't stall later steps or stop read-ahead; a step waits for one being read ahead 15 s at most, then reads it itself. - A CF date's leading minus is the year's sign, not a separator (date_parts), in model calendars. Co-Authored-By: Claude Opus 5.5 * Zarr: the layer shows the slider's step after an export and undo; the slider waits for a first read; model calendars keep their dates apart (#602 review) - time.export, and undo or redo, bring following Zarr layers back to the slider's step, not the last frame's. - layer.addZarr's time reads the first step before turning the slider on, so a store that can't be read leaves the map as it was. - A step that couldn't be read isn't read again on every call; it's tried again once the slider moves to it anew. - In model calendars the time of day in `since` counts, and a 360- or 366-day year is laid over its real year in proportion, so daily dates (30 February, 1 March) stay distinct and in order instead of being clamped onto one. Co-Authored-By: Claude Opus 5.5 * Zarr: the layer shows the slider's step after an export and undo; the slider waits for a first read; model calendars keep their dates apart (#602 review) - time.export, and undo or redo, bring following Zarr layers back to the slider's step, not the last frame's. - layer.addZarr's time reads the first step before turning the slider on, so a store that can't be read leaves the map as it was. - A step that couldn't be read isn't read again on every call; it's tried again once the slider moves to it anew. - In model calendars the time of day in `since` counts, and a 360- or 366-day year is laid over its real year in proportion, so daily dates (30 February, 1 March) stay distinct and in order instead of being clamped onto one. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder with no variable named follows the slider with its first one; read-ahead never panics for want of a thread (#601 review) - add_following picks the variable before reading a folder's coordinates, so leaving `variable` out (its default, the store's first) no longer fails with "no time dimension". Tested. - Read-ahead starts its thread with thread::Builder, and gives back its keys and place when no thread can be had. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder with no variable named follows the slider with its first one; read-ahead never panics for want of a thread (#601 review) - add_following picks the variable before reading a folder's coordinates, so leaving `variable` out (its default, the store's first) no longer fails with "no time dimension". Tested. - Read-ahead starts its thread with thread::Builder, and gives back its keys and place when no thread can be had. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder store rewritten on disk is read again, not served from the slice cache (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a folder store rewritten on disk is read again, not served from the slice cache (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the series chart breaks its lines at gaps, labels units only when they agree, and keeps a click on its dates (#602 review) Co-Authored-By: Claude Opus 5.5 * Zarr: one slice key per read; a time coordinate of the dimension's length; follow off for a variable without time Co-Authored-By: Claude Opus 5.5 * Docs: a following Zarr layer is kept by reference for a folder or a web store; the slice rule is for a single step Co-Authored-By: Claude Opus 5.5 * Zarr: a web store's slice keeps the coordinate system given for it, for its time series Co-Authored-By: Claude Opus 5.5 * Zarr: Follow takes a long axis from the step picked; web reads within a total; zarr.series checks its point and needs the network permission Co-Authored-By: Claude Opus 5.5 * Time Series: the legend's colour taken with get, as the chart's Co-Authored-By: Claude Opus 5.5 * Zarr: a long axis taken in part is said, with the period the layer takes Co-Authored-By: Claude Opus 5.5 * Zarr: a step picked past a long axis is refused; Time Series is left out of the web app's ribbon Co-Authored-By: Claude Opus 5.5 * Time Series: the chart and the CSV read the series in place, not copied every frame Co-Authored-By: Claude Opus 5.5 * Zarr series: the time axis must be increasing without repeats, as for following the slider Co-Authored-By: Claude Opus 5.5 * Zarr series: its menu path is where it is, View › Time Series Co-Authored-By: Claude Opus 5.5 * Zarr series: a test of points between cell centres Co-Authored-By: Claude Opus 5.5 * Zarr series: a chunk file there but too large or unreadable is said, not read as no value Co-Authored-By: Claude Opus 5.5 * Zarr series: files refused (HTTP 403) are said; a Time Series pick ends when the pane isn't in view Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/data/src/raster/zarr.rs | 181 +++++++++++++++++++ crates/engine/src/cmd/plugins.rs | 2 +- crates/engine/src/cmd/zarr.rs | 228 +++++++++++++++++++++++- crates/engine/src/tests.rs | 55 ++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panes.rs | 7 + crates/ui-egui/src/ribbon.rs | 14 ++ crates/ui-egui/src/workspacepanes.rs | 249 +++++++++++++++++++++++++++ docs/user-guide/data.md | 16 +- docs/user-guide/workspace.md | 2 + 10 files changed, 751 insertions(+), 6 deletions(-) diff --git a/crates/data/src/raster/zarr.rs b/crates/data/src/raster/zarr.rs index 803ea638c..fd3f928ef 100644 --- a/crates/data/src/raster/zarr.rs +++ b/crates/data/src/raster/zarr.rs @@ -1302,6 +1302,187 @@ pub fn time_axis(store: &Store, variable: &str, chunks: &HashMap None } +/// The chunk keys of `variable`'s map coordinates (its y and x), for [`locate`]. +pub fn map_coordinate_keys(store: &Store, variable: &str) -> Vec { + let Some(a) = store.array(variable) else { return Vec::new() }; + let names = a.dim_names(); + let (y, x) = map_dims(a); + let mut keys = Vec::new(); + for d in [y, x] { + if let Some(c) = names.get(d).and_then(|n| store.coordinate(a, n)).filter(|c| c.shape.first() == a.shape.get(d)) { + keys.extend(all_keys(c)); + } + } + keys +} + +/// Whether `variable`'s map is a longitude/latitude grid; otherwise its coordinates are in the +/// coordinate system [`grid_crs`] gives (or the one a caller is told). +pub fn is_lonlat_grid(store: &Store, variable: &str) -> bool { + let Some(a) = store.array(variable) else { return false }; + let names = a.dim_names(); + let (y, x) = map_dims(a); + let geo = |d: usize| names.get(d).and_then(|n| store.coordinate(a, n)).is_some_and(geographic); + geo(y) && geo(x) +} + +/// The coordinate system `variable`'s attributes (or its grid mapping's) say, for a projected grid. +pub fn grid_crs(store: &Store, variable: &str) -> Option { + store.array(variable).and_then(|a| crs_of(store, a)) +} + +/// The stored (row, column) of `variable`'s map cell holding the point (`x`, `y`), in the grid's +/// own coordinates: degrees of any longitude convention (-180–180 or 0–360) for a +/// longitude/latitude grid. `None` outside the grid. The coordinates must be evenly spaced, as +/// for a slice. +pub fn locate(store: &Store, variable: &str, chunks: &HashMap>, x: f64, y: f64) -> Result> { + let a = store.array(variable).ok_or_else(|| bad(format!("no variable named {variable}")))?; + let names = a.dim_names(); + let (yd, xd) = map_dims(a); + let coord = |d: usize| names.get(d).and_then(|n| store.coordinate(a, n)).filter(|c| c.shape.first() == a.shape.get(d)); + let (Some(yc), Some(xc)) = (coord(yd), coord(xd)) else { + return Err(bad(format!("{}: its y and x dimensions have no coordinates", a.path))); + }; + let (ys, xs) = (values_1d(yc, chunks)?, values_1d(xc, chunks)?); + let (Some((y0, dy)), Some((x0, dx))) = (axis(&ys), axis(&xs)) else { + return Err(bad(format!("{}: its coordinates aren't evenly spaced", a.path))); + }; + let cell = |v: f64, v0: f64, dv: f64, n: usize| { + let i = ((v - v0) / dv).floor(); + (i >= 0.0 && i < n as f64).then_some(i as u64) + }; + let row = cell(y, y0, dy, ys.len()); + // A longitude may be written another way round the globe than the grid's. + let turns: &[f64] = if geographic(xc) { &[0.0, 360.0, -360.0] } else { &[0.0] }; + let col = turns.iter().find_map(|k| cell(x + k, x0, dx, xs.len())); + Ok(row.zip(col)) +} + +/// What a point's series needs (see [`plan_series`]). +#[derive(Clone, Debug)] +pub struct SeriesPlan { + pub keys: Vec, + var: String, + /// The time dimension, and every dimension's step (the cell's, and the others' fixed ones). + t: usize, + at: Vec, + from: u64, + count: u64, +} + +/// The chunks holding the stored cell (`row`, `col`) of `sel`'s variable along dimension `dim`, +/// from step `from` for `count` steps, the other dimensions at `sel`'s steps. At most +/// [`MAX_CHUNKS`]: a store chunked a step at a time over a long axis needs a shorter period. +pub fn plan_series(store: &Store, sel: &Selection, dim: &str, from: u64, count: u64, row: u64, col: u64) -> Result { + let a = store.array(&sel.variable).ok_or_else(|| bad(format!("no variable named {}", sel.variable)))?; + if let Some(why) = a.unsupported() { + return Err(bad(why)); + } + let names = a.dim_names(); + let (y, x) = map_dims(a); + let t = names + .iter() + .position(|n| n == dim) + .filter(|t| *t != y && *t != x) + .ok_or_else(|| bad(format!("{} has no dimension {dim}", a.path)))?; + let n = a.shape.get(t).copied().unwrap_or(0); + if count == 0 || from.checked_add(count).is_none_or(|end| end > n) { + return Err(bad(format!("{dim} has {n} steps; {from} to {} is past them", from.saturating_add(count)))); + } + if row >= a.shape.get(y).copied().unwrap_or(0) || col >= a.shape.get(x).copied().unwrap_or(0) { + return Err(bad("the cell is outside the grid")); + } + let mut at = vec![0u64; a.shape.len()]; + for (d, i) in &sel.index { + let Some(k) = names.iter().position(|nm| nm == d) else { return Err(bad(format!("{} has no dimension {d}", a.path))) }; + if k != y && k != x && k != t { + if *i >= a.shape.get(k).copied().unwrap_or(0) { + return Err(bad(format!("{d} has {} steps; {i} is past them", a.shape.get(k).copied().unwrap_or(0)))); + } + if let Some(v) = at.get_mut(k) { + *v = *i; + } + } + } + if let Some(v) = at.get_mut(y) { + *v = row; + } + if let Some(v) = at.get_mut(x) { + *v = col; + } + let ct = a.chunks.get(t).copied().unwrap_or(1).max(1); + let (first, last) = (from / ct, (from + count - 1) / ct); + if last - first + 1 > MAX_CHUNKS as u64 { + return Err(bad(format!( + "{count} steps of {} need {} chunks, more than {MAX_CHUNKS}: take a shorter period", + a.path, + last - first + 1 + ))); + } + let keys = (first..=last) + .map(|g| { + let idx: Vec = (0..a.shape.len()) + .map(|d| if d == t { g } else { at.get(d).copied().unwrap_or(0) / a.chunks.get(d).copied().unwrap_or(1).max(1) }) + .collect(); + a.chunk_key(&idx) + }) + .collect(); + Ok(SeriesPlan { keys, var: a.path.clone(), t, at, from, count }) +} + +/// The values of a [`plan_series`], one a step (`NaN` for no data, or a chunk that wasn't +/// fetched), scaled as a slice's are. +pub fn assemble_series(store: &Store, plan: &SeriesPlan, chunks: &HashMap>) -> Result> { + let a = store.array(&plan.var).ok_or_else(|| bad("the variable is gone"))?; + let nd = a.shape.len(); + let v2_fill = if a.zarr_format == 2 { a.fill } else { None }; + let mut missing: Vec = [v2_fill, attr_f64(a, "_FillValue"), attr_f64(a, "missing_value")].into_iter().flatten().collect(); + missing.dedup(); + let scale = attr_f64(a, "scale_factor").unwrap_or(1.0); + let offset = attr_f64(a, "add_offset").unwrap_or(0.0); + let chunk = |d: usize| a.chunks.get(d).copied().unwrap_or(1).max(1); + // A cell's place in a chunk, by dimension: saturating, so chunk shapes from a hostile store + // can't overflow (the value read is then just missing). + let strides: Vec = { + let mut s = vec![1u64; nd]; + if a.fortran { + for d in 1..nd { + s[d] = s[d - 1].saturating_mul(chunk(d - 1)); + } + } else { + for d in (0..nd.saturating_sub(1)).rev() { + s[d] = s[d + 1].saturating_mul(chunk(d + 1)); + } + } + s + }; + let step_at = |d: usize, step: u64| if d == plan.t { step } else { plan.at.get(d).copied().unwrap_or(0) }; + let mut out = Vec::with_capacity(usize::try_from(plan.count).unwrap_or(0).min(1 << 20)); + let mut decoded: Option<(u64, Option<(Vec, DType)>)> = None; + for step in plan.from..plan.from.saturating_add(plan.count) { + let g = step / chunk(plan.t); + if decoded.as_ref().is_none_or(|(k, _)| *k != g) { + let idx: Vec = (0..nd).map(|d| step_at(d, step) / chunk(d)).collect(); + let got = match chunks.get(&a.chunk_key(&idx)) { + Some(b) => Some(a.decode_bytes(b)?), + None => None, + }; + decoded = Some((g, got)); + } + let Some((_, Some((vals, dt)))) = decoded.as_ref() else { + out.push(f64::NAN); + continue; + }; + let i = (0..nd) + .map(|d| (step_at(d, step) % chunk(d)).saturating_mul(strides.get(d).copied().unwrap_or(0))) + .fold(0u64, u64::saturating_add); + let at = usize::try_from(i).ok().and_then(|i| i.checked_mul(dt.size)); + let v = at.and_then(|at| vals.get(at..at.checked_add(dt.size)?)).map(|b| dt.value(b)).unwrap_or(f64::NAN); + out.push(if v.is_nan() || missing.contains(&v) { f64::NAN } else { v * scale + offset }); + } + Ok(out) +} + /// A CF time coordinate's value as milliseconds since 1970 (see [`time_axis`]). fn time_ms(c: &Array, v: f64) -> Option { let units = attr_str(c, "units")?; diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index d035bd34a..7ad7ee920 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -289,7 +289,7 @@ pub const SQL: &str = "layer.addSql"; /// Commands that read a database, which can be a PostgreSQL server's as well as a file, and a /// Zarr store, a folder or a web address. const DATABASES: &[&str] = - &[SQL, "layer.addDatabase", "info.databaseTables", "sql.query", "sql.schema", "raster.zarrInfo", "layer.addZarr"]; + &[SQL, "layer.addDatabase", "info.databaseTables", "sql.query", "sql.schema", "raster.zarrInfo", "layer.addZarr", "zarr.series"]; /// What a command reaches beyond the project when run with some parameters. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/engine/src/cmd/zarr.rs b/crates/engine/src/cmd/zarr.rs index 1ab9d04ff..f0d56e41b 100644 --- a/crates/engine/src/cmd/zarr.rs +++ b/crates/engine/src/cmd/zarr.rs @@ -21,6 +21,9 @@ pub fn commands() -> Vec { C::new("raster.zarrInfo", "Zarr Variables", "Home › Add › Add Data › Zarr", info) .view() .params(r#"{"path": "a Zarr store's folder" | "url": "https://… a store with consolidated metadata"}"#), + C::new("zarr.series", "Time Series at a Point", "View › Time Series", series).view().params( + r#"{"lon", "lat", "layer"? (a Zarr layer: its store, variable and steps; default the active layer) | "path": folder | "url": "https://…", "variable"?, "index"?: {"level": 0} (the other dimensions' steps), "start"?, "end"?: dates (default: the layer's period, else the whole axis), "crs"?: for a projected store that doesn't say} → dates, values (null for no data) and labels along the variable's time dimension at the cell holding the point (desktop)"#, + ), C::new("layer.addZarr", "Add Zarr Variable", "Home › Add › Add Data", add).params( r#"{"path": folder | "url": "https://…", "variable"?: "its path in the store (default: the first)", "index"?: {"time": 3, "level": 0} (other dimensions' steps, default 0), "crs"?: "EPSG:… for a store that doesn't say", "name"?: text, "time"?: bool (follow the time slider: the layer shows the step the slider is at, and turns the slider on over the variable's dates when it is off; desktop), "start"?, "end"?: dates (with time: the period to take, for an axis longer than the slider's 20,000 steps), "ran"?: (internal: a slice the app has read)}"#, ), @@ -81,6 +84,9 @@ fn fetch(root: &str, keys: &[String]) -> Result<(HashMap>, usize use std::sync::Mutex; let out = Mutex::new(HashMap::new()); let forbidden = std::sync::atomic::AtomicUsize::new(0); + // All the files fetched together, within what a folder's read takes, so many chunks each + // within the download limit can't fill the memory. + let total = std::sync::atomic::AtomicU64::new(0); let failure: Mutex> = Mutex::new(None); let next = std::sync::atomic::AtomicUsize::new(0); std::thread::scope(|scope| { @@ -94,6 +100,13 @@ fn fetch(root: &str, keys: &[String]) -> Result<(HashMap>, usize } match crate::render::rustgis_tiles::cache::download_blocking(&format!("{root}/{k}")) { Ok(b) => { + let len = b.len() as u64; + if total.fetch_add(len, std::sync::atomic::Ordering::Relaxed).saturating_add(len) > zarr::MAX_READ { + if let Ok(mut f) = failure.lock() { + f.get_or_insert(format!("{root}: more than {} GB to read: take a smaller part", zarr::MAX_READ >> 30)); + } + break; + } if let Ok(mut o) = out.lock() { o.insert(k.clone(), b); } @@ -237,6 +250,9 @@ pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult { let tif = raster::geotiff::write(&r, 0).map_err(|e| e.to_string())?; let mut layer = RasterLayer::from_bytes(r, format!("{}.tif", store_name(&u)), tif); layer.subdataset = Some(sel.label()); + // The coordinate system given for a store that doesn't say, kept as a folder's slice + // keeps it, so a time series read from the layer finds it. + layer.crs = crs.map(str::to_string); super::raster::add_raster(s, &name, layer) } } @@ -548,8 +564,19 @@ fn add_following(s: &mut Session, p: &Value) -> CmdResult { }; let (start, end) = (date("start")?, date("end")?); let inside = |d: &f64| start.is_none_or(|a| *d >= a) && end.is_none_or(|z| *d <= z); - let first = dates.iter().position(inside).ok_or("no step of the variable falls between start and end")?; - let picked: Vec = dates[first..].iter().copied().take_while(inside).collect(); + let (cut, dates_len) = (start.is_none() && end.is_none() && dates.len() > rustgis_map::raster::MAX_ZARR_STEPS, dates.len()); + let (first, picked): (usize, Vec) = if cut { + // An axis longer than the slider takes, with no period given (the Zarr dialog gives + // none): from the step picked on it, as many steps as the slider takes. + let at = sel.index.iter().find(|(d, _)| *d == dim).map_or(0, |(_, i)| usize::try_from(*i).unwrap_or(usize::MAX)); + if at >= dates.len() { + return Err(format!("the step picked on {dim} is past its {} steps", dates.len())); + } + (at, dates[at..].iter().copied().take(rustgis_map::raster::MAX_ZARR_STEPS).collect()) + } else { + let first = dates.iter().position(inside).ok_or("no step of the variable falls between start and end")?; + (first, dates[first..].iter().copied().take_while(inside).collect()) + }; if picked.len() > rustgis_map::raster::MAX_ZARR_STEPS { return Err(format!( "{} has {} steps there; the time slider takes {} at most: give start and end", @@ -603,7 +630,202 @@ fn add_following(s: &mut Session, p: &Value) -> CmdResult { let name = ps(p, "name") .map(str::to_string) .unwrap_or_else(|| layer_name(&root, &zarr::Selection { variable: zt.variable.clone(), index: zt.index.clone() }, &[])); + // A long axis taken in part (no period given): said, with the period taken. + let part = cut.then(|| { + let day = |d: Option<&f64>| d.map_or_else(String::new, |d| rustgis_data::time::format(*d, "YYYY-MM-DD HH:mm")); + format!( + "{} has {dates_len} steps: the layer takes {} of them, {} to {}; give start and end for another period", + zt.variable, + zt.dates.len(), + day(zt.dates.first()), + day(zt.dates.last()) + ) + }); layer.time_zarr = Some(zt); - super::raster::add_raster(s, &name, layer) + let mut out = super::raster::add_raster(s, &name, layer)?; + if let Some(w) = part { + s.notify(w.clone(), false); + out["warning"] = serde_json::json!(w); + } + Ok(out) + } +} + +// --- A point's time series (opengeos/RustGIS#555) ------------------------------------------------- + +/// The store a series reads, and what of it: from a Zarr layer (one that follows the time slider, +/// or a slice: its store, variable and steps), or from the parameters. +#[cfg(not(target_arch = "wasm32"))] +struct SeriesSource { + root: String, + sel: zarr::Selection, + /// A layer's period of the axis (a following layer's offset and dates), else the whole axis. + period: Option<(u64, usize)>, + crs: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +fn series_source(s: &Session, p: &Value) -> Result { + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + if !given("path") && !given("url") { + let id = s.layer_id(p)?; + let l = s.map().layer(id).ok_or("no such layer")?; + let rl = l.raster.as_ref().ok_or_else(|| format!("{} isn't a Zarr layer", l.name))?; + if let Some(zt) = &rl.time_zarr { + return Ok(SeriesSource { + root: zt.store.clone(), + sel: zarr::Selection { variable: zt.variable.clone(), index: zt.index.clone() }, + period: Some((zt.offset, zt.dates.len())), + crs: zt.crs.clone(), + }); + } + // A slice: its store is its folder, or the address it was read from. + let label = rl.subdataset.clone().ok_or_else(|| format!("{} isn't a Zarr layer", l.name))?; + let root = rl + .path + .clone() + .or_else(|| rl.data.as_ref().and_then(|d| d.source.clone())) + .ok_or_else(|| format!("{}: its store isn't known", l.name))?; + return Ok(SeriesSource { root, sel: zarr::Selection::parse(&label), period: None, crs: rl.crs.clone() }); + } + let root = match source(p)? { + Source::Dir(d) | Source::Url(d) => d, + }; + let crs = ps(p, "crs").map(str::trim).filter(|c| !c.is_empty()).map(str::to_string); + Ok(SeriesSource { root, sel: selection(p), period: None, crs }) +} + +/// A store's metadata, and its files `keys`: a folder's read (files only, bounded), a web +/// store's fetched. +#[cfg(not(target_arch = "wasm32"))] +fn store_files(root: &str, keys: &[String]) -> Result>, String> { + if is_web(root) { + // In batches, within the same total a folder's read takes: a store chunked a map a step + // (one chunk per step) can't fill the memory. + let mut out = HashMap::new(); + let mut total = 0u64; + for batch in keys.chunks(64) { + let (got, forbidden) = fetch(root, batch)?; + // A file refused (HTTP 403) isn't one never written: said, not read as missing. + if forbidden > 0 { + return Err(format!("{root}: access denied (HTTP 403) to {forbidden} of its files")); + } + for (k, b) in got { + total = total.saturating_add(b.len() as u64); + if total > zarr::MAX_READ { + return Err(format!("{root}: the series is too large to read: take a shorter period")); + } + out.insert(k, b); + } + } + return Ok(out); + } + let mut out = HashMap::new(); + let mut total = 0u64; + for k in keys { + let path = Path::new(root).join(k); + // A file that isn't there is a chunk never written (Zarr's fill value); one that is but + // can't be taken is said, so a value isn't silently missing. + let Ok(meta) = std::fs::metadata(&path) else { continue }; + if !meta.is_file() || meta.len() > zarr::MAX_CHUNK_FILE { + return Err(format!("{root}: {k} isn't a chunk file that can be read (too large, or not a file)")); + } + total = total.saturating_add(meta.len()); + if total > zarr::MAX_READ { + return Err(format!("{root}: the series is too large to read")); + } + let b = std::fs::read(&path).map_err(|e| format!("{root}: {k}: {e}"))?; + out.insert(k.clone(), b); + } + Ok(out) +} + +/// `zarr.series`: a variable's values along its time dimension at the cell holding a point, +/// reading only the chunks that hold that cell. +fn series(s: &mut Session, p: &Value) -> CmdResult { + #[cfg(target_arch = "wasm32")] + { + let _ = (s, p); + Err("in the web app, a Zarr time series can't be read yet".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let num = |k: &str| super::f(p, k).ok_or_else(|| format!("{k} is required (degrees)")); + let (lon, lat) = (num("lon")?, num("lat")?); + if !lon.is_finite() || !(-90.0..=90.0).contains(&lat) { + return Err("lon is a longitude and lat a latitude from -90 to 90 (degrees)".into()); + } + let mut src = series_source(s, p)?; + let store = if is_web(&src.root) { + cached_store(&src.root)? + } else { + std::sync::Arc::new(zarr::read_metadata(Path::new(&src.root)).map_err(|e| format!("{}: {e}", src.root))?) + }; + pick_variable(&store, &mut src.sel)?; + let var = src.sel.variable.clone(); + let mut keys = zarr::coordinate_keys(&store, &var); + keys.extend(zarr::map_coordinate_keys(&store, &var)); + let coords = store_files(&src.root, &keys)?; + let (dim, dates) = zarr::time_axis(&store, &var, &coords) + .ok_or_else(|| format!("{var} has no time dimension (a coordinate with CF units, such as \"days since 2001-01-01\")"))?; + // A period is found along the axis in order, as following the slider takes it. + if !dates.iter().all(|d| d.is_finite()) || !dates.windows(2).all(|w| w[0] < w[1]) { + return Err(format!("{var}'s time axis isn't in increasing order without repeats, so its series can't be read by date")); + } + // The period: the one asked for, else the layer's, else the whole axis. + let date = |k: &str| -> Result, String> { + match p.get(k).filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => rustgis_data::time::parse_input(v) + .map(Some) + .ok_or_else(|| format!("{k} is a date (2015-06-01, a year, or milliseconds since 1970)")), + } + }; + let (start, end) = (date("start")?, date("end")?); + let (from, count) = if start.is_some() || end.is_some() { + let inside = |d: &f64| start.is_none_or(|a| *d >= a) && end.is_none_or(|z| *d <= z); + let first = dates.iter().position(inside).ok_or("no step of the variable falls between start and end")?; + (first, dates[first..].iter().take_while(|d| inside(d)).count()) + } else { + match src.period { + Some((o, n)) => (usize::try_from(o).unwrap_or(usize::MAX).min(dates.len()), n), + None => (0, dates.len()), + } + }; + let count = count.min(dates.len().saturating_sub(from)); + if count == 0 { + return Err("no step to read".into()); + } + // The point in the grid's coordinates. + let (x, y) = if zarr::is_lonlat_grid(&store, &var) { + (lon, lat) + } else { + let crs = match zarr::grid_crs(&store, &var) { + Some(c) => c, + None => { + let given = + src.crs.as_deref().ok_or("the store doesn't say its coordinate system; give \"crs\" (such as EPSG:3035)")?; + Crs::parse(given)? + } + }; + let m = crs + .transformer()? + .from_lonlat(rustgis_geom::Coord::new(lon, lat)) + .ok_or("the point can't be placed in the store's coordinate system")?; + (m.x, m.y) + }; + let (row, col) = + zarr::locate(&store, &var, &coords, x, y).map_err(|e| e.to_string())?.ok_or("the point is outside the variable's grid")?; + let plan = zarr::plan_series(&store, &src.sel, &dim, from as u64, count as u64, row, col).map_err(|e| e.to_string())?; + let chunks = store_files(&src.root, &plan.keys)?; + let values = zarr::assemble_series(&store, &plan, &chunks).map_err(|e| e.to_string())?; + let picked = &dates[from..from + count]; + let labels: Vec = picked.iter().map(|d| rustgis_data::time::format(*d, "YYYY-MM-DD HH:mm")).collect(); + let values: Vec = values.iter().map(|v| if v.is_finite() { serde_json::json!(v) } else { Value::Null }).collect(); + let units = store.array(&var).and_then(|a| a.attrs.get("units")).and_then(Value::as_str).map(str::to_string); + Ok(serde_json::json!({ + "variable": var, "units": units, "dimension": dim, "lon": lon, "lat": lat, "row": row, "col": col, + "dates": picked, "labels": labels, "values": values, + })) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bb7c6f2a8..9540e4ea5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6911,6 +6911,61 @@ fn zarr_store_over_time(tag: &str) -> (std::path::PathBuf, String) { (dir, path) } +/// A Zarr variable's series at a point (#555): the values along its time dimension at the cell +/// holding it, read from the chunks holding that cell, through a layer or the store itself. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_zarr_series_at_a_point() { + let (dir, path) = zarr_store_over_time("series"); + let mut s = Session::new(Project::default()); + // At 1°E 30°N: the stored row 2 (latitudes 10, 20, 30), column 0, so 100·k + 8 at step k. + let at = json!({"path": path, "variable": "t", "lon": 1.0, "lat": 30.0}); + let r = s.run("zarr.series", &at).unwrap(); + assert_eq!(r["values"], json!([8.0, 108.0, 208.0]), "{r}"); + assert_eq!(r["labels"], json!(["2001-01-01 00:00", "2001-02-01 00:00", "2001-03-01 00:00"])); + assert_eq!( + (r["dimension"].as_str(), r["units"].as_str(), r["row"].as_u64(), r["col"].as_u64()), + (Some("time"), Some("K"), Some(2), Some(0)) + ); + // A point between centres takes the nearest (the axis starts half a cell before the first + // centre): 24°N is the 20°N row, 26°N the 30°N row. + let mut at = + |lat: f64| s.run("zarr.series", &json!({"path": path, "variable": "t", "lon": 1.0, "lat": lat})).unwrap()["values"].clone(); + let (twenty, thirty) = (at(20.0), at(30.0)); + assert_eq!(at(24.0), twenty); + assert_eq!(at(26.0), thirty); + assert_ne!(twenty, thirty); + // Longitudes either way round the globe; another cell. + let r = s.run("zarr.series", &json!({"path": path, "variable": "t", "lon": 363.5, "lat": 11.0})).unwrap(); + assert_eq!(r["values"], json!([3.0, 103.0, 203.0]), "{r}"); + // A period. + let r = s.run("zarr.series", &json!({"path": path, "variable": "t", "lon": 1.0, "lat": 30.0, "start": "2001-01-15"})).unwrap(); + assert_eq!(r["values"], json!([108.0, 208.0])); + // Through a layer: one that follows the slider (its period), and a slice (the whole axis). + let late = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true, "start": "2001-01-20"})).unwrap()["layer"] + .as_u64() + .unwrap(); + let r = s.run("zarr.series", &json!({"layer": late, "lon": 1.0, "lat": 30.0})).unwrap(); + assert_eq!(r["values"], json!([108.0, 208.0])); + let slice = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "index": {"time": 1}})).unwrap()["layer"].as_u64().unwrap(); + let r = s.run("zarr.series", &json!({"layer": slice, "lon": 1.0, "lat": 30.0})).unwrap(); + assert_eq!(r["values"], json!([8.0, 108.0, 208.0])); + for bad in [ + json!({"path": path, "variable": "t", "lon": 50.0, "lat": 30.0}), + json!({"path": path, "variable": "t", "lon": 1.0, "lat": 95.0}), + json!({"path": path, "variable": "t", "lat": 30.0}), + json!({"path": path, "variable": "t", "lon": 1.0, "lat": 30.0, "start": "2003-01-01"}), + json!({"path": path, "variable": "lat", "lon": 1.0, "lat": 30.0}), + json!({"path": "/nowhere/x.zarr", "lon": 1.0, "lat": 30.0}), + ] { + assert!(s.run("zarr.series", &bad).is_err(), "{bad}"); + } + // A layer that isn't Zarr. + let mut w = Session::new(sample::world_project()); + assert!(w.run("zarr.series", &json!({"layer": "Countries", "lon": 1.0, "lat": 30.0})).is_err()); + let _ = std::fs::remove_dir_all(dir); +} + /// A Zarr variable follows the time slider (#554): the slice drawn is the step the slider is at, /// the stretch is the same at every step, and the project keeps the layer by its store. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8d473a3fc..14ce8f21f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -397,6 +397,8 @@ pub enum RightPane { Tasks, Jobs, Profile, + /// A Zarr variable's time series at points clicked on the map (#555). + TimeSeries, Diagnostics, Infographics, Report, @@ -2384,6 +2386,7 @@ impl RustGisApp { }, "ui.pane.jobs" => toggle_right(&mut self.ui, RightPane::Jobs), "ui.pane.profile" => toggle_right(&mut self.ui, RightPane::Profile), + "ui.pane.timeSeries" => toggle_right(&mut self.ui, RightPane::TimeSeries), "ui.pane.diagnostics" => toggle_right(&mut self.ui, RightPane::Diagnostics), "ui.pane.infographics" => toggle_right(&mut self.ui, RightPane::Infographics), "ui.pane.report" => toggle_right(&mut self.ui, RightPane::Report), diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 252774783..8dac2c878 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -42,6 +42,11 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { ui.advance_cursor_after_rect(rect); }); } + // A point picked for the Time Series pane is taken only while that pane shows: with it + // closed or another in view, the pick ends rather than waiting with a stale click. + if app.ui.right != Some(RightPane::TimeSeries) && app.workflow.pick.as_ref().is_some_and(|p| p.purpose == "ts_point") { + app.workflow.pick = None; + } // The pane in view always has its tab, however it was set. if let Some(right) = app.ui.right && !app.ui.right_open.contains(&right) @@ -100,6 +105,7 @@ fn right_title(pane: RightPane) -> (&'static str, &'static str) { RightPane::Tasks => ("Tasks", "ui.pane.tasks"), RightPane::Jobs => ("Workflow Manager", "ui.pane.jobs"), RightPane::Profile => ("Profile", "ui.pane.profile"), + RightPane::TimeSeries => ("Time Series", "ui.pane.timeSeries"), RightPane::Diagnostics => ("Diagnostic Monitor", "ui.pane.diagnostics"), RightPane::Infographics => ("Infographics", "ui.pane.infographics"), RightPane::Report => ("Report", "ui.pane.report"), @@ -157,6 +163,7 @@ fn right_pane(app: &mut RustGisApp, ui: &mut Ui, right: RightPane, t: &Tokens) { RightPane::Tasks => crate::workspacepanes::tasks(app, ui, t), RightPane::Jobs => crate::workspacepanes::jobs(app, ui, t), RightPane::Profile => crate::workspacepanes::profile(app, ui, t), + RightPane::TimeSeries => crate::workspacepanes::time_series(app, ui, t), RightPane::Diagnostics => crate::workspacepanes::diagnostics(app, ui, t), RightPane::Infographics => crate::workspacepanes::infographics(app, ui, t), RightPane::Report => crate::reportpane::report(app, ui, t), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0b3e32147..27aefa180 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2092,6 +2092,20 @@ fn view_tab(app: &mut RustGisApp, ui: &mut Ui) { app.ui.right == Some(RightPane::Profile), false, ); + // A Zarr series is read by the desktop app (the web app can't read one yet). + if !cfg!(target_arch = "wasm32") { + big( + ui, + app, + "measure", + "Time\nSeries", + "A Zarr variable's values through time at points clicked on the map.", + "ui.pane.timeSeries", + Value::Null, + app.ui.right == Some(RightPane::TimeSeries), + false, + ); + } }); } diff --git a/crates/ui-egui/src/workspacepanes.rs b/crates/ui-egui/src/workspacepanes.rs index 4f318c31c..d3717c75a 100644 --- a/crates/ui-egui/src/workspacepanes.rs +++ b/crates/ui-egui/src/workspacepanes.rs @@ -32,6 +32,9 @@ pub struct WorkspaceState { pub profile_layer: String, pub profile_lines: String, pub profile: Option, + /// The Time Series pane's Zarr layer, and the series read at points (`zarr.series`), newest last. + pub series_layer: String, + pub series: Vec, pub message: Option<(String, bool)>, pub catalog_view: bool, pub item_popups: bool, @@ -460,6 +463,190 @@ pub fn profile(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ); } +/// The most series the Time Series pane keeps (one line each). +const MAX_SERIES: usize = 6; + +/// A Zarr variable's time series at points clicked on the map (opengeos/RustGIS#555): a line a +/// point, the time slider's step marked, and a click on the chart takes the slider there. +pub fn time_series(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { + message(app, ui); + let map = app.session.map(); + // Zarr layers: one that follows the time slider, or a slice (`t2m[time=3]`). + let layers: Vec = map + .layers + .iter() + .filter(|l| l.raster.as_ref().is_some_and(|r| r.time_zarr.is_some() || r.subdataset.as_deref().is_some_and(|s| s.contains('[')))) + .map(|l| l.name.clone()) + .collect(); + if layers.is_empty() { + ui.label(egui::RichText::new(tl!("Add a Zarr variable first (Add Data › Zarr…).")).weak()); + return; + } + if !layers.contains(&app.ws.series_layer) { + app.ws.series_layer = layers.first().cloned().unwrap_or_default(); + } + egui::ComboBox::from_id_salt("series_layer").selected_text(app.ws.series_layer.as_str()).show_ui(ui, |ui| { + for l in &layers { + ui.selectable_value(&mut app.ws.series_layer, l.clone(), l.as_str()); + } + }); + ui.horizontal_wrapped(|ui| { + let active = app.workflow.pick.as_ref().is_some_and(|p| p.purpose == "ts_point"); + if ui.selectable_label(active, tl!("Pick a Point")).clicked() { + app.workflow.pick = + if active { None } else { Some(crate::analysispane::Pick { purpose: "ts_point", needed: 1, points: Vec::new() }) }; + } + if !app.ws.series.is_empty() { + if ui.button(tl!("Clear")).clicked() { + app.ws.series.clear(); + } + #[cfg(not(target_arch = "wasm32"))] + if ui.button(tl!("Export CSV…")).clicked() + && let Some(path) = crate::dialogs::pick_save("time_series.csv", &["csv"], tl!("Time series")) + { + app.ws.message = Some(match std::fs::write(&path, series_csv(&app.ws.series)) { + Ok(()) => (crate::i18n::fmt("Saved {path}", &[("path", &path)]), false), + Err(e) => (format!("{path}: {e}"), true), + }); + } + } + }); + // A point picked: its series, under the layer chosen. + let picked = app.workflow.pick.as_ref().filter(|p| p.purpose == "ts_point").and_then(|p| p.points.first().copied()); + if let Some(c) = picked { + app.workflow.pick = None; + let layer = app.ws.series_layer.clone(); + if let Some(v) = run(app, "zarr.series", json!({"layer": layer, "lon": c.x, "lat": c.y})) { + app.ws.series.push(v); + let excess = app.ws.series.len().saturating_sub(MAX_SERIES); + app.ws.series.drain(..excess); + } + } + if app.ws.series.is_empty() { + ui.label(egui::RichText::new(tl!("Pick a point on the map to see the variable's values through time there.")).weak().small()); + return; + } + // One chart for every line: the dates and values of all of them, each line in runs between + // steps with no data, so a gap shows as one rather than as a straight join. + let lines: Vec>> = app + .ws + .series + .iter() + .map(|s| { + // Read in place, not copied: the chart is drawn every frame. + let dates = s["dates"].as_array().map(Vec::as_slice).unwrap_or(&[]); + let values = s["values"].as_array().map(Vec::as_slice).unwrap_or(&[]); + let mut runs: Vec> = vec![Vec::new()]; + for (d, v) in dates.iter().zip(values) { + match (d.as_f64(), v.as_f64()) { + (Some(d), Some(v)) => runs.last_mut().into_iter().for_each(|r| r.push((d, v))), + _ if runs.last().is_some_and(|r| !r.is_empty()) => runs.push(Vec::new()), + _ => {} + } + } + runs.retain(|r| !r.is_empty()); + runs + }) + .collect(); + let all = lines.iter().flatten().flatten(); + let (t0, t1) = all.clone().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (d, _)| (a.min(*d), b.max(*d))); + let (lo, hi) = all.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (_, v)| (a.min(*v), b.max(*v))); + if !(t0.is_finite() && lo.is_finite()) { + ui.label(egui::RichText::new(tl!("No data at the points picked.")).weak().small()); + return; + } + let (t1, hi) = (if t1 > t0 { t1 } else { t0 + 1.0 }, if hi > lo { hi } else { lo + 1.0 }); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 170.0), Sense::click()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 4.0, t.canvas); + let plot = rect.shrink2(vec2(8.0, 18.0)); + let to = |(d, v): (f64, f64)| { + pos2(plot.min.x + ((d - t0) / (t1 - t0)) as f32 * plot.width(), plot.max.y - ((v - lo) / (hi - lo)) as f32 * plot.height()) + }; + // The time slider's step. + if let Some(sl) = app.session.map().time.as_ref() + && (t0..=t1).contains(&sl.current) + { + let x = to((sl.current, lo)).x; + painter.line_segment([pos2(x, plot.min.y), pos2(x, plot.max.y)], egui::Stroke::new(1.0, t.text_dim)); + } + let colours = [ + t.accent, + egui::Color32::from_rgb(214, 96, 77), + egui::Color32::from_rgb(77, 146, 33), + egui::Color32::from_rgb(118, 42, 131), + egui::Color32::from_rgb(230, 171, 2), + egui::Color32::from_rgb(27, 158, 119), + ]; + for (i, runs) in lines.iter().enumerate() { + let colour = colours.get(i % colours.len()).copied().unwrap_or(egui::Color32::GRAY); + for run in runs { + let pts: Vec = run.iter().map(|p| to(*p)).collect(); + if let [point] = pts.as_slice() { + painter.circle_filled(*point, 2.5, colour); + } else { + painter.add(egui::Shape::line(pts, egui::Stroke::new(1.6, colour))); + } + } + } + let fmt = |v: f64| rustgis_engine::map::symbology::fmt_num(v); + // The units, when every line has the same (lines of other variables share the scale without them). + let mut all_units = app.ws.series.iter().map(|s| s["units"].as_str().unwrap_or("")); + let first_units = all_units.next().unwrap_or(""); + let units = if all_units.all(|u| u == first_units) { first_units.to_string() } else { String::new() }; + painter.text(rect.left_top() + vec2(6.0, 3.0), egui::Align2::LEFT_TOP, format!("{} {units}", fmt(hi)), regular(10.0), t.text_dim); + painter.text( + rect.left_bottom() + vec2(6.0, -3.0), + egui::Align2::LEFT_BOTTOM, + format!("{} {units}", fmt(lo)), + regular(10.0), + t.text_dim, + ); + let day = |ms: f64| rustgis_engine::data::time::format(ms, "YYYY-MM-DD"); + painter.text( + rect.right_bottom() + vec2(-6.0, -3.0), + egui::Align2::RIGHT_BOTTOM, + format!("{} – {}", day(t0), day(t1)), + regular(10.0), + t.text_dim, + ); + // A click on the chart takes the time slider to that date. + if resp.clicked() + && app.session.map().time.is_some() + && let Some(pos) = resp.interact_pointer_pos() + { + let ms = t0 + f64::from(((pos.x - plot.min.x) / plot.width().max(1.0)).clamp(0.0, 1.0)) * (t1 - t0); + let _ = app.run("time.goTo", json!({"date": ms})); + } + // The points, in their lines' colours. + for (i, s) in app.ws.series.iter().enumerate() { + let colour = colours.get(i % colours.len()).copied().unwrap_or(egui::Color32::GRAY); + let text = format!( + "● {:.4}°, {:.4}° · {}", + s["lon"].as_f64().unwrap_or(0.0), + s["lat"].as_f64().unwrap_or(0.0), + s["variable"].as_str().unwrap_or("") + ); + ui.label(egui::RichText::new(text).small().color(colour)); + } +} + +/// The Time Series pane's lines as CSV: one row a date and point. +#[cfg(not(target_arch = "wasm32"))] +fn series_csv(series: &[Value]) -> String { + let mut out = String::from("lon,lat,variable,date,value\n"); + for s in series { + let (lon, lat, var) = (s["lon"].as_f64().unwrap_or(0.0), s["lat"].as_f64().unwrap_or(0.0), s["variable"].as_str().unwrap_or("")); + let labels = s["labels"].as_array().map(Vec::as_slice).unwrap_or(&[]); + let values = s["values"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for (l, v) in labels.iter().zip(values) { + let v = v.as_f64().map(|v| v.to_string()).unwrap_or_default(); + out.push_str(&format!("{lon},{lat},\"{}\",{},{v}\n", var.replace('"', "\"\""), l.as_str().unwrap_or(""))); + } + } + out +} + /// The Diagnostic Monitor. pub fn diagnostics(app: &mut RustGisApp, ui: &mut Ui, _t: &Tokens) { ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); @@ -664,4 +851,66 @@ mod tests { follow_delete(&mut app, 1); assert_eq!(app.ws.task_step, 0); } + + /// The Time Series pane (#555): a point picked on the map reads the series under the Zarr + /// layer chosen, the chart takes the slider to a date clicked, and Clear empties it. + #[test] + fn the_time_series_pane_reads_a_point() { + use egui_kittest::kittest::Queryable; + // A store of `t` over three monthly steps on a 3 × 4 grid (step k's values 100·k + i). + let dir = std::env::temp_dir().join(format!("rustgis-ui-series-{}", std::process::id())); + let store = dir.join("air.zarr"); + let _ = std::fs::remove_dir_all(&dir); + for d in ["t", "lat", "lon", "time"] { + std::fs::create_dir_all(store.join(d)).unwrap(); + } + let arr = |shape: &str, dtype: &str, chunks: &str| json!({"shape": serde_json::from_str::(shape).unwrap(), "chunks": serde_json::from_str::(chunks).unwrap(), "dtype": dtype, "compressor": null, "fill_value": null, "filters": null, "order": "C", "zarr_format": 2}); + let meta = json!({"metadata": { + ".zgroup": {"zarr_format": 2}, + "t/.zarray": arr("[3,3,4]", ">(); + std::fs::write(store.join("lat/0"), f32s(&[10.0, 20.0, 30.0])).unwrap(); + std::fs::write(store.join("lon/0"), f32s(&[1.0, 2.0, 3.0, 4.0])).unwrap(); + std::fs::write(store.join("time/0"), [0i32, 31, 59].iter().flat_map(|x| x.to_le_bytes()).collect::>()).unwrap(); + for k in 0..3 { + let v: Vec = (0..12).map(|i| (k * 100 + i) as f32).collect(); + std::fs::write(store.join(format!("t/{k}.0.0")), f32s(&v)).unwrap(); + } + let mut app = RustGisApp::with_sample(); + app.run("layer.addZarr", json!({"path": store.display().to_string(), "variable": "t", "time": true, "name": "air"})).unwrap(); + // A point picked at 1°E 30°N. + app.workflow.pick = + Some(crate::analysispane::Pick { purpose: "ts_point", needed: 1, points: vec![rustgis_engine::geom::Coord::new(1.0, 30.0)] }); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(420.0, 600.0)).build_ui_state( + |ui, app: &mut RustGisApp| { + let t = crate::theme::Tokens::get(ui.ctx()); + time_series(app, ui, &t); + }, + app, + ); + h.run_steps(3); + assert_eq!(h.state().ws.series_layer, "air"); + assert_eq!(h.state().ws.series.len(), 1); + assert_eq!(h.state().ws.series[0]["values"], json!([8.0, 108.0, 208.0])); + assert!(h.state().workflow.pick.is_none(), "the pick is used up"); + // The CSV: one row a step. + let csv = series_csv(&h.state().ws.series); + assert_eq!(csv.lines().count(), 4, "{csv}"); + assert!(csv.lines().nth(2).is_some_and(|l| l.ends_with("2001-02-01 00:00,108")), "{csv}"); + // Clear empties the chart. + h.get_by_label("Clear").click(); + h.run_steps(2); + assert!(h.state().ws.series.is_empty()); + let _ = std::fs::remove_dir_all(dir); + } } diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index f505e0d84..4cbdbfca8 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -489,14 +489,26 @@ off, and the layer shows the last of its steps in the slider's window, or nothin in it. Moving or playing the slider reads that step's slice (only its chunks), keeping the last eight read. A web store's next two steps are read ahead in the background. The stretch is fixed at the first slice's range, so a colour means the same value at every step, and **Export** writes an -animation of it. An axis longer than the slider's 20,000 steps (hourly ERA5) needs `"start"` and -`"end"` to take a period. The project keeps such a layer by its store (a folder or a web address) +animation of it. An axis longer than the slider's 20,000 steps (hourly ERA5) takes a period with `"start"` and +`"end"`; given neither (as the dialog adds it), the layer takes 20,000 steps from the one picked. The project keeps such a layer by its store (a folder or a web address) and selection, not as a slice, and reads the slider's step again when it opens. ```sh rustgis-cli run --cmd 'layer.addZarr={"path":"air.zarr","variable":"t","time":true}' --cmd 'time.export={"path":"t.gif"}' ``` +**View › Time Series** (desktop; `zarr.series`) reads a Zarr variable's values through time at a +point. Pick a Zarr layer, then **Pick a Point** on the map. A layer that follows the time slider +gives its period, a slice the whole axis, and `"start"`/`"end"` any other. Only the chunks holding +that point's cell are read. A store chunked one step at a time, such as ARCO-ERA5's map-oriented +copy, reads a chunk per step: about 4,000 steps at most, so a long axis needs a shorter period. +The chart draws up to six points as lines and marks the time slider's step, and a click on it +takes the slider to that date. **Export CSV…** writes the date and value of each point. + +```sh +rustgis-cli run --cmd 'zarr.series={"path":"air.zarr","variable":"t","lon":-55.7,"lat":-12.5}' +``` + A single step (a layer that doesn't follow the slider) from a store in a folder is saved in the project by its folder and slice (`2m_temperature[time=539100]`) and read again when the project opens, as Add Data on the folder does (its first variable); a single step from a store by web diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index 214bc6279..603335b2e 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -74,6 +74,8 @@ Tools for working across maps and views, and for setting up the app the way you - **Export** writes the steps as an animated GIF (`time.export` also writes a zip of PNG frames). The slider, its window and the bindings are saved with the project; the off button turns it off and every feature shows again. +- **Time Series**: a Zarr variable's values through time at points clicked on the map, as lines on + one chart; see [Zarr stores](data.md#zarr-stores). - **Profile**: elevation profiles along clicked points or selected lines, auto-completed to the raster's edge, or across a line (a cross section), with gain, loss and a chart. - **Infographics** (Home tab) summarise what's in view: counts, totals and top categories. From b199c32ff3432522138f914bfc13a965beabc247 Mon Sep 17 00:00:00 2001 From: Ryan Shields <94950489+ryanjshields@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:33:44 -0600 Subject: [PATCH 002/297] feat(imagery): warp a raster with any control-point fit (#669) raster.georeference takes "transform" (any of georef's six kinds): it fits the points and warps the layer's raster onto a north-up grid in "crs" that covers the warped image, so a rotated, sheared, bent or spline fit can be applied although GeoTransform has no rotation terms. "pointsCrs" (default EPSG:4326) is the system the points' coordinates are in, and "crs" (default "pointsCrs") the output's. Each output cell is taken back through the reprojection and the fit and sampled with "resampling" (nearest, bilinear or cubic). Cells outside the image are no data; a colour image without alpha or no-data gains an alpha band for them, 0-255 in every pixel type, and a grey byte image becomes grey with alpha. "cellSize" defaults to about the source pixel's size on the ground. The result replaces the layer's raster, or becomes a new layer with "name"; "path": "*.tif" also writes it as a cloud-optimised GeoTIFF, which then is the layer's file. A raster opened from an overview is warped at that resolution, and the result says so ("warpedFromOverview"). The output's extent comes from the image's edges and a 33x33 lattice inside it. A geographic output that would span more than 180 degrees of longitude is refused with a hint to give a projected "crs". Memory is checked before the warp, counting the per-row buffers as well as the output. The way back to the image is exact at every 8th cell of a row and linear between, falling back to exact where a midpoint is off by over 1/8 pixel. Without "transform" the north-up points and extent behave as before, and raster.georeference counts as a file command only when it is given a "path". Claude-Session: https://claude.ai/code/session_01L6xUk3wRNWgvWYBQMzY4TG Co-authored-by: Claude Opus 5.5 --- crates/analysis/src/lib.rs | 1 + crates/analysis/src/warp.rs | 505 +++++++++++++++++++++++++++++++ crates/assistant/src/tests.rs | 8 + crates/engine/src/cmd/imagery.rs | 100 +++++- crates/engine/src/cmd/plugins.rs | 4 + crates/engine/src/tests.rs | 401 ++++++++++++++++++++++++ docs/user-guide/analysis.md | 18 +- 7 files changed, 1034 insertions(+), 3 deletions(-) create mode 100644 crates/analysis/src/warp.rs diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index 7bf36ebf9..1cf95ec82 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -20,6 +20,7 @@ pub mod rasterops; pub mod tissot; mod tools; pub mod topology; +pub mod warp; pub use tools::{TOOLS, holds_coordinates, simplify_geometry}; diff --git a/crates/analysis/src/warp.rs b/crates/analysis/src/warp.rs new file mode 100644 index 000000000..062f2c9d7 --- /dev/null +++ b/crates/analysis/src/warp.rs @@ -0,0 +1,505 @@ +//! Warping a raster onto a north-up grid through a control-point fit ([`crate::georef::Fit`]). +//! +//! `GeoTransform` has no rotation terms, so an image that a fit turns, shears or bends can only be +//! placed by resampling it. Every cell of the output grid is taken back through the fit (after +//! reprojection, when the output's coordinate system isn't the one the control points are in) to a +//! position in the image and sampled there; cells whose position falls outside the image are +//! no data. +//! +//! The way back to the image is computed exactly at every [`STEP`]th cell of a row and linearly +//! between, as GDAL's approximate transformer does: where the midpoint of a stretch is off by more +//! than [`TOLERANCE`] image pixels, the whole stretch is computed exactly. + +use rustgis_data::raster::{ColorHint, Crs, GeoTransform, MAX_SAMPLES, Raster, Transformer}; +use rustgis_geom::Coord; + +use crate::georef::Fit; + +/// Cells between exact computations of the way back to the image. +const STEP: usize = 8; +/// The most, in image pixels, a linearly placed midpoint may be off. +const TOLERANCE: f64 = 0.125; +/// Points along each side of the image when its footprint is measured. +const EDGE_POINTS: usize = 64; +/// Divisions of the image each way for the lattice of points inside it. +const INTERIOR_POINTS: usize = 32; +/// What one output column costs in working buffers, in `f32` samples: its image position (24 +/// bytes) and its share of the exact nodes (4 bytes). +const COLUMN_COST: f64 = 7.0; + +/// How cells are sampled from the image. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Resampling { + /// The pixel the position falls in: values are kept, which is right for classes. + Nearest, + /// The four nearest pixel centres, by distance. + Bilinear, + /// Catmull-Rom over the sixteen nearest, kept within the band's range; where one of them has + /// no value, bilinear. + Cubic, +} + +impl Resampling { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "nearest" => Some(Resampling::Nearest), + "bilinear" => Some(Resampling::Bilinear), + "cubic" => Some(Resampling::Cubic), + _ => None, + } + } + + pub fn id(self) -> &'static str { + match self { + Resampling::Nearest => "nearest", + Resampling::Bilinear => "bilinear", + Resampling::Cubic => "cubic", + } + } +} + +/// What to warp onto. +#[derive(Clone, Debug)] +pub struct Options { + /// The output grid's coordinate system. + pub crs: Crs, + /// The coordinate system of the fit's map coordinates. + pub points_crs: Crs, + pub resampling: Resampling, + /// Cell size in the output's units; about the image pixel's size on the ground when `None`. + pub cell: Option, + pub name: String, +} + +/// Map coordinates of the fit, to and from the output's coordinate system. +struct Reproject { + from: Transformer, + to: Transformer, + same: bool, +} + +impl Reproject { + fn new(points: Crs, out: Crs) -> Result { + Ok(Reproject { from: points.transformer()?, to: out.transformer()?, same: points == out }) + } + + /// Fit coordinates to output coordinates. + fn forward(&self, x: f64, y: f64) -> Option<(f64, f64)> { + if self.same { + return (x.is_finite() && y.is_finite()).then_some((x, y)); + } + let c = self.to.from_lonlat(self.from.to_lonlat(Coord::new(x, y))?)?; + Some((c.x, c.y)) + } + + /// Output coordinates to fit coordinates. + fn back(&self, x: f64, y: f64) -> Option<(f64, f64)> { + if self.same { + return (x.is_finite() && y.is_finite()).then_some((x, y)); + } + let c = self.from.from_lonlat(self.to.to_lonlat(Coord::new(x, y))?)?; + Some((c.x, c.y)) + } +} + +/// Reads pixels at fractional image positions. +struct Sampler<'a> { + r: &'a Raster, + method: Resampling, + /// The band whose value weighs the others (alpha), if the image has one. + alpha: Option, + /// Whole-number pixel types are rounded. + round: bool, +} + +impl Sampler<'_> { + /// The value of band `b` at index `idx`, if it is a real measurement. + fn at(&self, b: usize, idx: usize) -> Option { + let v = *self.r.bands.get(b)?.data.get(idx)?; + self.r.is_valid(v).then_some(v) + } + + /// Every band at image position (`c`, `row`), in pixels from the top left corner; NaN where + /// there's no value. The position must be inside the image. + fn sample(&self, c: f64, row: f64, out: &mut [f32]) { + let (w, h) = (i64::from(self.r.width), i64::from(self.r.height)); + let index = |i: i64, j: i64| (j.clamp(0, h - 1) * w + i.clamp(0, w - 1)) as usize; + if self.method == Resampling::Nearest { + let idx = index(c.floor() as i64, row.floor() as i64); + for (b, o) in out.iter_mut().enumerate() { + *o = self.at(b, idx).unwrap_or(f32::NAN); + } + return; + } + // Pixel centres are at half integers. + let (u, v) = (c - 0.5, row - 0.5); + let (i0, j0) = (u.floor() as i64, v.floor() as i64); + let (fu, fv) = (u - i0 as f64, v - j0 as f64); + for (b, o) in out.iter_mut().enumerate() { + let cubic = (self.method == Resampling::Cubic).then(|| self.cubic(b, &index, i0, j0, fu, fv)).flatten(); + *o = cubic.unwrap_or_else(|| self.bilinear(b, &index, i0, j0, fu, fv)); + if self.round && o.is_finite() { + *o = o.round(); + } + } + } + + fn bilinear(&self, b: usize, index: &dyn Fn(i64, i64) -> usize, i0: i64, j0: i64, fu: f64, fv: f64) -> f32 { + let weighted = self.alpha.filter(|a| *a != b); + let (mut sum, mut weight) = (0.0f64, 0.0f64); + for (di, dj, w) in [(0, 0, (1.0 - fu) * (1.0 - fv)), (1, 0, fu * (1.0 - fv)), (0, 1, (1.0 - fu) * fv), (1, 1, fu * fv)] { + let idx = index(i0 + di, j0 + dj); + let Some(v) = self.at(b, idx) else { continue }; + // Colours count as much as their pixel is opaque. + let k = weighted.map_or(1.0, |a| self.at(a, idx).map_or(0.0, f64::from)); + sum += f64::from(v) * w * k; + weight += w * k; + } + if weight > 1e-12 { (sum / weight) as f32 } else { f32::NAN } + } + + /// Catmull-Rom over the sixteen pixels around the position; `None` where one has no value. + fn cubic(&self, b: usize, index: &dyn Fn(i64, i64) -> usize, i0: i64, j0: i64, fu: f64, fv: f64) -> Option { + let kernel = |t: f64| { + [ + (-t * t * t + 2.0 * t * t - t) / 2.0, + (3.0 * t * t * t - 5.0 * t * t + 2.0) / 2.0, + (-3.0 * t * t * t + 4.0 * t * t + t) / 2.0, + (t * t * t - t * t) / 2.0, + ] + }; + let (wx, wy) = (kernel(fu), kernel(fv)); + let mut sum = 0.0f64; + for (dj, wj) in wy.iter().enumerate() { + for (di, wi) in wx.iter().enumerate() { + let v = self.at(b, index(i0 + di as i64 - 1, j0 + dj as i64 - 1))?; + sum += f64::from(v) * wi * wj; + } + } + // Kept within the band's range (a range that is missing or reversed leaves it be). + let s = &self.r.bands.get(b)?.stats; + Some(if s.min <= s.max { sum.clamp(s.min, s.max) } else { sum } as f32) + } +} + +/// Warps `r` onto a north-up grid in `o.crs` through `fit`, whose pixel positions are those of +/// `r` (in the full image's pixels, when `r` is an overview of it) and whose map coordinates are +/// in `o.points_crs`. The grid covers the warped image's footprint. A colour or grey byte raster +/// with neither alpha nor no-data gets an alpha band (0–255), so the cells outside the image are +/// see-through. Geographic output cells are square in degrees. +pub fn warp(r: &Raster, fit: &Fit, o: &Options) -> Result { + let re = Reproject::new(o.points_crs, o.crs)?; + let (w, h) = (f64::from(r.width), f64::from(r.height)); + // An overview's pixels are bigger than the full image's, which the fit is in. + let (sx, sy) = r.overview.map_or((1.0, 1.0), |(fw, fh)| (f64::from(fw) / w, f64::from(fh) / h)); + let place = |c: f64, row: f64| fit.to_map(c * sx, row * sy).and_then(|(x, y)| re.forward(x, y)); + + // The footprint: the image's edge and a lattice inside it, placed (a pole inside the image + // is no extreme of its edge). + let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); + let mut placed = 0; + let mut add = |c: f64, row: f64| { + if let Some((x, y)) = place(c, row) { + (min_x, min_y, max_x, max_y) = (min_x.min(x), min_y.min(y), max_x.max(x), max_y.max(y)); + placed += 1; + } + }; + for i in 0..=EDGE_POINTS { + let t = i as f64 / EDGE_POINTS as f64; + for (c, row) in [(t * w, 0.0), (t * w, h), (0.0, t * h), (w, t * h)] { + add(c, row); + } + } + for i in 0..=INTERIOR_POINTS { + for j in 0..=INTERIOR_POINTS { + add(i as f64 / INTERIOR_POINTS as f64 * w, j as f64 / INTERIOR_POINTS as f64 * h); + } + } + if placed < 4 || ![min_x, min_y, max_x, max_y].iter().all(|v| v.is_finite()) { + return Err("the image's edge can't be placed in the output coordinate system".into()); + } + if o.crs.is_geographic() && max_x - min_x > 180.0 { + return Err("the warped image spans more than 180° of longitude (it crosses the antimeridian, or the fit sends part of it far away): give a projected crs".into()); + } + if max_x <= min_x || max_y <= min_y { + return Err("the fit puts the image on a line, with no area".into()); + } + + let nb = r.bands.len(); + let alpha_src = match r.color { + ColorHint::Rgb { alpha: true } if nb >= 4 => Some(3), + ColorHint::GrayAlpha if nb >= 2 => Some(1), + _ => None, + }; + let rgb = matches!(r.color, ColorHint::Rgb { .. }) && nb >= 3; + let gray = matches!(r.color, ColorHint::Gray) && nb == 1 && r.data_type == "UInt8"; + let add_alpha = (rgb || gray) && alpha_src.is_none() && r.nodata.is_none(); + let out_bands = nb + usize::from(add_alpha); + + // The cell: asked for, or the image pixel's size on the ground (the root of its area, where + // the fit turns or skews it), coarser if the grid would hold too many samples. + let cells = |cell: f64| -> (f64, f64) { (((max_x - min_x) / cell).ceil().max(1.0), ((max_y - min_y) / cell).ceil().max(1.0)) }; + // The samples held, and the buffers a row needs (they add up on a long thin grid). + let load = |(ow, oh): (f64, f64)| ow * (oh * out_bands as f64 + COLUMN_COST); + let too_many = |n: (f64, f64)| load(n) > MAX_SAMPLES as f64; + let cell = match o.cell { + Some(c) if !(c.is_finite() && c > 0.0) => return Err("cellSize must be a positive number".into()), + Some(c) => { + if too_many(cells(c)) { + return Err(format!("a cell size of {c} makes a grid of more than {MAX_SAMPLES} samples: give a larger cellSize")); + } + c + } + None => { + let (cx, cy) = (w / 2.0, h / 2.0); + let (a, b, d) = (place(cx, cy), place(cx + 1.0, cy), place(cx, cy + 1.0)); + let (Some(a), Some(b), Some(d)) = (a, b, d) else { + return Err("the source pixel's size can't be worked out: give a cellSize".into()); + }; + let mut cell = ((b.0 - a.0) * (d.1 - a.1) - (d.0 - a.0) * (b.1 - a.1)).abs().sqrt(); + if !(cell.is_finite() && cell > 0.0) { + return Err("the source pixel's size can't be worked out: give a cellSize".into()); + } + // Each step at least 1% coarser, so a grid rounded up past the limit still shrinks. + for _ in 0..64 { + let n = cells(cell); + if !too_many(n) { + break; + } + cell *= (load(n) / MAX_SAMPLES as f64).sqrt().max(1.01); + } + if too_many(cells(cell)) { + return Err("the warped image is too large to hold: give a cellSize".into()); + } + cell + } + }; + let (ow, oh) = cells(cell); + let (ow, oh) = (ow as usize, oh as usize); + + let method = if matches!(r.color, ColorHint::Palette(_)) { Resampling::Nearest } else { o.resampling }; + let sampler = Sampler { r, method, alpha: alpha_src, round: !r.data_type.starts_with("Float") }; + // Cells with no value: no-data where the raster has some. Colours with alpha are zero there. + let clear = alpha_src.is_some() || add_alpha; + let outside = if clear { 0.0 } else { r.nodata.map_or(f32::NAN, |d| d as f32) }; + // Alpha is read as 0–255 whatever the pixel type. + let opaque = 255.0; + + let mut bands: Vec> = vec![vec![outside; ow * oh]; out_bands]; + let mut coords: Vec> = vec![None; ow]; + let mut nodes: Vec<(usize, Option<(f64, f64)>)> = Vec::new(); + let mut px = vec![f32::NAN; nb]; + // The image position of an output coordinate. + let source = |x: f64, y: f64| -> Option<(f64, f64)> { + let (px, py) = re.back(x, y)?; + let (c, row) = fit.to_pixel(px, py)?; + let (c, row) = (c / sx, row / sy); + (c.is_finite() && row.is_finite()).then_some((c, row)) + }; + for oy in 0..oh { + let y = max_y - (oy as f64 + 0.5) * cell; + let x_at = |ox: usize| min_x + (ox as f64 + 0.5) * cell; + nodes.clear(); + let mut ox = 0; + loop { + nodes.push((ox, source(x_at(ox), y))); + if ox + 1 >= ow { + break; + } + ox = (ox + STEP).min(ow - 1); + } + for pair in nodes.windows(2) { + let [(a, pa), (b, pb)] = pair else { continue }; + let linear = match (pa, pb) { + (Some(pa), Some(pb)) if b - a >= 2 => { + let mid = a + (b - a) / 2; + let t = (mid - a) as f64 / (b - a) as f64; + let guess = (pa.0 + (pb.0 - pa.0) * t, pa.1 + (pb.1 - pa.1) * t); + source(x_at(mid), y).is_some_and(|m| (m.0 - guess.0).abs().max((m.1 - guess.1).abs()) <= TOLERANCE) + } + (Some(_), Some(_)) => true, + _ => false, + }; + for i in *a..*b { + let t = (i - a) as f64 / (b - a) as f64; + let c = match (linear, pa, pb) { + (true, Some(pa), Some(pb)) => Some((pa.0 + (pb.0 - pa.0) * t, pa.1 + (pb.1 - pa.1) * t)), + _ => source(x_at(i), y), + }; + if let Some(slot) = coords.get_mut(i) { + *slot = c; + } + } + } + if let Some((last, p)) = nodes.last() + && let Some(c) = coords.get_mut(*last) + { + *c = *p; + } + for (ox, c) in coords.iter().enumerate() { + let Some((c, row)) = *c else { continue }; + if !(c >= 0.0 && row >= 0.0 && c < w && row < h) { + continue; + } + sampler.sample(c, row, &mut px); + let at = oy * ow + ox; + for (band, v) in bands.iter_mut().zip(&px) { + if let Some(o) = band.get_mut(at) { + // A cell with no value is no-data (or see-through) like one outside. + *o = if v.is_nan() { if clear { 0.0 } else { outside } } else { *v }; + } + } + if add_alpha && let Some(o) = bands.get_mut(nb).and_then(|b| b.get_mut(at)) { + *o = opaque; + } + } + } + + let color = match (add_alpha, rgb) { + (true, true) => ColorHint::Rgb { alpha: true }, + (true, false) => ColorHint::GrayAlpha, + _ => r.color.clone(), + }; + let transform = GeoTransform { origin_x: min_x, origin_y: max_y, pixel_width: cell, pixel_height: -cell }; + let mut out = Raster::new(o.name.clone(), ow as u32, oh as u32, bands, transform, o.crs, r.nodata, color, r.data_type.clone()) + .map_err(|e| e.to_string())?; + for (i, band) in out.bands.iter_mut().enumerate() { + band.name = r.bands.get(i).map_or_else(|| "Alpha".to_string(), |b| b.name.clone()); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::georef::{self, Gcp, Kind}; + + fn scan(w: u32, h: u32, bands: Vec>, color: ColorHint, data_type: &str) -> Raster { + let gt = GeoTransform { origin_x: 0.0, origin_y: f64::from(h), pixel_width: 1.0, pixel_height: -1.0 }; + Raster::new("scan", w, h, bands, gt, Crs::LonLat, None, color, data_type).unwrap() + } + + fn opts(cell: Option, resampling: Resampling) -> Options { + Options { crs: Crs::from_epsg(32613).unwrap(), points_crs: Crs::from_epsg(32613).unwrap(), resampling, cell, name: "w".into() } + } + + fn gcps(w: f64, h: f64, f: impl Fn(f64, f64) -> (f64, f64)) -> Vec { + let mut v = Vec::new(); + for i in 0..4 { + for j in 0..4 { + let (col, row) = (w * f64::from(i) / 3.0, h * f64::from(j) / 3.0); + let (x, y) = f(col, row); + v.push(Gcp { col, row, x, y, on: true }); + } + } + v + } + + #[test] + fn a_bent_fit_is_followed_between_exact_steps() { + // x runs ahead of the column by 0.1 col²: bent enough that the linear steps between + // exact computations are too far off in places, and the exact way back is used there. + let (w, h) = (60u32, 40u32); + let data: Vec = (0..w * h).map(|i| (i % w) as f32 + 0.5).collect(); + let r = scan(w, h, vec![data], ColorHint::Gray, "Float32"); + let map = |c: f64, rw: f64| (500_000.0 + 10.0 * c + 0.1 * c * c, 4_000_000.0 - 10.0 * rw); + let fit = georef::fit(Kind::Polynomial2, &gcps(60.0, 40.0, map)).unwrap(); + let out = warp(&r, &fit, &opts(Some(5.0), Resampling::Bilinear)).unwrap(); + let mut checked = 0; + for row in (0..i64::from(out.height)).step_by(11) { + for col in 0..i64::from(out.width) { + let p = out.transform.to_world(col as f64 + 0.5, row as f64 + 0.5); + // The fit's own way back, computed for this cell alone (polynomial fits are + // fitted in each direction, so it isn't quite the inverse of the way forward). + let Some((c, _)) = fit.to_pixel(p.x, p.y) else { continue }; + if (1.0..59.0).contains(&c) { + let v = out.value(0, col, row).unwrap(); + assert!((f64::from(v) - c).abs() <= TOLERANCE + 1e-3, "cell {col},{row}: {v} against {c}"); + checked += 1; + } + } + } + assert!(checked > 100); + } + + #[test] + fn a_colour_image_gets_an_alpha_band_and_the_outside_is_see_through() { + let (w, h) = (40u32, 40u32); + let n = (w * h) as usize; + let r = scan(w, h, vec![vec![200.0; n], vec![100.0; n], vec![50.0; n]], ColorHint::Rgb { alpha: false }, "UInt8"); + let (sin, cos) = 40f64.to_radians().sin_cos(); + let map = |c: f64, rw: f64| (500_000.0 + c * cos + rw * sin, 4_000_000.0 + c * sin - rw * cos); + let fit = georef::fit(Kind::Similarity, &gcps(40.0, 40.0, map)).unwrap(); + let out = warp(&r, &fit, &opts(None, Resampling::Bilinear)).unwrap(); + assert_eq!((out.band_count(), out.color.clone()), (4, ColorHint::Rgb { alpha: true })); + assert_eq!(out.value(3, 0, 0), Some(0.0), "the box's corner is outside the image"); + let (mid_c, mid_r) = (i64::from(out.width) / 2, i64::from(out.height) / 2); + assert_eq!([0, 1, 2, 3].map(|b| out.value(b, mid_c, mid_r)), [Some(200.0), Some(100.0), Some(50.0), Some(255.0)]); + // The image's cells keep whole-number values. + assert!(out.bands.iter().all(|b| b.data.iter().all(|v| v.fract() == 0.0))); + } + + #[test] + fn cubic_stays_within_the_bands_range_at_an_edge() { + let (w, h) = (30u32, 30u32); + let data: Vec = (0..w * h).map(|i| if i % w < 15 { 0.0 } else { 100.0 }).collect(); + let r = scan(w, h, vec![data], ColorHint::Gray, "Float32"); + let map = |c: f64, rw: f64| (500_000.0 + 7.0 * c, 4_000_000.0 - 7.0 * rw); + let fit = georef::fit(Kind::Affine, &gcps(30.0, 30.0, map)).unwrap(); + let out = warp(&r, &fit, &opts(Some(2.0), Resampling::Cubic)).unwrap(); + let (lo, hi) = out.bands[0].data.iter().filter(|v| v.is_finite()).fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(*v), b.max(*v))); + assert!(lo >= 0.0 && hi <= 100.0, "{lo}..{hi}"); + // Nearest keeps only the two values. + let out = warp(&r, &fit, &opts(Some(2.0), Resampling::Nearest)).unwrap(); + assert!(out.bands[0].data.iter().filter(|v| v.is_finite()).all(|v| *v == 0.0 || *v == 100.0)); + } + + #[test] + fn no_data_is_kept_outside_and_skipped_inside() { + let gt = GeoTransform { origin_x: 0.0, origin_y: 4.0, pixel_width: 1.0, pixel_height: -1.0 }; + let mut data = vec![10.0f32; 16]; + data[5] = -9999.0; + let r = Raster::new("nd", 4, 4, vec![data], gt, Crs::LonLat, Some(-9999.0), ColorHint::Gray, "Float32").unwrap(); + let (sin, cos) = 25f64.to_radians().sin_cos(); + let map = |c: f64, rw: f64| (500_000.0 + (c * cos + rw * sin) * 10.0, 4_000_000.0 + (c * sin - rw * cos) * 10.0); + let fit = georef::fit(Kind::Similarity, &gcps(4.0, 4.0, map)).unwrap(); + let out = warp(&r, &fit, &opts(Some(2.0), Resampling::Bilinear)).unwrap(); + assert_eq!(out.nodata, Some(-9999.0)); + assert_eq!(out.bands[0].data[0], -9999.0, "outside the image: the raster's no-data value"); + // Bilinear skips the no-data pixel: nothing but 10 and no-data comes out. + assert!(out.bands[0].data.iter().all(|v| *v == 10.0 || *v == -9999.0)); + } + + #[test] + fn hostile_fits_and_options_are_refused() { + let r = scan(4, 4, vec![vec![1.0; 16]], ColorHint::Gray, "Float32"); + let map = |c: f64, rw: f64| (500_000.0 + c, 4_000_000.0 - rw); + let fit = georef::fit(Kind::Affine, &gcps(4.0, 4.0, map)).unwrap(); + for cell in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e-12, 1e-300] { + assert!(warp(&r, &fit, &opts(Some(cell), Resampling::Nearest)).is_err(), "{cell}"); + } + // Enormous coordinates: no panic, whatever the answer. + let huge = |c: f64, rw: f64| (c * 1e300, rw * 1e300); + if let Ok(fit) = georef::fit(Kind::Affine, &gcps(4.0, 4.0, huge)) { + let _ = warp(&r, &fit, &opts(None, Resampling::Cubic)); + let _ = warp(&r, &fit, &opts(Some(1.0), Resampling::Cubic)); + } + } + + #[test] + fn a_grey_byte_image_becomes_grey_with_alpha_and_every_type_is_opaque_at_255() { + let (sin, cos) = 30f64.to_radians().sin_cos(); + let map = |c: f64, rw: f64| (500_000.0 + (c * cos + rw * sin) * 10.0, 4_000_000.0 + (c * sin - rw * cos) * 10.0); + let fit = georef::fit(Kind::Similarity, &gcps(20.0, 20.0, map)).unwrap(); + let r = scan(20, 20, vec![vec![90.0; 400]], ColorHint::Gray, "UInt8"); + let out = warp(&r, &fit, &opts(None, Resampling::Bilinear)).unwrap(); + assert_eq!((out.band_count(), out.color.clone()), (2, ColorHint::GrayAlpha)); + let (c, rw) = (i64::from(out.width) / 2, i64::from(out.height) / 2); + assert_eq!((out.value(0, c, rw), out.value(1, c, rw), out.value(1, 0, 0)), (Some(90.0), Some(255.0), Some(0.0))); + // A 16-bit colour image: alpha is 255 too, as the renderer reads it. + let n = 400; + let r = scan(20, 20, vec![vec![60_000.0; n], vec![100.0; n], vec![7.0; n]], ColorHint::Rgb { alpha: false }, "UInt16"); + let out = warp(&r, &fit, &opts(None, Resampling::Bilinear)).unwrap(); + assert_eq!(out.value(3, c, rw), Some(255.0)); + } +} diff --git a/crates/assistant/src/tests.rs b/crates/assistant/src/tests.rs index b550c59b3..cb1119619 100644 --- a/crates/assistant/src/tests.rs +++ b/crates/assistant/src/tests.rs @@ -168,6 +168,14 @@ fn gate_rules() { assert!(g("list_layers", json!({})).is_none()); assert!(g("run_command", json!({"command": "info.fileLayers", "params": {"path": "/etc/passwd"}})).is_some()); assert!(g("run_command", json!({"command": "info.fileColumns", "params": {"path": "/etc/passwd"}})).is_some()); + // Georeferencing is a file command only when it writes a file. + let extent = json!({"layer": "r", "extent": [10.0, 45.0, 10.04, 45.04]}); + assert!(g("run_command", json!({"command": "raster.georeference", "params": extent})).is_none()); + let pts = json!({"layer": "r", "points": [[0, 0, 1, 1], [1, 0, 2, 1], [0, 1, 1, 2]], "transform": "affine", "crs": 32613}); + assert!(g("run_command", json!({"command": "raster.georeference", "params": pts})).is_none()); + let mut file = pts.clone(); + file["path"] = json!("/tmp/x.tif"); + assert!(g("run_command", json!({"command": "raster.georeference", "params": file})).is_some()); } /// What a command says beside its result reaches the model (issue #85). diff --git a/crates/engine/src/cmd/imagery.rs b/crates/engine/src/cmd/imagery.rs index e3e58d9e2..fecf2d1e2 100644 --- a/crates/engine/src/cmd/imagery.rs +++ b/crates/engine/src/cmd/imagery.rs @@ -6,9 +6,11 @@ use std::sync::Arc; use rustgis_analysis::georef::{self, Gcp, Kind}; use rustgis_analysis::rasterops::{self, NODATA}; +use rustgis_analysis::warp; use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; use rustgis_data::{Geometry, GeometryType}; use rustgis_geom::{Coord, Extent}; +use rustgis_map::RasterLayer; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -37,7 +39,7 @@ fn first() -> String { pub fn commands() -> Vec { vec![ C::new("raster.georeference", "Georeference", "Analyze › Alignment", georeference).params( - r#"{"layer": raster, "points"?: [[column, row, lon, lat], …] (two or more), "extent"?: [minLon, minLat, maxLon, maxLat]}"#, + r#"{"layer": raster, "points"?: [[column, row, lon, lat], …] (two or more; with "transform": [column, row, x, y, on?]), "extent"?: [minLon, minLat, maxLon, maxLat], "transform"?: "similarity"|"affine"|"projective"|"polynomial2"|"polynomial3"|"spline" (fits the points and warps the raster onto a north-up grid covering the warped image; without it the points and extent only shift and scale the grid), "pointsCrs"?: EPSG code or "EPSG:n" of the points' x and y (default 4326), "crs"?: the output's (default pointsCrs), "resampling"?: "nearest"|"bilinear"|"cubic" (default bilinear), "cellSize"?: output units (default about the source pixel's size on the ground), "name"?: new layer for the result (default replaces the layer's raster), "path"?: "*.tif" (also writes the warp as a cloud-optimised GeoTIFF)}"#, ), // A fit is a measurement of the points, not a project change: no undo step. C::new("raster.gcpFit", "Control Point Fit", "Analyze › Alignment", gcp_fit).view().params( @@ -155,8 +157,12 @@ pub(crate) fn add(s: &mut Session, r: Raster, classes: bool) -> CmdResult { /// Replaces a raster layer's pixels, keeping its look. fn replace(s: &mut Session, layer: &str, r: Raster) -> CmdResult { + replace_layer(s, layer, layer_of(r)?) +} + +/// [`replace`], with the new pixels already in a layer (kept in a file, say, not embedded). +fn replace_layer(s: &mut Session, layer: &str, mut new: rustgis_map::RasterLayer) -> CmdResult { let id = s.map().layer_by_name(layer).map(|l| l.id).ok_or_else(|| format!("no raster layer '{layer}'"))?; - let mut new = layer_of(r)?; let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; if let Some(old) = &l.raster { new.style = old.style.clone(); @@ -179,6 +185,9 @@ fn rebuild(r: &Raster, transform: GeoTransform, crs: Crs, bands: Vec>) fn georeference(s: &mut Session, p: &Value) -> CmdResult { let layer = ps(p, "layer").ok_or("layer is required")?.to_string(); let r = raster(s, p)?; + if p.get("transform").is_some() { + return warp_to_fit(s, p, &layer, &r); + } let gt = if let Some(e) = p.get("extent").and_then(Value::as_array) { let n: Vec = e.iter().filter_map(Value::as_f64).collect(); let [x0, y0, x1, y1] = n.as_slice() else { return Err("extent is [minLon, minLat, maxLon, maxLat]".into()) }; @@ -232,6 +241,93 @@ fn georeference(s: &mut Session, p: &Value) -> CmdResult { Ok(json!({"layer": v["layer"], "extent": [e.min_x, e.min_y, e.max_x, e.max_y]})) } +/// A coordinate system given as an EPSG code or any text [`Crs::parse`] reads. +fn crs_param(p: &Value, key: &str) -> Result, String> { + let bad = || format!("{key} is an EPSG code such as 32613 or \"EPSG:32613\""); + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(t)) => Crs::parse(t).map(Some), + Some(v) => { + let code = v.as_u64().filter(|c| *c <= u64::from(u32::MAX)).ok_or_else(bad)?; + Crs::from_epsg(code as u32).map(Some).ok_or_else(|| format!("unknown coordinate system EPSG:{code}")) + } + } +} + +/// `raster.georeference` with a transform: fits the control points and warps the raster onto a +/// north-up grid in the output coordinate system (see `rustgis_analysis::warp`). +fn warp_to_fit(s: &mut Session, p: &Value, layer: &str, r: &Raster) -> CmdResult { + let kind = ps(p, "transform") + .and_then(Kind::parse) + .ok_or("transform is similarity, affine, projective, polynomial2, polynomial3 or spline")?; + let fit = georef::fit(kind, &gcps(p)?)?; + // Points are longitude/latitude unless said otherwise, and the output stays in their system + // unless asked to reproject. + let points_crs = crs_param(p, "pointsCrs")?.unwrap_or(Crs::LonLat); + let crs = crs_param(p, "crs")?.unwrap_or(points_crs); + let resampling = match p.get("resampling") { + None | Some(Value::Null) => warp::Resampling::Bilinear, + Some(v) => v.as_str().and_then(warp::Resampling::parse).ok_or("resampling is nearest, bilinear or cubic")?, + }; + let cell = match p.get("cellSize") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|c| c.is_finite() && *c > 0.0).ok_or("cellSize is a positive number, in the output's units")?), + }; + let path = match p.get("path") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_str().filter(|t| is_tif(t)).ok_or("path is a *.tif or *.tiff file")?.to_string()), + }; + let new_layer = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(|n| n.chars().take(200).collect::()); + let out_name = new_layer.clone().unwrap_or_else(|| r.name.clone()); + let out = warp::warp(r, &fit, &warp::Options { crs, points_crs, resampling, cell, name: out_name })?; + if let Some((fw, fh)) = r.overview { + let note = + format!("{}: warped at reduced resolution, from its {} × {} overview of the {fw} × {fh} raster", r.name, r.width, r.height); + s.notify(note, false); + } + let mut info = json!({ + "transform": kind.id(), "crs": crs.label(), "pointsCrs": points_crs.label(), "resampling": resampling.id(), + "width": out.width, "height": out.height, "bands": out.band_count(), "cellSize": out.transform.pixel_width, + }); + if let Some((fw, fh)) = r.overview { + info["warpedFromOverview"] = json!({"width": r.width, "height": r.height, "of": [fw, fh]}); + } + // A file written is the layer's source; without one the pixels are kept in the project. + let rl = match &path { + Some(path) => { + let cog = + rustgis_data::raster::cogwrite::write_file(&out, 0, std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; + info["path"] = json!(path); + info["cog"] = json!({"overviews": cog.levels.len().saturating_sub(1), "bigTiff": cog.big, "bytes": cog.bytes}); + let e = out.extent(); + info["extent"] = json!([e.min_x, e.min_y, e.max_x, e.max_y]); + RasterLayer::from_path(out, path.clone()) + } + None => { + let e = out.extent(); + info["extent"] = json!([e.min_x, e.min_y, e.max_x, e.max_y]); + layer_of(out)? + } + }; + let v = match new_layer { + Some(n) => super::raster::add_raster(s, &n, rl)?, + None => replace_layer(s, layer, rl)?, + }; + if let (Some(o), Some(i)) = (info.as_object_mut(), v.as_object()) { + for (k, val) in i { + o.entry(k.clone()).or_insert_with(|| val.clone()); + } + } + Ok(info) +} + +fn is_tif(path: &str) -> bool { + std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("tif") || e.eq_ignore_ascii_case("tiff")) +} + /// Control points from `[column, row, x, y, on?]` entries. fn gcps(p: &Value) -> Result, String> { const SHAPE: &str = "each control point is [column, row, x, y] or [column, row, x, y, on]"; diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index 7ad7ee920..56bd8702e 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -327,6 +327,10 @@ pub fn reaches(spec: &C, params: &Value) -> Option { if DATABASES.contains(&spec.id) { return Some(Reach::Files); } + // Georeferencing writes a file only when it is given a `path`; the rest of it stays in the project. + if spec.id == "raster.georeference" { + return super::items::names_place(params, PLACE_KEYS, 0).then_some(Reach::Files); + } let files = super::items::names_place(params, PLACE_KEYS, 0) || PLACE_KEYS.iter().any(|k| spec.params.contains(&format!("\"{k}\""))) || WRITES_FILES.contains(&spec.id) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9540e4ea5..d339c6333 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1053,6 +1053,9 @@ fn hostile_params_never_panic() { json!({"points": [[], 7, [1, 2, 3, 4, "x"]], "transform": "spline"}), json!({"points": [[0, 0, 1e308, -1e308], [1, 0, -1e308, 1e308], [0, 1, 1e308, 1e308], [1, 1, 0, 0, 0]], "transform": "spline"}), json!({"points": vec![json!([5, 5, 1, 1]); 1000], "compare": true}), + // A warp: junk systems, sizes and paths with points that reach the fit. + json!({"transform": "spline", "crs": "EPSG:999999999", "pointsCrs": [7], "resampling": 1, "cellSize": -1e308, "path": 5, "points": [[0, 0, 1, 1]]}), + json!({"transform": "affine", "crs": 32613, "pointsCrs": 4326, "cellSize": 1e-300, "path": "/nonexistent/x.tif", "points": [[0, 0, 1, 1], [1, 0, 2, 1], [0, 1, 1, 2]]}), json!({"points": [[0, 0, f64::MAX, f64::MAX], [1, 0, -f64::MAX, f64::MAX], [0, 1, f64::MAX, -f64::MAX]], "compare": true}), ]; let ids: Vec<&str> = Session::new(Project::default()).registry.commands.iter().map(|c| c.id).collect(); @@ -2836,6 +2839,404 @@ fn analysis_workflows_models_scripts_and_raster_functions() { assert!(g.as_array().unwrap().iter().any(|t| t["id"] == "raster.function")); } +/// A 100 × 80 scan whose value is `ramp(col, row)` (so a bilinear warp reproduces it exactly), with +/// a bright pixel at (50, 40), added as a layer with a placeholder position. +fn scan_layer(s: &mut Session, name: &str) -> (fn(f64, f64) -> f32, (f64, f64)) { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; + fn ramp(c: f64, r: f64) -> f32 { + (c * 2.0 + r * 0.5) as f32 + } + let (w, h) = (100u32, 80u32); + let mut z: Vec = (0..w * h).map(|i| ramp(f64::from(i % w) + 0.5, f64::from(i / w) + 0.5)).collect(); + z[(40 * w + 50) as usize] = 5000.0; + let gt = GeoTransform { origin_x: 0.0, origin_y: 80.0, pixel_width: 1.0, pixel_height: -1.0 }; + let r = Raster::new(name, w, h, vec![z], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let bytes = geotiff::write(&r, 0).unwrap(); + crate::cmd::raster::add_raster(s, name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); + (ramp, (50.5, 40.5)) +} + +/// The control points of a known map from the scan's corners and middle: `f` takes a pixel position +/// to map coordinates. +fn points_of(f: impl Fn(f64, f64) -> (f64, f64)) -> Value { + let pts: Vec = [(0.0, 0.0), (100.0, 0.0), (0.0, 80.0), (100.0, 80.0), (50.0, 40.0), (25.0, 60.0)] + .iter() + .map(|&(c, r)| { + let (x, y) = f(c, r); + json!([c, r, x, y]) + }) + .collect(); + Value::Array(pts) +} + +fn out_raster(s: &Session, v: &Value) -> std::sync::Arc { + let id = v["layer"].as_u64().unwrap() as u32; + s.map().layer(id).unwrap().raster.as_ref().unwrap().data.clone().unwrap() +} + +/// Warping through a known sheared affine puts every pixel where the matrix says: a bilinear +/// warp of a ramp reproduces the ramp at the pixel each output cell comes from, and the bright +/// pixel lands within half a cell of its place. +#[test] +fn a_warp_through_a_known_affine_puts_pixels_where_the_matrix_says() { + let mut s = world(); + let (ramp, bright) = scan_layer(&mut s, "scan"); + // x = 500000 + 10 col + 2 row, y = 4000000 - 10 row + 1 col, in UTM 13N. + let map = |c: f64, r: f64| (500_000.0 + 10.0 * c + 2.0 * r, 4_000_000.0 - 10.0 * r + c); + let v = s + .run( + "raster.georeference", + &json!({"layer": "scan", "transform": "affine", "pointsCrs": 32613, "crs": 32613, "points": points_of(map), "name": "warped", "resampling": "bilinear"}), + ) + .unwrap(); + assert_eq!(v["crs"], "EPSG:32613", "{v}"); + let out = out_raster(&s, &v); + assert!(s.map().layer_by_name("scan").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().width == 100, "the scan is kept"); + // The cell is the root of the pixel's area: |10 × -10 - 2 × 1| = 102. + let cell = 102f64.sqrt(); + assert!((out.transform.pixel_width - cell).abs() < 1e-9 && (out.transform.pixel_height + cell).abs() < 1e-9); + // The grid covers the footprint, whose corners are the matrix's corners. + let corners = [(0.0, 0.0), (100.0, 0.0), (0.0, 80.0), (100.0, 80.0)].map(|(c, r)| map(c, r)); + let (min_x, max_x) = corners.iter().fold((f64::MAX, f64::MIN), |(a, b), (x, _)| (a.min(*x), b.max(*x))); + let (min_y, max_y) = corners.iter().fold((f64::MAX, f64::MIN), |(a, b), (_, y)| (a.min(*y), b.max(*y))); + assert_eq!((out.transform.origin_x, out.transform.origin_y), (min_x, max_y)); + assert!(out.transform.origin_x + out.transform.pixel_width * f64::from(out.width) >= max_x); + assert!(out.transform.origin_y + out.transform.pixel_height * f64::from(out.height) <= min_y); + // The matrix's inverse takes a map position back to the scan. + let det = 10.0 * -10.0 - 2.0 * 1.0; + let back = |x: f64, y: f64| { + let (dx, dy) = (x - 500_000.0, y - 4_000_000.0); + ((-10.0 * dx - 2.0 * dy) / det, (-dx + 10.0 * dy) / det) + }; + let (mut checked, mut empty) = (0, 0); + for row in (0..i64::from(out.height)).step_by(7) { + for col in (0..i64::from(out.width)).step_by(5) { + let c = out.transform.to_world(col as f64 + 0.5, row as f64 + 0.5); + let (sc, sr) = back(c.x, c.y); + match out.value(0, col, row) { + // Away from the scan's edge and its bright pixel the value is the ramp's there. + Some(v) if (1.5..98.5).contains(&sc) && (1.5..78.5).contains(&sr) && (sc - 50.5).abs() > 2.0 && (sr - 40.5).abs() > 2.0 => { + assert!((v - ramp(sc, sr)).abs() < 1e-3, "cell {col},{row}: {v} against {}", ramp(sc, sr)); + checked += 1; + } + Some(_) => {} + None => { + assert!(!(0.0..100.0).contains(&sc) || !(0.0..80.0).contains(&sr), "cell {col},{row} comes from inside the scan"); + empty += 1; + } + } + } + } + assert!(checked > 100 && empty > 0, "{checked} checked, {empty} outside the scan"); + // The bright pixel: its intensity-weighted position is within half a cell of the matrix's. + let (mut sum, mut sx, mut sy) = (0.0f64, 0.0f64, 0.0f64); + for row in 0..i64::from(out.height) { + for col in 0..i64::from(out.width) { + if let Some(v) = out.value(0, col, row).filter(|v| *v > 1000.0) { + let c = out.transform.to_world(col as f64 + 0.5, row as f64 + 0.5); + sum += f64::from(v); + sx += f64::from(v) * c.x; + sy += f64::from(v) * c.y; + } + } + } + let (ex, ey) = map(bright.0, bright.1); + assert!( + sum > 0.0 && (sx / sum - ex).abs() <= cell / 2.0 && (sy / sum - ey).abs() <= cell / 2.0, + "({}, {}) against ({ex}, {ey})", + sx / sum, + sy / sum + ); +} + +/// A 30° rotation, nearest sampling, replacing the layer's raster: the cells of the bright pixel +/// are centred within half a cell of its place, and the grid's corners outside the turned image +/// are no data. +#[test] +fn a_warp_through_a_known_rotation_puts_pixels_where_the_turn_says() { + let mut s = world(); + let (_, bright) = scan_layer(&mut s, "scan"); + let (sin, cos) = 30f64.to_radians().sin_cos(); + // Ten metres a pixel, turned 30° about the origin (rows still run down the picture). + let map = |c: f64, r: f64| (500_000.0 + 10.0 * (c * cos + r * sin), 4_000_000.0 + 10.0 * (c * sin - r * cos)); + let v = s + .run( + "raster.georeference", + &json!({"layer": "scan", "transform": "similarity", "pointsCrs": 32613, "crs": "EPSG:32613", "points": points_of(map), "resampling": "nearest", "cellSize": 2.5}), + ) + .unwrap(); + let out = out_raster(&s, &v); + assert_eq!( + s.map().layer_by_name("scan").unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().width, + out.width, + "replaced in place" + ); + assert!(out.width > 400, "the turned image needs a wider grid than its 1000 m / 2.5 m: {}", out.width); + // Nearest keeps the values: the bright pixel's cells are 5000, a square 10 m across turned 30°. + let (mut n, mut sx, mut sy) = (0.0f64, 0.0f64, 0.0f64); + for row in 0..i64::from(out.height) { + for col in 0..i64::from(out.width) { + if out.value(0, col, row) == Some(5000.0) { + let c = out.transform.to_world(col as f64 + 0.5, row as f64 + 0.5); + n += 1.0; + sx += c.x; + sy += c.y; + } + } + } + let (ex, ey) = map(bright.0, bright.1); + let cell = out.transform.pixel_width; + // About 100 / 6.25 = 16 cells. + assert!((10.0..=22.0).contains(&n), "{n} cells"); + assert!((sx / n - ex).abs() <= cell / 2.0 && (sy / n - ey).abs() <= cell / 2.0, "({}, {}) against ({ex}, {ey})", sx / n, sy / n); + // The turned rectangle leaves the box's corners empty. + assert_eq!(out.value(0, 0, 0), None); + assert_eq!(out.value(0, i64::from(out.width) - 1, i64::from(out.height) - 1), None); + // At its default the cell is the pixel's 10 m. + let d = s + .run( + "raster.georeference", + &json!({"layer": "scan", "transform": "similarity", "pointsCrs": 32613, "crs": 32613, "points": points_of(map), "name": "d"}), + ) + .unwrap(); + assert!((out_raster(&s, &d).transform.pixel_width - 10.0).abs() < 1e-6); +} + +/// Control points in longitude/latitude, warped into UTM: every cell holds the scan's value at +/// the pixel the projection takes the cell's centre back to. +#[test] +fn a_warp_reprojects_from_the_points_crs() { + use rustgis_data::raster::Crs; + let mut s = world(); + let (ramp, bright) = scan_layer(&mut s, "scan"); + // A north-up block at 40°N, 105°W, a thousandth of a degree a pixel. + let map = |c: f64, r: f64| (-105.0 + c * 0.001, 40.0 - r * 0.001); + let v = s + .run( + "raster.georeference", + &json!({"layer": "scan", "transform": "affine", "crs": 32613, "pointsCrs": "EPSG:4326", "points": points_of(map), "name": "utm"}), + ) + .unwrap(); + let out = out_raster(&s, &v); + assert_eq!(out.crs.epsg(), 32613); + let t = Crs::from_epsg(32613).unwrap().transformer().unwrap(); + let mut checked = 0; + for row in (0..i64::from(out.height)).step_by(3) { + for col in (0..i64::from(out.width)).step_by(3) { + let c = out.transform.to_world(col as f64 + 0.5, row as f64 + 0.5); + let ll = t.to_lonlat(c).unwrap(); + let (sc, sr) = ((ll.x + 105.0) / 0.001, (40.0 - ll.y) / 0.001); + if (2.0..98.0).contains(&sc) && (2.0..78.0).contains(&sr) && (sc - 50.5).abs() > 2.5 && (sr - 40.5).abs() > 2.5 { + let got = out.value(0, col, row).unwrap(); + assert!((got - ramp(sc, sr)).abs() < 0.05, "cell {col},{row}: {got} against {}", ramp(sc, sr)); + checked += 1; + } + } + } + assert!(checked > 100, "{checked}"); + // The bright pixel's own UTM position is within a cell of a bright cell. + let (lon, lat) = map(bright.0, bright.1); + let at = t.from_lonlat(rustgis_geom::Coord::new(lon, lat)).unwrap(); + let (col, row) = out.transform.to_pixel(at); + let (col, row) = (col.floor() as i64, row.floor() as i64); + let near = (-1..=1) + .flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))) + .filter_map(|(dx, dy)| out.value(0, col + dx, row + dy)) + .fold(f32::MIN, f32::max); + assert!(near > 500.0, "{near}"); +} + +/// A 48-bit colour scan (UInt16 RGB) warps with alpha the renderer reads as opaque (it takes +/// alpha as 0–255 in every type), and the COG keeps the 16-bit type. +#[test] +fn a_sixteen_bit_colour_warp_is_opaque_to_the_renderer() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; + let mut s = world(); + let n = 60 * 40; + let bands = vec![vec![60_000.0; n], vec![30_000.0; n], vec![1000.0; n]]; + let gt = GeoTransform { origin_x: 0.0, origin_y: 40.0, pixel_width: 1.0, pixel_height: -1.0 }; + let r = Raster::new("deep", 60, 40, bands, gt, Crs::LonLat, None, ColorHint::Rgb { alpha: false }, "UInt16").unwrap(); + let bytes = rustgis_data::raster::geotiff::write(&r, 0).unwrap(); + crate::cmd::raster::add_raster(&mut s, "deep", rustgis_map::RasterLayer::from_bytes(r, "deep.tif", bytes)).unwrap(); + let (sin, cos) = 20f64.to_radians().sin_cos(); + let map = |c: f64, r: f64| (500_000.0 + 10.0 * (c * cos + r * sin), 4_000_000.0 + 10.0 * (c * sin - r * cos)); + let pts: Vec = + [(0.0, 0.0), (60.0, 0.0), (0.0, 40.0), (60.0, 40.0)].iter().map(|&(c, r)| json!([c, r, map(c, r).0, map(c, r).1])).collect(); + let path = std::env::temp_dir().join(format!("rustgis-deep-{}.tif", std::process::id())); + let v = s + .run("raster.georeference", &json!({"layer": "deep", "transform": "similarity", "pointsCrs": 32613, "points": pts, "name": "w", "path": path.display().to_string()})) + .unwrap(); + let out = out_raster(&s, &v); + assert_eq!(out.color, ColorHint::Rgb { alpha: true }); + let (mid_c, mid_r) = (i64::from(out.width) / 2, i64::from(out.height) / 2); + // The renderer clamps alpha to 0–255: inside is 255, the collar 0. + let alpha = |c, r| out.value(3, c, r).map(|v| v.clamp(0.0, 255.0) as u8); + assert_eq!((alpha(mid_c, mid_r), alpha(0, 0)), (Some(255), Some(0))); + let back = rustgis_data::raster::read_path(&path).unwrap(); + assert_eq!((back.data_type.as_str(), back.band_count()), ("UInt16", 4)); + assert_eq!(back.value(0, mid_c, mid_r), Some(60_000.0)); + std::fs::remove_file(&path).ok(); +} + +/// A scan that would span the globe in degrees is refused, and a long thin grid can't slip past +/// the size limit through its row buffers. +#[test] +fn a_warp_refuses_a_globe_in_degrees_and_bounds_its_memory() { + use rustgis_analysis::georef::{self, Gcp, Kind}; + use rustgis_analysis::warp::{self, Options, Resampling}; + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; + let (w, h) = (40u32, 40u32); + let gt = GeoTransform { origin_x: 0.0, origin_y: 40.0, pixel_width: 1.0, pixel_height: -1.0 }; + let r = Raster::new("s", w, h, vec![vec![1.0; 1600]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + // A square of 2000 km centred on the pole, in polar stereographic (EPSG:3413). + let gcps = |f: &dyn Fn(f64, f64) -> (f64, f64)| -> Vec { + [(0.0, 0.0), (40.0, 0.0), (0.0, 40.0), (40.0, 40.0)] + .iter() + .map(|&(c, r)| { + let (x, y) = f(c, r); + Gcp { col: c, row: r, x, y, on: true } + }) + .collect() + }; + let fit = georef::fit(Kind::Affine, &gcps(&|c, r| (-1_000_000.0 + 50_000.0 * c, 1_000_000.0 - 50_000.0 * r))).unwrap(); + let o = |crs: Crs, points: Crs, cell| Options { crs, points_crs: points, resampling: Resampling::Nearest, cell, name: "o".into() }; + let polar = Crs::from_epsg(3413).unwrap(); + // In degrees the scan spans every longitude: refused, with a hint. + let e = warp::warp(&r, &fit, &o(Crs::LonLat, polar, Some(0.05))).unwrap_err(); + assert!(e.contains("projected crs"), "{e}"); + // In Web Mercator it is placed (to its latitude limit), with the pole inside it. + assert!(warp::warp(&r, &fit, &o(Crs::WebMercator, polar, Some(100_000.0))).is_ok()); + // A strip a hundred million metres long and a metre tall, from hostile points. + let strip = georef::fit(Kind::Affine, &gcps(&|c, r| (500_000.0 + c * 2.5e6, 4_000_000.0 - r * 0.025))).unwrap(); + let utm13 = Crs::from_epsg(32613).unwrap(); + let e = warp::warp(&r, &strip, &o(utm13, utm13, Some(1.0))).unwrap_err(); + assert!(e.contains("cellSize"), "{e}"); +} + +/// A warp written as a COG becomes the layer's file; the options are checked. +#[test] +fn a_warp_can_write_a_cog_and_refuses_what_it_cannot_place() { + let mut s = world(); + scan_layer(&mut s, "scan"); + let map = |c: f64, r: f64| (500_000.0 + 10.0 * c, 4_000_000.0 - 10.0 * r); + let dir = std::env::temp_dir().join(format!("rustgis-warp-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("warped.tif"); + let v = s + .run( + "raster.georeference", + &json!({"layer": "scan", "transform": "affine", "pointsCrs": 32613, "crs": 32613, "points": points_of(map), "resampling": "cubic", "cellSize": 5, "name": "w", "path": path.display().to_string()}), + ) + .unwrap(); + assert_eq!(v["cog"]["overviews"], 0, "200 × 160 is one tile: {v}"); + assert_eq!((v["width"].as_u64(), v["height"].as_u64()), (Some(200), Some(160)), "half-size cells double the grid"); + let back = rustgis_data::raster::read_path(&path).unwrap(); + assert_eq!((back.width, back.height, back.crs.epsg()), (200, 160, 32613)); + assert_eq!(back.transform.origin_x, 500_000.0); + let layer = s.map().layer_by_name("w").unwrap().raster.as_ref().unwrap(); + assert_eq!(layer.path.as_deref(), Some(path.display().to_string().as_str()), "the file is the layer's source"); + // The same command without a transform is the old north-up fit. + s.run("raster.georeference", &json!({"layer": "scan", "extent": [10.0, 45.0, 10.1, 45.08]})).unwrap(); + let ok = json!({"layer": "scan", "transform": "affine", "pointsCrs": 32613, "crs": 32613, "points": points_of(map)}); + s.run("raster.georeference", &ok).unwrap(); + for (k, bad) in [ + ("transform", json!("warp")), + ("transform", json!(7)), + ("crs", json!("EPSG:99999999")), + ("crs", json!(-1)), + ("crs", json!(1e30)), + ("pointsCrs", json!([])), + ("resampling", json!("lanczos")), + ("cellSize", json!(0)), + ("cellSize", json!(-3)), + ("cellSize", json!(1e-9)), + ("cellSize", json!("x")), + ("path", json!("warped.png")), + ("path", json!(5)), + ("points", json!([[0, 0, 1, 1]])), + ] { + let mut p = ok.clone(); + p[k] = bad.clone(); + assert!(s.run("raster.georeference", &p).is_err(), "{k} = {bad}"); + } + // A fit that collapses the image onto a line has no grid. + assert!( + s.run( + "raster.georeference", + &json!({"layer": "scan", "transform": "affine", "pointsCrs": 32613, "crs": 32613, "points": points_of(|c, _| (c, c))}) + ) + .is_err() + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// A scan opened from a PNG and warped onto its points has coordinates for real: the cell is the +/// fit's pixel on the ground, not the stand-in size it was laid over the view at; the pixels land +/// where the fit says; it can be exported; and the project reopens it where the warp put it, not +/// laid over the view again. +#[test] +fn a_scan_warped_onto_its_points_is_placed_for_real() { + let dir = std::env::temp_dir().join(format!("rustgis-scan-warp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("plate.png"); + image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(40, 20, |x, y| image::Rgb([x as u8, y as u8, 9]))) + .save_with_format(&png, image::ImageFormat::Png) + .unwrap(); + let mut s = world(); + let r = s.run("layer.addData", &json!({"path": png.display().to_string()})).unwrap(); + assert_eq!(r["georeferenced"], false, "{r}"); + let laid = s.map().layer_by_name("plate").unwrap().raster.clone().unwrap(); + assert!(laid.placement.is_some()); + let stand_in = laid.data.as_ref().unwrap().transform.pixel_width; + // 7.5 m a pixel in UTM 13N, from the scan's corners and two points inside it; the output's + // system is the points'. + let map = |c: f64, r: f64| (500_000.0 + 7.5 * c, 4_000_000.0 - 7.5 * r); + let pts: Vec = [(0.0, 0.0), (40.0, 0.0), (0.0, 20.0), (40.0, 20.0), (20.0, 10.0), (10.0, 15.0)] + .iter() + .map(|&(c, r)| { + let (x, y) = map(c, r); + json!([c, r, x, y]) + }) + .collect(); + let v = s + .run( + "raster.georeference", + &json!({"layer": "plate", "transform": "affine", "pointsCrs": 32613, "points": pts, "resampling": "nearest"}), + ) + .unwrap(); + assert_eq!(v["crs"], "EPSG:32613", "{v}"); + let l = s.map().layer_by_name("plate").unwrap().raster.clone().unwrap(); + assert!(l.placement.is_none(), "no longer laid over the view"); + let out = l.data.clone().unwrap(); + assert!(out.georeferenced && out.crs.epsg() == 32613); + assert!( + (out.transform.pixel_width - 7.5).abs() < 1e-6 && (out.transform.pixel_height + 7.5).abs() < 1e-6 && (stand_in - 7.5).abs() > 0.5, + "{:?}, laid at {stand_in}", + out.transform + ); + // Red is the column and green the row, so a pixel reads back where the fit puts it; the scan + // had no alpha or no data, so it gains an opaque alpha band. + assert_eq!(out.band_count(), 4); + let (x, y) = map(10.5, 5.5); + let (col, row) = out.transform.to_pixel(rustgis_geom::Coord { x, y }); + let (col, row) = (col.floor() as i64, row.floor() as i64); + let px: Vec> = (0..4).map(|b| out.value(b, col, row)).collect(); + assert_eq!(px, [Some(10.0), Some(5.0), Some(9.0), Some(255.0)], "cell {col},{row}"); + // Exporting is no longer refused. + let tif = dir.join("plate.tif"); + s.run("share.exportRaster", &json!({"layer": "plate", "path": tif.display().to_string(), "cog": true})).unwrap(); + let back = rustgis_data::raster::read_path(&tif).unwrap(); + assert!(back.georeferenced && back.crs.epsg() == 32613 && back.transform == out.transform); + // The project reopens it where the warp put it. + let text = serde_json::to_string(&s.project).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.openText", &json!({"text": text})).unwrap(); + let again = t.map().layer_by_name("plate").unwrap().raster.as_ref().unwrap().data.clone().unwrap(); + assert!(again.georeferenced && again.transform == out.transform, "{:?} vs {:?}", again.transform, out.transform); + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn exporting_a_raster_as_a_cog() { let mut s = world(); diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index ed84a14fa..648c5b206 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -284,7 +284,23 @@ The Analyze tab's imagery groups open their tools in the Workflows pane: - **Ortho Mapping** and **Reality Mapping** workspaces mosaic georeferenced images into an orthomosaic, or elevation rasters into a surface model that exports as a 3D mesh (OBJ). - **Georeference** places a raster from control points (column, row, longitude, latitude) or - stretches it over the map view. + stretches it over the map view. Those only shift and scale a north-up grid, as RustGIS's + geotransform has no rotation. With `transform` (`raster.georeference`) the points are fitted by any + of the Control Point Fit transforms and the raster is warped onto a new north-up grid covering the + warped image: `{"layer": "scan", "transform": "spline", "points": [[column, row, x, y], …], + "pointsCrs": "EPSG:4326", "crs": 32613, "resampling": "bilinear", "cellSize": 5, "name": "warped", + "path": "warped.tif"}`. `pointsCrs` is the coordinate system the points' x and y are in (default + EPSG:4326) and `crs` the output's (default `pointsCrs`, so nothing is reprojected unless asked). + Each output cell is taken back through the fit, after reprojection, to the image and sampled there + with `nearest`, `bilinear` (the default) or `cubic`; cells that fall outside the image are no + data, or see-through for a colour image, which gains an alpha band for it (a grey byte image + becomes grey with alpha; alpha is 0–255 in every pixel type). An image warped into a geographic + `crs` gets square cells in degrees, and one that would span more than 180° of longitude is + refused: give a projected `crs`. The cell is about the source pixel's size on the ground unless + `cellSize` (output units) says otherwise. The result replaces the layer's raster, or becomes a new + layer with `name`; `path` also writes it as a cloud-optimised GeoTIFF, which then is the layer's + file. A raster opened from an overview is warped at that reduced resolution, and the result says + so. - **Control Point Fit** (`raster.gcpFit`) fits a similarity, affine, projective, second- or third-order polynomial, or thin plate spline transform to control points and reports how well it holds: each point's residual in pixels and map units, the RMSE, and each point's error when From 2cb12393efb0e72caa7593b262d37e5bc7ceafea Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Wed, 21 Oct 2026 20:35:34 +0200 Subject: [PATCH 003/297] fix(symbology): honor explicit polygon class alpha (#670) * fix(symbology): honor explicit polygon class alpha * fix(ui): preserve class RGB during opacity edits * fix(ui): retain numeric RGB edits on transparent classes --- crates/engine/src/cmd/layer.rs | 5 +- crates/engine/src/tests.rs | 176 +++++++++++++++++++++++++++++++++ crates/map/src/symbology.rs | 64 +++++++----- crates/ui-egui/src/panes.rs | 158 ++++++++++++++++++++++++++--- docs/user-guide/cartography.md | 21 +++- 5 files changed, 380 insertions(+), 44 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index dd91580cf..427f5a959 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -106,7 +106,7 @@ pub fn commands() -> Vec { C::new("layer.classStyle", "Class Style", "Style › Drawing", class_style) .when(need_feature_layer) .params( - r##"{"layer"?, "value"?: text|number (a Unique Values class), "index"?: number (a Graduated Colors class, 0 = the lowest), "other"?: true (Unique Values' ), "color"?: "#RRGGBB"|"#RRGGBBAA" (a polygon class keeps its own transparency), "outline"?: "#RRGGBB"|"#RRGGBBAA", "label"?: text (the legend label; "" goes back to the value or range)}"##, + r##"{"layer"?, "value"?: text|number (a Unique Values class), "index"?: number (a Graduated Colors class, 0 = the lowest), "other"?: true (Unique Values' ), "color"?: "#RRGGBB" (preserves polygon translucency, minimum alpha 150)|"#RRGGBBAA" (sets alpha exactly), "outline"?: "#RRGGBB"|"#RRGGBBAA", "label"?: text (the legend label; "" goes back to the value or range)}"##, ), C::new("layer.color", "Symbol Color", "Style › Drawing", |s, p| { let hex = s_req(p, "color")?; @@ -2380,7 +2380,8 @@ fn class_style(s: &mut Session, p: &Value) -> CmdResult { return Err(format!("{k} must be #RRGGBB or #RRGGBBAA")); } } - let style = ClassStyle { color: s.get_color(p, "color")?, outline: s.get_color(p, "outline")?, label }; + let preserve_fill_alpha = ps(p, "color").is_some_and(|hex| hex.trim().trim_start_matches('#').len() == 6); + let style = ClassStyle { color: s.get_color(p, "color")?, outline: s.get_color(p, "outline")?, label, preserve_fill_alpha }; if style == ClassStyle::default() { return Err("nothing to change: give a color, an outline or a label".into()); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d339c6333..f9022ab46 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1830,6 +1830,182 @@ fn layer_class_style_edits_one_class() { assert!(s.run("layer.classStyle", &json!({"layer": "Countries", "index": 3, "label": "x"})).is_err()); } +/// Read classes by their command identity, not their sorted legend position. +fn class_style_rows(s: &Session, layer: &str) -> Vec<(Value, rustgis_map::Symbol)> { + match &s.map().layer_by_name(layer).unwrap().renderer { + rustgis_map::Renderer::Graduated { symbols, .. } => { + symbols.iter().enumerate().map(|(i, symbol)| (json!({"layer": layer, "index": i}), *symbol)).collect() + } + rustgis_map::Renderer::Unique { classes, other, .. } => classes + .iter() + .map(|(value, symbol)| (json!({"layer": layer, "value": value}), *symbol)) + .chain(std::iter::once((json!({"layer": layer, "other": true}), *other))) + .collect(), + _ => panic!("class renderer expected"), + } +} + +#[test] +fn layer_class_style_polygon_alpha_targets_only_the_named_class() { + use rustgis_map::Rgba; + for (kind, field) in [("graduated", "pop_est"), ("unique", "income_grp")] { + let mut s = world(); + s.run("layer.symbology", &json!({"layer": "Countries", "type": kind, "field": field, "classes": 3})).unwrap(); + let rows = class_style_rows(&s, "Countries"); + // A middle graduated class, a unique value, and the unique fallback all use the same rules. + let targets = if kind == "unique" { vec![1, rows.len() - 1] } else { vec![1] }; + for target in targets { + for (color, alpha) in [ + ("#123456", 170), + (" #123456FF ", 255), + ("12345600", 0), + ("#12345640", 64), + ("#123456", 150), + ("#123456C8", 200), + (" 123456 ", 200), + ] { + let before = class_style_rows(&s, "Countries"); + let mut params = before[target].0.clone(); + params["color"] = json!(color); + let result = s.run("layer.classStyle", ¶ms).unwrap(); + let after = class_style_rows(&s, "Countries"); + let fill = Rgba::rgb(0x12, 0x34, 0x56).with_alpha(alpha); + assert_eq!(after[target].1.fill, fill, "{kind}: {params}"); + assert_eq!(Rgba::from_hex(result["color"].as_str().unwrap()), Some(fill)); + for (i, (old, new)) in before.iter().zip(&after).enumerate() { + assert_eq!(new.0, old.0); + if i != target { + assert_eq!(new.1, old.1, "{kind}: {params} changed class {i}"); + } + } + if alpha == 0 { + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(class_style_rows(&s, "Countries"), before); + s.run("edit.redo", &Value::Null).unwrap(); + assert_eq!(class_style_rows(&s, "Countries"), after); + let saved = serde_json::to_string(&s.project).unwrap(); + let reopened = Session::new(Project::from_json(&saved).unwrap()); + assert_eq!(class_style_rows(&reopened, "Countries"), after); + } + } + } + } +} + +#[test] +fn layer_class_alpha_survives_reclassification_and_ramp() { + for (kind, field, scheme, next_scheme) in [("graduated", "pop_est", "Ember", "Harbour"), ("unique", "income_grp", "Harbour", "Ember")] { + let mut s = world(); + let mut symbology = json!({"layer": "Countries", "type": kind, "field": field, "classes": 3, "ramp": scheme}); + s.run("layer.symbology", &symbology).unwrap(); + let original = class_style_rows(&s, "Countries"); + for (i, (mut params, symbol)) in original.iter().cloned().enumerate() { + // Keep exactly the ramp's RGB: an alpha-only edit must not be mistaken for an untouched fill. + params["color"] = json!(symbol.fill.with_alpha([0, 64, 255][i % 3]).hex8()); + s.run("layer.classStyle", ¶ms).unwrap(); + } + let edited = s.map().layer_by_name("Countries").unwrap().renderer.clone(); + let before = class_style_rows(&s, "Countries"); + for (i, ((_, old), (_, new))) in original.iter().zip(&before).enumerate() { + assert_eq!((new.fill.r(), new.fill.g(), new.fill.b()), (old.fill.r(), old.fill.g(), old.fill.b())); + assert_eq!(new.fill.a(), [0, 64, 255][i % 3]); + } + // Both an omitted scheme and an explicit unchanged scheme are reapplications, not resets. + let mut same = symbology.clone(); + same.as_object_mut().unwrap().remove("ramp"); + for params in [&same, &symbology] { + s.run("layer.symbology", params).unwrap(); + assert_eq!(s.map().layer_by_name("Countries").unwrap().renderer, edited, "{kind}"); + } + // Recolouring through layer.ramp keeps every class's exact alpha, including the fallback's. + s.run("layer.ramp", &json!({"layer": "Countries", "ramp": next_scheme})).unwrap(); + let recolored = class_style_rows(&s, "Countries"); + for ((old_target, old), (new_target, new)) in before.iter().zip(&recolored) { + assert_eq!(new_target, old_target); + assert_eq!(new.fill.a(), old.fill.a(), "{kind}: {new_target}"); + } + assert!( + before + .iter() + .zip(&recolored) + .any(|((_, old), (_, new))| { (old.fill.r(), old.fill.g(), old.fill.b()) != (new.fill.r(), new.fill.g(), new.fill.b()) }) + ); + // A different scheme chosen through layer.symbology still resets polygon opacity as before. + symbology["ramp"] = json!(scheme); + s.run("layer.symbology", &symbology).unwrap(); + assert!(class_style_rows(&s, "Countries").iter().all(|(_, symbol)| symbol.fill.a() == 170), "{kind}"); + } +} + +#[test] +fn layer_class_creation_keeps_explicit_layer_fill_alpha() { + for (kind, field) in [("graduated", "pop_est"), ("unique", "income_grp")] { + for alpha in [0, 64, 255] { + let mut s = world(); + s.run("layer.color", &json!({"layer": "Countries", "color": format!("#336699{alpha:02X}")})).unwrap(); + s.run("layer.symbology", &json!({"layer": "Countries", "type": kind, "field": field, "classes": 3})).unwrap(); + for (target, symbol) in class_style_rows(&s, "Countries") { + assert_eq!(symbol.fill.a(), alpha, "{kind}: {target}"); + } + } + } +} + +#[test] +fn layer_class_style_point_and_line_colors_keep_their_supplied_alpha() { + use rustgis_map::Rgba; + let mut s = world(); + for layer in ["Cities", "Rivers"] { + let field = s.map().layer_by_name(layer).unwrap().dataset.fields[0].name.clone(); + s.run("layer.symbology", &json!({"layer": layer, "type": "unique", "field": field})).unwrap(); + let mut params = class_style_rows(&s, layer)[0].0.clone(); + for color in ["#12345600", "#12345640", "#123456FF", "#123456"] { + let before = class_style_rows(&s, layer); + params["color"] = json!(color); + s.run("layer.classStyle", ¶ms).unwrap(); + let after = class_style_rows(&s, layer); + let fill = Rgba::from_hex(color).unwrap(); + assert_eq!(after[0].1.fill, fill, "{layer}: {color}"); + assert_eq!(after[0].1.stroke, if layer == "Rivers" { fill } else { before[0].1.stroke }); + assert_eq!(&after[1..], &before[1..], "{layer}: other classes are untouched"); + } + } +} + +#[test] +fn layer_class_style_polygon_alpha_reaches_exported_pixels() { + isolate_plugins(); + let fixture = json!({"type": "FeatureCollection", "features": [{ + "type": "Feature", "properties": {"value": 1}, + "geometry": {"type": "Polygon", "coordinates": [[[-0.25, -0.25], [0.25, -0.25], [0.25, 0.25], [-0.25, 0.25], [-0.25, -0.25]]]} + }]}); + for kind in ["graduated", "unique"] { + let mut s = Session::new(Project::default()); + s.run("layer.addDataText", &json!({"fileName": "square.geojson", "text": fixture.to_string()})).unwrap(); + s.run("layer.symbology", &json!({"layer": "square", "type": kind, "field": "value", "classes": 1})).unwrap(); + s.view_size = (128.0, 128.0); + s.map_mut().basemap = rustgis_map::Basemap::Blank; + s.map_mut().graticule = false; + s.map_mut().camera = rustgis_map::Camera { center: rustgis_geom::Coord::new(0.0, 0.0), res: 1000.0, bearing: 0.0, pitch: 0.0 }; + let mut params = class_style_rows(&s, "square")[0].0.clone(); + for alpha in [255, 0, 64] { + params["color"] = json!(format!("#FF0000{alpha:02X}")); + s.run("layer.classStyle", ¶ms).unwrap(); + let png = crate::cmd::share::map_png(&mut s, Some(128.0), Some(128.0), None).unwrap(); + let pixels = crate::render::decode_png(&png).unwrap(); + let center = pixels.pixel(64, 64).unwrap(); + let background = pixels.pixel(0, 0).unwrap(); + assert_eq!(center.alpha(), 255, "the export composites onto the blank basemap"); + for (actual, source, behind) in + [(center.red(), 255, background.red()), (center.green(), 0, background.green()), (center.blue(), 0, background.blue())] + { + let expected = (source * alpha + u32::from(behind) * (255 - alpha) + 127) / 255; + assert!((i64::from(actual) - i64::from(expected)).abs() <= 1, "{kind}, alpha {alpha}: {center:?} over {background:?}"); + } + } + } +} + #[test] fn layout_legends_list_what_the_frame_draws() { // #207's repro: classes taken before the query (or from a style) name values no drawn country has. diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 59bc13c10..35fff1d92 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -81,6 +81,15 @@ impl Symbol { } self } + + /// Ramp colours change hue, not a polygon's existing fill opacity (even below 150). + fn with_ramp_fill(self, c: Rgba, g: GeometryType) -> Symbol { + let mut out = self.with_fill(c, g); + if g == GeometryType::Polygon { + out.fill = c.with_alpha(self.fill.a()); + } + out + } } /// How a stroke's open ends are drawn. @@ -312,6 +321,8 @@ pub enum ClassRef { #[derive(Clone, Debug, Default, PartialEq)] pub struct ClassStyle { pub color: Option, + /// For RGB-only command input, keep the polygon's legacy translucency instead of setting alpha. + pub preserve_fill_alpha: bool, pub outline: Option, /// `Some("")` clears a label set by hand, so the class shows its value or range again. pub label: Option, @@ -350,8 +361,8 @@ impl Renderer { let fi = ds.field_index(field).ok_or_else(|| format!("no field named '{field}'"))?; let vals = ds.unique_values(fi, rows, MAX_UNIQUE); let n = vals.len(); - let classes = vals.into_iter().enumerate().map(|(k, v)| (v, base.with_fill(ramp.pick(k, n), ds.geometry_type))).collect(); - let other = base.with_fill(OTHER_FILL, ds.geometry_type); + let classes = vals.into_iter().enumerate().map(|(k, v)| (v, base.with_ramp_fill(ramp.pick(k, n), ds.geometry_type))).collect(); + let other = base.with_ramp_fill(OTHER_FILL, ds.geometry_type); Ok(Renderer::Unique { field: ds.fields.get(fi).map(|f| f.name.clone()).unwrap_or_default(), classes, @@ -385,7 +396,7 @@ impl Renderer { let k = classes.clamp(1, 12); let breaks = class_breaks(&vals, k, method); let n = breaks.len(); - let symbols = (0..n).map(|i| base.with_fill(ramp.pick(i, n), ds.geometry_type)).collect(); + let symbols = (0..n).map(|i| base.with_ramp_fill(ramp.pick(i, n), ds.geometry_type)).collect(); let min = vals.first().copied().unwrap_or(0.0); Ok(Renderer::Graduated { field: ds.fields.get(fi).map(|f| f.name.clone()).unwrap_or_default(), @@ -556,20 +567,20 @@ impl Renderer { } } - /// Recolours every class from a ramp. + /// Recolours every class from a ramp, preserving each polygon fill's opacity. pub fn apply_ramp(&mut self, ramp: Ramp, g: GeometryType) { match self { Renderer::Unique { classes, ramp: r, .. } => { let n = classes.len(); for (k, c) in classes.iter_mut().enumerate() { - c.1 = c.1.with_fill(ramp.pick(k, n), g); + c.1 = c.1.with_ramp_fill(ramp.pick(k, n), g); } *r = ramp; } Renderer::Graduated { symbols, ramp: r, .. } => { let n = symbols.len(); for (k, s) in symbols.iter_mut().enumerate() { - *s = s.with_fill(ramp.pick(k, n), g); + *s = s.with_ramp_fill(ramp.pick(k, n), g); } *r = ramp; } @@ -577,16 +588,18 @@ impl Renderer { } } - /// Sets one class's colour, outline colour and legend label (#314). The colour goes through - /// [`Symbol::with_fill`], as a ramp's does, so a line class takes it as its line colour and a - /// polygon class gets a matching outline unless `outline` names one; a polygon class also keeps - /// its own transparency, so the colour's alpha applies to points and lines only. Returns the class's - /// legend row as it now is. + /// Sets one class's colour, outline colour and legend label (#314). A line class takes the + /// colour as its line colour; a polygon class gets a matching outline unless `outline` names + /// one. Colours include their alpha unless `preserve_fill_alpha` requests the polygon's + /// legacy translucency for RGB-only input. Returns the class's legend row as it now is. pub fn set_class_style(&mut self, class: &ClassRef, style: &ClassStyle, g: GeometryType) -> Result { let label = style.label.as_deref().map(normalize_label); let restyle = |s: &mut Symbol| { if let Some(c) = style.color { *s = s.with_fill(c, g); + if g == GeometryType::Polygon && !style.preserve_fill_alpha { + s.fill = c; + } } if let Some(c) = style.outline { s.stroke = c; @@ -718,9 +731,9 @@ impl Renderer { /// `new` with the hand-made parts of `old`, a class whose scheme colour was `scheme`: the fill /// when it isn't that colour, and the outline when it isn't the one [`Symbol::with_fill`] derives /// (a polygon's shaded fill, a line's own colour; for points, the new base symbol's outline). -/// A polygon's fill compares without alpha (`with_fill` keeps a polygon's own transparency) and, -/// when edited, carries over whole, transparency included; a point's or a line's compares with it, -/// since `with_fill` gives them the colour's alpha as it is. Outlines compare whole. +/// Polygon hue edits compare without alpha because ramps keep polygon opacity. That opacity +/// carries over independently, including alpha-only edits; point and line colours compare whole. +/// Outlines compare whole. fn carry_class(new: Symbol, old: &Symbol, scheme: Rgba, g: GeometryType) -> Symbol { let rgb = |c: Rgba| (c.r(), c.g(), c.b()); let mut s = new; @@ -728,9 +741,9 @@ fn carry_class(new: Symbol, old: &Symbol, scheme: Rgba, g: GeometryType) -> Symb if edited { let c = if g == GeometryType::Polygon { Rgba::rgb(old.fill.r(), old.fill.g(), old.fill.b()) } else { old.fill }; s = s.with_fill(c, g); - if g == GeometryType::Polygon { - s.fill = old.fill; - } + } + if g == GeometryType::Polygon { + s.fill = s.fill.with_alpha(old.fill.a()); } let derived = match g { GeometryType::Polygon => Rgba::rgb(old.fill.r(), old.fill.g(), old.fill.b()).shade(0.6), @@ -1550,10 +1563,11 @@ mod tests { let s = |c| Symbol::default_for(g, c); let (red, blue) = (Rgba::rgb(0xC0, 0x30, 0x20), Rgba::rgb(0, 0, 0xFF)); let mut r = unique(&[("a", s(0)), ("b", s(1))], s(3), Ramp::Ember); - let style = |color, outline, label: Option<&str>| ClassStyle { color, outline, label: label.map(str::to_string) }; - // The colour goes through with_fill, as a ramp's does; the label is trimmed. + let style = + |color, outline, label: Option<&str>| ClassStyle { color, outline, label: label.map(str::to_string), ..Default::default() }; let e = r.set_class_style(&ClassRef::Value("b".into()), &style(Some(red), None, Some(" Bee ")), g).unwrap(); - assert_eq!((e.label.as_str(), e.symbol, e.fallback), ("Bee", s(1).with_fill(red, g), false)); + assert_eq!(e.label, "Bee"); + assert_eq!((e.symbol.fill, e.symbol.stroke, e.fallback), (red, red.shade(0.6), false)); assert_eq!(labels_of(&r), ["a", "Bee", OTHER_VALUES]); // The fallback class, with an outline alone: the fill stays. let e = r.set_class_style(&ClassRef::Other, &style(None, Some(blue), Some("Others")), g).unwrap(); @@ -1590,7 +1604,7 @@ mod tests { // Graduated Colors: by number, and the label replaces the range. let mut gr = graduated(&[10.0, 20.0], &[s(0), s(1)], Ramp::Ember); let e = gr.set_class_style(&ClassRef::Index(1), &style(Some(red), None, Some("High")), g).unwrap(); - assert_eq!((e.label.as_str(), e.symbol), ("High", s(1).with_fill(red, g))); + assert_eq!((e.label.as_str(), e.symbol.fill, e.symbol.stroke), ("High", red, red.shade(0.6))); assert_eq!(labels_of(&gr), ["0 – 10", "High"]); gr.set_class_style(&ClassRef::Index(0), &style(None, None, Some("0 – 10")), g).unwrap(); assert!(matches!(&gr, Renderer::Graduated { class_labels, .. } if !class_labels.contains_key(&0)), "the range itself is no label"); @@ -1638,7 +1652,7 @@ mod tests { }; let (red, blue) = (Rgba::rgb(0xC0, 0x30, 0x20), Rgba::rgb(0, 0, 0xFF)); let edit = |r: &mut Renderer, c: ClassRef, color: Option, outline: Option, label: Option<&str>| { - r.set_class_style(&c, &ClassStyle { color, outline, label: label.map(str::to_string) }, g).unwrap(); + r.set_class_style(&c, &ClassStyle { color, outline, label: label.map(str::to_string), ..Default::default() }, g).unwrap(); }; let mut old = ramped(&["a", "b", "c"], Ramp::Ember); edit(&mut old, ClassRef::Value("b".into()), Some(red), None, Some("Bee")); @@ -1649,13 +1663,13 @@ mod tests { let fresh = ramped(&["b", "c", "d"], Ramp::Ember); let mut again = fresh.clone(); again.carry_edits_from(&old, g); - let (Renderer::Unique { classes, other, .. }, Renderer::Unique { classes: fc, other: fo, .. }) = (&again, &fresh) else { + let (Renderer::Unique { classes, other, .. }, Renderer::Unique { classes: fc, .. }) = (&again, &fresh) else { panic!("unique renderers expected") }; - assert_eq!(classes[0].1, fc[0].1.with_fill(red, g), "b keeps its colour"); + assert_eq!((classes[0].1.fill, classes[0].1.stroke), (red, red.shade(0.6)), "b keeps its colour"); assert_eq!((classes[1].1.fill, classes[1].1.stroke), (fc[1].1.fill, blue), "c keeps its outline only"); assert_eq!(classes[2].1, fc[2].1, "d is new"); - assert_eq!(*other, fo.with_fill(red, g)); + assert_eq!((other.fill, other.stroke), (red, red.shade(0.6))); assert_eq!(labels_of(&again), ["Bee", "c", "d", "Rest"]); // A polygon class's own transparency, and an outline that differs only in it, carry over too. let mut tinted = ramped(&["a"], Ramp::Ember); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 8dac2c878..a574de094 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3453,20 +3453,35 @@ fn legend_list(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { p }; ui.horizontal(|ui| { - // A polygon class keeps its own transparency when recoloured (`with_fill`), so - // its picker is colour only: no alpha, no blending, and a solid button. Points - // and lines take the colour's alpha, so theirs has it. - let picked = if l.geometry_type() == GeometryType::Polygon { - let f = e.symbol.fill; - let mut rgb = [f.r(), f.g(), f.b()]; - let r = ui.color_edit_button_srgb(&mut rgb).on_hover_text(tl!("Class color")); - r.changed().then(|| rustgis_engine::map::Rgba::rgb(rgb[0], rgb[1], rgb[2])) - } else { - let mut c = c32(e.symbol.fill); - crate::widgets::color_button(ui, &mut c).on_hover_text(tl!("Class color")).changed().then(|| rgba(c)) - }; + // Construct hue independently of alpha: Color32 discards hidden RGB at zero. + let fill = e.symbol.fill; + let mut hsva = egui::ecolor::Hsva::from_srgb([fill.r(), fill.g(), fill.b()]); + hsva.a = f32::from(fill.a()) / 255.0; + let alpha = hsva.a; + // egui creates the popup from the context, not this Ui's scoped style. + // Borrow linear numeric editing only while a transparent picker is open. + let styles = (fill.a() == 0 && egui::Popup::is_any_open(ui.ctx())) + .then(|| [egui::Theme::Light, egui::Theme::Dark].map(|theme| (theme, ui.ctx().style_of(theme)))); + if styles.is_some() { + ui.ctx().global_style_mut(|style| style.visuals.numeric_color_space = egui::style::NumericColorSpace::Linear); + } + let response = egui::color_picker::color_edit_button_hsva(ui, &mut hsva, egui::color_picker::Alpha::OnlyBlend); + if let Some(styles) = styles { + // The popup's numeric-mode button can change both themes; restore both + // immediately so no other picker or later frame inherits the override. + for (theme, style) in styles { + ui.ctx().set_style_of(theme, style); + } + } + let picked = response.on_hover_text(tl!("Class color")).changed().then(|| { + let edited = rustgis_engine::map::Rgba(hsva.to_srgba_unmultiplied()); + // The numeric alpha editor also rounds RGB through Color32; an + // opacity-only edit must keep the class's original channels exactly. + if hsva.a != alpha { fill.with_alpha(edited.a()) } else { edited } + }); if let Some(color) = picked { - let params = with("color", json!(color.hex())); + // Keep FF too: six-digit polygon colours deliberately preserve translucency. + let params = with("color", json!(color.hex8())); let key = egui::Id::new(CLASS_COLOR_DRAG); let mut dragging = ui.data(|d| d.get_temp::(key)); // One left pending on another layer (the pane switched layers mid-drag) is @@ -4620,6 +4635,123 @@ mod tests { assert_eq!(transparency(&h), start, "one undo takes the whole drag back"); } + /// Editing opacity must not turn a transparent red or a near-transparent colour into black. + /// Exercise the real class picker and command, including one-step undo after the edit. + #[test] + fn class_opacity_edits_keep_unmultiplied_rgb() { + use egui::accesskit::{Action, ActionData, ActionRequest, Role}; + use egui_kittest::kittest::{NodeT, Queryable}; + use rustgis_engine::map::Rgba; + + for original in [Rgba::new(255, 0, 0, 0), Rgba::new(47, 162, 214, 1)] { + let mut app = RustGisApp::with_sample(); + app.run("layer.symbology", json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 1})).unwrap(); + app.run("layer.classStyle", json!({"layer": "Countries", "index": 0, "color": original.hex8()})).unwrap(); + let id = layer(&app, "Countries"); + let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 600.0)).with_max_steps(64).build_ui_state( + move |ui, app: &mut RustGisApp| { + finish_class_color_drag(app, ui); + let layer = app.session.map().layer(id).unwrap().clone(); + legend_list(app, ui, &layer, &Tokens::light()); + }, + app, + ); + h.run_steps(3); + let fill = + |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer(id).unwrap().renderer.legend()[0].symbol.fill; + assert_eq!(fill(&h), original, "displaying the picker must not change the class"); + h.get_by_role(Role::ColorWell).click(); + h.run_steps(3); + for alpha in [255, 0, 128] { + let before = h.state().session.undo_depth(); + let previous = fill(&h); + // The popup's RGBA editors are its four spin buttons, with alpha last. + let (node, tree) = h.query_all_by_role(Role::SpinButton).last().unwrap().accesskit_node().locate(); + h.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: Action::SetValue, + target_tree: tree, + target_node: node, + data: Some(ActionData::NumericValue(if previous.a() == 0 { f64::from(alpha) / 255.0 } else { f64::from(alpha) })), + })); + h.run_steps(3); + assert_eq!(fill(&h), original.with_alpha(alpha), "opacity edits must retain the original RGB"); + assert_eq!(h.state().session.undo_depth(), before + 1); + h.state_mut().run("edit.undo", Value::Null).unwrap(); + assert_eq!(fill(&h), previous, "one undo restores the previous colour and opacity"); + h.state_mut().run("edit.redo", Value::Null).unwrap(); + assert_eq!(fill(&h), original.with_alpha(alpha)); + // Closing and reopening must reconstruct the same hidden colour, even at zero. + h.key_press(egui::Key::Escape); + h.run_steps(3); + h.get_by_role(Role::ColorWell).click(); + h.run_steps(3); + } + } + } + + /// Numeric RGB edits at alpha zero keep the newly selected colour, not transparent black + /// or the previous RGB. Reopening and raising opacity must reveal that selection. + #[test] + fn transparent_class_numeric_rgb_edits_survive_reopening() { + use egui::accesskit::{Action, ActionData, ActionRequest, Role}; + use egui_kittest::kittest::{NodeT, Queryable}; + use rustgis_engine::map::Rgba; + + let mut app = RustGisApp::with_sample(); + app.run("layer.symbology", json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 1})).unwrap(); + app.run("layer.classStyle", json!({"layer": "Countries", "index": 0, "color": "#FF000000"})).unwrap(); + let id = layer(&app, "Countries"); + let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 600.0)).with_max_steps(64).build_ui_state( + move |ui, app: &mut RustGisApp| { + finish_class_color_drag(app, ui); + let layer = app.session.map().layer(id).unwrap().clone(); + legend_list(app, ui, &layer, &Tokens::light()); + }, + app, + ); + h.run_steps(3); + let fill = |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer(id).unwrap().renderer.legend()[0].symbol.fill; + let inactive_theme = match h.ctx.theme() { + egui::Theme::Light => egui::Theme::Dark, + egui::Theme::Dark => egui::Theme::Light, + }; + h.ctx.style_mut_of(inactive_theme, |style| style.visuals.numeric_color_space = egui::style::NumericColorSpace::Linear); + let styles = [egui::Theme::Light, egui::Theme::Dark].map(|theme| h.ctx.style_of(theme).visuals.numeric_color_space); + h.get_by_role(Role::ColorWell).click(); + h.run_steps(3); + // Fully transparent fills use linear 0..1 numeric controls: green 1 makes yellow. + let edit = |h: &mut egui_kittest::Harness<'_, RustGisApp>, channel, value| { + let (node, tree) = h.query_all_by_role(Role::SpinButton).nth(channel).unwrap().accesskit_node().locate(); + h.event(egui::Event::AccessKitActionRequest(ActionRequest { + action: Action::SetValue, + target_tree: tree, + target_node: node, + data: Some(ActionData::NumericValue(value)), + })); + h.run_steps(3); + }; + edit(&mut h, 1, 1.0); + assert_eq!(fill(&h), Rgba::new(255, 255, 0, 0)); + // Even the popup's mode toggle must not leak changes into either theme. + h.get_by_role_and_label(Role::Button, "F").click(); + h.run_steps(3); + assert_eq!([egui::Theme::Light, egui::Theme::Dark].map(|theme| h.ctx.style_of(theme).visuals.numeric_color_space), styles); + h.state_mut().run("edit.undo", Value::Null).unwrap(); + assert_eq!(fill(&h), Rgba::new(255, 0, 0, 0)); + h.state_mut().run("edit.redo", Value::Null).unwrap(); + assert_eq!(fill(&h), Rgba::new(255, 255, 0, 0)); + h.key_press(egui::Key::Escape); + h.run_steps(3); + h.get_by_role(Role::ColorWell).click(); + h.run_steps(3); + edit(&mut h, 3, 1.0); + assert_eq!(fill(&h), Rgba::new(255, 255, 0, 255)); + // Opaque colours retain the ordinary byte-based editor. + edit(&mut h, 1, 64.0); + assert_eq!(fill(&h), Rgba::new(255, 64, 0, 255)); + assert_eq!([egui::Theme::Light, egui::Theme::Dark].map(|theme| h.ctx.style_of(theme).visuals.numeric_color_space), styles); + } + /// The layer menu selects its layer as it opens, not on every frame it stays open: each /// command moves the session's revision, which drew the whole map again every frame. #[test] diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 427a36b93..9847c4a1d 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -37,7 +37,7 @@ projects. ### Edit a class With Unique Values or Graduated Colors, each class in the Symbology pane's list has a colour button -and a legend label. Pick a colour to give that class its own. Type a label to show in the Layers +and a legend label. Pick a colour and opacity to give that class its own. Type a label to show in the Layers pane and in layout legends instead of the value or range (for example `High income` for `1. High income: OECD`); clear it to show the value again. `` can be edited the same way. Applying the symbology again on the same field keeps the colours, outlines and labels @@ -48,13 +48,26 @@ class keeps its colour and label while its range stays the same. By command: ```text layer.classStyle={"layer":"Countries","value":"1. High income: OECD","color":"#2E6B4F","label":"High income"} layer.classStyle={"layer":"Countries","index":0,"label":"Under 1 million"} +layer.classStyle={"layer":"Countries","index":0,"color":"#2E6B4FFF"} layer.classStyle={"layer":"Countries","other":true,"label":"No data"} ``` `value` names a Unique Values class, `index` a Graduated Colors class (0 is the lowest) and -`other` the `` class; `outline` sets a class's outline colour. A polygon class -keeps its own transparency (the layer's, as a colour scheme leaves it), so the `AA` of a -`#RRGGBBAA` colour applies to points and lines only. +`other` the `` class; `outline` sets a class's outline colour. +`#RRGGBBAA` sets the fill opacity exactly for every geometry type: `FF` is fully opaque for +print choropleths, `00` fully transparent, and intermediate values partly transparent. The class +colour picker includes opacity and sends all eight digits, including `FF`. Changing opacity +keeps the class's RGB colour, even when making a fully transparent class visible again. +For fully transparent fills, the numeric controls temporarily use linear 0–1 values so you can +edit the hidden RGB colour without losing it. Other colour pickers keep their numeric settings. + +For polygons, six-digit `#RRGGBB` retains the existing translucency, with the legacy minimum +alpha of 150; new polygon layers still default to 170. Colour schemes preserve a polygon's +fill opacity when classes are first created or recoloured with `layer.ramp`. Reapplying the +same field, ranges and scheme also preserves opacity, including edits that change only alpha. +As with other class colour edits, changing the field or graduated ranges starts fresh; choosing +a different scheme through `layer.symbology` resets polygon opacity to its default. +Layer/group transparency still applies on top of the symbol's opacity in maps and exports. ### Line strokes From eadad68ece3a4a40ca5aa99b4132ff3925f30ae4 Mon Sep 17 00:00:00 2001 From: quanguet0409 Date: Thu, 22 Oct 2026 01:40:39 +0700 Subject: [PATCH 004/297] feat(analysis): add spatial join geoprocessing tool and engine command (#657) * feat(analysis): add spatial join geoprocessing tool and engine command * perf(analysis): index join geometries on grid and test collision deduplication * perf(analysis): bound grid index occupancy and add indexed-path test coverage * refactor(analysis): address PR review comments for spatial join --- crates/analysis/src/tools.rs | 389 ++++++++++++++++++++++++++++++++++- crates/engine/src/cmd/gp.rs | 14 +- crates/engine/src/tests.rs | 37 +++- 3 files changed, 437 insertions(+), 3 deletions(-) diff --git a/crates/analysis/src/tools.rs b/crates/analysis/src/tools.rs index 4c3a03643..33b81313c 100644 --- a/crates/analysis/src/tools.rs +++ b/crates/analysis/src/tools.rs @@ -1,6 +1,6 @@ //! The tools. -use geo::{BooleanOps, ConvexHull, InteriorPoint, Intersects, MultiPolygon, Orient, Simplify, orient::Direction}; +use geo::{BooleanOps, Contains, ConvexHull, InteriorPoint, Intersects, MultiPolygon, Orient, Simplify, orient::Direction}; use rustgis_data::raster::Crs; use rustgis_data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value}; use rustgis_geom::{Extent, geodesic_length_m, geodesic_ring_area_m2}; @@ -11,6 +11,9 @@ use crate::geodesic::geodesic_buffer; use crate::{Inputs, Output, OutputLayer, ParamKind as K, ParamSpec as P, ToolResult, ToolSpec}; const UNITS: &[&str] = &["Kilometers", "Meters", "Miles"]; +const MATCH_OPTIONS: &[&str] = &["Intersects", "Contains", "Within"]; +const JOIN_OPERATIONS: &[&str] = &["Join one to one", "Join one to many"]; +const TARGET_RECORDS: &[&str] = &["Keep all", "Keep only matching"]; pub static TOOLS: &[ToolSpec] = &[ ToolSpec { @@ -319,6 +322,64 @@ pub static TOOLS: &[ToolSpec] = &[ ], run: idw_interpolation, }, + ToolSpec { + id: "spatialJoin", + name: "Spatial Join", + toolbox: "Analysis › Overlay", + summary: "Joins attributes from a join layer to a target layer based on their spatial relationship.", + params: &[ + P { + name: "target", + label: "Target Features", + kind: K::Layer, + required: true, + default: "", + help: "The features that receive joined attributes.", + }, + P { + name: "join", + label: "Join Features", + kind: K::Layer, + required: true, + default: "", + help: "The features whose attributes are joined.", + }, + P { + name: "match", + label: "Match Option", + kind: K::Choice(MATCH_OPTIONS), + required: false, + default: "\"Intersects\"", + help: "The spatial relationship between target and join features.", + }, + P { + name: "operation", + label: "Join Operation", + kind: K::Choice(JOIN_OPERATIONS), + required: false, + default: "\"Join one to one\"", + help: "One to one joins the first match and counts total matches; one to many creates a row for each match.", + }, + P { + name: "keepAll", + label: "Target Records", + kind: K::Choice(TARGET_RECORDS), + required: false, + default: "\"Keep all\"", + help: "Whether target features without matching join features are kept.", + }, + P { + name: "prefix", + label: "Field Prefix", + kind: K::Text, + required: false, + default: "\"join_\"", + help: "Prefix added to join feature fields to avoid collision.", + }, + P { name: "output", label: "Output Name", kind: K::Text, required: false, default: "", help: "" }, + ], + run: spatial_join, + }, ]; // --------------------------------------------------------------------------------------------- @@ -939,6 +1000,207 @@ fn export_features(inp: &dyn Inputs, p: &Json) -> Result { result(out, format!("Export Features: {n} features")) } +fn extent_contains(a: &Extent, b: &Extent) -> bool { + !a.is_empty() && !b.is_empty() && a.min_x <= b.min_x && a.max_x >= b.max_x && a.min_y <= b.min_y && a.max_y >= b.max_y +} + +fn spatial_join(inp: &dyn Inputs, p: &Json) -> Result { + let target = input(inp, p, "target")?; + let join = input(inp, p, "join")?; + + if let (Some(tc), Some(jc)) = (target.crs, join.crs) + && tc != jc + { + return Err("the target and join layers must have the same coordinate reference system".into()); + } + + let match_opt = match s(p, "match") { + "Contains" => "Contains", + "Within" => "Within", + _ => "Intersects", + }; + + let op_many = s(p, "operation").eq_ignore_ascii_case("Join one to many"); + let keep_all = !s(p, "keepAll").eq_ignore_ascii_case("Keep only matching"); + + let prefix_raw = s(p, "prefix"); + let prefix = if prefix_raw.is_empty() && p.get("prefix").is_none() { "join_" } else { prefix_raw }; + + let target_rows = rows(inp, p, "target", target); + let join_rows = rows(inp, p, "join", join); + + let mut join_geoms = Vec::with_capacity(join_rows.len()); + for &jr in &join_rows { + if let Some(f) = join.features.get(jr) + && let Some(g) = f.geometry.as_ref() + { + join_geoms.push((jr, g.extent(), to_geo(g))); + } + } + + let mut out = copy_schema(target, out_name(p, format!("{}_SpatialJoin", target.name)), target.geometry_type); + out.source = None; + out.crs = target.crs; + + let count_field_name = { + let base = "join_count"; + let mut name = base.to_string(); + let mut k = 1usize; + while out.field_index(&name).is_some() { + k += 1; + name = format!("{base}_{k}"); + } + name + }; + out.fields.push(Field::new(count_field_name, FieldType::Integer)); + + let mut join_field_indices = Vec::with_capacity(join.fields.len()); + for (orig_idx, jf) in join.fields.iter().enumerate() { + let raw = if prefix.is_empty() { jf.name.clone() } else { format!("{prefix}{}", jf.name) }; + let mut name = raw.clone(); + let mut k = 1usize; + while out.field_index(&name).is_some() { + k += 1; + name = format!("{raw}_{k}"); + } + out.fields.push(Field::new(name, jf.ty)); + join_field_indices.push(orig_idx); + } + + let target_fields_len = target.fields.len(); + let join_fields_len = join.fields.len(); + + // Spatial grid index over join geometries for efficient candidate pruning + let mut total_ext = Extent::EMPTY; + for (_, ext, _) in &join_geoms { + total_ext = total_ext.union(ext); + } + let mut use_grid = join_geoms.len() > 16 && !total_ext.is_empty(); + let n_cells = (join_geoms.len() as f64).sqrt().clamp(4.0, 256.0); + let cell_w = (total_ext.width() / n_cells).max(1e-9); + let cell_h = (total_ext.height() / n_cells).max(1e-9); + let key = |x: f64, y: f64| -> (i64, i64) { + let kx = if x.is_finite() { ((x - total_ext.min_x) / cell_w).floor() } else { 0.0 }; + let ky = if y.is_finite() { ((y - total_ext.min_y) / cell_h).floor() } else { 0.0 }; + (kx.clamp(0.0, 512.0) as i64, ky.clamp(0.0, 512.0) as i64) + }; + + const MAX_GRID_REFERENCES: usize = 1_000_000; + if use_grid { + let mut projected_refs = 0usize; + for (_, j_ext, _) in &join_geoms { + let (gx0, gy0) = key(j_ext.min_x, j_ext.min_y); + let (gx1, gy1) = key(j_ext.max_x, j_ext.max_y); + let cells = ((gx1 - gx0 + 1) as usize).saturating_mul((gy1 - gy0 + 1) as usize); + projected_refs = projected_refs.saturating_add(cells); + if projected_refs > MAX_GRID_REFERENCES { + use_grid = false; + break; + } + } + } + + let mut grid: std::collections::HashMap<(i64, i64), Vec> = std::collections::HashMap::new(); + if use_grid { + for (idx, (_, j_ext, _)) in join_geoms.iter().enumerate() { + let (gx0, gy0) = key(j_ext.min_x, j_ext.min_y); + let (gx1, gy1) = key(j_ext.max_x, j_ext.max_y); + for gx in gx0..=gx1 { + for gy in gy0..=gy1 { + grid.entry((gx, gy)).or_default().push(idx); + } + } + } + } + + let hit = |t_ext: &Extent, t_geo: &geo::Geometry, j_ext: &Extent, j_geo: &geo::Geometry| -> bool { + match match_opt { + "Contains" => extent_contains(t_ext, j_ext) && Contains::contains(t_geo, j_geo), + "Within" => extent_contains(j_ext, t_ext) && Contains::contains(j_geo, t_geo), + _ => t_ext.intersects(j_ext) && t_geo.intersects(j_geo), + } + }; + + let mut total_matches = 0usize; + let mut written_features = 0usize; + + for tr in target_rows { + let Some(tf) = target.features.get(tr) else { continue }; + let mut matches: Vec = Vec::new(); + + if let Some(tg) = tf.geometry.as_ref() { + let t_ext = tg.extent(); + let t_geo = to_geo(tg); + + if use_grid { + if t_ext.intersects(&total_ext) { + let (tx0, ty0) = key(t_ext.min_x, t_ext.min_y); + let (tx1, ty1) = key(t_ext.max_x, t_ext.max_y); + let mut candidates = Vec::new(); + for gx in tx0..=tx1 { + for gy in ty0..=ty1 { + if let Some(indices) = grid.get(&(gx, gy)) { + candidates.extend_from_slice(indices); + } + } + } + candidates.sort_unstable(); + candidates.dedup(); + + for idx in candidates { + if let Some((jr, j_ext, j_geo)) = join_geoms.get(idx) + && hit(&t_ext, &t_geo, j_ext, j_geo) + { + matches.push(*jr); + } + } + } + } else { + for (jr, j_ext, j_geo) in &join_geoms { + if hit(&t_ext, &t_geo, j_ext, j_geo) { + matches.push(*jr); + } + } + } + } + + let m_count = matches.len(); + total_matches += m_count; + + let mut push_row = |count: i64, jf: Option<&rustgis_data::Feature>| { + let mut attrs = Vec::with_capacity(out.fields.len()); + for i in 0..target_fields_len { + attrs.push(tf.attrs.get(i).cloned().unwrap_or(Value::Null)); + } + attrs.push(Value::Int(count)); + if let Some(f) = jf { + for &ji in &join_field_indices { + attrs.push(f.attrs.get(ji).cloned().unwrap_or(Value::Null)); + } + } else { + attrs.resize(attrs.len() + join_fields_len, Value::Null); + } + out.push_next(tf.geometry.clone(), attrs); + written_features += 1; + }; + + if m_count == 0 { + if keep_all { + push_row(0, None); + } + } else if op_many { + for &jr in &matches { + push_row(m_count as i64, join.features.get(jr)); + } + } else { + let jf = matches.first().and_then(|&jr| join.features.get(jr)); + push_row(m_count as i64, jf); + } + } + + result(out, format!("Spatial Join: {written_features} features joined ({total_matches} matches)")) +} + #[cfg(test)] mod tests { use super::*; @@ -1003,6 +1265,11 @@ mod tests { Output::Selection { rows, .. } => assert_eq!(rows, vec![0, 1]), _ => panic!(), } + let sj = layer(crate::run("spatialJoin", &m, &j(serde_json::json!({"target": "pts", "join": "polys"}))).unwrap()); + assert_eq!(sj.features.len(), 3); + assert_eq!(sj.value(0, sj.field_index("join_count").unwrap()), &Value::Int(1)); + assert_eq!(sj.value(0, sj.field_index("join_k").unwrap()), &Value::Text("a".into())); + assert_eq!(sj.value(2, sj.field_index("join_count").unwrap()), &Value::Int(0)); for id in ["convexHull", "featureToPoint", "simplify", "calculateGeometry", "exportFeatures", "erase"] { let r = crate::run(id, &m, &j(serde_json::json!({"input": "polys", "erase": "polys"}))); assert!(r.is_ok(), "{id}: {r:?}"); @@ -1473,4 +1740,124 @@ mod tests { _ => panic!("expected selection"), } } + + #[test] + fn spatial_join_cases() { + let mut polys = Dataset::new("polys", GeometryType::Polygon, vec![Field::new("k", FieldType::Text)]); + polys.push(Some(sq(0.0, 0.0, 2.0)), vec![Value::Text("a".into())]); + polys.push(Some(sq(1.0, 0.0, 2.0)), vec![Value::Text("b".into())]); + + let mut pts = Dataset::new("pts", GeometryType::Point, vec![Field::new("name", FieldType::Text), Field::new("k", FieldType::Text)]); + pts.push(Some(Geometry::Point(Coord::new(1.5, 1.0))), vec![Value::Text("both".into()), Value::Text("orig".into())]); + pts.push(Some(Geometry::Point(Coord::new(0.5, 0.5))), vec![Value::Text("first".into()), Value::Text("orig".into())]); + pts.push(Some(Geometry::Point(Coord::new(50.0, 50.0))), vec![Value::Text("outside".into()), Value::Text("orig".into())]); + + let m = Map(HashMap::from([("polys".to_string(), polys), ("pts".to_string(), pts)])); + + // 1. One to one join (Intersects) + let r1 = crate::run("spatialJoin", &m, &serde_json::json!({"target": "pts", "join": "polys"})).unwrap(); + let ds1 = layer(r1); + assert_eq!(ds1.features.len(), 3); + let jc = ds1.field_index("join_count").unwrap(); + let jk = ds1.field_index("join_k").unwrap(); + assert_eq!(ds1.value(0, jc), &Value::Int(2)); + assert!(matches!(ds1.value(0, jk), Value::Text(_))); + assert_eq!(ds1.value(1, jc), &Value::Int(1)); + assert_eq!(ds1.value(1, jk), &Value::Text("a".into())); + assert_eq!(ds1.value(2, jc), &Value::Int(0)); + assert_eq!(ds1.value(2, jk), &Value::Null); + + // 2. One to many join + let r2 = + crate::run("spatialJoin", &m, &serde_json::json!({"target": "pts", "join": "polys", "operation": "Join one to many"})).unwrap(); + let ds2 = layer(r2); + assert_eq!(ds2.features.len(), 4); + + // 3. Inner join (Keep only matching) + let r3 = + crate::run("spatialJoin", &m, &serde_json::json!({"target": "pts", "join": "polys", "keepAll": "Keep only matching"})).unwrap(); + let ds3 = layer(r3); + assert_eq!(ds3.features.len(), 2); + + // 4. Match option Contains (target polys, join pts) + let r4 = crate::run("spatialJoin", &m, &serde_json::json!({"target": "polys", "join": "pts", "match": "Contains"})).unwrap(); + let ds4 = layer(r4); + assert_eq!(ds4.features.len(), 2); + let jc4 = ds4.field_index("join_count").unwrap(); + assert_eq!(ds4.value(0, jc4), &Value::Int(2)); + assert_eq!(ds4.value(1, jc4), &Value::Int(1)); + + // 5. Match option Within (target pts, join polys) + let r5 = crate::run("spatialJoin", &m, &serde_json::json!({"target": "pts", "join": "polys", "match": "Within"})).unwrap(); + let ds5 = layer(r5); + assert_eq!(ds5.features.len(), 3); + let jc5 = ds5.field_index("join_count").unwrap(); + assert_eq!(ds5.value(0, jc5), &Value::Int(2)); + assert_eq!(ds5.value(1, jc5), &Value::Int(1)); + assert_eq!(ds5.value(2, jc5), &Value::Int(0)); + + // 6. Custom/empty prefix and field name collision deduplication + let r6 = crate::run("spatialJoin", &m, &serde_json::json!({"target": "pts", "join": "polys", "prefix": ""})).unwrap(); + let ds6 = layer(r6); + assert!(ds6.field_index("k").is_some()); + assert!(ds6.field_index("k_2").is_some(), "colliding field k should be deduplicated to k_2"); + assert_eq!(ds6.value(1, ds6.field_index("k").unwrap()), &Value::Text("orig".into())); + assert_eq!(ds6.value(1, ds6.field_index("k_2").unwrap()), &Value::Text("a".into())); + + // 7. Indexed path (> 16 join geometries) for Intersects, Contains, and Within, including outside extent + let mut grid_polys = Dataset::new("grid_polys", GeometryType::Polygon, vec![Field::new("id", FieldType::Integer)]); + for y in 0..5 { + for x in 0..5 { + grid_polys.push(Some(sq(x as f64, y as f64, 1.0)), vec![Value::Int(y * 5 + x)]); + } + } + assert_eq!(grid_polys.features.len(), 25); + + let mut test_pts = Dataset::new("test_pts", GeometryType::Point, vec![Field::new("tag", FieldType::Text)]); + test_pts.push(Some(Geometry::Point(Coord::new(0.5, 0.5))), vec![Value::Text("in_0".into())]); + test_pts.push(Some(Geometry::Point(Coord::new(4.5, 4.5))), vec![Value::Text("in_24".into())]); + test_pts.push(Some(Geometry::Point(Coord::new(100.0, 100.0))), vec![Value::Text("far".into())]); + + let m2 = Map(HashMap::from([("grid_polys".to_string(), grid_polys), ("test_pts".to_string(), test_pts)])); + + let r_int = crate::run("spatialJoin", &m2, &serde_json::json!({"target": "test_pts", "join": "grid_polys", "match": "Intersects"})) + .unwrap(); + let ds_int = layer(r_int); + assert_eq!(ds_int.features.len(), 3); + let jc_int = ds_int.field_index("join_count").unwrap(); + let jid_int = ds_int.field_index("join_id").unwrap(); + assert_eq!(ds_int.value(0, jc_int), &Value::Int(1)); + assert_eq!(ds_int.value(0, jid_int), &Value::Int(0)); + assert_eq!(ds_int.value(1, jc_int), &Value::Int(1)); + assert_eq!(ds_int.value(1, jid_int), &Value::Int(24)); + assert_eq!(ds_int.value(2, jc_int), &Value::Int(0)); + assert_eq!(ds_int.value(2, jid_int), &Value::Null); + + let r_within = + crate::run("spatialJoin", &m2, &serde_json::json!({"target": "test_pts", "join": "grid_polys", "match": "Within"})).unwrap(); + let ds_within = layer(r_within); + assert_eq!(ds_within.value(0, jc_int), &Value::Int(1)); + assert_eq!(ds_within.value(1, jc_int), &Value::Int(1)); + assert_eq!(ds_within.value(2, jc_int), &Value::Int(0)); + + let r_contains = + crate::run("spatialJoin", &m2, &serde_json::json!({"target": "grid_polys", "join": "test_pts", "match": "Contains"})).unwrap(); + let ds_contains = layer(r_contains); + assert_eq!(ds_contains.features.len(), 25); + let jc_poly = ds_contains.field_index("join_count").unwrap(); + assert_eq!(ds_contains.value(0, jc_poly), &Value::Int(1)); + assert_eq!(ds_contains.value(24, jc_poly), &Value::Int(1)); + assert_eq!(ds_contains.value(12, jc_poly), &Value::Int(0)); + + // 8. Mismatched coordinate reference systems error + let Some(pts_layer) = m.0.get("pts") else { panic!("missing pts") }; + let Some(polys_layer) = m.0.get("polys") else { panic!("missing polys") }; + let mut pts_crs = pts_layer.clone(); + pts_crs.crs = Some(rustgis_data::raster::Crs::LonLat); + let mut polys_crs = polys_layer.clone(); + polys_crs.crs = Some(rustgis_data::raster::Crs::WebMercator); + let m_crs = Map(HashMap::from([("polys".to_string(), polys_crs), ("pts".to_string(), pts_crs)])); + let err = crate::run("spatialJoin", &m_crs, &serde_json::json!({"target": "pts", "join": "polys"})).unwrap_err(); + assert!(err.contains("coordinate reference system")); + } } diff --git a/crates/engine/src/cmd/gp.rs b/crates/engine/src/cmd/gp.rs index 7aa4b56b6..9fcaf7075 100644 --- a/crates/engine/src/cmd/gp.rs +++ b/crates/engine/src/cmd/gp.rs @@ -10,6 +10,11 @@ use crate::{CmdResult, CommandSpec as C, GpRecord, Session}; pub fn commands() -> Vec { vec![ C::new("gp.run", "Run Tool", "Analyze › Geoprocessing", run).when(need_layers).params(r#"{"tool": tool id, "params": {...} (a layer by name; a standalone table by name or id)}"#), + C::new("gp.spatialJoin", "Spatial Join", "Analyze › Overlay", spatial_join) + .when(need_layers) + .params( + r#"{"target": layer, "join": layer, "match"?: "Intersects" (default) | "Contains" | "Within", "operation"?: "Join one to one" (default) | "Join one to many", "keepAll"?: "Keep all" (default) | "Keep only matching", "prefix"?: "join_", "output"?: text}"#, + ), C::new("gp.bufferPoint", "Buffer A Point", "Analyze › Geoprocessing", buffer_point).params( r#"{"lon": number, "lat": number, "distance": number, "units"?: "Kilometers" (default) | "Meters" | "Miles", "name"?: output}"#, ), @@ -563,7 +568,9 @@ fn env_rows( params: &Value, ) -> std::collections::HashMap> { let Some(e) = env.extent else { return std::collections::HashMap::new() }; - let Some(input) = params.get("input").and_then(Value::as_str) else { return std::collections::HashMap::new() }; + let Some(input) = params.get("target").or_else(|| params.get("input")).and_then(Value::as_str) else { + return std::collections::HashMap::new(); + }; // The layer the name finds (the first of that name), as the tool will. map.layer_by_name(input) .into_iter() @@ -689,6 +696,11 @@ fn buffer_point(s: &mut Session, p: &Value) -> CmdResult { apply(s, ran, params, res, millis) } +/// Joins attributes from another layer spatially: `target` receives attributes from `join`. +fn spatial_join(s: &mut Session, p: &Value) -> CmdResult { + run_tool(s, "spatialJoin", p) +} + /// Runs a (built-in) tool and applies its result, as `gp.run` does. pub fn run_tool(s: &mut Session, tool: &str, params: &Value) -> CmdResult { let tool = tool_id(tool).unwrap_or(tool).to_string(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f9022ab46..f741fdfa2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -278,6 +278,41 @@ fn geoprocessing_adds_layers_and_history() { assert_eq!(s.project.gp_history.len(), 2, "a failed run restores the project"); } +#[test] +fn spatial_join_command_joins_layers_and_records_history() { + let mut s = world(); + s.run( + "gp.spatialJoin", + &json!({ + "target": "Cities", + "join": "Countries", + "match": "Intersects", + "operation": "Join one to one", + "output": "Joined_Cities" + }), + ) + .unwrap(); + let l = s.map().layer_by_name("Joined_Cities").unwrap(); + assert_eq!(l.dataset.features.len(), s.map().layer_by_name("Cities").unwrap().dataset.features.len()); + assert!(l.dataset.field_index("join_count").is_some()); + assert_eq!(s.project.gp_history.len(), 1); + + s.run( + "gp.run", + &json!({ + "tool": "spatialJoin", + "params": { + "target": "Countries", + "join": "Cities", + "match": "Contains", + "output": "Countries_With_Cities" + } + }), + ) + .unwrap(); + assert!(s.map().layer_by_name("Countries_With_Cities").is_some()); +} + #[test] fn editing_features_and_fields() { let mut s = world(); @@ -8436,7 +8471,7 @@ fn literal_keys(body: &str, prefix: &str) -> Vec { #[test] fn params_name_every_key_a_command_reads() { // Read by a shared helper but of no use to the command. - let unused = [("profile.alongSelected", "row"), ("sources.remove", "name"), ("sources.remove", "extra")]; + let unused = [("gp.spatialJoin", "input"), ("profile.alongSelected", "row"), ("sources.remove", "name"), ("sources.remove", "extra")]; let s = Session::new(Project::default()); let mut missing = Vec::new(); let found = keys_read_by_commands(); From 8e00d96fa03d302c61c653a438719064446a2535 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Wed, 21 Oct 2026 16:45:37 -0300 Subject: [PATCH 005/297] Zarr: summarise a variable over time into a raster (#556) (#603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Zarr: a variable that follows the time slider (#554) layer.addZarr takes "time": true. The variable's time axis is read from its CF coordinate (model calendars included) and the layer shows the last of its steps in the slider's window, or nothing when none falls in it; the slider turns on over the axis when it is off, and "start"/"end" take a period of a longer axis. Moving, playing or exporting the slider reads that step's slice, only its chunks, keeping the last eight; a web store's next two steps are read ahead in the background. The stretch is fixed at the first slice's range, so a colour means one value at every step. The project keeps the layer by its store and selection (ZarrTime) and reads the slider's step again when it opens. The Zarr dialog has a Follow the time slider box (desktop). Co-Authored-By: Claude Opus 5.5 * Zarr: a variable's time series at a point, as a chart (#555) zarr.series reads a variable along its time dimension at the cell holding a point, from only the chunks that hold that cell: through a Zarr layer (one following the time slider gives its period, a slice the whole axis) or a store's folder or address, with start/end, the other dimensions' steps, any longitude convention, and a projected grid's coordinate system. A store chunked a step at a time is capped at MAX_CHUNKS chunks, with the count in the error. The data crate gains locate, plan_series and assemble_series (scaled, no data as NaN), map_coordinate_keys, is_lonlat_grid and grid_crs. View › Time Series: pick a Zarr layer and a point on the map; up to six lines on one chart, the time slider's step marked, a click on the chart moves the slider there, and Export CSV. Co-Authored-By: Claude Opus 5.5 * Zarr: summarise a variable over time into a raster (#556) zarr.reduce takes mean, min, max, sum, std (population), count or countAbove a threshold of a Zarr variable's steps: a layer's period by default, else start/end, and months for one season across years. Slices are read one at a time into running totals (Welford for the standard deviation), so memory stays at a slice; at most 20,000 steps, and minValid leaves a cell with too few values empty. The result is a one-band raster named after what it holds, kept in the project as a GeoTIFF. ReduceRun is prepared on the session (reduce_start) and run anywhere: the Time Series pane's Summarise over time runs it on a thread with progress and Cancel, then hands the GeoTIFF over to layer.addZarr. Co-Authored-By: Claude Opus 5.5 * Zarr: dates stay one per step; read-ahead fetches each slice once, on two threads at most (#601 review) - An axis out of order or with a date twice is refused by layer.addZarr's time, and repair clears dates it can't match to steps instead of sorting them onto the wrong ones. - Read-ahead skips slices kept or already being read, runs on at most two threads, and the step shown waits for a read ahead of it rather than fetching it again. - Tests: the Zarr store helper sits above the doc comment it had taken from zarr_stores_in_folders_and_handed_over, which gets its doc and cfg back; the new test is desktop-only like its helper. Co-Authored-By: Claude Opus 5.5 * Zarr: dates stay one per step; read-ahead fetches each slice once, on two threads at most (#601 review) - An axis out of order or with a date twice is refused by layer.addZarr's time, and repair clears dates it can't match to steps instead of sorting them onto the wrong ones. - Read-ahead skips slices kept or already being read, runs on at most two threads, and the step shown waits for a read ahead of it rather than fetching it again. - Tests: the Zarr store helper sits above the doc comment it had taken from zarr_stores_in_folders_and_handed_over, which gets its doc and cfg back; the new test is desktop-only like its helper. Co-Authored-By: Claude Opus 5.5 * Zarr: dates stay one per step; read-ahead fetches each slice once, on two threads at most (#601 review) - An axis out of order or with a date twice is refused by layer.addZarr's time, and repair clears dates it can't match to steps instead of sorting them onto the wrong ones. - Read-ahead skips slices kept or already being read, runs on at most two threads, and the step shown waits for a read ahead of it rather than fetching it again. - Tests: the Zarr store helper sits above the doc comment it had taken from zarr_stores_in_folders_and_handed_over, which gets its doc and cfg back; the new test is desktop-only like its helper. Co-Authored-By: Claude Opus 5.5 * Zarr: the series test is desktop-only, like the store it builds (#601 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the series test is desktop-only, like the store it builds (#601 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the summary test is desktop-only, like the store it builds (#601 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a series reads hostile chunk shapes without overflow; the CSV helper is desktop-only (#602 review) assemble_series takes chunk sizes through chunk(), multiplies and adds saturating, and bounds its allocation; series_csv is only used by the desktop pane, so the web build leaves it out. Co-Authored-By: Claude Opus 5.5 * Zarr: a series reads hostile chunk shapes without overflow; the CSV helper is desktop-only (#602 review) assemble_series takes chunk sizes through chunk(), multiplies and adds saturating, and bounds its allocation; series_csv is only used by the desktop pane, so the web build leaves it out. Co-Authored-By: Claude Opus 5.5 * Zarr: read-ahead gives its keys back even on a panic; dates before 1 CE read (#601 review) - A read-ahead thread releases its keys and its place among the threads in a drop guard, so a panic on a hostile store can't stall later steps or stop read-ahead; a step waits for one being read ahead 15 s at most, then reads it itself. - A CF date's leading minus is the year's sign, not a separator (date_parts), in model calendars. Co-Authored-By: Claude Opus 5.5 * Zarr: read-ahead gives its keys back even on a panic; dates before 1 CE read (#601 review) - A read-ahead thread releases its keys and its place among the threads in a drop guard, so a panic on a hostile store can't stall later steps or stop read-ahead; a step waits for one being read ahead 15 s at most, then reads it itself. - A CF date's leading minus is the year's sign, not a separator (date_parts), in model calendars. Co-Authored-By: Claude Opus 5.5 * Zarr: read-ahead gives its keys back even on a panic; dates before 1 CE read (#601 review) - A read-ahead thread releases its keys and its place among the threads in a drop guard, so a panic on a hostile store can't stall later steps or stop read-ahead; a step waits for one being read ahead 15 s at most, then reads it itself. - A CF date's leading minus is the year's sign, not a separator (date_parts), in model calendars. Co-Authored-By: Claude Opus 5.5 * Zarr: the layer shows the slider's step after an export and undo; the slider waits for a first read; model calendars keep their dates apart (#602 review) - time.export, and undo or redo, bring following Zarr layers back to the slider's step, not the last frame's. - layer.addZarr's time reads the first step before turning the slider on, so a store that can't be read leaves the map as it was. - A step that couldn't be read isn't read again on every call; it's tried again once the slider moves to it anew. - In model calendars the time of day in `since` counts, and a 360- or 366-day year is laid over its real year in proportion, so daily dates (30 February, 1 March) stay distinct and in order instead of being clamped onto one. Co-Authored-By: Claude Opus 5.5 * Zarr: the layer shows the slider's step after an export and undo; the slider waits for a first read; model calendars keep their dates apart (#602 review) - time.export, and undo or redo, bring following Zarr layers back to the slider's step, not the last frame's. - layer.addZarr's time reads the first step before turning the slider on, so a store that can't be read leaves the map as it was. - A step that couldn't be read isn't read again on every call; it's tried again once the slider moves to it anew. - In model calendars the time of day in `since` counts, and a 360- or 366-day year is laid over its real year in proportion, so daily dates (30 February, 1 March) stay distinct and in order instead of being clamped onto one. Co-Authored-By: Claude Opus 5.5 * Zarr: the layer shows the slider's step after an export and undo; the slider waits for a first read; model calendars keep their dates apart (#602 review) - time.export, and undo or redo, bring following Zarr layers back to the slider's step, not the last frame's. - layer.addZarr's time reads the first step before turning the slider on, so a store that can't be read leaves the map as it was. - A step that couldn't be read isn't read again on every call; it's tried again once the slider moves to it anew. - In model calendars the time of day in `since` counts, and a 360- or 366-day year is laid over its real year in proportion, so daily dates (30 February, 1 March) stay distinct and in order instead of being clamped onto one. Co-Authored-By: Claude Opus 5.5 * Zarr: a summary refuses a slice of another length rather than index past its totals (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a folder with no variable named follows the slider with its first one; read-ahead never panics for want of a thread (#601 review) - add_following picks the variable before reading a folder's coordinates, so leaving `variable` out (its default, the store's first) no longer fails with "no time dimension". Tested. - Read-ahead starts its thread with thread::Builder, and gives back its keys and place when no thread can be had. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder with no variable named follows the slider with its first one; read-ahead never panics for want of a thread (#601 review) - add_following picks the variable before reading a folder's coordinates, so leaving `variable` out (its default, the store's first) no longer fails with "no time dimension". Tested. - Read-ahead starts its thread with thread::Builder, and gives back its keys and place when no thread can be had. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder with no variable named follows the slider with its first one; read-ahead never panics for want of a thread (#601 review) - add_following picks the variable before reading a folder's coordinates, so leaving `variable` out (its default, the store's first) no longer fails with "no time dimension". Tested. - Read-ahead starts its thread with thread::Builder, and gives back its keys and place when no thread can be had. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder store rewritten on disk is read again, not served from the slice cache (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a folder store rewritten on disk is read again, not served from the slice cache (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a folder store rewritten on disk is read again, not served from the slice cache (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the series chart breaks its lines at gaps, labels units only when they agree, and keeps a click on its dates (#602 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the series chart breaks its lines at gaps, labels units only when they agree, and keeps a click on its dates (#602 review) Co-Authored-By: Claude Opus 5.5 * Zarr: one slice key per read; a time coordinate of the dimension's length; follow off for a variable without time Co-Authored-By: Claude Opus 5.5 * Zarr reduce: read each step under its slice key, as read_step now takes it Co-Authored-By: Claude Opus 5.5 * Docs: a following Zarr layer is kept by reference for a folder or a web store; the slice rule is for a single step Co-Authored-By: Claude Opus 5.5 * Time Series: a month that isn't a number is said, not dropped; a cancelled summary stops quietly Co-Authored-By: Claude Opus 5.5 * Zarr: a summary handed over by the app is no slice of the variable Co-Authored-By: Claude Opus 5.5 * Time Series: the summary thread catches a panic and says when it can't start Co-Authored-By: Claude Opus 5.5 * Zarr: a web store's slice keeps the coordinate system given for it, for its time series Co-Authored-By: Claude Opus 5.5 * Zarr: Follow takes a long axis from the step picked; web reads within a total; zarr.series checks its point and needs the network permission Co-Authored-By: Claude Opus 5.5 * Zarr: zarr.reduce needs the network permission, as layer.addZarr does Co-Authored-By: Claude Opus 5.5 * Time Series: the legend's colour taken with get, as the chart's Co-Authored-By: Claude Opus 5.5 * Zarr: a long axis taken in part is said, with the period the layer takes Co-Authored-By: Claude Opus 5.5 * Zarr: a step picked past a long axis is refused; Time Series is left out of the web app's ribbon Co-Authored-By: Claude Opus 5.5 * Zarr summary: a run cancelled as it finishes adds nothing; its totals are bounded by a cell limit Co-Authored-By: Claude Opus 5.5 * Zarr summary: the params say std is the population standard deviation Co-Authored-By: Claude Opus 5.5 * Zarr summary: only the totals the statistic needs; the pane's messages go through i18n Co-Authored-By: Claude Opus 5.5 * Zarr summary: an axis with a step that isn't a date is refused Co-Authored-By: Claude Opus 5.5 * Time Series: the chart and the CSV read the series in place, not copied every frame Co-Authored-By: Claude Opus 5.5 * Zarr summary: its title stays over the progress; the guide says what the totals take and what count gives where there's no value Co-Authored-By: Claude Opus 5.5 * Zarr series: the time axis must be increasing without repeats, as for following the slider Co-Authored-By: Claude Opus 5.5 * Zarr series: its menu path is where it is, View › Time Series Co-Authored-By: Claude Opus 5.5 * Zarr summary: slices read without being kept, so the slider's stay; its menu path is View › Time Series Co-Authored-By: Claude Opus 5.5 * Zarr series: a test of points between cell centres Co-Authored-By: Claude Opus 5.5 * Zarr summary: minValid's share isn't rounded up past a whole number of steps Co-Authored-By: Claude Opus 5.5 * Zarr summary: a step never written only lowers the counts; a summary lands only in its project; the ran hand-over keeps its crs and isn't recorded in tasks Co-Authored-By: Claude Opus 5.5 * Zarr summary: every step on one grid (place and CRS too); a period with nothing written says so Co-Authored-By: Claude Opus 5.5 * Zarr: a slice with chunks refused (HTTP 403) says so, not read as fill Co-Authored-By: Claude Opus 5.5 * Zarr series: a chunk file there but too large or unreadable is said, not read as no value Co-Authored-By: Claude Opus 5.5 * Zarr summary: a name given is trimmed to 200 characters, as the trend's Co-Authored-By: Claude Opus 5.5 * Zarr series: files refused (HTTP 403) are said; a Time Series pick ends when the pane isn't in view Co-Authored-By: Claude Opus 5.5 * Zarr summary: a layer's period end added with saturation Co-Authored-By: Claude Opus 5.5 * Zarr: say why a handed-over slice's subdataset only names it Co-Authored-By: Claude Opus 5.5 * Zarr: a summary handed over says so; any slice keeps its subdataset, a default step included Co-Authored-By: Claude Opus 5.5 * Time Series: a summary lands whatever pane is in view, from the frame, not only from its pane Co-Authored-By: Claude Opus 5.5 * Zarr: a step never written is told apart by its error kind (DataError::NoChunks), not its message Co-Authored-By: Claude Opus 5.5 * Time Series: summarise keeps its own doc; RasterLayer::crs says it serves Zarr slices too Co-Authored-By: Claude Opus 5.5 * Time Series: a summary dropped because another project opened says so Co-Authored-By: Claude Opus 5.5 * Zarr summary: only the first slice's grid is kept through the run; dates taken with get Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/data/src/lib.rs | 4 + crates/data/src/raster/zarr.rs | 2 +- crates/engine/src/cmd/plugins.rs | 13 +- crates/engine/src/cmd/zarr.rs | 363 +++++++++++++++++++++++++-- crates/engine/src/tests.rs | 66 +++++ crates/map/src/raster.rs | 4 +- crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/panes.rs | 6 + crates/ui-egui/src/workspacepanes.rs | 182 ++++++++++++++ docs/user-guide/data.md | 16 ++ 10 files changed, 640 insertions(+), 21 deletions(-) diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index f6f8e5b63..8c77d3c5f 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -46,6 +46,10 @@ pub enum DataError { Format(String), #[error("unsupported file type: {0}")] Unsupported(String), + /// A Zarr slice none of whose chunks is there (the array named): a step never written, + /// which Zarr reads as its fill value, told apart from a slice that can't be read. + #[error("{0}: none of the slice's chunks could be read")] + NoChunks(String), } pub type Result = std::result::Result; diff --git a/crates/data/src/raster/zarr.rs b/crates/data/src/raster/zarr.rs index fd3f928ef..870685467 100644 --- a/crates/data/src/raster/zarr.rs +++ b/crates/data/src/raster/zarr.rs @@ -1160,7 +1160,7 @@ pub fn assemble(store: &Store, plan: &Plan, chunks: &HashMap>, n } } if found == 0 { - return Err(bad(format!("{}: none of the slice's chunks could be read", a.path))); + return Err(DataError::NoChunks(a.path.clone())); } // Georeferencing from the coordinate arrays. let coords = |p: &Option| -> Result, &Array)>> { diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index 56bd8702e..0fc5cc865 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -288,8 +288,17 @@ pub const SQL: &str = "layer.addSql"; /// Commands that read a database, which can be a PostgreSQL server's as well as a file, and a /// Zarr store, a folder or a web address. -const DATABASES: &[&str] = - &[SQL, "layer.addDatabase", "info.databaseTables", "sql.query", "sql.schema", "raster.zarrInfo", "layer.addZarr", "zarr.series"]; +const DATABASES: &[&str] = &[ + SQL, + "layer.addDatabase", + "info.databaseTables", + "sql.query", + "sql.schema", + "raster.zarrInfo", + "layer.addZarr", + "zarr.series", + "zarr.reduce", +]; /// What a command reaches beyond the project when run with some parameters. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/engine/src/cmd/zarr.rs b/crates/engine/src/cmd/zarr.rs index f0d56e41b..ca417b892 100644 --- a/crates/engine/src/cmd/zarr.rs +++ b/crates/engine/src/cmd/zarr.rs @@ -24,8 +24,11 @@ pub fn commands() -> Vec { C::new("zarr.series", "Time Series at a Point", "View › Time Series", series).view().params( r#"{"lon", "lat", "layer"? (a Zarr layer: its store, variable and steps; default the active layer) | "path": folder | "url": "https://…", "variable"?, "index"?: {"level": 0} (the other dimensions' steps), "start"?, "end"?: dates (default: the layer's period, else the whole axis), "crs"?: for a projected store that doesn't say} → dates, values (null for no data) and labels along the variable's time dimension at the cell holding the point (desktop)"#, ), + C::new("zarr.reduce", "Summarise Zarr Over Time", "View › Time Series", reduce).params( + r#"{"layer"? (a Zarr layer: its store, variable and steps; default the active layer) | "path": folder | "url": "https://…", "variable"?, "index"?, "stat": "mean"|"min"|"max"|"sum"|"std" (the population standard deviation, n)|"count"|"countAbove", "above"?: number (countAbove's threshold), "start"?, "end"?: dates (default: the layer's period, else the whole axis), "months"?: [1–12] (a season across years: [12, 1, 2]), "minValid"?: 0–1 (a cell with a smaller share of values is left empty; default 0), "name"?: text, "crs"?} → a raster layer of the statistic (desktop)"#, + ), C::new("layer.addZarr", "Add Zarr Variable", "Home › Add › Add Data", add).params( - r#"{"path": folder | "url": "https://…", "variable"?: "its path in the store (default: the first)", "index"?: {"time": 3, "level": 0} (other dimensions' steps, default 0), "crs"?: "EPSG:… for a store that doesn't say", "name"?: text, "time"?: bool (follow the time slider: the layer shows the step the slider is at, and turns the slider on over the variable's dates when it is off; desktop), "start"?, "end"?: dates (with time: the period to take, for an axis longer than the slider's 20,000 steps), "ran"?: (internal: a slice the app has read)}"#, + r#"{"path": folder | "url": "https://…", "variable"?: "its path in the store (default: the first)", "index"?: {"time": 3, "level": 0} (other dimensions' steps, default 0), "crs"?: "EPSG:… for a store that doesn't say", "name"?: text, "time"?: bool (follow the time slider: the layer shows the step the slider is at, and turns the slider on over the variable's dates when it is off; desktop), "start"?, "end"?: dates (with time: the period to take, for an axis longer than the slider's 20,000 steps), "ran"?: (internal: a slice the app has read), "summary"?: bool (internal: with ran, a summary over time rather than a slice)}"#, ), ] } @@ -216,7 +219,13 @@ pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult { r.source = Some(root.clone()); let name = ps(p, "name").map(str::to_string).unwrap_or_else(|| layer_name(&root, &sel, &[])); let mut layer = RasterLayer::from_bytes(r, format!("{}.tif", store_name(&root)), tif); - layer.subdataset = Some(sel.label()); + // The layer is its GeoTIFF's bytes (`from_bytes`: no path), so it's never read again from + // the store and `subdataset` only names it: a slice by its variable and steps, a summary + // (said by the app that worked it out) by nothing, so it isn't taken for a slice. + layer.subdataset = (super::b(p, "summary") != Some(true)).then(|| sel.label()); + // The coordinate system given for a store that doesn't say, kept for its series and + // summaries, as a folder's slice keeps it. + layer.crs = crs.map(str::to_string); return super::raster::add_raster(s, &name, layer); } #[cfg(target_arch = "wasm32")] @@ -242,7 +251,10 @@ pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult { let store = store_at(&u)?; pick_variable(&store, &mut sel)?; let plan = zarr::plan(&store, &sel).map_err(|e| format!("{u}: {e}"))?; - let (chunks, _) = fetch(&u, &plan.keys)?; + let (chunks, forbidden) = fetch(&u, &plan.keys)?; + if forbidden > 0 { + return Err(format!("{u}: access denied (HTTP 403) to {forbidden} of the slice's chunks")); + } let name = ps(p, "name").map(str::to_string).unwrap_or_else(|| layer_name(&u, &sel, &[])); let mut r = zarr::assemble(&store, &plan, &chunks, &name, crs_parsed).map_err(|e| format!("{u}: {e}"))?; r.source = Some(u.clone()); @@ -415,21 +427,10 @@ fn read_waiting(zt: &ZarrTime, i: usize) -> Result Result, String> { - let sel = zt.selection(i); - if let Some((_, r)) = slices().lock().ok().and_then(|c| c.iter().find(|(k, _)| *k == key).cloned()) { + if let Some(r) = kept_slice(&key) { return Ok(r); } - let crs = zt.crs.as_deref().map(str::trim).filter(|c| !c.is_empty()); - let r = if is_web(&zt.store) { - let st = cached_store(&zt.store)?; - let plan = zarr::plan(&st, &zarr::Selection::parse(&sel)).map_err(|e| e.to_string())?; - let (chunks, _) = fetch(&zt.store, &plan.keys)?; - let crs = crs.map(Crs::parse).transpose()?; - zarr::assemble(&st, &plan, &chunks, &sel, crs).map_err(|e| e.to_string())? - } else { - zarr::read_dir(Path::new(&zt.store), &sel, crs).map_err(|e| e.to_string())? - }; - let r = std::sync::Arc::new(r); + let r = std::sync::Arc::new(read_slice(zt, i)?.ok_or_else(|| format!("{}: step {i} has no data written", zt.store))?); if let Ok(mut c) = slices().lock() { c.retain(|(k, _)| *k != key); c.push((key, r.clone())); @@ -439,6 +440,39 @@ fn read_step(zt: &ZarrTime, i: usize, key: String) -> Result Option> { + slices().lock().ok().and_then(|c| c.iter().find(|(k, _)| k == key).map(|(_, r)| r.clone())) +} + +/// Step `i` of a following layer's variable, read from its store (no slice kept). +#[cfg(not(target_arch = "wasm32"))] +fn read_slice(zt: &ZarrTime, i: usize) -> Result, String> { + let sel = zt.selection(i); + let crs = zt.crs.as_deref().map(str::trim).filter(|c| !c.is_empty()); + let read = if is_web(&zt.store) { + let st = cached_store(&zt.store)?; + let plan = zarr::plan(&st, &zarr::Selection::parse(&sel)).map_err(|e| e.to_string())?; + let (chunks, forbidden) = fetch(&zt.store, &plan.keys)?; + // A chunk refused (HTTP 403) isn't one never written: it's said, not read as fill. + if forbidden > 0 { + return Err(format!("{}: access denied (HTTP 403) to {forbidden} of the slice's chunks", zt.store)); + } + let crs = crs.map(Crs::parse).transpose()?; + zarr::assemble(&st, &plan, &chunks, &sel, crs) + } else { + zarr::read_dir(Path::new(&zt.store), &sel, crs) + }; + // A step none of whose chunks is there was never written (Zarr's fill value): `None`, told + // apart by its error kind, not its message. + match read { + Ok(r) => Ok(Some(r)), + Err(rustgis_data::DataError::NoChunks(_)) => Ok(None), + Err(e) => Err(e.to_string()), + } +} + /// Layers that follow the time slider read the step it shows, in every map: the last of their /// steps in the slider's window, or none (the layer draws nothing) when no step falls in it. A /// web store's next steps are then read ahead in the background, so playing doesn't wait for @@ -829,3 +863,300 @@ fn series(s: &mut Session, p: &Value) -> CmdResult { })) } } + +// --- A summary over time (opengeos/RustGIS#556) ---------------------------------------------------- + +/// What a summary over time takes from each cell's values. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Stat { + Mean, + Min, + Max, + Sum, + Std, + Count, + /// How many of the values are above the threshold. + CountAbove(f64), +} + +#[cfg(not(target_arch = "wasm32"))] +impl Stat { + fn code(self) -> &'static str { + match self { + Stat::Mean => "mean", + Stat::Min => "min", + Stat::Max => "max", + Stat::Sum => "sum", + Stat::Std => "std", + Stat::Count => "count", + Stat::CountAbove(_) => "countAbove", + } + } +} + +/// A summary over time, prepared on the session (`zarr.reduce`'s parameters checked and the +/// steps chosen) and computed anywhere with [`ReduceRun::run`], which reads one slice at a time: +/// the desktop app runs it on a thread of its own, with progress and Cancel. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Debug)] +pub struct ReduceRun { + zt: ZarrTime, + /// The steps to read, as indexes into `zt.dates`. + steps: Vec, + stat: Stat, + min_valid: f64, + /// The layer's name, and the store and variable to hand the result over with. + pub name: String, + pub root: String, + pub variable: String, +} + +#[cfg(not(target_arch = "wasm32"))] +impl ReduceRun { + /// How many slices it reads. + pub fn total(&self) -> usize { + self.steps.len() + } + + /// Reads the steps one at a time and folds them into running totals (Welford's method for + /// the standard deviation), so memory stays one slice and the totals whatever the period. + /// `done` counts the slices read; `cancel` stops it between slices. + pub fn run(&self, cancel: &std::sync::atomic::AtomicBool, done: &std::sync::atomic::AtomicUsize) -> Result { + use std::sync::atomic::Ordering; + // The first slice's grid (its size, place and coordinate system), all the run keeps of it. + let mut first: Option<(u32, u32, raster::GeoTransform, Crs)> = None; + // Only the totals the statistic needs: each cell's count, one running value (the mean, + // the least, the most, the sum or the count above), and for std the squared deviations. + let (mut n, mut acc, mut m2) = (Vec::::new(), Vec::::new(), Vec::::new()); + // Steps never written (no chunk at all), read as having no value. + let mut unwritten = 0usize; + for &i in &self.steps { + if cancel.load(Ordering::Relaxed) { + return Err("cancelled".into()); + } + // A slice already kept is used; the others are read without being kept, so a long + // summary doesn't push out the slices a layer following the slider holds. + let r = match kept_slice(&slice_key(&self.zt, i)) { + Some(r) => r, + None => match read_slice(&self.zt, i)? { + Some(r) => std::sync::Arc::new(r), + // A step never written is its fill value: no value for any cell, so it only + // lowers the counts (and the step after it still sets the grid). + None => { + unwritten += 1; + done.fetch_add(1, Ordering::Relaxed); + continue; + } + }, + }; + let vals = &r.bands.first().ok_or("the slice has no band")?.data; + match &first { + None => { + let len = vals.len(); + // The running totals take 12 to 20 bytes a cell: bounded before they're made. + if len > MAX_REDUCE_CELLS { + return Err(format!("{len} cells, more than a summary takes ({MAX_REDUCE_CELLS})")); + } + n = vec![0; len]; + acc = match self.stat { + Stat::Count => Vec::new(), + Stat::Min => vec![f64::INFINITY; len], + Stat::Max => vec![f64::NEG_INFINITY; len], + _ => vec![0.0; len], + }; + if matches!(self.stat, Stat::Std) { + m2 = vec![0.0; len]; + } + first = Some((r.width, r.height, r.transform, r.crs)); + } + // One grid for every step: the same size, place and coordinate system. + Some(f) if *f != (r.width, r.height, r.transform, r.crs) || vals.len() != n.len() => { + return Err(format!("step {} is {} × {}, unlike the first ({} × {})", i, r.width, r.height, f.0, f.1)); + } + Some(_) => {} + } + // The totals are the first slice's length, which every slice is checked to have. + for (c, v) in vals.iter().enumerate().take(n.len()) { + if !v.is_finite() { + continue; + } + let v = f64::from(*v); + let Some(k) = n.get_mut(c) else { continue }; + *k += 1; + let k = f64::from(*k); + let (Some(a), m) = (acc.get_mut(c), m2.get_mut(c)) else { continue }; + match self.stat { + Stat::Mean | Stat::Std => { + let d = v - *a; + *a += d / k; + if let Some(m) = m { + *m += d * (v - *a); + } + } + Stat::Min => *a = a.min(v), + Stat::Max => *a = a.max(v), + Stat::Sum => *a += v, + Stat::CountAbove(t) if v > t => *a += 1.0, + Stat::CountAbove(_) | Stat::Count => {} + } + } + done.fetch_add(1, Ordering::Relaxed); + } + let first = first.ok_or_else(|| { + if unwritten > 0 { + format!("none of the {unwritten} steps in the period has data written (only fill values)") + } else { + "no step to read".to_string() + } + })?; + // Too few values: empty, unless the statistic is a count. + let need = (self.min_valid.clamp(0.0, 1.0) * self.steps.len() as f64 - 1e-9).ceil().max(1.0) as u32; + let out: Vec = n + .iter() + .enumerate() + .map(|(c, k)| { + let count = matches!(self.stat, Stat::Count | Stat::CountAbove(_)); + if *k < need && !(count && self.min_valid == 0.0) { + return f32::NAN; + } + let a = acc.get(c).copied().unwrap_or(f64::NAN); + (match self.stat { + Stat::Std => (m2.get(c).copied().unwrap_or(f64::NAN) / f64::from((*k).max(1))).sqrt(), + Stat::Count => f64::from(*k), + _ => a, + }) as f32 + }) + .collect(); + // One band: a project keeps the result as a GeoTIFF of one band (the count is a statistic of its own). + let mut r = + raster::Raster::new(&self.name, first.0, first.1, vec![out], first.2, first.3, None, raster::ColorHint::Gray, "Float32") + .map_err(|e| e.to_string())?; + r.source = Some(self.root.clone()); + Ok(r) + } +} + +/// Prepares `zarr.reduce`: the store, the statistic, and the steps of the period (and months) +/// asked for. At most the time slider's 20,000 steps. +#[cfg(not(target_arch = "wasm32"))] +pub fn reduce_start(s: &Session, p: &Value) -> Result { + let stat = match ps(p, "stat").map(str::trim).ok_or("stat is required (mean, min, max, sum, std, count or countAbove)")? { + x if x.eq_ignore_ascii_case("mean") => Stat::Mean, + x if x.eq_ignore_ascii_case("min") || x.eq_ignore_ascii_case("minimum") => Stat::Min, + x if x.eq_ignore_ascii_case("max") || x.eq_ignore_ascii_case("maximum") => Stat::Max, + x if x.eq_ignore_ascii_case("sum") => Stat::Sum, + x if x.eq_ignore_ascii_case("std") => Stat::Std, + x if x.eq_ignore_ascii_case("count") => Stat::Count, + x if x.eq_ignore_ascii_case("countAbove") => { + Stat::CountAbove(super::f(p, "above").ok_or("countAbove takes \"above\", the threshold")?) + } + x => return Err(format!("unknown stat '{x}' (mean, min, max, sum, std, count or countAbove)")), + }; + let min_valid = match p.get("minValid").filter(|v| !v.is_null()) { + None => 0.0, + Some(v) => v.as_f64().filter(|v| (0.0..=1.0).contains(v)).ok_or("minValid is a share from 0 to 1")?, + }; + let months: Vec = match p.get("months").filter(|v| !v.is_null()) { + None => Vec::new(), + Some(Value::Array(a)) => a + .iter() + .map(|m| m.as_u64().filter(|m| (1..=12).contains(m)).map(|m| m as u32).ok_or("months are numbers from 1 to 12")) + .collect::>()?, + Some(_) => return Err("months is a list of months (1–12)".into()), + }; + let mut src = series_source(s, p)?; + let store = if is_web(&src.root) { + cached_store(&src.root)? + } else { + std::sync::Arc::new(zarr::read_metadata(Path::new(&src.root)).map_err(|e| format!("{}: {e}", src.root))?) + }; + pick_variable(&store, &mut src.sel)?; + let var = src.sel.variable.clone(); + let coords = store_files(&src.root, &zarr::coordinate_keys(&store, &var))?; + let (dim, dates) = zarr::time_axis(&store, &var, &coords) + .ok_or_else(|| format!("{var} has no time dimension (a coordinate with CF units, such as \"days since 2001-01-01\")"))?; + // Every step a date, so the period and months pick the steps they say. + if !dates.iter().all(|d| d.is_finite()) { + return Err(format!("{var}'s time axis has a step that isn't a date")); + } + let date = |k: &str| -> Result, String> { + match p.get(k).filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => rustgis_data::time::parse_input(v) + .map(Some) + .ok_or_else(|| format!("{k} is a date (2015-06-01, a year, or milliseconds since 1970)")), + } + }; + let (start, end) = (date("start")?, date("end")?); + // The layer's period, when no period is given. + let (lo, hi) = match (start, end, src.period) { + (None, None, Some((o, n))) => { + let o = usize::try_from(o).unwrap_or(usize::MAX).min(dates.len()); + (o, o.saturating_add(n).min(dates.len())) + } + _ => (0, dates.len()), + }; + let month = |d: f64| rustgis_data::time::format(d, "MM").parse::().unwrap_or(0); + let steps: Vec = (lo..hi) + .filter(|i| { + let d = dates.get(*i).copied().unwrap_or(f64::NAN); + start.is_none_or(|a| d >= a) && end.is_none_or(|z| d <= z) && (months.is_empty() || months.contains(&month(d))) + }) + .collect(); + if steps.is_empty() { + return Err("no step of the variable falls in the period (and months) asked for".into()); + } + if steps.len() > rustgis_map::raster::MAX_ZARR_STEPS { + return Err(format!( + "{} steps; a summary reads {} at most: take a shorter period", + steps.len(), + rustgis_map::raster::MAX_ZARR_STEPS + )); + } + let day = |i: usize| rustgis_data::time::format(dates.get(i).copied().unwrap_or(f64::NAN), "YYYY-MM-DD"); + let (a, z) = (steps.first().copied().unwrap_or(0), steps.last().copied().unwrap_or(0)); + let mut label = format!("{var} {} {}–{}", stat.code(), day(a), day(z)); + if !months.is_empty() { + let m: Vec = months.iter().map(u32::to_string).collect(); + label = format!("{label} (months {})", m.join(",")); + } + let name = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or(label, |n| n.chars().take(200).collect()); + let zt = ZarrTime { + store: src.root.clone(), + variable: var.clone(), + index: src.sel.index.iter().filter(|(d, _)| *d != dim).cloned().collect(), + dim, + offset: 0, + dates, + crs: src.crs.clone(), + shown: None, + }; + Ok(ReduceRun { zt, steps, stat, min_valid, name, root: src.root, variable: var }) +} + +/// The most cells a summary keeps running totals for (12 to 20 bytes each): 32 million, at most +/// 640 MB. +#[cfg(not(target_arch = "wasm32"))] +const MAX_REDUCE_CELLS: usize = 32_000_000; + +/// `zarr.reduce`: the summary computed here (blocking), and added as a raster layer kept in the +/// project as a GeoTIFF. +fn reduce(s: &mut Session, p: &Value) -> CmdResult { + #[cfg(target_arch = "wasm32")] + { + let _ = (s, p); + Err("in the web app, a Zarr variable can't be summarised over time yet".into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + let run = reduce_start(s, p)?; + let r = run.run(&std::sync::atomic::AtomicBool::new(false), &std::sync::atomic::AtomicUsize::new(0))?; + let tif = raster::geotiff::write(&r, 0).map_err(|e| e.to_string())?; + let mut layer = RasterLayer::from_bytes(r, format!("{}.tif", store_name(&run.root)), tif); + layer.subdataset = None; + let mut out = super::raster::add_raster(s, &run.name, layer)?; + out["steps"] = serde_json::json!(run.total()); + Ok(out) + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f741fdfa2..e5a1e8c1d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7578,6 +7578,72 @@ fn a_zarr_series_at_a_point() { let _ = std::fs::remove_dir_all(dir); } +/// A Zarr variable summarised over time (#556): each statistic, a season, a period and a layer's +/// period, into a raster layer kept in the project. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_zarr_variable_summarised_over_time() { + let (dir, path) = zarr_store_over_time("reduce"); + let mut s = Session::new(Project::default()); + // The first pixel (north up) is the stored cell 8: 8, 108 and 208 at the three steps. + let first = |s: &Session, r: &Value| { + let l = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap(); + let d = l.raster.as_ref().unwrap().data.clone().unwrap(); + d.bands[0].data[0] + }; + for (stat, want) in [("mean", 108.0), ("min", 8.0), ("max", 208.0), ("sum", 324.0), ("count", 3.0)] { + let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": stat})).unwrap(); + assert_eq!(first(&s, &r), want, "{stat}"); + assert_eq!(r["steps"], 3); + } + let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "std"})).unwrap(); + assert!((first(&s, &r) - 81.649_66).abs() < 1e-3, "{}", first(&s, &r)); + let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "countAbove", "above": 150})).unwrap(); + assert_eq!(first(&s, &r), 1.0); + // A season (February alone), and a period. + let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "mean", "months": [2]})).unwrap(); + assert_eq!(first(&s, &r), 108.0); + let name = s.map().layer(r["layer"].as_u64().unwrap() as u32).unwrap().name.clone(); + assert!(name.starts_with("t mean 2001-02-01–2001-02-01 (months 2)"), "{name}"); + let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "mean", "start": "2001-01-15"})).unwrap(); + assert_eq!(first(&s, &r), 158.0); + // Through a layer that follows the slider: its period. + let late = s.run("layer.addZarr", &json!({"path": path, "variable": "t", "time": true, "start": "2001-01-20"})).unwrap()["layer"] + .as_u64() + .unwrap(); + let r = s.run("zarr.reduce", &json!({"layer": late, "stat": "max", "name": "warmest"})).unwrap(); + assert_eq!(first(&s, &r), 208.0); + assert!(s.map().layer_by_name("warmest").is_some()); + // Kept in the project as a GeoTIFF, read again. + let text = serde_json::to_string(&s.project).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.openText", &json!({"text": text})).unwrap(); + let again = t.map().layer_by_name("warmest").and_then(|l| l.raster.as_ref()).and_then(|r| r.data.clone()).unwrap(); + assert_eq!(again.bands[0].data[0], 208.0); + // A step never written (its chunk absent) is Zarr's fill value: no value that step, so the + // summary goes on with the others. + let (gap_dir, gap) = zarr_store_over_time("reduce-gap"); + std::fs::remove_file(std::path::Path::new(&gap).join("t/1.0.0")).unwrap(); + let r = s.run("zarr.reduce", &json!({"path": gap, "variable": "t", "stat": "count"})).unwrap(); + assert_eq!(first(&s, &r), 2.0); + let r = s.run("zarr.reduce", &json!({"path": gap, "variable": "t", "stat": "mean"})).unwrap(); + assert_eq!(first(&s, &r), 108.0); + let _ = std::fs::remove_dir_all(gap_dir); + for bad in [ + json!({"path": path, "variable": "t"}), + json!({"path": path, "variable": "t", "stat": "median"}), + json!({"path": path, "variable": "t", "stat": "countAbove"}), + json!({"path": path, "variable": "t", "stat": "mean", "months": [13]}), + json!({"path": path, "variable": "t", "stat": "mean", "months": "June"}), + json!({"path": path, "variable": "t", "stat": "mean", "minValid": 1.5}), + json!({"path": path, "variable": "t", "stat": "mean", "start": "2005-01-01"}), + json!({"path": path, "variable": "lat", "stat": "mean"}), + ] { + assert!(s.run("zarr.reduce", &bad).is_err(), "{bad}"); + } + let _ = std::fs::remove_dir_all(dir); +} + /// A Zarr variable follows the time slider (#554): the slice drawn is the step the slider is at, /// the stretch is the same at every step, and the project keeps the layer by its store. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/map/src/raster.rs b/crates/map/src/raster.rs index d90f97a8a..2f9cb9b16 100644 --- a/crates/map/src/raster.rs +++ b/crates/map/src/raster.rs @@ -29,7 +29,9 @@ pub struct RasterLayer { /// For a raster GDAL reads: the subdataset added (a NetCDF variable), so it is read again. #[serde(default, skip_serializing_if = "Option::is_none")] pub subdataset: Option, - /// For a raster GDAL reads: the coordinate system given for a file that doesn't say. + /// The coordinate system given for a file or store that doesn't say: for a raster GDAL reads + /// (applied when it's read again from its path), and for a Zarr slice (which its series and + /// summaries read the store with; a slice kept as its GeoTIFF's bytes has it written in). #[serde(default, skip_serializing_if = "Option::is_none")] pub crs: Option, /// A Zarr variable that follows the time slider: its slice is the step the slider shows, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 14ce8f21f..d4fcf87a7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1459,7 +1459,9 @@ impl RustGisApp { let Some(spec) = self.session.registry.get(id).filter(|c| c.mutates).copied() else { return }; // Downloaded data is kept for this session only, and adding it uses it up: a task // couldn't add it again. - if id == "layer.addDownload" { + // A result handed over by the app (`ran`: a summary worked out on a thread) is used up + // the same way. + if id == "layer.addDownload" || (id == "layer.addZarr" && params.get("ran").is_some_and(|r| !r.is_null())) { let text = crate::i18n::fmt( "{command} isn't recorded: the downloaded data is kept for this session only, so a task couldn't add it again.", &[("command", tl_raw!(spec.label))], @@ -3368,6 +3370,7 @@ const DEFAULTS_TO_ACTIVE_LAYER: &[&str] = &[ "share.webLayer", "share.webStyle", "share.locator", + "zarr.reduce", ]; #[cfg(test)] diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index a574de094..277c9b072 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -47,6 +47,12 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { if app.ui.right != Some(RightPane::TimeSeries) && app.workflow.pick.as_ref().is_some_and(|p| p.purpose == "ts_point") { app.workflow.pick = None; } + // A Zarr summary running on its thread lands whatever pane is in view, and the frames go on + // while it runs so it's taken when it ends. + #[cfg(not(target_arch = "wasm32"))] + if crate::workspacepanes::land_summary(app) { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(200)); + } // The pane in view always has its tab, however it was set. if let Some(right) = app.ui.right && !app.ui.right_open.contains(&right) diff --git a/crates/ui-egui/src/workspacepanes.rs b/crates/ui-egui/src/workspacepanes.rs index d3717c75a..7f2a424bf 100644 --- a/crates/ui-egui/src/workspacepanes.rs +++ b/crates/ui-egui/src/workspacepanes.rs @@ -35,6 +35,15 @@ pub struct WorkspaceState { /// The Time Series pane's Zarr layer, and the series read at points (`zarr.series`), newest last. pub series_layer: String, pub series: Vec, + /// The Time Series pane's summary over time (#556): the statistic, its threshold, the period + /// and months as typed, and the run under way. + pub reduce_stat: String, + pub reduce_above: f64, + pub reduce_start: String, + pub reduce_end: String, + pub reduce_months: String, + #[cfg(not(target_arch = "wasm32"))] + pub reducing: Option, pub message: Option<(String, bool)>, pub catalog_view: bool, pub item_popups: bool, @@ -463,6 +472,154 @@ pub fn profile(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ); } +/// A summary over time running on a thread of its own: Cancel, the slices read, and the result. +#[cfg(not(target_arch = "wasm32"))] +pub struct Reducing { + pub run: rustgis_engine::cmd::zarr::ReduceRun, + /// Its place among the project's downloads: a summary that lands after another project + /// opened is dropped. + pub id: u64, + pub cancel: std::sync::Arc, + pub done: std::sync::Arc, + pub result: std::sync::Arc, String>>>>, +} + +/// A summary that has finished, added to the map (or its error kept for the pane): called +/// every frame, whatever pane is in view, so a run lands though the Time Series pane closed or +/// its layer went. `true` while one is still running. +#[cfg(not(target_arch = "wasm32"))] +pub fn land_summary(app: &mut RustGisApp) -> bool { + use std::sync::atomic::Ordering; + let Some(r) = &app.ws.reducing else { return false }; + let Some(res) = r.result.lock().ok().and_then(|mut g| g.take()) else { return true }; + let (root, variable, name, id) = (r.run.root.clone(), r.run.variable.clone(), r.run.name.clone(), r.id); + // A run the user cancelled stops quietly: no error for it. + let cancelled = r.cancel.load(Ordering::Relaxed); + app.ws.reducing = None; + match res { + // Cancelled as it finished: nothing added either. + Ok(_) if cancelled => app.session.download_failed(id), + // Added to the project it was run in: one opened meanwhile doesn't get it. + Ok(tif) => { + if app.session.download_landed(id, tif) { + let key = if root.starts_with("http://") || root.starts_with("https://") { "url" } else { "path" }; + // `run` puts any error in the pane's message. + let _ = run(app, "layer.addZarr", json!({key: root, "variable": variable, "ran": id, "summary": true, "name": name})); + } else { + app.ws.message = + Some((tl!("The summary finished after another project was opened, so it wasn't added").to_string(), false)); + } + } + Err(_) if cancelled => app.session.download_failed(id), + Err(e) => { + app.session.download_failed(id); + app.ws.message = Some((e, true)); + } + } + false +} + +/// The Time Series pane's Summarise over time section (#556): a statistic of the chosen Zarr +/// layer's steps over a period (and months), computed on a thread with progress and Cancel, and +/// added as a raster layer. +#[cfg(not(target_arch = "wasm32"))] +fn summarise(app: &mut RustGisApp, ui: &mut Ui) { + use std::sync::atomic::Ordering; + // A run under way: its progress and Cancel (its result lands through `land_summary`). + if land_summary(app) + && let Some(r) = &app.ws.reducing + { + let (done, total) = (r.done.load(Ordering::Relaxed), r.run.total().max(1)); + // The section's title stays over its progress. + ui.label(egui::RichText::new(tl!("Summarise over time")).strong()); + ui.horizontal(|ui| { + ui.add(egui::ProgressBar::new(done as f32 / total as f32).text(format!("{done} / {total}")).desired_width(200.0)); + if ui.button(tl!("Cancel")).clicked() { + r.cancel.store(true, Ordering::Relaxed); + } + }); + return; + } + egui::CollapsingHeader::new(tl!("Summarise over time")).id_salt("series_reduce").show(ui, |ui| { + if app.ws.reduce_stat.is_empty() { + app.ws.reduce_stat = "mean".into(); + } + egui::Grid::new("reduce_grid").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Statistic")); + egui::ComboBox::from_id_salt("reduce_stat").selected_text(app.ws.reduce_stat.as_str()).show_ui(ui, |ui| { + for st in ["mean", "min", "max", "sum", "std", "count", "countAbove"] { + ui.selectable_value(&mut app.ws.reduce_stat, st.to_string(), st); + } + }); + ui.end_row(); + if app.ws.reduce_stat == "countAbove" { + ui.label(tl!("Above")); + ui.add(egui::DragValue::new(&mut app.ws.reduce_above).speed(0.5)); + ui.end_row(); + } + ui.label(tl!("From")); + ui.add(egui::TextEdit::singleline(&mut app.ws.reduce_start).hint_text("1991-01-01").desired_width(110.0)); + ui.end_row(); + ui.label(tl!("To")); + ui.add(egui::TextEdit::singleline(&mut app.ws.reduce_end).hint_text("2020-12-31").desired_width(110.0)); + ui.end_row(); + ui.label(tl!("Months")); + ui.add(egui::TextEdit::singleline(&mut app.ws.reduce_months).hint_text("12, 1, 2").desired_width(110.0)); + ui.end_row(); + }); + if ui.button(tl!("Summarise")).clicked() { + let mut p = json!({"layer": app.ws.series_layer, "stat": app.ws.reduce_stat}); + if app.ws.reduce_stat == "countAbove" { + p["above"] = json!(app.ws.reduce_above); + } + for (k, v) in [("start", &app.ws.reduce_start), ("end", &app.ws.reduce_end)] { + if !v.trim().is_empty() { + p[k] = json!(v.trim()); + } + } + // Every month written a number: one that isn't (`Dec`, a typo) is said, not dropped. + let typed: Vec<&str> = app.ws.reduce_months.split([',', ' ']).map(str::trim).filter(|m| !m.is_empty()).collect(); + let months: Vec = typed.iter().filter_map(|m| m.parse::().ok()).map(|m| json!(m)).collect(); + if months.len() != typed.len() { + app.ws.message = Some((tl!("Months are numbers from 1 to 12 (12, 1, 2 for December to February)").to_string(), true)); + return; + } + if !months.is_empty() { + p["months"] = Value::Array(months); + } + match rustgis_engine::cmd::zarr::reduce_start(&app.session, &p) { + Ok(job) => { + let id = app.session.download_started(); + let r = + Reducing { run: job.clone(), id, cancel: Default::default(), done: Default::default(), result: Default::default() }; + let (cancel, done, result) = (r.cancel.clone(), r.done.clone(), r.result.clone()); + // A panic on the thread (a hostile store) lands as an error, so the run always + // ends; a thread the system can't start is said. + let spawned = std::thread::Builder::new().name("zarr-summary".into()).spawn(move || { + let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + job.run(&cancel, &done) + .and_then(|r| rustgis_engine::data::raster::geotiff::write(&r, 0).map_err(|e| e.to_string())) + })) + .unwrap_or_else(|_| Err(tl!("The summary failed unexpectedly").to_string())); + if let Ok(mut g) = result.lock() { + *g = Some(out); + } + }); + match spawned { + Ok(_) => app.ws.reducing = Some(r), + Err(e) => { + app.session.download_failed(id); + app.ws.message = + Some((crate::i18n::fmt("The summary couldn't start: {error}", &[("error", &e.to_string())]), true)) + } + } + } + Err(e) => app.ws.message = Some((e, true)), + } + } + }); +} + /// The most series the Time Series pane keeps (one line each). const MAX_SERIES: usize = 6; @@ -522,6 +679,8 @@ pub fn time_series(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { app.ws.series.drain(..excess); } } + #[cfg(not(target_arch = "wasm32"))] + summarise(app, ui); if app.ws.series.is_empty() { ui.label(egui::RichText::new(tl!("Pick a point on the map to see the variable's values through time there.")).weak().small()); return; @@ -911,6 +1070,29 @@ mod tests { h.get_by_label("Clear").click(); h.run_steps(2); assert!(h.state().ws.series.is_empty()); + // Summarise over time: the mean of the layer's three steps, computed on a thread and added. + h.get_by_label("Summarise over time").click(); + h.run_steps(2); + h.get_by_label("Summarise").click(); + for _ in 0..500 { + h.run_steps(1); + if h.state().ws.reducing.is_none() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(h.state().ws.reducing.is_none(), "the summary lands"); + let mean = h + .state() + .session + .map() + .layer_by_name("t mean 2001-01-01–2001-03-01") + .and_then(|l| l.raster.as_ref()) + .and_then(|r| r.data.clone()); + assert_eq!(mean.map(|d| d.bands[0].data[0]), Some(108.0)); + // A summary is no slice of the variable: Time Series doesn't offer it as one. + let summary = h.state().session.map().layer_by_name("t mean 2001-01-01–2001-03-01").and_then(|l| l.raster.as_ref()); + assert_eq!(summary.and_then(|r| r.subdataset.clone()), None); let _ = std::fs::remove_dir_all(dir); } } diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 4cbdbfca8..bb070aabc 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -509,6 +509,22 @@ takes the slider to that date. **Export CSV…** writes the date and value of ea rustgis-cli run --cmd 'zarr.series={"path":"air.zarr","variable":"t","lon":-55.7,"lat":-12.5}' ``` +**Summarise over time** (in the Time Series pane; `zarr.reduce`) makes a raster of one statistic of +a Zarr variable's steps: **mean**, **min**, **max**, **sum**, **std** (the population standard +deviation), **count** of values, or **countAbove** a threshold (days above 30 °C, say). It reads a +layer's period by default, else the `"start"`/`"end"` given, and `"months"` keeps one season across +years (`[12, 1, 2]`). Slices are read one at a time and folded into running totals (12 to 20 bytes a +cell, for grids of up to 32 million cells), so memory depends on the grid, not on the period: at most +20,000 steps, with progress and **Cancel** in the app. A cell keeps no value when it had fewer values +than `"minValid"` (a share, 0 by default) of the steps read; with the default, **count** and +**countAbove** give 0 where a cell never had a value (land in an ocean variable, say), and a +`"minValid"` above 0 leaves such cells empty. The layer is named after what it holds (`tas mean 1991-01-01–2020-12-31`) and kept in the +project as a GeoTIFF. + +```sh +rustgis-cli run --cmd 'zarr.reduce={"path":"air.zarr","variable":"t","stat":"mean","months":[12,1,2]}' +``` + A single step (a layer that doesn't follow the slider) from a store in a folder is saved in the project by its folder and slice (`2m_temperature[time=539100]`) and read again when the project opens, as Add Data on the folder does (its first variable); a single step from a store by web From 2a82824d72028145060761d99463227a0bfa2e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Wed, 21 Oct 2026 14:58:28 -0500 Subject: [PATCH 006/297] feat(ui): show the highlighted command's signature, name and parameters beside the Script suggestions (#672) * feat(ui): show the highlighted command's signature, name and parameters beside the Script suggestions * test(engine): check every command's parameter entries start with a known key * refactor(ui): keep the Script help's id and keys apart * fix(ui): let the last to move, the pointer or the arrows, lead the Script help * fix(ui): put the Script help under the suggestions when the screen is too narrow beside them * fix(engine): skip escaped quotes when splitting a parameter description * fix(ui): keep the Script suggestions within the room by the cursor, scrolling to the highlight * fix(engine): skip escaped quotes when splitting a parameter description --- crates/engine/src/lib.rs | 47 +++++ crates/engine/src/tests.rs | 28 +++ crates/ui-egui/src/scriptcolor.rs | 71 ++++++-- crates/ui-egui/src/scriptcomplete.rs | 257 +++++++++++++++++++++++++-- docs/user-guide/analysis.md | 3 +- 5 files changed, 378 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2b17c0e8..c63e8e07f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -95,6 +95,53 @@ pub fn param_keys(desc: &str) -> Option> { Some(keys) } +/// One entry of a parameter description: its key, whether it may be left out, and its text as +/// written (`"size"?: number`), for help that lists the parameters one per line. +#[derive(Clone, Debug, PartialEq)] +pub struct Param<'a> { + pub key: &'a str, + pub optional: bool, + pub text: &'a str, +} + +/// The entries of a `{"key": …, "other"?: …}` description, split at its top-level commas (commas +/// inside quotes, parentheses, brackets or braces belong to an entry's text). The key is an +/// entry's first quoted word; an entry without one (prose) is left out. None when the +/// description isn't an object. +pub fn param_list(desc: &str) -> Option>> { + // Up to the last brace: a note may follow the object, as in `{"path"?: file} (a style)`. + let body = desc.trim().strip_prefix('{')?; + let inner = body.get(..body.rfind('}')?)?; + let mut out = Vec::new(); + let (mut depth, mut quoted, mut start, mut escaped) = (0usize, false, 0, false); + for (i, c) in inner.char_indices() { + // A quote after a backslash (`\"`, in a plugin's description) doesn't open or close one. + let was_escaped = escaped; + escaped = c == '\\' && !was_escaped; + match c { + '"' if !was_escaped => quoted = !quoted, + '(' | '[' | '{' if !quoted => depth += 1, + ')' | ']' | '}' if !quoted => depth = depth.saturating_sub(1), + ',' if !quoted && depth == 0 => { + out.extend(inner.get(start..i).and_then(param)); + start = i + 1; + } + _ => {} + } + } + out.extend(inner.get(start..).and_then(param)); + Some(out) +} + +/// An entry of a parameter description (see `param_list`): None for prose without a key. +fn param(part: &str) -> Option> { + let text = part.trim(); + let rest = text.strip_prefix('"')?; + let (key, after) = rest.split_at(rest.find('"')?); + let optional = after.get(1..).is_some_and(|a| a.trim_start().starts_with('?')); + Some(Param { key, optional, text }) +} + /// Whether a parameter description has a required key. Question marks in values do not count. pub fn needs_input(desc: &str) -> bool { let mut required = false; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e5a1e8c1d..4a0214b66 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8575,6 +8575,34 @@ fn needs_input_reads_descriptions() { assert!(s.registry.get("map.zoomIn").is_some_and(|c| !c.needs_input())); } +/// A description splits into one entry per parameter, for help that lists them (#662): commas +/// inside quotes, parentheses, brackets or braces stay in their entry. +#[test] +fn param_list_splits_a_description_into_entries() { + use crate::{Param, param_list}; + let desc = + r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "steps"?: [{"tool": id}, …], "where": "a, b"}"#; + let list = param_list(desc).unwrap(); + let keys: Vec<(&str, bool)> = list.iter().map(|p| (p.key, p.optional)).collect(); + assert_eq!(keys, [("layer", true), ("class", true), ("steps", true), ("where", false)]); + assert_eq!(list[1].text, r#""class"?: name (none: the Default class, by whatever name it has)"#); + assert_eq!(param_list("{}"), Some(Vec::::new())); + // An escaped quote (a plugin's description may have one) doesn't end a value early. + let escaped = param_list(r#"{"title"?: "a \"quoted, word\"", "n": number}"#).unwrap(); + assert_eq!(escaped.iter().map(|p| p.key).collect::>(), ["title", "n"]); + assert_eq!(param_list("a GeoJSON text"), None); + // Every command's description splits into entries, each led by a key `param_keys` finds: one + // cut in the wrong place (an unclosed bracket or quote in its prose) would lead with another. + let s = Session::new(Project::default()); + for c in &s.registry.commands { + let list = param_list(c.params).unwrap_or_else(|| panic!("{}: {}", c.id, c.params)); + let keys = c.param_keys().unwrap_or_default(); + for p in &list { + assert!(keys.contains(&p.key), "{}: entry {:?} in {}", c.id, p.text, c.params); + } + } +} + #[test] fn param_keys_reads_descriptions() { use crate::param_keys; diff --git a/crates/ui-egui/src/scriptcolor.rs b/crates/ui-egui/src/scriptcolor.rs index 553c69da2..19a29bcdf 100644 --- a/crates/ui-egui/src/scriptcolor.rs +++ b/crates/ui-egui/src/scriptcolor.rs @@ -134,7 +134,9 @@ fn json_spans(text: &str, at: usize, broken: bool, out: &mut Vec) { } // A key is a string with a colon after it. let after = text.get(i..).map(str::trim_start); - let kind = if after.is_some_and(|a| a.starts_with(':')) { Kind::Key } else { Kind::Str }; + // (A command's parameter description marks a key that may be left out with `?`: + // `"size"?: number`, `"layer"?,`. A script's JSON never has one there.) + let kind = if after.is_some_and(|a| a.starts_with(':') || a.starts_with('?')) { Kind::Key } else { Kind::Str }; push(s..i, kind, out); } b'-' | b'0'..=b'9' => { @@ -207,23 +209,46 @@ impl Palette { pub fn job(script: &str, unknown: &[String], palette: Palette, font: &FontId) -> LayoutJob { let mut job = LayoutJob::default(); for s in spans(script, unknown) { - let color = match s.kind { - Kind::Plain | Kind::Key => palette.text, - Kind::Comment => palette.comment, - Kind::Punct | Kind::Unread => palette.dim, - Kind::Id => palette.id, - Kind::BadId => palette.bad, - Kind::Str => palette.string, - Kind::Num | Kind::Literal => palette.number, - }; - // What won't run is underlined too, not told by its colour alone (WCAG 1.4.1). - let underline = if s.broken || s.kind == Kind::BadId { Stroke::new(1.0, palette.bad) } else { Stroke::NONE }; - let format = TextFormat { font_id: font.clone(), color, underline, italics: s.kind == Kind::Comment, ..Default::default() }; - job.append(script.get(s.range).unwrap_or_default(), 0.0, format); + append(&mut job, script, &s, palette, font); + } + job +} + +/// A command's signature or one of its parameters, for the Script suggestions' help (#662), +/// coloured as the script colours a command and its JSON, so the help reads as what's typed. +/// `id` leads, in the id's colour, when given. +pub fn help_job(id: Option<&str>, text: &str, palette: Palette, font: &FontId) -> LayoutJob { + let full = format!("{}{text}", id.unwrap_or_default()); + let at = full.len() - text.len(); + let mut spans = Vec::new(); + if at > 0 { + spans.push(Span { range: 0..at, kind: Kind::Id, broken: false }); + } + json_spans(text, at, false, &mut spans); + let mut job = LayoutJob::default(); + for s in &spans { + append(&mut job, &full, s, palette, font); } job } +/// `s` of `text` appended to `job` in its colour. +fn append(job: &mut LayoutJob, text: &str, s: &Span, palette: Palette, font: &FontId) { + let color = match s.kind { + Kind::Plain | Kind::Key => palette.text, + Kind::Comment => palette.comment, + Kind::Punct | Kind::Unread => palette.dim, + Kind::Id => palette.id, + Kind::BadId => palette.bad, + Kind::Str => palette.string, + Kind::Num | Kind::Literal => palette.number, + }; + // What won't run is underlined too, not told by its colour alone (WCAG 1.4.1). + let underline = if s.broken || s.kind == Kind::BadId { Stroke::new(1.0, palette.bad) } else { Stroke::NONE }; + let format = TextFormat { font_id: font.clone(), color, underline, italics: s.kind == Kind::Comment, ..Default::default() }; + job.append(text.get(s.range.clone()).unwrap_or_default(), 0.0, format); +} + /// Works out a script's colours once per change of the script (or theme, or of the ids no /// command has), not every frame (egui's frame cache). #[derive(Default)] @@ -593,4 +618,22 @@ mod tests { h.run(); assert!(h.state().workflow.script.contains('z')); } + + /// The help's signature and parameters take the script's colours: the id, keys (`?` marks + /// one that may be left out) and string values. + #[test] + fn help_reads_in_the_scripts_colours() { + let palette = super::Palette::of(&crate::theme::Tokens::light()); + let font = egui::FontId::monospace(12.0); + // The colour where `piece` starts (egui joins neighbouring pieces of one format). + let colour = |job: &egui::text::LayoutJob, piece: &str| { + let at = job.text.find(piece)?; + job.sections.iter().find(|s| (s.byte_range.start.0..s.byte_range.end.0).contains(&at)).map(|s| s.format.color) + }; + let sig = super::help_job(Some("map.body"), " {name?, basemap?}", palette, &font); + assert_eq!(colour(&sig, "map.body"), Some(palette.id)); + let p = super::help_job(None, r#""name"?: "earth"|"moon" (map.bodies lists them)"#, palette, &font); + assert_eq!(colour(&p, r#""name""#), Some(palette.text), "a key, `?` and all"); + assert_eq!(colour(&p, r#""moon""#), Some(palette.string)); + } } diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 01e517761..b2e6ea02c 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -89,6 +89,79 @@ pub fn script_ids(session: &rustgis_engine::Session) -> Vec { builtin.chain(plugins).filter(|id| !refused_in_script(id)).collect() } +/// What the help beside the list says about a command (#662), most useful first: its signature +/// (required keys bare, optional ones with `?`), its name and help (a plugin's command has a help +/// text; a built-in one, its label), its parameters one per line as described, and last where it +/// sits in the interface. +#[derive(Clone, Debug, PartialEq)] +pub struct Help { + pub id: String, + /// The keys after the id, as the signature reads (`labels.style` then ` {layer?, size?}`); + /// empty for a command with none. + pub keys: String, + pub label: String, + pub about: String, + pub params: Vec, + pub location: String, +} + +/// The help for command `id`, as Command Search names and places it (translated); None for an id +/// no command has. +pub fn help(session: &rustgis_engine::Session, id: &str) -> Option { + let lang = crate::i18n::current().code(); + let (label, about, location, desc) = match session.registry.get(id) { + Some(c) => (tl!(c.label).to_string(), String::new(), crate::i18n::location(c.location), c.params.to_string()), + None => { + let c = rustgis_engine::plugins::command(id)?; + let m = &c.plugin.manifest; + let location = format!("{} › {}", tl!("Plugins"), m.translate(lang, &m.name)); + (m.translate(lang, &c.decl.label).to_string(), m.translate(lang, &c.decl.help).to_string(), location, c.decl.params.clone()) + } + }; + let params = rustgis_engine::param_list(&desc).unwrap_or_default(); + let names: Vec = params.iter().map(|p| format!("{}{}", p.key, if p.optional { "?" } else { "" })).collect(); + let keys = if names.is_empty() { String::new() } else { format!(" {{{}}}", names.join(", ")) }; + Some(Help { id: id.to_string(), keys, label, about, params: params.iter().map(|p| p.text.to_string()).collect(), location }) +} + +/// The help's widest line, so a long signature or parameter wraps rather than widen the list +/// without end. +const HELP_WIDTH: f32 = 360.0; +/// The narrowest the help gets beside the list; with less room it goes under the list. +const HELP_MIN_WIDTH: f32 = 200.0; +/// The gap between the list and the help, and the popup frame's margins (both sides). +const GAP: f32 = 12.0; +const FRAME: f32 = 24.0; + +/// Draws `help` beside the list: the signature and parameters coloured as the script is, in its +/// monospace font (smaller for the parameters). +fn draw_help(ui: &mut Ui, help: &Help, width: f32) { + let palette = crate::scriptcolor::Palette::of(&crate::theme::Tokens::get(ui.ctx())); + let mono = egui::TextStyle::Monospace.resolve(ui.style()); + let small = egui::FontId::monospace(mono.size * 0.85); + let coloured = |id: Option<&str>, text: &str, font: &egui::FontId| { + let mut job = crate::scriptcolor::help_job(id, text, palette, font); + job.wrap.max_width = width; + job + }; + ui.vertical(|ui| { + ui.set_max_width(width); + ui.label(coloured(Some(&help.id), &help.keys, &mono)); + ui.label(&help.label); + if !help.about.is_empty() { + ui.label(egui::RichText::new(&help.about).small()); + } + if !help.params.is_empty() { + ui.add_space(4.0); + for p in &help.params { + ui.label(coloured(None, p, &small)); + } + } + ui.add_space(4.0); + ui.label(egui::RichText::new(&help.location).small().weak()); + }); +} + /// The list's state between frames, kept in one place. #[derive(Clone, Default)] struct List { @@ -108,6 +181,13 @@ struct List { /// Where the list was drawn last frame: a press on it takes the focus from the box (a click /// elsewhere), so the list stays while the pointer is on it, for the press to reach it. rect: Option, + /// Whether the help follows the item under the pointer (it moved last) or the highlighted one + /// (an arrow key moved it last): a pointer resting on the list mustn't hold the help while + /// the arrows move the highlight. + pointer_leads: bool, + /// The highlighted item the list last scrolled to: it scrolls to a new one only (so a wheel + /// can still move it). + scrolled_to: Option, } fn load(ui: &Ui, box_id: Id) -> List { @@ -161,6 +241,7 @@ pub fn keys(ui: &Ui, box_id: Id, script: &str) -> Option<(Offer, String)> { if up || down { let selected = if up { (selected + n - 1) % n } else { (selected + 1) % n }; list.open = Some((offer, selected)); + list.pointer_leads = false; } None }; @@ -233,6 +314,14 @@ pub fn show( store(ui, box_id, list); return None; }; + if ui.input(|i| i.pointer.delta() != egui::Vec2::ZERO) { + list.pointer_leads = true; + } + let pointer_leads = list.pointer_leads; + // A new list starts from its top: its scroll is the old list's otherwise. + if list.open.as_ref().is_none_or(|(o, _)| o.items != offer.items) { + list.scrolled_to = None; + } let kept = list.open.as_ref().and_then(|(o, s)| o.items.get(*s)); let selected = kept.and_then(|item| offer.items.iter().position(|i| i == item)).unwrap_or(0); // Just under the cursor, or above it when the list would run off the bottom; kept on screen @@ -241,22 +330,59 @@ pub fn show( let row = ui.text_style_height(&egui::TextStyle::Monospace) + ui.spacing().item_spacing.y + 4.0; let size = list.rect.map_or(egui::vec2(MIN_WIDTH + 16.0, row * offer.items.len() as f32 + 12.0), |r| r.size()); let screen = ui.ctx().content_rect(); - let y = if cursor.bottom() + size.y > screen.bottom() { cursor.top() - size.y } else { cursor.bottom() }; + // Under the cursor if it fits there, else above if it fits there, else on the side with more + // room; the list and help scroll within that room (a short window, a long help). + let (below, above) = (screen.bottom() - cursor.bottom(), cursor.top() - screen.top()); + let under = size.y <= below || (size.y > above && below >= above); + let room = (if under { below } else { above } - FRAME).max(row * 3.0); + let y = if under { cursor.bottom() } else { cursor.top() - size.y.min(above) }; let x = cursor.left().min(screen.right() - size.x).max(screen.left()); + // The help beside the list in the room the screen leaves, or under it when that's too narrow + // to read (a narrow window), so neither is cut off at the screen's edge. + let beside = (screen.width() - MIN_WIDTH - GAP - FRAME).min(HELP_WIDTH); + let (beside, help_width) = + if beside >= HELP_MIN_WIDTH { (true, beside) } else { (false, (screen.width() - FRAME).clamp(0.0, HELP_WIDTH)) }; + let layout = if beside { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; let mut clicked = None; let area = egui::Area::new(box_id.with("complete_list")).order(egui::Order::Foreground).fixed_pos(egui::pos2(x, y)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(MIN_WIDTH); - for (i, item) in offer.items.iter().enumerate() { - let r = ui.selectable_label(i == selected, egui::RichText::new(item).monospace()); - // Taken as the button goes down, as in a code editor: the press already takes the - // focus from the box, and a click (down, then up a frame or more later) could find - // the list gone by then. A click still counts (assistive technology). - if r.clicked() || (r.hovered() && ui.input(|i| i.pointer.primary_pressed())) { - clicked = Some((offer.clone(), item.clone())); - } - } + egui::ScrollArea::vertical().max_height(room).auto_shrink([true, true]).show(ui, |ui| { + ui.with_layout(layout, |ui| { + // The help follows the item under the pointer, else the highlighted one. + let mut hovered = None; + ui.vertical(|ui| { + ui.set_min_width(MIN_WIDTH); + for (i, item) in offer.items.iter().enumerate() { + let r = ui.selectable_label(i == selected, egui::RichText::new(item).monospace()); + // Taken as the button goes down, as in a code editor: the press already + // takes the focus from the box, and a click (down, then up a frame or more + // later) could find the list gone by then. A click still counts + // (assistive technology). + if r.clicked() || (r.hovered() && ui.input(|i| i.pointer.primary_pressed())) { + clicked = Some((offer.clone(), item.clone())); + } + if r.hovered() { + hovered = Some(i); + } + if i == selected && list.scrolled_to != Some(selected) { + r.scroll_to_me(None); + list.scrolled_to = Some(selected); + } + } + }); + // Worked out each frame the list shows, for one command: a registry lookup and a + // short description split. Kept, it would need the id and the language as its + // key (the help is translated), for next to nothing saved. + let shown = hovered.filter(|_| pointer_leads).unwrap_or(selected); + if let Some(help) = offer.items.get(shown).and_then(|id| help(session, id)) { + // A gap, not a separator: in a row, egui's separator takes all the height it + // can, and would stretch the list down to the bottom of the screen. + ui.add_space(GAP); + draw_help(ui, &help, help_width); + } + }); + }); }); }); if clicked.is_some() { @@ -417,7 +543,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut h = pane(); type_in(&mut h, "map.zoomF"); - let at = h.get_by_label("map.zoomFull").rect().center(); + let at = h.get_by_role_and_label(egui::accesskit::Role::Button, "map.zoomFull").rect().center(); let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, @@ -555,7 +681,7 @@ mod tests { h.state_mut().workflow.script = "map.zoomFu".into(); h.run(); assert!(h.query_by_label("labels.style").is_none(), "the old offer isn't reused"); - h.get_by_label("map.zoomFull"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "map.zoomFull"); } /// A list left open over a script changed from outside the box isn't acted on: Enter doesn't @@ -571,4 +697,109 @@ mod tests { h.run(); assert!(!h.state().workflow.script.contains("labels.style"), "{:?}", h.state().workflow.script); } + + /// The help puts the signature first, then the name, the parameters one per line and the + /// location last. + #[test] + fn help_shows_the_signature_first() { + let app = crate::RustGisApp::with_sample(); + let h = super::help(&app.session, "labels.style").unwrap(); + assert!(h.keys.starts_with(" {layer?, class?, size?"), "{}", h.keys); + assert_eq!(h.label, "Text Symbol"); + assert!(h.params.iter().any(|p| p == r#""size"?: number"#), "{:?}", h.params); + assert_eq!(h.location, "Style › Text Symbol"); + // Required keys go bare. + assert!(super::help(&app.session, "labels.field").unwrap().keys.contains("field}")); + // No parameters: the id alone. + let z = super::help(&app.session, "map.zoomFull").unwrap(); + assert_eq!((z.keys.as_str(), z.params.len()), ("", 0)); + assert!(super::help(&app.session, "nope.cmd").is_none()); + } + + /// The help shows beside the list, for the highlighted item, and follows the arrows. + #[test] + fn the_help_follows_the_highlight() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + type_in(&mut h, "labels."); + let first = super::offer("labels.", 7, &super::script_ids(&h.state().session)).unwrap().items; + let sig = |id: &str| super::help(&h.state().session, id).map(|h| format!("{}{}", h.id, h.keys)).unwrap(); + let (one, two) = (sig(&first[0]), sig(&first[1])); + h.get_by_label(&one); + h.key_press(egui::Key::ArrowDown); + h.run(); + assert!(h.query_by_label(&one).is_none()); + h.get_by_label(&two); + } + + /// With the pointer resting on the list, the arrows still move the help with the highlight; + /// moving the pointer gives it back to the item under it. + #[test] + fn the_last_to_move_leads_the_help() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + type_in(&mut h, "labels."); + let items = super::offer("labels.", 7, &super::script_ids(&h.state().session)).unwrap().items; + let sig = |h: &egui_kittest::Harness<'static, crate::RustGisApp>, id: &str| { + super::help(&h.state().session, id).map(|x| format!("{}{}", x.id, x.keys)).unwrap() + }; + // The pointer over the third item: the help follows it. + let third = h.get_by_role_and_label(egui::accesskit::Role::Button, &items[2]).rect().center(); + // From somewhere first: egui's first pointer position has no delta to move by. + h.event(egui::Event::PointerMoved(egui::pos2(1.0, 1.0))); + h.run(); + h.event(egui::Event::PointerMoved(third)); + h.run(); + h.get_by_label(&sig(&h, &items[2])); + // ↓ with the pointer still: the help follows the highlight (the second item). + h.key_press(egui::Key::ArrowDown); + h.run(); + h.get_by_label(&sig(&h, &items[1])); + } + + /// In a window too narrow for the help beside the list, the help goes under it: neither is + /// cut off at the edge. + #[test] + fn a_narrow_window_puts_the_help_under_the_list() { + use egui_kittest::kittest::Queryable; + let width = 380.0; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, 700.0)).build_ui_state( + |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), + crate::RustGisApp::with_sample(), + ); + h.run(); + h.get_by_role(egui::accesskit::Role::MultilineTextInput).focus(); + h.run(); + h.event(egui::Event::Text("labels.sty".into())); + h.run(); + let item = h.get_by_role_and_label(egui::accesskit::Role::Button, "labels.style").rect(); + let help = super::help(&h.state().session, "labels.style").unwrap(); + let sig = h.get_by_label(&format!("{}{}", help.id, help.keys)).rect(); + assert!(sig.min.y > item.max.y, "under the list: {sig:?} {item:?}"); + assert!(sig.max.x <= width + 0.5, "within the window: {sig:?}"); + } + + /// In a short window the list and help scroll within the room by the cursor: the popup stays + /// on screen, and ↓ to the last item scrolls it into view. + #[test] + fn a_short_window_scrolls_the_list() { + use egui_kittest::kittest::Queryable; + let (width, height) = (380.0, 300.0); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, height)).build_ui_state( + |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), + crate::RustGisApp::with_sample(), + ); + h.run(); + h.get_by_role(egui::accesskit::Role::MultilineTextInput).focus(); + h.run(); + h.event(egui::Event::Text("labels.".into())); + h.run(); + let items = super::offer("labels.", 7, &super::script_ids(&h.state().session)).unwrap().items; + for _ in 1..items.len() { + h.key_press(egui::Key::ArrowDown); + h.run_steps(10); + } + let last = h.get_by_role_and_label(egui::accesskit::Role::Button, items.last().unwrap()).rect(); + assert!(last.min.y >= 0.0 && last.max.y <= height, "the last item scrolled into view: {last:?}"); + } } diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 648c5b206..ae6bc54e9 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -228,7 +228,8 @@ both themes: `#` comments in green italics, the command id, and the JSON's strin `notebook.run`), and parameters that aren't valid JSON are red and underlined. A script is read to its 10,000th line; any further are dimmed, as Run doesn't read them. Typing a line's first word opens a list of the commands it starts, under the cursor: ↑/↓ to move, Enter or Tab to accept, -Escape to close, or a click. +Escape to close, or a click. Beside it, the highlighted command's help: its signature (keys with `?` +may be left out), its name, its parameters one per line, and where it sits in the interface. ## ModelBuilder From 38b7693dfd2d90020dcbddb3fcb3d2241d08513b Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Wed, 21 Oct 2026 17:29:39 -0400 Subject: [PATCH 007/297] Ship third-party licence notices and check dependency licences (#674) * Ship third-party licence notices and check dependency licences The MIT, BSD, ISC, Zlib and Apache crates RustGIS is built from ask for their copyright and licence text to go with every copy, binaries included, but the packages shipped only RustGIS's own LICENSE. - `cargo xtask notices` writes THIRD-PARTY-LICENSES.txt (the workspace) and apps/rustgis-python/THIRD-PARTY-LICENSES.txt (the Python package) from cargo-about's report: each licence text once with the crates under it, plus the crates' NOTICE files. about.toml adds the licences of the fonts epaint_default_fonts embeds. - Every package ships the file next to LICENSE: tarballs, .deb, .rpm, AppImage, the macOS app and CLI, the Windows zip, installers and MSIX, the Python sdist and wheels, and the web app. - The About dialog and backstage link to it and to ATTRIBUTION.md. - CI runs `cargo deny check licenses` against deny.toml's allow-list and warns when the notices are stale; the release build fails then. * Address review: --fail, stale exit code, install command, web link - cargo about runs with --fail, so a licence it can't work out is an error instead of a gap in the notices. - `cargo xtask notices --check` exits 2 for stale files; CI warns only for that and fails on any other error. Unknown arguments are rejected. - The install command needs --features cli for the cargo-about binary. - The web app links to the notices it serves. --- .github/workflows/ci.yml | 30 + .github/workflows/python.yml | 1 + .github/workflows/release.yml | 20 +- AGENTS.md | 3 + ATTRIBUTION.md | 8 +- Cargo.lock | 3 + THIRD-PARTY-LICENSES.txt | 10849 +++++++++++++++++ about.toml | 68 + apps/rustgis-python/THIRD-PARTY-LICENSES.txt | 8312 +++++++++++++ apps/rustgis-python/pyproject.toml | 5 +- apps/rustgis-web/index.html | 2 + crates/ui-egui/src/backstage.rs | 1 + crates/ui-egui/src/chrome.rs | 18 + deny.toml | 31 + packaging/package.sh | 16 +- packaging/windows/rustgis.nsi | 4 +- packaging/windows/rustgis.wxs | 6 +- xtask/Cargo.toml | 1 + xtask/src/main.rs | 44 +- xtask/src/notices.rs | 216 + 20 files changed, 19621 insertions(+), 17 deletions(-) create mode 100644 THIRD-PARTY-LICENSES.txt create mode 100644 about.toml create mode 100644 apps/rustgis-python/THIRD-PARTY-LICENSES.txt create mode 100644 deny.toml create mode 100644 xtask/src/notices.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c0934c61..fa0c4aecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,36 @@ jobs: - name: Build for the browser run: cargo build --locked --target wasm32-unknown-unknown -p rustgis-web + # Every dependency's licence must be one the project accepts (deny.toml). The third-party + # notices the packages ship (`cargo xtask notices`) only warn here when out of date, since + # Dependabot can't regenerate them; the release build requires them current. + licenses: + name: Licences + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + # Only main saves the cache (see test-rust.yml). + save-if: ${{ github.ref == 'refs/heads/main' }} + - uses: taiki-e/install-action@v2 + with: + tool: cargo-deny,cargo-about@0.9.2 + - name: Dependency licences + run: cargo deny --locked check licenses + - name: Third-party notices + # Exit code 2 is stale notices (a warning); anything else (cargo-about failing, a licence + # it can't work out) fails the job. + run: | + status=0 + cargo xtask notices --check || status=$? + if [ "$status" -eq 2 ]; then + echo "::warning::THIRD-PARTY-LICENSES.txt is out of date: run cargo xtask notices and commit it before the next release." + elif [ "$status" -ne 0 ]; then + exit "$status" + fi + plan: name: Platforms runs-on: ubuntu-latest diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 9d4d35c7c..3aae3d124 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -125,6 +125,7 @@ jobs: # conda-forge and other repackagers need the license in the sdist. # PyPI rejects an sdist whose PKG-INFO License-File isn't at its root. tar tzf ./*.tar.gz | grep -E '^[^/]+/LICENSE$' || { echo "::error::The sdist has no LICENSE file at its root."; exit 1; } + tar tzf ./*.tar.gz | grep -E '^[^/]+/THIRD-PARTY-LICENSES.txt$' || { echo "::error::The sdist has no THIRD-PARTY-LICENSES.txt at its root."; exit 1; } python -m pip install ./*.tar.gz pytest python -m pytest -q test_session.py - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 203fb14a3..04fbfc372 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -179,6 +179,7 @@ jobs: function Check($dir, $scope) { & "$dir\rustgis-cli.exe" logo "$env:RUNNER_TEMP\logo.png" --size 64 if ($LASTEXITCODE) { throw "rustgis-cli failed in $dir" } + if (-not (Test-Path "$dir\THIRD-PARTY-LICENSES.txt")) { throw "no THIRD-PARTY-LICENSES.txt in $dir" } $path = [Environment]::GetEnvironmentVariable('Path', $scope) -split ';' if ($path -notcontains $dir.TrimEnd('\') -and $path -notcontains "$($dir.TrimEnd('\'))\") { throw "$dir is not on the $scope PATH" @@ -220,6 +221,7 @@ jobs: sudo apt-get install -y ./dist/rustgis_*.deb dpkg-deb -f dist/rustgis_*.deb Depends rustgis-cli logo "$RUNNER_TEMP/logo.png" --size 64 + test -s /usr/share/doc/rustgis/THIRD-PARTY-LICENSES.txt - name: Check the .rpm installs on Fedora if: runner.os == 'Linux' @@ -251,9 +253,25 @@ jobs: path: dist/* if-no-files-found: error + # The packages ship THIRD-PARTY-LICENSES.txt as committed: it must match Cargo.lock. + notices: + name: Third-party notices + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + save-if: false + - uses: taiki-e/install-action@v2 + with: + tool: cargo-about@0.9.2 + - name: The notices are up to date + run: cargo xtask notices --check + checksums: name: Checksums - needs: build + needs: [build, notices] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v8 diff --git a/AGENTS.md b/AGENTS.md index a5ce7be66..a51d3ce9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,9 @@ is a command, a thin egui front end, headless screenshots for UI work. ## Quality gates `cargo xtask ci` (fmt, clippy -D warnings, tests, layers) before every commit. +A new dependency must have a licence `deny.toml` allows (CI runs `cargo deny check licenses`); after +changing dependencies, run `cargo xtask notices` to refresh the `THIRD-PARTY-LICENSES.txt` files the +packages ship (the release build fails on stale ones). Pull requests run CI on Linux; pushes to `main` add macOS and Windows. Label a pull request `ci:windows`, `ci:macos` or `ci:full` to test there before merging (platform-specific code, paths, file dialogs). The release build of the apps runs nightly (`.github/workflows/nightly.yml`), with every diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 80e781356..3767d726b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -1,6 +1,12 @@ # Attribution -Every asset file in this repository, with its source and licence. +Every asset file in this repository, with its source and licence. The licences of the Rust +crates RustGIS is built from, with their copyright notices, are in +[THIRD-PARTY-LICENSES.txt](THIRD-PARTY-LICENSES.txt) (the Python package's in +[apps/rustgis-python/THIRD-PARTY-LICENSES.txt](apps/rustgis-python/THIRD-PARTY-LICENSES.txt)), +which every package ships next to LICENSE. They are generated from `Cargo.lock` by +`cargo xtask notices` (it needs `cargo install --locked --features cli cargo-about@0.9.2`); +`deny.toml` lists the licences a dependency may have. | File | Author / source | Licence | |---|---|---| diff --git a/Cargo.lock b/Cargo.lock index c863b2551..a5db5a9ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7023,6 +7023,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" version = "0.3.0" +dependencies = [ + "serde_json", +] [[package]] name = "yoke" diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt new file mode 100644 index 000000000..fc0ad2276 --- /dev/null +++ b/THIRD-PARTY-LICENSES.txt @@ -0,0 +1,10849 @@ +Third-party software notices for RustGIS (the desktop app, the command line and the web app) + +RustGIS is licensed under the Apache License 2.0 (see LICENSE). It is built with the +open-source Rust crates below, each under its own licence, whose copyright notices and terms +are reproduced here, followed by the NOTICE files the crates ship. Generated from Cargo.lock +by `cargo xtask notices`. + +==================================================================================================== +Apache License 2.0 +Used by: a5, ab_glyph, ab_glyph_rasterizer, accesskit, accesskit_atspi_common, accesskit_consumer, +accesskit_macos, accesskit_unix, accesskit_windows, accesskit_winit, ahash, allocator-api2, approx, +arboard, arc-swap, arrayvec, as-raw-xcb-connection, async-channel, async-executor, async-io, +async-lock, async-process, async-recursion, async-signal, async-task, async-trait, atomic-waker, +atspi, atspi-common, atspi-proxies, autocfg, aws-lc-sys, base64, bit-set, bit-vec, bitflags, +block-buffer, blocking, btoi, bufstream, bumpalo, bytemuck, bytemuck_derive, cc, cfg-if, chacha20, +cmake, cmov, codepage, codespan-reporting, color, concurrent-queue, console_log, const-oid, +const-random, const-random-macro, core-foundation, core-foundation-sys, core-graphics, +core-graphics-types, core_detect, cpufeatures, crossbeam-deque, crossbeam-epoch, crossbeam-queue, +crossbeam-utils, crypto-common, ctutils, der, der_derive, derive_utils, digest, dispatch2, +displaydoc, document-features, downcast-rs, dunce, ecolor, eframe, egui, egui-wgpu, egui-winit, +egui_extras, either, emath, encoding_rs, enum-map, enum-map-derive, epaint, equivalent, errno, +euclid, event-listener, event-listener-strategy, fastrand, fdeflate, fearless_simd, filetime, +find-msvc-tools, flagset, flatbuffers, flate2, form_urlencoded, futures-lite, futures-timer, geo, +geo-traits, geo-types, geozero, gethostname, gif, gl_generator, glow, guillotiere, half, hash32, +hashbrown, hashlink, heapless, heck, hmac, httparse, hybrid-array, i_overlay, idna, idna_adapter, +image, image-webp, indexmap, io-enum, itertools, itoa, jobserver, js-sys, khronos-egl, khronos_api, +kurbo, laz, lazy_static, leb128fmt, libc, linebender_resource_handle, linux-raw-sys, litrs, +lock_api, log, lyon, lyon_algorithms, lyon_geom, lyon_path, lyon_tessellation, md-5, md5, +miniz_oxide, multiversion_no_op, mysql, mysql_common, naga, naga-types, named_pipe, nohash-hasher, +num-bigint, num-conv, num-integer, num-traits, objc2-app-kit, objc2-core-foundation, +objc2-core-graphics, objc2-metal, objc2-quartz-core, objc2-system-configuration, once_cell, +openssl-probe, ordered-stream, owned_ttf_parser, parking, parking_lot, parking_lot_core, parquet, +peniko, percent-encoding, pin-project, pin-project-internal, pin-project-lite, piper, pkg-config, +png, polling, pollster, polycool, postgres, postgres-protocol, postgres-types, presser, proc-macro2, +profiling, proj4rs, proj4rs-geodesic, pyo3, pyo3-build-config, pyo3-ffi, pyo3-macros, +pyo3-macros-backend, quote, rand, range-alloc, raw-window-handle, raw-window-metal, rayon, +rayon-core, regex, regex-automata, regex-syntax, renderdoc-sys, ring, robust, roxmltree, rstar, +rustc-hash, rustc_version, rustix, rustls, rustls-native-certs, rustls-pemfile, rustversion, ryu, +scoped-tls, scopeguard, secrecy, security-framework, security-framework-sys, self_cell, semver, +seq-macro, serde, serde_core, serde_derive, serde_json, serde_repr, sha1, sha2, shlex, sif-itree, +signal-hook-registry, simdutf8, siphasher, smallvec, smol_str, socket2, spade, spirv, spki, +stable_deref_trait, static_assertions, syn, sys-locale, tar, tempfile, thiserror, thiserror-impl, +tiberius-macros, time, time-core, tinyvec, tokio-postgres, ttf-parser, type-map, typed-path, +unicode-bidi, unicode-ident, unicode-normalization, unicode-properties, unicode-segmentation, +unicode-width, ureq, ureq-proto, url, utf8-zero, utf8_iter, uuid, vcpkg, vello_common, vello_cpu, +version_check, wasm-bindgen, wasm-bindgen-futures, wasm-bindgen-macro, wasm-bindgen-macro-support, +wasm-bindgen-shared, wasm-encoder, wasmi, wasmi_collections, wasmi_core, wasmi_ir, wasmparser, wast, +wat, web-sys, weezl, wgpu, wgpu-core, wgpu-core-deps-apple, wgpu-core-deps-wasm, +wgpu-core-deps-windows-linux-android, wgpu-hal, wgpu-naga-bridge, wgpu-types, whoami, winauth, wkt, +x11rb, x11rb-protocol, x509-cert, xattr, zeroize, zune-core, zune-jpeg +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: ash +==================================================================================================== + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright 2016 Maik Klein + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: async-broadcast, connection-string +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2020 Yoshua Wuyts + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: atoi_simd +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2022-NOW Dmitry Rodionov + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: chrono +==================================================================================================== + +Rust-chrono is dual-licensed under The MIT License [1] and +Apache 2.0 License [2]. Copyright (c) 2014--2026, Kang Seonghoon and +contributors. + +Nota Bene: This is same as the Rust Project's own license. + + +[1]: , which is reproduced below: + +~~~~ +The MIT License (MIT) + +Copyright (c) 2014, Kang Seonghoon. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +~~~~ + + +[2]: , which is reproduced below: + +~~~~ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +~~~~ + +==================================================================================================== +Apache License 2.0 +Used by: crc32fast, dpi, fallible-iterator, fallible-streaming-iterator, foreign-types, foreign-types-macros, +foreign-types-shared, hex, quick-error, stringprep, unicode-general-category, winapi, winit +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: cursor-icon +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Kirill Chibisov + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: debug_unsafe +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025-NOW Dmitry Rodionov + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: deranged +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Jacob Pratt et al. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: enumflags2 +==================================================================================================== + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + +Copyright 2017-2023 Maik Klein, Maja Kądziołka + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: enumflags2_derive +==================================================================================================== + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + +Copyright [2017] [Maik Klein] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: fast-float2 +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 Ivan Smirnov + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: font-types, read-fonts, skrifa +==================================================================================================== + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright 2019 Fontations Developers + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: futures-channel, futures-core, futures-io, futures-macro, futures-sink, futures-task, futures-util +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: getrandom, rand, rand_chacha, rand_core +==================================================================================================== + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: glutin_wgl_sys +==================================================================================================== + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022 Kirill Chibisov + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: gpu-allocator +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 Traverse Research B.V. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: http +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 http-rs authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: iana-time-zone +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2020 Andrew Straw + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: linux-raw-sys, rustix, target-lexicon, wasm-encoder, wast, wat +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +--- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + +==================================================================================================== +Apache License 2.0 +Used by: memmap2 +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [2015] [Dan Burkert] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: moxcms, pxfm +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Radzivon Bartoshyk + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: powerfmt +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Jacob Pratt et al. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: ppv-lite86 +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2019 The CryptoCorrosion Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: proc-macro-crate +==================================================================================================== + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/LICENSE-2.0 + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: rand_core, rand_pcg +==================================================================================================== + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +==================================================================================================== +Apache License 2.0 +Used by: ring +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +Licenses for support code +------------------------- + +Parts of the TLS test suite are under the Go license. This code is not included +in BoringSSL (i.e. libcrypto and libssl) when compiled, however, so +distributing code linked against BoringSSL does not trigger this license: + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +BoringSSL uses the Chromium test infrastructure to run a continuous build, +trybots etc. The scripts which manage this, and the script for generating build +metadata, are under the Chromium license. Distributing code linked against +BoringSSL does not trigger this license. + +Copyright 2015 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +Apache License 2.0 +Used by: rustls-pki-types +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2023 Dirkjan Ochtman + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: serde_spanned, toml, toml_datetime, toml_edit, toml_parser, toml_writer +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright Individual contributors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: tiberius +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 Tiberius Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: tokio-rustls +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 quininer kel + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: typenum +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2014 Paho Lurie-Gregg + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: web-time +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 dAxpeDDa + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: webbrowser +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +Copyright (c) 2015-2022 Amod Malviya + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: windows, windows-collections, windows-core, windows-future, windows-implement, windows-interface, +windows-link, windows-numerics, windows-result, windows-strings, windows-sys, windows-targets, +windows-threading, windows_x86_64_gnu, windows_x86_64_msvc +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: xkeysym +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2022-2023 John Nunley + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: zerocopy, zerocopy-derive +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Fuchsia Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: zopfli +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2011 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +BSD 2-Clause "Simplified" License +Used by: arrayref +==================================================================================================== + +Copyright (c) 2015 David Roundy +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 2-Clause "Simplified" License +Used by: flatgeobuf +==================================================================================================== + +Copyright (c) + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: alloc-no-stdlib, brotli-decompressor +==================================================================================================== + +Copyright (c) 2016 Dropbox, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: alloc-stdlib, aws-lc-sys +==================================================================================================== + +Copyright (c) . + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: encoding_rs +==================================================================================================== + +// Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +/// The PUA code points special-cased in the GB18030 encoder. +pub(crate) static GB18030_2022_OVERRIDE_PUA: [u16; 18] = [ + 0xE78D, 0xE78E, 0xE78F, 0xE790, 0xE791, 0xE792, 0xE793, 0xE794, 0xE795, 0xE796, 0xE81E, 0xE826, + 0xE82B, 0xE82C, 0xE832, 0xE843, 0xE854, 0xE864, +]; + +/// The bytes corresponding to the PUA code points special-cased in the GB18030 encoder. +pub(crate) static GB18030_2022_OVERRIDE_BYTES: [[u8; 2]; 18] = [ + [0xA6, 0xD9], + [0xA6, 0xDA], + [0xA6, 0xDB], + [0xA6, 0xDC], + [0xA6, 0xDD], + [0xA6, 0xDE], + [0xA6, 0xDF], + [0xA6, 0xEC], + [0xA6, 0xED], + [0xA6, 0xF3], + [0xFE, 0x59], + [0xFE, 0x61], + [0xFE, 0x66], + [0xFE, 0x67], + [0xFE, 0x6D], + [0xFE, 0x7E], + [0xFE, 0x90], + [0xFE, 0xA0], +]; + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: encoding_rs +==================================================================================================== + +Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: snap +==================================================================================================== + +Copyright 2011, The Snappy-Rust Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: subtle +==================================================================================================== + +Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved. +Copyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: tiny-skia, tiny-skia-path +==================================================================================================== + +Copyright (c) 2011 Google Inc. All rights reserved. +Copyright (c) 2020 Yevhenii Reizner All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: zstd, zstd-safe, zstd-sys +==================================================================================================== + +BSD 3-Clause License + +Copyright (c) 2026, Alexandre Bury + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: zstd-sys +==================================================================================================== + +BSD License + +For Zstandard software + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook, nor Meta, nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: zstd-sys +==================================================================================================== + +The auto-generated bindings are under the 3-clause BSD license: + +BSD License + +For Zstandard software + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook, nor Meta, nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD Zero Clause License +Used by: adler2 +==================================================================================================== + +Copyright (C) Jonas Schievink + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==================================================================================================== +Boost Software License 1.0 +Used by: clipboard-win, error-code +==================================================================================================== + +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +Community Data License Agreement Permissive 2.0 +Used by: webpki-roots +==================================================================================================== + +# Community Data License Agreement - Permissive - Version 2.0 + +This is the Community Data License Agreement - Permissive, Version +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree +as follows: + +## 1. Provision of the Data + +1.1. A Data Recipient may use, modify, and share the Data made +available by Data Provider(s) under this agreement if that Data +Recipient follows the terms of this agreement. + +1.2. This agreement does not impose any restriction on a Data +Recipient's use, modification, or sharing of any portions of the +Data that are in the public domain or that may be used, modified, +or shared under any other legal exception or limitation. + +## 2. Conditions for Sharing Data + +2.1. A Data Recipient may share Data, with or without modifications, so +long as the Data Recipient makes available the text of this agreement +with the shared Data. + +## 3. No Restrictions on Results + +3.1. This agreement does not impose any restriction or obligations +with respect to the use, modification, or sharing of Results. + +## 4. No Warranty; Limitation of Liability + +4.1. All Data Recipients receive the Data subject to the following +terms: + +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING +WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +## 5. Definitions + +5.1. "Data" means the material received by a Data Recipient under +this agreement. + +5.2. "Data Provider" means any person who is the source of Data +provided under this agreement and in reliance on a Data Recipient's +agreement to its terms. + +5.3. "Data Recipient" means any person who receives Data directly +or indirectly from a Data Provider and agrees to the terms of this +agreement. + +5.4. "Results" means any outcome obtained by computational analysis +of Data, including for example machine learning models and models' +insights. + +==================================================================================================== +Creative Commons Zero v1.0 Universal +Used by: crs-definitions, tiny-keccak +==================================================================================================== + +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. + +==================================================================================================== +ISC License +Used by: aws-lc-rs, aws-lc-sys +==================================================================================================== + +ISC License: + +Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC") +Copyright (c) 1995-2003 by Internet Software Consortium + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==================================================================================================== +ISC License +Used by: earcut +==================================================================================================== + +ISC License + +Copyright (c) 2016, Mapbox +Copyright (c) 2023, Taku Fukada + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + +==================================================================================================== +ISC License +Used by: libloading +==================================================================================================== + +Copyright © 2015, Simonas Kazlauskas + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without +fee is hereby granted, provided that the above copyright notice and this permission notice appear +in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS +SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +/* Copyright (c) 2014, Intel Corporation. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ + +#ifndef OPENSSL_HEADER_EC_ECP_NISTZ384_H +#define OPENSSL_HEADER_EC_ECP_NISTZ384_H + +#include "../../limbs/limbs.h" + +#define P384_LIMBS (384u / LIMB_BITS) + +typedef struct { + Limb X[P384_LIMBS]; + Limb Y[P384_LIMBS]; + Limb Z[P384_LIMBS]; +} P384_POINT; + +typedef struct { + Limb X[P384_LIMBS]; + Limb Y[P384_LIMBS]; +} P384_POINT_AFFINE; + + +#endif // OPENSSL_HEADER_EC_ECP_NISTZ384_H + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2015-2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +//! EdDSA Signatures. + +use super::ops::ELEM_LEN; +use crate::digest; + +pub mod signing; +pub mod verification; + +/// The length of an Ed25519 public key. +pub const ED25519_PUBLIC_KEY_LEN: usize = ELEM_LEN; + +pub fn eddsa_digest(signature_r: &[u8], public_key: &[u8], msg: &[u8]) -> digest::Digest { + let mut ctx = digest::Context::new(&digest::SHA512); + ctx.update(signature_r); + ctx.update(public_key); + ctx.update(msg); + ctx.finish() +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2015-2022 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use crate::limb::Limb; + +#[derive(Clone, Copy)] +#[repr(transparent)] +pub struct N0([Limb; 2]); + +impl N0 { + #[cfg(feature = "alloc")] + pub(super) const LIMBS_USED: usize = 64 / crate::limb::LIMB_BITS; + + #[inline] + pub const fn precalculated(n0: u64) -> Self { + #[cfg(target_pointer_width = "64")] + { + Self([n0, 0]) + } + + #[cfg(target_pointer_width = "32")] + { + Self([n0 as Limb, (n0 >> crate::limb::LIMB_BITS) as Limb]) + } + } +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2015-2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use crate::{bb, error}; + +#[deprecated( + note = "To be removed. Internal function not intended for external use with no promises regarding side channels." +)] +pub fn verify_slices_are_equal(a: &[u8], b: &[u8]) -> Result<(), error::Unspecified> { + bb::verify_slices_are_equal(a, b) +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +//! Elliptic curve operations and schemes using Curve25519. + +pub mod ed25519; + +pub mod x25519; + +mod ops; +mod scalar; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2016-2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use crate::error::{KeyRejected, Unspecified}; + +impl From for Unspecified { + fn from(source: untrusted::EndOfInput) -> Self { + super::erase(source) + } +} + +impl From for Unspecified { + fn from(source: core::array::TryFromSliceError) -> Self { + super::erase(source) + } +} + +impl From for Unspecified { + fn from(source: KeyRejected) -> Self { + super::erase(source) + } +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2018 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +//! Serialization and deserialization. + +#[doc(hidden)] +pub mod der; + +#[cfg(feature = "alloc")] +mod writer; + +#[cfg(feature = "alloc")] +pub(crate) mod der_writer; + +pub(crate) mod positive; + +pub use self::positive::Positive; + +#[cfg(feature = "alloc")] +pub(crate) use self::writer::TooLongError; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2019-2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use super::BlockLen; + +pub(super) use self::{ + sha2_32::{block_data_order_32, State32, SHA256_BLOCK_LEN}, + sha2_64::{block_data_order_64, State64, SHA512_BLOCK_LEN}, +}; + +pub(super) const CHAINING_WORDS: usize = 8; + +#[cfg(any( + all(target_arch = "aarch64", target_endian = "little"), + all(target_arch = "arm", target_endian = "little"), + target_arch = "x86_64" +))] +#[macro_use] +mod ffi; + +pub(super) mod fallback; +mod sha2_32; +mod sha2_64; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +// TODO(MSRV 1.76): Replace with `core::ptr::from_mut`. +#[allow(dead_code)] +#[inline(always)] +pub fn from_mut(r: &mut T) -> *mut T { + r +} + +// TODO(MSRV 1.76): Replace with `core::ptr::from_ref`. +#[allow(dead_code)] +#[inline(always)] +pub const fn from_ref(r: &T) -> *const T { + r +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +//! Integration tests for non-public APIs. + +mod bits_tests; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +pub use self::{ + array::Array, + base::{IndexError, Overlapping}, + partial_block::PartialBlock, +}; + +mod array; +mod base; +mod partial_block; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +#![cfg(all(target_arch = "aarch64", target_endian = "little"))] + +pub(in super::super) mod mont; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +#![cfg(target_arch = "x86_64")] + +pub(in super::super::super) mod mont; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +mod storage; + +pub(super) use self::storage::{AlignedStorage, LIMBS_PER_CHUNK}; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +pub(super) mod aarch64; +pub(super) mod x86_64; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +Copyright 2015-2025 Brian Smith. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==================================================================================================== +ISC License +Used by: rustls-webpki, webpki +==================================================================================================== + +Except as otherwise noted, this project is licensed under the following +(ISC-style) terms: + +Copyright 2015 Brian Smith. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +The files under third-party/chromium are licensed as described in +third-party/chromium/LICENSE. + +==================================================================================================== +ISC License +Used by: untrusted +==================================================================================================== + +// Copyright 2015-2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==================================================================================================== +MIT License +Used by: aho-corasick, byteorder, byteorder-lite, csv, csv-core, memchr +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: asynchronous-codec +==================================================================================================== + +MIT License + +Copyright (c) 2019 - 2020 Matt Hunzinger +Copyright (c) 2021 Max Inden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: aws-lc-sys +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2015-2020 the fiat-crypto authors (see +https://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS). + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: block2, bufrw, dispatch, dpi, i_key_sort, libm, objc-sys, objc2, objc2-app-kit, objc2-encode, +objc2-foundation, rsqlite-vfs +==================================================================================================== + +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: bytes +==================================================================================================== + +Copyright (c) 2018 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: calamine, quick-xml +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2016 Johann Tuffe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: calloop, smithay-client-toolkit +==================================================================================================== + +Copyright (c) 2018 Victor Berger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: calloop-wayland-source, wayland-csd-frame +==================================================================================================== + +Copyright (c) 2023 Kirill Chibisov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: cfg_aliases +==================================================================================================== + +# 3rd Party Notices + +The `cfg_aliases!` macro uses a lot of the code from [`tectonic_cfg_support::target_cfg!`] macro which is under the following license: + +[`tectonic_cfg_support::target_cfg!`]: https://github.com/tectonic-typesetting/tectonic/blob/f2439b936470ad27bdf92882064bc4702ee01899/cfg_support/src/lib.rs#L166 + + tectonic_cfg_support is licensed under the MIT License. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the “Software”), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +--- + +==================================================================================================== +MIT License +Used by: cfg_aliases +==================================================================================================== + +MIT License + +Copyright (c) 2020 Katharos Technology + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: color_quant +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2016 PistonDevelopers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: crunchy +==================================================================================================== + +The MIT License (MIT) + +Copyright 2017-2023 Eira Fransham. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: dbase, shapefile +==================================================================================================== + +MIT License + +Copyright (c) 2019 Thomas Montaigu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: dlib +==================================================================================================== + +Copyright (c) 2015 Victor Berger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: embed-resource +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2017 nabijaczleweli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: endi, winnow, x11-dl, zbus-lockstep, zbus-lockstep-macros, zmij, zvariant_utils +==================================================================================================== + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: epaint_default_fonts +==================================================================================================== + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: epaint_default_fonts +==================================================================================================== + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. + +==================================================================================================== +MIT License +Used by: fax +==================================================================================================== + +Copyright © 2021 The pdf-rs contributers. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: float_next_after +==================================================================================================== + +MIT License + +Copyright (c) 2020 Scripta Qumranica Electronica + +Created by Bronson Brown-deVost + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: fs_extra +==================================================================================================== + +MIT License + +Copyright (c) 2017 Denis Kurilenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: generic-array +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2015 Bartłomiej Kamiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: geographiclib-rs +==================================================================================================== + +MIT License + +Copyright (c) 2019 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: harfrust +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) HarfBuzz developers +Copyright (c) 2020 Yevhenii Reizner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: i_float, i_shape +==================================================================================================== + +MIT License + +Copyright (c) 2023 iShape-Rust + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: i_tree +==================================================================================================== + +MIT License + +Copyright (c) 2024 iShape-Rust + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: libsqlite3-sys, rusqlite +==================================================================================================== + +Copyright (c) 2014 The rusqlite developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: lru +==================================================================================================== + +MIT License + +Copyright (c) 2016 Jerome Froelich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: lz4_flex +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2020 Pascal Seitz + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: memoffset +==================================================================================================== + +Copyright (c) 2017 Gilad Naaman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: mio +==================================================================================================== + +Copyright (c) 2014 Carl Lerche and other MIO contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: ordered-float +==================================================================================================== + +Copyright (c) 2015 Jonathan Reem + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: pem +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2016 Jonathan Creekmore + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: phf, phf_generator, phf_macros, phf_shared +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: pretty-hex +==================================================================================================== + +MIT License + +Copyright (c) 2018 Andrei Volnin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: rfd +==================================================================================================== + +MIT License + +Copyright (c) 2020 Bilal Elmoussaoui + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: rfd +==================================================================================================== + +MIT License + +Copyright (c) 2022 Bartłomiej Maryńczak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: ruzstd +==================================================================================================== + +MIT License + +Copyright (c) 2019 Moritz Borcherding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: saturating +==================================================================================================== + +MIT License + +Copyright (c) 2019 Breezewish + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: schannel +==================================================================================================== + +Copyright (c) 2015 steffengy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: scroll +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) m4b 2016 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: simd-adler32 +==================================================================================================== + +MIT License + +Copyright (c) [2021] [Marvin Countryman] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: slab +==================================================================================================== + +Copyright (c) 2019 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: smithay-clipboard +==================================================================================================== + +Copyright (c) 2018 Lucas Timmins & Victor Berger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: spin, tracing-core, zip +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: sqlite-wasm-rs +==================================================================================================== + +MIT License + +Copyright (c) 2024 Spxg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: strict-num +==================================================================================================== + +Copyright (c) 2022 Yevhenii Reizner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: synstructure +==================================================================================================== + +Copyright 2016 Nika Layzell + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: tiff +==================================================================================================== + +MIT License + +Copyright (c) 2018 PistonDevelopers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: tokio, tokio-util +==================================================================================================== + +MIT License + +Copyright (c) Tokio Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: tokio-postgres-rustls +==================================================================================================== + +MIT License + +Copyright (c) 2019 Jasper Hugo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: tracing, tracing-attributes, tracing-core +==================================================================================================== + +Copyright (c) 2019 Tokio Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: twox-hash +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2015 Jake Goulding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: uds_windows +==================================================================================================== + + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + +==================================================================================================== +MIT License +Used by: vswhom, vswhom-sys +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2019 nabijaczleweli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: wayland-backend, wayland-client, wayland-cursor, wayland-protocols, wayland-protocols-experimental, +wayland-protocols-misc, wayland-protocols-plasma, wayland-protocols-wlr, wayland-scanner, +wayland-sys +==================================================================================================== + +Copyright (c) 2015 Elinor Berger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: wayland-protocols +==================================================================================================== + +Copyright © 2008-2013 Kristian Høgsberg +Copyright © 2010-2013 Intel Corporation +Copyright © 2013 Rafael Antognolli +Copyright © 2013 Jasper St. Pierre +Copyright © 2014 Jonas Ådahl +Copyright © 2014 Jason Ekstrand +Copyright © 2014-2015 Collabora, Ltd. +Copyright © 2015 Red Hat Inc. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +--- + +The above is the version of the MIT "Expat" License used by X.org: + + http://cgit.freedesktop.org/xorg/xserver/tree/COPYING + +==================================================================================================== +MIT License +Used by: wayland-protocols-plasma +==================================================================================================== + +MIT License Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice (including the next +paragraph) shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF +OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: winreg +==================================================================================================== + +Copyright (c) 2015 Igor Shaula + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: xcursor +==================================================================================================== + +MIT License + +Copyright (c) 2020 Samuele Esposito + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: xkbcommon-dl +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2023 Kirill Chibisov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: xml-rs +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2014 Vladimir Matveev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: zbus, zbus_macros, zbus_names, zbus_xml, zvariant, zvariant_derive +==================================================================================================== + +Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: zcheapstr +==================================================================================================== + +Copyright (c) 2026 Zeeshan Ali Khan & zcheapstr contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: zip +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Some files in the "tests/data" subdirectory of this repository are under other +licences; see files named LICENSE.*.txt for details. + +==================================================================================================== +MIT License +Used by: zstd +==================================================================================================== + +The MIT License (MIT) +Copyright (c) 2016 Alexandre Bury + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +SIL Open Font License 1.1 +Used by: epaint_default_fonts +==================================================================================================== + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +==================================================================================================== +Ubuntu Font Licence v1.0 +Used by: epaint_default_fonts +==================================================================================================== + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + +==================================================================================================== +Unicode License v3 +Used by: icu_collections, icu_locale_core, icu_normalizer, icu_normalizer_data, icu_properties, +icu_properties_data, icu_provider, litemap, potential_utf, tinystr, writeable, yoke, yoke-derive, +zerofrom, zerofrom-derive, zerotrie, zerovec, zerovec-derive +==================================================================================================== + +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2020-2024 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 + +— + +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. + +==================================================================================================== +Unicode License v3 +Used by: unicode-ident +==================================================================================================== + +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +==================================================================================================== +zlib License +Used by: foldhash +==================================================================================================== + +Copyright (c) 2024 Orson Peters + +This software is provided 'as-is', without any express or implied warranty. In +no event will the authors be held liable for any damages arising from the use of +this software. + +Permission is granted to anyone to use this software for any purpose, including +commercial applications, and to alter it and redistribute it freely, subject to +the following restrictions: + +1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a product, + an acknowledgment in the product documentation would be appreciated but is + not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. + +==================================================================================================== +zlib License +Used by: slotmap +==================================================================================================== + +Copyright (c) 2021 Orson Peters + +This software is provided 'as-is', without any express or implied warranty. In +no event will the authors be held liable for any damages arising from the use of +this software. + +Permission is granted to anyone to use this software for any purpose, including +commercial applications, and to alter it and redistribute it freely, subject to +the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a product, + an acknowledgment in the product documentation would be appreciated but is + not required. + + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + + 3. This notice may not be removed or altered from any source distribution. + +==================================================================================================== +zlib License +Used by: zlib-rs +==================================================================================================== + +(C) 2024 Trifecta Tech Foundation + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. + +==================================================================================================== +NOTICES.md of cfg_aliases +==================================================================================================== + +# 3rd Party Notices + +The `cfg_aliases!` macro uses a lot of the code from [`tectonic_cfg_support::target_cfg!`] macro which is under the following license: + +[`tectonic_cfg_support::target_cfg!`]: https://github.com/tectonic-typesetting/tectonic/blob/f2439b936470ad27bdf92882064bc4702ee01899/cfg_support/src/lib.rs#L166 + + tectonic_cfg_support is licensed under the MIT License. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the “Software”), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +--- + +==================================================================================================== +NOTICE.txt of parquet +==================================================================================================== + +Apache Arrow +Copyright 2016-2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +This product includes software from the chronoutil crate (MIT) + * Copyright (c) 2020-2022 Oliver Margetts + * https://github.com/olliemath/chronoutil + +This product includes software from the compact-thrift project (Apache 2.0) + * Copyright Jörn Horstmann + * https://github.com/jhorstmann/compact-thrift diff --git a/about.toml b/about.toml new file mode 100644 index 000000000..88e8e8303 --- /dev/null +++ b/about.toml @@ -0,0 +1,68 @@ +# cargo-about: the third-party notices shipped with RustGIS (`cargo xtask notices`). +accepted = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "OFL-1.1", + "Ubuntu-font-1.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +targets = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", + "wasm32-unknown-unknown", +] +ignore-build-dependencies = false +ignore-dev-dependencies = true +ignore-transitive-dependencies = false + +# The apps and xtask (`publish = false`) are RustGIS itself. +[private] +ignore = true + +# webpki 0.22 (through mysql) is ISC-style; its Cargo.toml names no licence. +[webpki.clarify] +license = "ISC" + +[[webpki.clarify.files]] +path = "LICENSE" +license = "ISC" +checksum = "5b698ca13897be3afdb7174256fa1574f8c6892b8bea1a66dd6469d3fe27885a" + +# epaint_default_fonts embeds four fonts, each with its own licence file; cargo-about finds only +# the OFL and Ubuntu ones by itself. Hack and the emoji icon font are MIT, Noto Emoji is OFL. +[epaint_default_fonts.clarify] +license = "MIT AND OFL-1.1 AND Ubuntu-font-1.0" + +[[epaint_default_fonts.clarify.files]] +path = "fonts/Hack-Regular.txt" +license = "MIT" +checksum = "47c0cccbeec7e8614548cc485588b28149e7874188df5f41b36efebcee285c87" + +[[epaint_default_fonts.clarify.files]] +path = "fonts/emoji-icon-font-mit-license.txt" +license = "MIT" +checksum = "b9d2c1d909aa149996fd4c91dcb92b2362a04431640c1d200959da94caf8cde1" + +[[epaint_default_fonts.clarify.files]] +path = "fonts/OFL.txt" +license = "OFL-1.1" +checksum = "6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2" + +[[epaint_default_fonts.clarify.files]] +path = "fonts/UFL.txt" +license = "Ubuntu-font-1.0" +checksum = "2f0015108d68627bd788d313f529c21ff4da2c2c42a5e1f3883acc83480f9002" diff --git a/apps/rustgis-python/THIRD-PARTY-LICENSES.txt b/apps/rustgis-python/THIRD-PARTY-LICENSES.txt new file mode 100644 index 000000000..86ef9af95 --- /dev/null +++ b/apps/rustgis-python/THIRD-PARTY-LICENSES.txt @@ -0,0 +1,8312 @@ +Third-party software notices for the rustgis Python package + +RustGIS is licensed under the Apache License 2.0 (see LICENSE). It is built with the +open-source Rust crates below, each under its own licence, whose copyright notices and terms +are reproduced here, followed by the NOTICE files the crates ship. Generated from Cargo.lock +by `cargo xtask notices`. + +==================================================================================================== +Apache License 2.0 +Used by: a5, ab_glyph, ab_glyph_rasterizer, ahash, allocator-api2, approx, arc-swap, arrayvec, async-trait, +autocfg, aws-lc-sys, base64, bitflags, block-buffer, btoi, bufstream, bumpalo, bytemuck, +bytemuck_derive, cc, cfg-if, chacha20, cmake, cmov, codepage, console_log, const-oid, const-random, +const-random-macro, core-foundation, core-foundation-sys, core_detect, cpufeatures, crossbeam-deque, +crossbeam-epoch, crossbeam-queue, crossbeam-utils, crypto-common, ctutils, der, der_derive, +derive_utils, digest, displaydoc, dunce, either, encoding_rs, equivalent, errno, euclid, fastrand, +fdeflate, filetime, find-msvc-tools, flagset, flatbuffers, flate2, form_urlencoded, futures-timer, +geo, geo-traits, geo-types, geozero, gif, half, hash32, hashbrown, hashlink, heapless, heck, hmac, +httparse, hybrid-array, i_overlay, idna, idna_adapter, image, image-webp, indexmap, io-enum, itoa, +jobserver, js-sys, laz, lazy_static, libc, linux-raw-sys, lock_api, log, lyon, lyon_algorithms, +lyon_geom, lyon_path, lyon_tessellation, md-5, md5, miniz_oxide, multiversion_no_op, mysql, +mysql_common, named_pipe, num-bigint, num-conv, num-integer, num-traits, objc2-core-foundation, +objc2-system-configuration, once_cell, openssl-probe, owned_ttf_parser, parking_lot, +parking_lot_core, parquet, percent-encoding, pin-project-lite, pkg-config, png, postgres, +postgres-protocol, postgres-types, proc-macro2, proj4rs, proj4rs-geodesic, pyo3, pyo3-build-config, +pyo3-ffi, pyo3-macros, pyo3-macros-backend, quote, rand, rayon, rayon-core, regex, regex-automata, +regex-syntax, ring, robust, roxmltree, rstar, rustc_version, rustix, rustls, rustls-native-certs, +rustls-pemfile, rustversion, ryu, scopeguard, secrecy, security-framework, security-framework-sys, +semver, seq-macro, serde, serde_core, serde_derive, serde_json, sha1, sha2, shlex, sif-itree, +simdutf8, siphasher, smallvec, socket2, spade, spki, stable_deref_trait, syn, sys-locale, tar, +tempfile, thiserror, thiserror-impl, tiberius-macros, time, time-core, tinyvec, tokio-postgres, +ttf-parser, typed-path, unicode-bidi, unicode-ident, unicode-normalization, unicode-properties, +ureq, ureq-proto, url, utf8-zero, utf8_iter, uuid, vcpkg, version_check, wasm-bindgen, +wasm-bindgen-macro, wasm-bindgen-macro-support, wasm-bindgen-shared, wasmi, wasmi_collections, +wasmi_core, wasmi_ir, wasmparser, web-sys, weezl, whoami, winauth, wkt, x509-cert, xattr, zeroize, +zune-core, zune-jpeg +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: atoi_simd +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2022-NOW Dmitry Rodionov + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: chrono +==================================================================================================== + +Rust-chrono is dual-licensed under The MIT License [1] and +Apache 2.0 License [2]. Copyright (c) 2014--2026, Kang Seonghoon and +contributors. + +Nota Bene: This is same as the Rust Project's own license. + + +[1]: , which is reproduced below: + +~~~~ +The MIT License (MIT) + +Copyright (c) 2014, Kang Seonghoon. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +~~~~ + + +[2]: , which is reproduced below: + +~~~~ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +~~~~ + +==================================================================================================== +Apache License 2.0 +Used by: connection-string +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2020 Yoshua Wuyts + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: crc32fast, fallible-iterator, fallible-streaming-iterator, quick-error, stringprep, winapi +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: debug_unsafe +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025-NOW Dmitry Rodionov + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: deranged +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Jacob Pratt et al. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: enumflags2 +==================================================================================================== + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + +Copyright 2017-2023 Maik Klein, Maja Kądziołka + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: enumflags2_derive +==================================================================================================== + +Apache License + +Version 2.0, January 2004 + +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + +You must give any other recipients of the Work or Derivative Works a copy of this License; and +You must cause any modified files to carry prominent notices stating that You changed the files; and +You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and +If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + + +Copyright [2017] [Maik Klein] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: fast-float2 +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 Ivan Smirnov + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: futures-channel, futures-core, futures-io, futures-macro, futures-sink, futures-task, futures-util +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: getrandom, rand, rand_chacha, rand_core +==================================================================================================== + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: http +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 http-rs authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: iana-time-zone +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2020 Andrew Straw + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: linux-raw-sys, rustix, target-lexicon +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +--- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + +==================================================================================================== +Apache License 2.0 +Used by: moxcms, pxfm +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Radzivon Bartoshyk + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: powerfmt +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Jacob Pratt et al. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: ppv-lite86 +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2019 The CryptoCorrosion Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: rand_core, rand_pcg +==================================================================================================== + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +==================================================================================================== +Apache License 2.0 +Used by: ring +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +Licenses for support code +------------------------- + +Parts of the TLS test suite are under the Go license. This code is not included +in BoringSSL (i.e. libcrypto and libssl) when compiled, however, so +distributing code linked against BoringSSL does not trigger this license: + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +BoringSSL uses the Chromium test infrastructure to run a continuous build, +trybots etc. The scripts which manage this, and the script for generating build +metadata, are under the Chromium license. Distributing code linked against +BoringSSL does not trigger this license. + +Copyright 2015 The Chromium Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +Apache License 2.0 +Used by: rustls-pki-types +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2023 Dirkjan Ochtman + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: tiberius +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 Tiberius Contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: tokio-rustls +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 quininer kel + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: typenum +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2014 Paho Lurie-Gregg + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: web-time +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 dAxpeDDa + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: windows-core, windows-implement, windows-interface, windows-link, windows-result, windows-strings, +windows-sys +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: zerocopy, zerocopy-derive +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Fuchsia Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +Apache License 2.0 +Used by: zopfli +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2011 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +==================================================================================================== +BSD 2-Clause "Simplified" License +Used by: arrayref +==================================================================================================== + +Copyright (c) 2015 David Roundy +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 2-Clause "Simplified" License +Used by: flatgeobuf +==================================================================================================== + +Copyright (c) + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: alloc-no-stdlib, brotli-decompressor +==================================================================================================== + +Copyright (c) 2016 Dropbox, Inc. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: alloc-stdlib, aws-lc-sys +==================================================================================================== + +Copyright (c) . + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: encoding_rs +==================================================================================================== + +// Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +/// The PUA code points special-cased in the GB18030 encoder. +pub(crate) static GB18030_2022_OVERRIDE_PUA: [u16; 18] = [ + 0xE78D, 0xE78E, 0xE78F, 0xE790, 0xE791, 0xE792, 0xE793, 0xE794, 0xE795, 0xE796, 0xE81E, 0xE826, + 0xE82B, 0xE82C, 0xE832, 0xE843, 0xE854, 0xE864, +]; + +/// The bytes corresponding to the PUA code points special-cased in the GB18030 encoder. +pub(crate) static GB18030_2022_OVERRIDE_BYTES: [[u8; 2]; 18] = [ + [0xA6, 0xD9], + [0xA6, 0xDA], + [0xA6, 0xDB], + [0xA6, 0xDC], + [0xA6, 0xDD], + [0xA6, 0xDE], + [0xA6, 0xDF], + [0xA6, 0xEC], + [0xA6, 0xED], + [0xA6, 0xF3], + [0xFE, 0x59], + [0xFE, 0x61], + [0xFE, 0x66], + [0xFE, 0x67], + [0xFE, 0x6D], + [0xFE, 0x7E], + [0xFE, 0x90], + [0xFE, 0xA0], +]; + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: encoding_rs +==================================================================================================== + +Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: snap +==================================================================================================== + +Copyright 2011, The Snappy-Rust Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: subtle +==================================================================================================== + +Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved. +Copyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: tiny-skia, tiny-skia-path +==================================================================================================== + +Copyright (c) 2011 Google Inc. All rights reserved. +Copyright (c) 2020 Yevhenii Reizner All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + + * Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: zstd, zstd-safe, zstd-sys +==================================================================================================== + +BSD 3-Clause License + +Copyright (c) 2026, Alexandre Bury + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: zstd-sys +==================================================================================================== + +BSD License + +For Zstandard software + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook, nor Meta, nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD 3-Clause "New" or "Revised" License +Used by: zstd-sys +==================================================================================================== + +The auto-generated bindings are under the 3-clause BSD license: + +BSD License + +For Zstandard software + +Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + * Neither the name Facebook, nor Meta, nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +==================================================================================================== +BSD Zero Clause License +Used by: adler2 +==================================================================================================== + +Copyright (C) Jonas Schievink + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==================================================================================================== +Community Data License Agreement Permissive 2.0 +Used by: webpki-roots +==================================================================================================== + +# Community Data License Agreement - Permissive - Version 2.0 + +This is the Community Data License Agreement - Permissive, Version +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree +as follows: + +## 1. Provision of the Data + +1.1. A Data Recipient may use, modify, and share the Data made +available by Data Provider(s) under this agreement if that Data +Recipient follows the terms of this agreement. + +1.2. This agreement does not impose any restriction on a Data +Recipient's use, modification, or sharing of any portions of the +Data that are in the public domain or that may be used, modified, +or shared under any other legal exception or limitation. + +## 2. Conditions for Sharing Data + +2.1. A Data Recipient may share Data, with or without modifications, so +long as the Data Recipient makes available the text of this agreement +with the shared Data. + +## 3. No Restrictions on Results + +3.1. This agreement does not impose any restriction or obligations +with respect to the use, modification, or sharing of Results. + +## 4. No Warranty; Limitation of Liability + +4.1. All Data Recipients receive the Data subject to the following +terms: + +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING +WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +## 5. Definitions + +5.1. "Data" means the material received by a Data Recipient under +this agreement. + +5.2. "Data Provider" means any person who is the source of Data +provided under this agreement and in reliance on a Data Recipient's +agreement to its terms. + +5.3. "Data Recipient" means any person who receives Data directly +or indirectly from a Data Provider and agrees to the terms of this +agreement. + +5.4. "Results" means any outcome obtained by computational analysis +of Data, including for example machine learning models and models' +insights. + +==================================================================================================== +Creative Commons Zero v1.0 Universal +Used by: crs-definitions, tiny-keccak +==================================================================================================== + +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. + +==================================================================================================== +ISC License +Used by: aws-lc-rs, aws-lc-sys +==================================================================================================== + +ISC License: + +Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC") +Copyright (c) 1995-2003 by Internet Software Consortium + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==================================================================================================== +ISC License +Used by: earcut +==================================================================================================== + +ISC License + +Copyright (c) 2016, Mapbox +Copyright (c) 2023, Taku Fukada + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +/* Copyright (c) 2014, Intel Corporation. + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY + * SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION + * OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN + * CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. */ + +#ifndef OPENSSL_HEADER_EC_ECP_NISTZ384_H +#define OPENSSL_HEADER_EC_ECP_NISTZ384_H + +#include "../../limbs/limbs.h" + +#define P384_LIMBS (384u / LIMB_BITS) + +typedef struct { + Limb X[P384_LIMBS]; + Limb Y[P384_LIMBS]; + Limb Z[P384_LIMBS]; +} P384_POINT; + +typedef struct { + Limb X[P384_LIMBS]; + Limb Y[P384_LIMBS]; +} P384_POINT_AFFINE; + + +#endif // OPENSSL_HEADER_EC_ECP_NISTZ384_H + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2015-2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +//! EdDSA Signatures. + +use super::ops::ELEM_LEN; +use crate::digest; + +pub mod signing; +pub mod verification; + +/// The length of an Ed25519 public key. +pub const ED25519_PUBLIC_KEY_LEN: usize = ELEM_LEN; + +pub fn eddsa_digest(signature_r: &[u8], public_key: &[u8], msg: &[u8]) -> digest::Digest { + let mut ctx = digest::Context::new(&digest::SHA512); + ctx.update(signature_r); + ctx.update(public_key); + ctx.update(msg); + ctx.finish() +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2015-2022 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use crate::limb::Limb; + +#[derive(Clone, Copy)] +#[repr(transparent)] +pub struct N0([Limb; 2]); + +impl N0 { + #[cfg(feature = "alloc")] + pub(super) const LIMBS_USED: usize = 64 / crate::limb::LIMB_BITS; + + #[inline] + pub const fn precalculated(n0: u64) -> Self { + #[cfg(target_pointer_width = "64")] + { + Self([n0, 0]) + } + + #[cfg(target_pointer_width = "32")] + { + Self([n0 as Limb, (n0 >> crate::limb::LIMB_BITS) as Limb]) + } + } +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2015-2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use crate::{bb, error}; + +#[deprecated( + note = "To be removed. Internal function not intended for external use with no promises regarding side channels." +)] +pub fn verify_slices_are_equal(a: &[u8], b: &[u8]) -> Result<(), error::Unspecified> { + bb::verify_slices_are_equal(a, b) +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +//! Elliptic curve operations and schemes using Curve25519. + +pub mod ed25519; + +pub mod x25519; + +mod ops; +mod scalar; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2016-2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use crate::error::{KeyRejected, Unspecified}; + +impl From for Unspecified { + fn from(source: untrusted::EndOfInput) -> Self { + super::erase(source) + } +} + +impl From for Unspecified { + fn from(source: core::array::TryFromSliceError) -> Self { + super::erase(source) + } +} + +impl From for Unspecified { + fn from(source: KeyRejected) -> Self { + super::erase(source) + } +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2018 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +//! Serialization and deserialization. + +#[doc(hidden)] +pub mod der; + +#[cfg(feature = "alloc")] +mod writer; + +#[cfg(feature = "alloc")] +pub(crate) mod der_writer; + +pub(crate) mod positive; + +pub use self::positive::Positive; + +#[cfg(feature = "alloc")] +pub(crate) use self::writer::TooLongError; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2019-2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +use super::BlockLen; + +pub(super) use self::{ + sha2_32::{block_data_order_32, State32, SHA256_BLOCK_LEN}, + sha2_64::{block_data_order_64, State64, SHA512_BLOCK_LEN}, +}; + +pub(super) const CHAINING_WORDS: usize = 8; + +#[cfg(any( + all(target_arch = "aarch64", target_endian = "little"), + all(target_arch = "arm", target_endian = "little"), + target_arch = "x86_64" +))] +#[macro_use] +mod ffi; + +pub(super) mod fallback; +mod sha2_32; +mod sha2_64; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +// TODO(MSRV 1.76): Replace with `core::ptr::from_mut`. +#[allow(dead_code)] +#[inline(always)] +pub fn from_mut(r: &mut T) -> *mut T { + r +} + +// TODO(MSRV 1.76): Replace with `core::ptr::from_ref`. +#[allow(dead_code)] +#[inline(always)] +pub const fn from_ref(r: &T) -> *const T { + r +} + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +//! Integration tests for non-public APIs. + +mod bits_tests; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2024 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +pub use self::{ + array::Array, + base::{IndexError, Overlapping}, + partial_block::PartialBlock, +}; + +mod array; +mod base; +mod partial_block; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +#![cfg(all(target_arch = "aarch64", target_endian = "little"))] + +pub(in super::super) mod mont; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +#![cfg(target_arch = "x86_64")] + +pub(in super::super::super) mod mont; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +mod storage; + +pub(super) use self::storage::{AlignedStorage, LIMBS_PER_CHUNK}; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +// Copyright 2025 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +// SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +// OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +// CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +pub(super) mod aarch64; +pub(super) mod x86_64; + +==================================================================================================== +ISC License +Used by: ring +==================================================================================================== + +Copyright 2015-2025 Brian Smith. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==================================================================================================== +ISC License +Used by: rustls-webpki, webpki +==================================================================================================== + +Except as otherwise noted, this project is licensed under the following +(ISC-style) terms: + +Copyright 2015 Brian Smith. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +The files under third-party/chromium are licensed as described in +third-party/chromium/LICENSE. + +==================================================================================================== +ISC License +Used by: untrusted +==================================================================================================== + +// Copyright 2015-2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +==================================================================================================== +MIT License +Used by: aho-corasick, byteorder, byteorder-lite, csv, csv-core, memchr +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: asynchronous-codec +==================================================================================================== + +MIT License + +Copyright (c) 2019 - 2020 Matt Hunzinger +Copyright (c) 2021 Max Inden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: aws-lc-sys +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2015-2020 the fiat-crypto authors (see +https://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS). + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: bufrw, i_key_sort, libm, rsqlite-vfs +==================================================================================================== + +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: bytes +==================================================================================================== + +Copyright (c) 2018 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: calamine, quick-xml +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2016 Johann Tuffe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: color_quant +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2016 PistonDevelopers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: crunchy +==================================================================================================== + +The MIT License (MIT) + +Copyright 2017-2023 Eira Fransham. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: dbase, shapefile +==================================================================================================== + +MIT License + +Copyright (c) 2019 Thomas Montaigu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: epaint_default_fonts +==================================================================================================== + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: epaint_default_fonts +==================================================================================================== + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. + +==================================================================================================== +MIT License +Used by: fax +==================================================================================================== + +Copyright © 2021 The pdf-rs contributers. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: float_next_after +==================================================================================================== + +MIT License + +Copyright (c) 2020 Scripta Qumranica Electronica + +Created by Bronson Brown-deVost + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: fs_extra +==================================================================================================== + +MIT License + +Copyright (c) 2017 Denis Kurilenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: generic-array +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2015 Bartłomiej Kamiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: geographiclib-rs +==================================================================================================== + +MIT License + +Copyright (c) 2019 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: i_float, i_shape +==================================================================================================== + +MIT License + +Copyright (c) 2023 iShape-Rust + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: i_tree +==================================================================================================== + +MIT License + +Copyright (c) 2024 iShape-Rust + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: libsqlite3-sys, rusqlite +==================================================================================================== + +Copyright (c) 2014 The rusqlite developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: lru +==================================================================================================== + +MIT License + +Copyright (c) 2016 Jerome Froelich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: lz4_flex +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2020 Pascal Seitz + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: mio +==================================================================================================== + +Copyright (c) 2014 Carl Lerche and other MIO contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: pem +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2016 Jonathan Creekmore + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: phf, phf_shared +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: pretty-hex +==================================================================================================== + +MIT License + +Copyright (c) 2018 Andrei Volnin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: ruzstd +==================================================================================================== + +MIT License + +Copyright (c) 2019 Moritz Borcherding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: saturating +==================================================================================================== + +MIT License + +Copyright (c) 2019 Breezewish + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: schannel +==================================================================================================== + +Copyright (c) 2015 steffengy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: scroll +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) m4b 2016 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: simd-adler32 +==================================================================================================== + +MIT License + +Copyright (c) [2021] [Marvin Countryman] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: slab +==================================================================================================== + +Copyright (c) 2019 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: spin, tracing-core, zip +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: sqlite-wasm-rs +==================================================================================================== + +MIT License + +Copyright (c) 2024 Spxg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: strict-num +==================================================================================================== + +Copyright (c) 2022 Yevhenii Reizner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: synstructure +==================================================================================================== + +Copyright 2016 Nika Layzell + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: tiff +==================================================================================================== + +MIT License + +Copyright (c) 2018 PistonDevelopers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: tokio, tokio-util +==================================================================================================== + +MIT License + +Copyright (c) Tokio Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: tokio-postgres-rustls +==================================================================================================== + +MIT License + +Copyright (c) 2019 Jasper Hugo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: tracing, tracing-attributes, tracing-core +==================================================================================================== + +Copyright (c) 2019 Tokio Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: twox-hash +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2015 Jake Goulding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +==================================================================================================== +MIT License +Used by: zip +==================================================================================================== + +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Some files in the "tests/data" subdirectory of this repository are under other +licences; see files named LICENSE.*.txt for details. + +==================================================================================================== +MIT License +Used by: zmij +==================================================================================================== + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +==================================================================================================== +MIT License +Used by: zstd +==================================================================================================== + +The MIT License (MIT) +Copyright (c) 2016 Alexandre Bury + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +==================================================================================================== +SIL Open Font License 1.1 +Used by: epaint_default_fonts +==================================================================================================== + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +==================================================================================================== +Ubuntu Font Licence v1.0 +Used by: epaint_default_fonts +==================================================================================================== + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. + +==================================================================================================== +Unicode License v3 +Used by: icu_collections, icu_locale_core, icu_normalizer, icu_normalizer_data, icu_properties, +icu_properties_data, icu_provider, litemap, potential_utf, tinystr, writeable, yoke, yoke-derive, +zerofrom, zerofrom-derive, zerotrie, zerovec, zerovec-derive +==================================================================================================== + +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2020-2024 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 + +— + +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. + +==================================================================================================== +Unicode License v3 +Used by: unicode-ident +==================================================================================================== + +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +==================================================================================================== +zlib License +Used by: foldhash +==================================================================================================== + +Copyright (c) 2024 Orson Peters + +This software is provided 'as-is', without any express or implied warranty. In +no event will the authors be held liable for any damages arising from the use of +this software. + +Permission is granted to anyone to use this software for any purpose, including +commercial applications, and to alter it and redistribute it freely, subject to +the following restrictions: + +1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a product, + an acknowledgment in the product documentation would be appreciated but is + not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. + +==================================================================================================== +zlib License +Used by: zlib-rs +==================================================================================================== + +(C) 2024 Trifecta Tech Foundation + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. + +==================================================================================================== +NOTICE.txt of parquet +==================================================================================================== + +Apache Arrow +Copyright 2016-2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +This product includes software from the chronoutil crate (MIT) + * Copyright (c) 2020-2022 Oliver Margetts + * https://github.com/olliemath/chronoutil + +This product includes software from the compact-thrift project (Apache 2.0) + * Copyright Jörn Horstmann + * https://github.com/jhorstmann/compact-thrift diff --git a/apps/rustgis-python/pyproject.toml b/apps/rustgis-python/pyproject.toml index cd04b8ff2..eebafba9c 100644 --- a/apps/rustgis-python/pyproject.toml +++ b/apps/rustgis-python/pyproject.toml @@ -8,8 +8,9 @@ description = "Python bindings to RustGIS, a fast, free and open-source GIS in p readme = "README.md" license = "Apache-2.0" # A copy of the repository's LICENSE: license-files can't reach outside this directory, and -# PyPI wants the file at the root of the sdist, where this puts it. -license-files = ["LICENSE"] +# PyPI wants the file at the root of the sdist, where this puts it. THIRD-PARTY-LICENSES.txt holds +# the notices of the crates compiled into the package (`cargo xtask notices`). +license-files = ["LICENSE", "THIRD-PARTY-LICENSES.txt"] requires-python = ">=3.12" authors = [{ name = "Qiusheng Wu", email = "giswqs@gmail.com" }] keywords = ["gis", "geospatial", "maps", "geoprocessing", "rust", "jupyter", "anywidget"] diff --git a/apps/rustgis-web/index.html b/apps/rustgis-web/index.html index c60f8d8b5..3341b449a 100644 --- a/apps/rustgis-web/index.html +++ b/apps/rustgis-web/index.html @@ -7,6 +7,8 @@ + + diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index a1c0514ef..1c7c2caa0 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -176,6 +176,7 @@ fn about(ui: &mut Ui, t: &Tokens) { regular(12.5), t.text_dim, ); + crate::chrome::licence_links(ui); } #[cfg(test)] diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index a7f3adc98..2fb83a425 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -990,6 +990,23 @@ pub(crate) fn project_links(ui: &mut egui::Ui) { }); } +/// Links to the licence notices of the software and data RustGIS is built with. The web app +/// serves its own copy of `THIRD-PARTY-LICENSES.txt`; the desktop packages ship one next to +/// LICENSE, and link to the current one on GitHub. +pub(crate) fn licence_links(ui: &mut egui::Ui) { + ui.horizontal(|ui| { + let notices = if cfg!(target_arch = "wasm32") { + "THIRD-PARTY-LICENSES.txt" + } else { + "https://github.com/opengeos/RustGIS/blob/main/THIRD-PARTY-LICENSES.txt" + }; + ui.add(egui::Hyperlink::from_label_and_url(tl!("Third-party licences"), notices).open_in_new_tab(true)); + ui.label("·"); + let attribution = "https://github.com/opengeos/RustGIS/blob/main/ATTRIBUTION.md"; + ui.add(egui::Hyperlink::from_label_and_url(tl!("Attribution"), attribution).open_in_new_tab(true)); + }); +} + pub fn about(app: &mut RustGisApp, ctx: &egui::Context) { if app.ui.about { let logo_tex = app.logo(ctx); @@ -1025,6 +1042,7 @@ pub fn about(app: &mut RustGisApp, ctx: &egui::Context) { ); let (fonts_from, _) = (rustgis_engine::render::fonts::ui_fonts().source.clone(), ()); ui.label(egui::RichText::new(crate::i18n::fmt("Interface font: {font}", &[("font", &fonts_from)])).small().weak()); + licence_links(ui); }); app.ui.about = open; } diff --git a/deny.toml b/deny.toml new file mode 100644 index 000000000..3c12fd75c --- /dev/null +++ b/deny.toml @@ -0,0 +1,31 @@ +# cargo-deny: the licences RustGIS accepts in its dependencies (`cargo deny check licenses`, run +# by CI). Keep this in step with `accepted` in about.toml, which writes the third-party notices. + +[graph] +all-features = false +exclude-dev = true + +[licenses] +confidence-threshold = 0.8 +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "OFL-1.1", + "Ubuntu-font-1.0", + "Unicode-3.0", + "Unlicense", + "Zlib", +] + +[licenses.private] +# RustGIS's own apps and xtask (`publish = false`). +ignore = true diff --git a/packaging/package.sh b/packaging/package.sh index 39d6d30a9..f646ccd69 100755 --- a/packaging/package.sh +++ b/packaging/package.sh @@ -40,6 +40,9 @@ for b in rustgis rustgis-cli; do [ -f "$bin/$b$exe" ] || { echo "missing $bin/$b$exe: build it first" >&2; exit 1; } done logo() { "$bin/rustgis-cli$exe" logo "$1" --size "$2"; } +# The licence notices of the crates in the binaries (`cargo xtask notices`), shipped with LICENSE. +notices="$root/THIRD-PARTY-LICENSES.txt" +[ -f "$notices" ] || { echo "missing $notices: run cargo xtask notices" >&2; exit 1; } name="RustGIS-$version-$os-$arch" case "$os" in @@ -47,7 +50,7 @@ case "$os" in # Portable tarball. d="$work/$name" mkdir -p "$d" - cp "$bin/rustgis" "$bin/rustgis-cli" "$root/LICENSE" "$root/README.md" \ + cp "$bin/rustgis" "$bin/rustgis-cli" "$root/LICENSE" "$notices" "$root/README.md" \ "$root/packaging/linux/rustgis.desktop" "$root/packaging/linux/rustgis-mime.xml" "$d/" logo "$d/rustgis.png" 512 tar -C "$work" -czf "$dist/$name.tar.gz" "$name" @@ -64,6 +67,7 @@ case "$os" in logo "$p/usr/share/icons/hicolor/${s}x${s}/apps/rustgis.png" "$s" done install -Dm644 "$root/LICENSE" "$p/usr/share/doc/rustgis/copyright" + install -Dm644 "$notices" "$p/usr/share/doc/rustgis/THIRD-PARTY-LICENSES.txt" mkdir -p "$p/DEBIAN" # Libraries the binaries link against, worked out by dpkg-shlibdeps (it wants a # debian/control in the working directory). The windowing and GPU libraries are loaded at @@ -138,6 +142,7 @@ cp -a "$p/usr" %{buildroot}/ /usr/share/mime/packages/rustgis.xml /usr/share/icons/hicolor/*/apps/rustgis.png %license /usr/share/doc/rustgis/copyright +%license /usr/share/doc/rustgis/THIRD-PARTY-LICENSES.txt SPEC rpmbuild --quiet -bb --target "$arch" --define "_topdir $r" "$r/SPECS/rustgis.spec" # The file is named after $version (0.2.0-rc.1), like the other assets: GitHub rewrites @@ -203,6 +208,7 @@ SPEC logo "$iconset/icon_${s}x${s}@2x.png" "$((s * 2))" done iconutil -c icns "$iconset" -o "$app/Contents/Resources/rustgis.icns" + cp "$root/LICENSE" "$notices" "$app/Contents/Resources/" # Signing: with MACOS_SIGN_IDENTITY (a Developer ID Application identity in the keychain) # the app and CLI get a hardened-runtime signature; without it, an ad-hoc one, which # Apple Silicon needs to launch anything. With APPLE_API_KEY_PATH, APPLE_API_KEY_ID and @@ -255,7 +261,7 @@ SPEC d="$work/rustgis-cli-$version-$os-$arch" mkdir -p "$d" - cp "$bin/rustgis-cli" "$root/LICENSE" "$d/" + cp "$bin/rustgis-cli" "$root/LICENSE" "$notices" "$d/" sign "$d/rustgis-cli" # A bare executable cannot be stapled: notarizing it registers the ticket that Gatekeeper # looks up online. @@ -270,7 +276,7 @@ SPEC # Portable zip: unzip anywhere and run, no installer or admin rights. d="$work/$name" mkdir -p "$d" - cp "$bin/rustgis.exe" "$bin/rustgis-cli.exe" "$root/LICENSE" "$root/README.md" "$d/" + cp "$bin/rustgis.exe" "$bin/rustgis-cli.exe" "$root/LICENSE" "$notices" "$root/README.md" "$d/" sed "s/@VERSION@/$version/g" "$root/packaging/windows/README-portable.txt" > "$d/README.txt" (cd "$work" && 7z a -tzip -bso0 "$dist/$name-portable.zip" "$name") @@ -280,7 +286,7 @@ SPEC case "$arch" in x86_64) instarch=x64 ;; aarch64) instarch=arm64 ;; *) instarch="$arch" ;; esac s="$work/installer" mkdir -p "$s" - cp "$bin/rustgis.exe" "$bin/rustgis-cli.exe" "$root/LICENSE" "$root/packaging/windows/path.ps1" "$s/" + cp "$bin/rustgis.exe" "$bin/rustgis-cli.exe" "$root/LICENSE" "$notices" "$root/packaging/windows/path.ps1" "$s/" "$bin/rustgis-cli.exe" logo "$s/rustgis.ico" "$bin/rustgis-cli.exe" logo "$s/sidebar.bmp" --size 128 --canvas 164x314 "$bin/rustgis-cli.exe" logo "$s/banner.bmp" --size 44 --canvas 493x58 --at 440,7 @@ -328,7 +334,7 @@ SPEC # self-signed sideload build. m="$work/msix" mkdir -p "$m/Assets" - cp "$bin/rustgis.exe" "$bin/rustgis-cli.exe" "$m/" + cp "$bin/rustgis.exe" "$bin/rustgis-cli.exe" "$root/LICENSE" "$notices" "$m/" logo "$m/Assets/Square44x44Logo.png" 44 logo "$m/Assets/Square150x150Logo.png" 150 logo "$m/Assets/StoreLogo.png" 50 diff --git a/packaging/windows/rustgis.nsi b/packaging/windows/rustgis.nsi index 6535b2047..220bcdfbd 100644 --- a/packaging/windows/rustgis.nsi +++ b/packaging/windows/rustgis.nsi @@ -2,7 +2,7 @@ ; rights, in %LOCALAPPDATA%\Programs\RustGIS: a Start menu shortcut, rustgis-cli on the user's ; PATH, .rgis/.rgisx projects opened by RustGIS and RustGIS offered under Open with for .geojson. ; Defines passed with -D: VERSION, VERSION4 (four numbers), STAGE (the folder holding the exes, -; LICENSE and path.ps1), ICON, SIDEBAR (a 164x314 BMP) and OUTFILE. +; LICENSE, THIRD-PARTY-LICENSES.txt and path.ps1), ICON, SIDEBAR (a 164x314 BMP) and OUTFILE. Unicode true SetCompressor /SOLID lzma @@ -63,6 +63,7 @@ Section "Install" File "${STAGE}\rustgis.exe" File "${STAGE}\rustgis-cli.exe" File /oname=LICENSE.txt "${STAGE}\LICENSE" + File "${STAGE}\THIRD-PARTY-LICENSES.txt" WriteUninstaller "$INSTDIR\uninstall.exe" CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\rustgis.exe" @@ -126,6 +127,7 @@ Section "Uninstall" Delete "$INSTDIR\rustgis.exe" Delete "$INSTDIR\rustgis-cli.exe" Delete "$INSTDIR\LICENSE.txt" + Delete "$INSTDIR\THIRD-PARTY-LICENSES.txt" Delete "$INSTDIR\uninstall.exe" RMDir "$INSTDIR" diff --git a/packaging/windows/rustgis.wxs b/packaging/windows/rustgis.wxs index b05cdc7c5..3256d3278 100644 --- a/packaging/windows/rustgis.wxs +++ b/packaging/windows/rustgis.wxs @@ -3,7 +3,8 @@ Windows Installer package (WiX 5), built by package.sh. An all-users install in Program Files: a Start menu shortcut, rustgis-cli on the system PATH, .rgis/.rgisx projects opened by RustGIS and RustGIS offered under Open with for .geojson. Variables passed with -d: Version (three - numbers), Stage (the folder holding the exes and LICENSE), Icon, License (RTF), Banner and + numbers), Stage (the folder holding the exes, LICENSE and + THIRD-PARTY-LICENSES.txt), Icon, License (RTF), Banner and Dialog (BMPs). --> @@ -54,6 +55,9 @@ + + + diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 55ce98e96..b636e2234 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,3 +5,4 @@ edition.workspace = true publish = false [dependencies] +serde_json.workspace = true diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 81f8b5e95..27554dced 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,10 +1,14 @@ -//! `cargo xtask ci | layers | web-pwa ` +//! `cargo xtask ci | layers | notices [--check] | web-pwa ` //! //! - `layers`: enforces the crate layering (nothing below L6 may depend on egui/eframe/rfd). //! - `ci`: fmt check, clippy -D warnings, tests and the layer check. +//! - `notices [--check]`: writes the third-party licence notices the packages ship (see +//! `notices.rs`), or checks they are up to date (exit code 2 when they are not). //! - `web-pwa `: writes the web app's service worker into a `trunk build` output directory //! (`dist/web`) from the template `apps/rustgis-web/sw.js`, listing the built files to cache. +mod notices; + use std::process::{Command, ExitCode}; const LAYERS: &[(&str, u8)] = &[ @@ -58,9 +62,9 @@ fn cargo(args: &[&str]) -> Result<(), String> { if st.success() { Ok(()) } else { Err(format!("cargo {} failed", args.join(" "))) } } -/// Files in a web build the service worker must not cache: itself, and the GitHub Pages -/// configuration the deploy adds. -const NOT_SHELL: &[&str] = &["sw.js", "CNAME", ".nojekyll"]; +/// Files in a web build the service worker must not cache: itself, the GitHub Pages +/// configuration the deploy adds, and the licence notices (served, but not needed offline). +const NOT_SHELL: &[&str] = &["sw.js", "CNAME", ".nojekyll", "LICENSE", "THIRD-PARTY-LICENSES.txt"]; /// The files under `dir` (a `trunk build` output) for the service worker to cache, as sorted /// `/`-separated paths relative to `dir`, plus a version that changes whenever any of them or the @@ -147,8 +151,25 @@ fn main() -> ExitCode { .and_then(|_| cargo(&["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"])) .and_then(|_| cargo(&["test", "--workspace", "-q"])) .and_then(|_| layers()), + "notices" => { + let check = match std::env::args().nth(2).as_deref() { + None => false, + Some("--check") => true, + Some(other) => { + eprintln!("unknown argument {other}: usage: cargo xtask notices [--check]"); + return ExitCode::FAILURE; + } + }; + return match notices::notices(&root(), check) { + Ok(()) => ExitCode::SUCCESS, + Err((code, e)) => { + eprintln!("{e}"); + ExitCode::from(code) + } + }; + } "web-pwa" => web_pwa(std::env::args().nth(2)), - _ => Err("usage: cargo xtask ci | layers | web-pwa ".into()), + _ => Err("usage: cargo xtask ci | layers | notices [--check] | web-pwa ".into()), }; match r { Ok(()) => ExitCode::SUCCESS, @@ -180,7 +201,18 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join("snippets/x")).unwrap(); std::fs::create_dir_all(dir.join(".stage")).unwrap(); - for f in ["index.html", "app-1.js", "app-1_bg.wasm", "snippets/x/inline0.js", "sw.js", "CNAME", ".nojekyll", ".stage/tmp"] { + for f in [ + "index.html", + "app-1.js", + "app-1_bg.wasm", + "snippets/x/inline0.js", + "sw.js", + "CNAME", + ".nojekyll", + "LICENSE", + "THIRD-PARTY-LICENSES.txt", + ".stage/tmp", + ] { std::fs::write(dir.join(f), f).unwrap(); } let (files, v1) = web_shell(&dir, "sw").unwrap(); diff --git a/xtask/src/notices.rs b/xtask/src/notices.rs new file mode 100644 index 000000000..33b8a059f --- /dev/null +++ b/xtask/src/notices.rs @@ -0,0 +1,216 @@ +//! `cargo xtask notices [--check]`: the third-party licence notices shipped with RustGIS. +//! +//! The crates RustGIS is built from are MIT, BSD, ISC, Apache and so on, and those licences ask +//! for their copyright and licence text to go with every copy, binaries included. This writes +//! them, from `cargo about` (see [`INSTALL`], configured by `about.toml`), +//! into a plain-text file next to each LICENSE the packages ship: +//! +//! - `THIRD-PARTY-LICENSES.txt`: every crate in the workspace (the desktop app, the command line +//! and the web app); +//! - `apps/rustgis-python/THIRD-PARTY-LICENSES.txt`: the crates in the Python package. +//! +//! Each distinct licence text appears once, with the crates under it (by name, not version, so a +//! routine version bump leaves the files alone), followed by the crates' NOTICE files (Apache-2.0 +//! section 4(d)). `--check` fails when a file is out of date instead of writing it. + +use std::collections::BTreeMap; +use std::path::Path; +use std::process::Command; + +use serde_json::Value; + +/// How to install the cargo-about the files were generated with (CI pins the same version). +pub const INSTALL: &str = "cargo install --locked --features cli cargo-about@0.9.2"; + +/// The `--check` exit code for files that are out of date (other failures exit with 1). +pub const STALE: u8 = 2; + +/// (output file, package manifest or "" for the whole workspace, what it covers). +const FILES: &[(&str, &str, &str)] = &[ + ("THIRD-PARTY-LICENSES.txt", "", "RustGIS (the desktop app, the command line and the web app)"), + ("apps/rustgis-python/THIRD-PARTY-LICENSES.txt", "apps/rustgis-python/Cargo.toml", "the rustgis Python package"), +]; + +/// Text of `v[key]`, or "". +fn text<'a>(v: &'a Value, key: &str) -> &'a str { + v.get(key).and_then(Value::as_str).unwrap_or("") +} + +/// A licence text with Windows line ends and trailing spaces gone, and no blank lines at either end. +fn tidy(s: &str) -> String { + s.replace("\r\n", "\n").replace('\r', "\n").lines().map(str::trim_end).collect::>().join("\n").trim_matches('\n').to_string() +} + +/// `names` as comma-separated lines no wider than 100 columns. +fn wrap(names: &[String]) -> String { + let mut out = String::new(); + let mut line = String::new(); + for (i, n) in names.iter().enumerate() { + let item = if i + 1 < names.len() { format!("{n},") } else { n.clone() }; + if !line.is_empty() && line.len() + 1 + item.len() > 100 { + out.push_str(&line); + out.push('\n'); + line.clear(); + } + if !line.is_empty() { + line.push(' '); + } + line.push_str(&item); + } + out.push_str(&line); + out +} + +/// The notices file for `cargo about`'s JSON report `about`; `what` names what it covers. +/// `notice` reads a crate's NOTICE files from its manifest path, as (file name, contents). +pub fn render(about: &Value, what: &str, notice: &dyn Fn(&str) -> Vec<(String, String)>) -> String { + let empty = Vec::new(); + // Licence texts, keyed by (licence id, text with its whitespace collapsed): one entry for the + // same text that differs only in wrapping. Value: (name, text, crate names). + let mut texts: BTreeMap<(String, String), (String, String, Vec)> = BTreeMap::new(); + for lic in about.get("licenses").and_then(Value::as_array).unwrap_or(&empty) { + let body = tidy(text(lic, "text")); + let key = (text(lic, "id").to_string(), body.split_whitespace().collect::>().join(" ")); + let entry = texts.entry(key).or_insert_with(|| (text(lic, "name").to_string(), body, Vec::new())); + for used in lic.get("used_by").and_then(Value::as_array).unwrap_or(&empty) { + let krate = used.get("crate").unwrap_or(&Value::Null); + // Path crates (`source` null) are RustGIS's own. + if !krate.get("source").is_some_and(Value::is_null) { + entry.2.push(text(krate, "name").to_string()); + } + } + } + let mut texts: Vec<_> = texts.into_values().filter(|(_, _, names)| !names.is_empty()).collect(); + for (_, _, names) in &mut texts { + names.sort(); + names.dedup(); + } + texts.sort_by(|a, b| (&a.0, &a.2).cmp(&(&b.0, &b.2))); + + let mut notices: BTreeMap> = BTreeMap::new(); + for c in about.get("crates").and_then(Value::as_array).unwrap_or(&empty) { + let p = c.get("package").unwrap_or(&Value::Null); + if p.get("source").is_some_and(Value::is_null) { + continue; + } + let found = notice(text(p, "manifest_path")); + if !found.is_empty() { + notices.entry(text(p, "name").to_string()).or_insert(found); + } + } + + let rule = "=".repeat(100); + let mut out = format!( + "Third-party software notices for {what}\n\n\ + RustGIS is licensed under the Apache License 2.0 (see LICENSE). It is built with the\n\ + open-source Rust crates below, each under its own licence, whose copyright notices and terms\n\ + are reproduced here, followed by the NOTICE files the crates ship. Generated from Cargo.lock\n\ + by `cargo xtask notices`.\n" + ); + for (name, body, names) in &texts { + out.push_str(&format!("\n{rule}\n{name}\nUsed by: {}\n{rule}\n\n{body}\n", wrap(names))); + } + for (krate, files) in ¬ices { + for (file, body) in files { + out.push_str(&format!("\n{rule}\n{file} of {krate}\n{rule}\n\n{}\n", tidy(body))); + } + } + out +} + +/// The NOTICE files next to the manifest at `manifest_path`. +fn notice_files(manifest_path: &str) -> Vec<(String, String)> { + let Some(dir) = Path::new(manifest_path).parent() else { return Vec::new() }; + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut found: Vec<(String, String)> = entries + .flatten() + .filter_map(|e| { + let name = e.file_name().to_string_lossy().into_owned(); + let ok = name.to_ascii_uppercase().starts_with("NOTICE") && e.file_type().is_ok_and(|t| t.is_file()); + ok.then(|| std::fs::read_to_string(e.path()).ok().map(|body| (name, body))).flatten() + }) + .collect(); + found.sort(); + found +} + +/// `cargo about`'s JSON report for `manifest` (the workspace when empty). +fn about(root: &Path, manifest: &str) -> Result { + let mut cmd = Command::new("cargo"); + // --fail: a crate whose licence cargo-about can't work out is an error, not a gap in the file. + cmd.args(["about", "generate", "--locked", "--fail", "--format", "json"]).current_dir(root); + if !manifest.is_empty() { + cmd.args(["--manifest-path", manifest]); + } + println!( + "$ cargo about generate --locked --fail --format json{}", + if manifest.is_empty() { String::new() } else { format!(" --manifest-path {manifest}") } + ); + let out = cmd.output().map_err(|e| format!("cargo about: {e} (install it with `{INSTALL}`)"))?; + if !out.status.success() { + return Err(format!("cargo about failed (install it with `{INSTALL}`):\n{}", String::from_utf8_lossy(&out.stderr))); + } + serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo about's report: {e}")) +} + +/// Writes the files, or with `check` reports the ones out of date: `Err((STALE, …))` for those, +/// `Err((1, …))` for any other failure. +pub fn notices(root: &Path, check: bool) -> Result<(), (u8, String)> { + let mut stale = Vec::new(); + for (file, manifest, what) in FILES { + let wanted = render(&about(root, manifest).map_err(|e| (1, e))?, what, ¬ice_files); + let path = root.join(file); + if std::fs::read_to_string(&path).ok().as_deref() == Some(wanted.as_str()) { + println!("notices: {file} is up to date"); + } else if check { + stale.push(*file); + } else { + std::fs::write(&path, wanted).map_err(|e| (1, format!("{file}: {e}")))?; + println!("notices: wrote {file}"); + } + } + if stale.is_empty() { + Ok(()) + } else { + Err((STALE, format!("out of date: {}; run `cargo xtask notices` and commit the result", stale.join(", ")))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_merges_texts_and_skips_rustgis_crates() { + let krate = |name: &str, source: Value| serde_json::json!({"crate": {"name": name, "version": "1.0.0", "source": source}}); + let reg = Value::from("registry+https://github.com/rust-lang/crates.io-index"); + let about = serde_json::json!({ + "licenses": [ + {"name": "MIT License", "id": "MIT", "text": "Copyright A\r\n\r\nPermission is granted. \r\n", "used_by": [krate("b", reg.clone()), krate("a", reg.clone())]}, + // The same text wrapped differently: one entry. + {"name": "MIT License", "id": "MIT", "text": "Copyright A\n\nPermission\nis granted.", "used_by": [krate("a", reg.clone()), krate("c", reg.clone())]}, + {"name": "Apache License 2.0", "id": "Apache-2.0", "text": "Apache text", "used_by": [krate("rustgis-geom", Value::Null)]}, + ], + "crates": [ + {"package": {"name": "a", "source": reg.clone(), "manifest_path": "/a/Cargo.toml"}}, + {"package": {"name": "rustgis-geom", "source": Value::Null, "manifest_path": "/g/Cargo.toml"}}, + ], + }); + let notice = |m: &str| if m == "/a/Cargo.toml" { vec![("NOTICE".to_string(), "Notice of a\n".to_string())] } else { Vec::new() }; + let out = render(&about, "RustGIS", ¬ice); + assert!(out.starts_with("Third-party software notices for RustGIS\n\nRustGIS is licensed")); + assert_eq!(out.matches("MIT License\nUsed by: a, b, c\n").count(), 1); + assert!(out.contains("Copyright A\n\nPermission is granted.\n")); + // Only RustGIS's own crates use that Apache text, so it is left out. + assert!(!out.contains("Apache text") && !out.contains("rustgis-geom")); + assert!(out.ends_with(&format!("NOTICE of a\n{}\n\nNotice of a\n", "=".repeat(100)))); + } + + #[test] + fn wrap_keeps_lines_short() { + let names: Vec = (0..40).map(|i| format!("crate-{i}")).collect(); + let w = wrap(&names); + assert!(w.lines().all(|l| l.len() <= 100) && w.lines().count() > 1); + assert!(w.ends_with("crate-39") && w.contains("crate-0, crate-1,")); + } +} From 3e76bfd08fa6230a606b8317f3051674eb27534e Mon Sep 17 00:00:00 2001 From: Ryan Shields <94950489+ryanjshields@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:41:58 -0600 Subject: [PATCH 008/297] fix(imagery): cubic takes no colour from see-through pixels (#673) * fix(imagery): cubic takes no colour from see-through pixels Bilinear weighs colour bands by alpha, but cubic sampled them plainly, so next to transparent pixels in an RGBA image their colour bled into the result. Cubic now leaves a position to bilinear unless the sixteen pixels around it all have the same alpha above 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6xUk3wRNWgvWYBQMzY4TG * test(imagery): cubic leaves unequal alphas to bilinear The first cubic test used only alphas of 0 and 255, so it would still pass if cubic ran wherever all sixteen alphas were above 0. The new test gives every pixel a positive alpha that rises across the columns, with bent colours, and checks that cubic's colour bands match bilinear's exactly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6xUk3wRNWgvWYBQMzY4TG --------- Co-authored-by: Claude Opus 5.5 --- crates/analysis/src/warp.rs | 64 +++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/analysis/src/warp.rs b/crates/analysis/src/warp.rs index 062f2c9d7..76db77772 100644 --- a/crates/analysis/src/warp.rs +++ b/crates/analysis/src/warp.rs @@ -158,7 +158,9 @@ impl Sampler<'_> { if weight > 1e-12 { (sum / weight) as f32 } else { f32::NAN } } - /// Catmull-Rom over the sixteen pixels around the position; `None` where one has no value. + /// Catmull-Rom over the sixteen pixels around the position; `None` where one has no value, + /// and for a colour band unless all sixteen have the same alpha above 0 (bilinear, which + /// weighs colours by alpha, takes the rest). fn cubic(&self, b: usize, index: &dyn Fn(i64, i64) -> usize, i0: i64, j0: i64, fu: f64, fv: f64) -> Option { let kernel = |t: f64| { [ @@ -169,10 +171,20 @@ impl Sampler<'_> { ] }; let (wx, wy) = (kernel(fu), kernel(fv)); + let weighted = self.alpha.filter(|a| *a != b); + let mut alpha = None; let mut sum = 0.0f64; for (dj, wj) in wy.iter().enumerate() { for (di, wi) in wx.iter().enumerate() { - let v = self.at(b, index(i0 + di as i64 - 1, j0 + dj as i64 - 1))?; + let idx = index(i0 + di as i64 - 1, j0 + dj as i64 - 1); + if let Some(a) = weighted { + // A see-through pixel's colour mustn't count. + let k = self.at(a, idx)?; + if k <= 0.0 || *alpha.get_or_insert(k) != k { + return None; + } + } + let v = self.at(b, idx)?; sum += f64::from(v) * wi * wj; } } @@ -454,6 +466,54 @@ mod tests { assert!(out.bands[0].data.iter().filter(|v| v.is_finite()).all(|v| *v == 0.0 || *v == 100.0)); } + #[test] + fn cubic_takes_no_colour_from_see_through_pixels() { + // Opaque red on the left, see-through green on the right. + let (w, h) = (30u32, 30u32); + let half = |a: f32, b: f32| (0..w * h).map(|i| if i % w < 15 { a } else { b }).collect::>(); + let bands = vec![half(255.0, 0.0), half(0.0, 255.0), half(0.0, 0.0), half(255.0, 0.0)]; + let r = scan(w, h, bands, ColorHint::Rgb { alpha: true }, "UInt8"); + let map = |c: f64, rw: f64| (500_000.0 + 7.0 * c, 4_000_000.0 - 7.0 * rw); + let fit = georef::fit(Kind::Affine, &gcps(30.0, 30.0, map)).unwrap(); + let out = warp(&r, &fit, &opts(Some(2.0), Resampling::Cubic)).unwrap(); + let mut edge = 0; + for at in 0..out.bands[3].data.len() { + let a = out.bands[3].data[at]; + if a > 0.0 { + let rgb = [0, 1, 2].map(|b| out.bands[b].data[at]); + assert_eq!(rgb, [255.0, 0.0, 0.0], "cell {at}, alpha {a}"); + edge += usize::from(a < 255.0); + } + } + assert!(edge > 0, "some cells are partly see-through"); + } + + #[test] + fn cubic_leaves_unequal_alphas_to_bilinear() { + // Alpha rises across every column, so no sixteen pixels share one, and the colours bend. + let (w, h) = (30u32, 30u32); + let band = |f: &dyn Fn(u32, u32) -> f32| (0..w * h).map(|i| f(i % w, i / w)).collect::>(); + let bands = vec![ + band(&|x, _| (x * x / 4) as f32), + band(&|_, y| (255 - 8 * y) as f32), + band(&|x, y| ((x * 37 + y * 91) % 256) as f32), + band(&|x, _| (40 + 7 * x) as f32), + ]; + let r = scan(w, h, bands, ColorHint::Rgb { alpha: true }, "UInt8"); + let map = |c: f64, rw: f64| (500_000.0 + 7.0 * c, 4_000_000.0 - 7.0 * rw); + let fit = georef::fit(Kind::Affine, &gcps(30.0, 30.0, map)).unwrap(); + let cubic = warp(&r, &fit, &opts(Some(2.0), Resampling::Cubic)).unwrap(); + let bilinear = warp(&r, &fit, &opts(Some(2.0), Resampling::Bilinear)).unwrap(); + for b in 0..3 { + let (c, l) = (&cubic.bands[b].data, &bilinear.bands[b].data); + assert_eq!(c.len(), l.len()); + assert!(c.iter().filter(|v| v.is_finite()).count() > 100, "band {b} has cells to compare"); + for (at, (x, y)) in c.iter().zip(l).enumerate() { + assert!(x == y || (x.is_nan() && y.is_nan()), "band {b}, cell {at}: cubic {x}, bilinear {y}"); + } + } + } + #[test] fn no_data_is_kept_outside_and_skipped_inside() { let gt = GeoTransform { origin_x: 0.0, origin_y: 4.0, pixel_width: 1.0, pixel_height: -1.0 }; From 0be4a1df0a6f375ad8934378b85900d4000542b8 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Wed, 21 Oct 2026 17:46:30 -0400 Subject: [PATCH 009/297] Size the macOS DMG from its contents (#676) hdiutil's estimate for -srcfolder ran out of room copying the app on the Apple Silicon runner once the notices were added; give the image the folder's size plus 64 MB, which UDZO compresses away. --- packaging/package.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packaging/package.sh b/packaging/package.sh index f646ccd69..90cf80387 100755 --- a/packaging/package.sh +++ b/packaging/package.sh @@ -251,7 +251,11 @@ SPEC mkdir -p "$stage" cp -R "$app" "$stage/" ln -s /Applications "$stage/Applications" - hdiutil create -volname "RustGIS $version" -srcfolder "$stage" -ov -format UDZO "$dist/$name.dmg" + # hdiutil's own size estimate for -srcfolder can fall short ("No space left on device" while + # copying the app): size the image from the folder, with room to spare. UDZO compresses the + # free space away. + size_mb=$(($(du -sm "$stage" | cut -f1) + 64)) + hdiutil create -volname "RustGIS $version" -srcfolder "$stage" -size "${size_mb}m" -ov -format UDZO "$dist/$name.dmg" if [ -n "$identity" ]; then sign "$dist/$name.dmg" if notarize "$dist/$name.dmg" && [ -n "${APPLE_API_KEY_PATH:-}" ]; then From 446bc512aeacd37e723aa58ae735f93a083fd49f Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Wed, 21 Oct 2026 18:48:36 -0300 Subject: [PATCH 010/297] Analysis: trend over a data cube, Sen's slope and the modified Mann-Kendall test, cell by cell (#579) (#604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Zarr: a variable that follows the time slider (#554) layer.addZarr takes "time": true. The variable's time axis is read from its CF coordinate (model calendars included) and the layer shows the last of its steps in the slider's window, or nothing when none falls in it; the slider turns on over the axis when it is off, and "start"/"end" take a period of a longer axis. Moving, playing or exporting the slider reads that step's slice, only its chunks, keeping the last eight; a web store's next two steps are read ahead in the background. The stretch is fixed at the first slice's range, so a colour means one value at every step. The project keeps the layer by its store and selection (ZarrTime) and reads the slider's step again when it opens. The Zarr dialog has a Follow the time slider box (desktop). Co-Authored-By: Claude Opus 5.5 * Zarr: a variable's time series at a point, as a chart (#555) zarr.series reads a variable along its time dimension at the cell holding a point, from only the chunks that hold that cell: through a Zarr layer (one following the time slider gives its period, a slice the whole axis) or a store's folder or address, with start/end, the other dimensions' steps, any longitude convention, and a projected grid's coordinate system. A store chunked a step at a time is capped at MAX_CHUNKS chunks, with the count in the error. The data crate gains locate, plan_series and assemble_series (scaled, no data as NaN), map_coordinate_keys, is_lonlat_grid and grid_crs. View › Time Series: pick a Zarr layer and a point on the map; up to six lines on one chart, the time slider's step marked, a click on the chart moves the slider there, and Export CSV. Co-Authored-By: Claude Opus 5.5 * Zarr: summarise a variable over time into a raster (#556) zarr.reduce takes mean, min, max, sum, std (population), count or countAbove a threshold of a Zarr variable's steps: a layer's period by default, else start/end, and months for one season across years. Slices are read one at a time into running totals (Welford for the standard deviation), so memory stays at a slice; at most 20,000 steps, and minValid leaves a cell with too few values empty. The result is a one-band raster named after what it holds, kept in the project as a GeoTIFF. ReduceRun is prepared on the session (reduce_start) and run anywhere: the Time Series pane's Summarise over time runs it on a thread with progress and Cancel, then hands the GeoTIFF over to layer.addZarr. Co-Authored-By: Claude Opus 5.5 * Analysis: trend over a data cube, Sen's slope and the modified Mann-Kendall test, cell by cell (#579) - rustgis-analysis trend: Sen's slope and intercept; the Mann-Kendall test, modified for autocorrelation by Hamed & Rao (1998, the default) or Yue & Wang (2004, lag 1), seasonal (Hirsch et al. 1982) or plain, ties corrected; the normal distribution from Cody's erfc. - engine cube: a TimeCube (dates, and a slice at each) from a stack of raster layers dated by their names or "dates", a raster's bands, or a Zarr variable (zarr_cube, shared with zarr.reduce, as is the choice of steps). - cube.trend reads the slices into one block and works the cells out on every core: four layers, Sen's slope, Z, p and the slope where p < alpha. Capped at 64 million values and 2,000 dates. - Workflows pane: a Trend Analysis section after Change Detection, run on a thread with progress and Cancel, its layers added through cube.trend so the run is in the History. - Docs: analysis.md, Trend analysis. Co-Authored-By: Claude Opus 5.5 * Zarr: dates stay one per step; read-ahead fetches each slice once, on two threads at most (#601 review) - An axis out of order or with a date twice is refused by layer.addZarr's time, and repair clears dates it can't match to steps instead of sorting them onto the wrong ones. - Read-ahead skips slices kept or already being read, runs on at most two threads, and the step shown waits for a read ahead of it rather than fetching it again. - Tests: the Zarr store helper sits above the doc comment it had taken from zarr_stores_in_folders_and_handed_over, which gets its doc and cfg back; the new test is desktop-only like its helper. Co-Authored-By: Claude Opus 5.5 * Zarr: dates stay one per step; read-ahead fetches each slice once, on two threads at most (#601 review) - An axis out of order or with a date twice is refused by layer.addZarr's time, and repair clears dates it can't match to steps instead of sorting them onto the wrong ones. - Read-ahead skips slices kept or already being read, runs on at most two threads, and the step shown waits for a read ahead of it rather than fetching it again. - Tests: the Zarr store helper sits above the doc comment it had taken from zarr_stores_in_folders_and_handed_over, which gets its doc and cfg back; the new test is desktop-only like its helper. Co-Authored-By: Claude Opus 5.5 * Zarr: dates stay one per step; read-ahead fetches each slice once, on two threads at most (#601 review) - An axis out of order or with a date twice is refused by layer.addZarr's time, and repair clears dates it can't match to steps instead of sorting them onto the wrong ones. - Read-ahead skips slices kept or already being read, runs on at most two threads, and the step shown waits for a read ahead of it rather than fetching it again. - Tests: the Zarr store helper sits above the doc comment it had taken from zarr_stores_in_folders_and_handed_over, which gets its doc and cfg back; the new test is desktop-only like its helper. Co-Authored-By: Claude Opus 5.5 * Zarr: dates stay one per step; read-ahead fetches each slice once, on two threads at most (#601 review) - An axis out of order or with a date twice is refused by layer.addZarr's time, and repair clears dates it can't match to steps instead of sorting them onto the wrong ones. - Read-ahead skips slices kept or already being read, runs on at most two threads, and the step shown waits for a read ahead of it rather than fetching it again. - Tests: the Zarr store helper sits above the doc comment it had taken from zarr_stores_in_folders_and_handed_over, which gets its doc and cfg back; the new test is desktop-only like its helper. Co-Authored-By: Claude Opus 5.5 * Zarr: the series test is desktop-only, like the store it builds (#601 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the series test is desktop-only, like the store it builds (#601 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the summary test is desktop-only, like the store it builds (#601 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the series test is desktop-only, like the store it builds (#601 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the summary test is desktop-only, like the store it builds (#601 review) Co-Authored-By: Claude Opus 5.5 * Analysis: the trend test and its Zarr store are desktop-only (#601 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a series reads hostile chunk shapes without overflow; the CSV helper is desktop-only (#602 review) assemble_series takes chunk sizes through chunk(), multiplies and adds saturating, and bounds its allocation; series_csv is only used by the desktop pane, so the web build leaves it out. Co-Authored-By: Claude Opus 5.5 * Zarr: a series reads hostile chunk shapes without overflow; the CSV helper is desktop-only (#602 review) assemble_series takes chunk sizes through chunk(), multiplies and adds saturating, and bounds its allocation; series_csv is only used by the desktop pane, so the web build leaves it out. Co-Authored-By: Claude Opus 5.5 * Zarr: a series reads hostile chunk shapes without overflow; the CSV helper is desktop-only (#602 review) assemble_series takes chunk sizes through chunk(), multiplies and adds saturating, and bounds its allocation; series_csv is only used by the desktop pane, so the web build leaves it out. Co-Authored-By: Claude Opus 5.5 * Analysis: the trend works in the browser on one thread; trend() checks its level and its size (#604 review) - cube.trend works the cells out on the calling thread in the web build, which has no threads. - trend() gives None for a level outside (0, 1) or more than 10,000 values, so a direct caller can't make it allocate without bound; the always-true guard on Hamed-Rao goes. Co-Authored-By: Claude Opus 5.5 * Zarr: read-ahead gives its keys back even on a panic; dates before 1 CE read (#601 review) - A read-ahead thread releases its keys and its place among the threads in a drop guard, so a panic on a hostile store can't stall later steps or stop read-ahead; a step waits for one being read ahead 15 s at most, then reads it itself. - A CF date's leading minus is the year's sign, not a separator (date_parts), in model calendars. Co-Authored-By: Claude Opus 5.5 * Zarr: read-ahead gives its keys back even on a panic; dates before 1 CE read (#601 review) - A read-ahead thread releases its keys and its place among the threads in a drop guard, so a panic on a hostile store can't stall later steps or stop read-ahead; a step waits for one being read ahead 15 s at most, then reads it itself. - A CF date's leading minus is the year's sign, not a separator (date_parts), in model calendars. Co-Authored-By: Claude Opus 5.5 * Zarr: read-ahead gives its keys back even on a panic; dates before 1 CE read (#601 review) - A read-ahead thread releases its keys and its place among the threads in a drop guard, so a panic on a hostile store can't stall later steps or stop read-ahead; a step waits for one being read ahead 15 s at most, then reads it itself. - A CF date's leading minus is the year's sign, not a separator (date_parts), in model calendars. Co-Authored-By: Claude Opus 5.5 * Zarr: read-ahead gives its keys back even on a panic; dates before 1 CE read (#601 review) - A read-ahead thread releases its keys and its place among the threads in a drop guard, so a panic on a hostile store can't stall later steps or stop read-ahead; a step waits for one being read ahead 15 s at most, then reads it itself. - A CF date's leading minus is the year's sign, not a separator (date_parts), in model calendars. Co-Authored-By: Claude Opus 5.5 * Zarr: the layer shows the slider's step after an export and undo; the slider waits for a first read; model calendars keep their dates apart (#602 review) - time.export, and undo or redo, bring following Zarr layers back to the slider's step, not the last frame's. - layer.addZarr's time reads the first step before turning the slider on, so a store that can't be read leaves the map as it was. - A step that couldn't be read isn't read again on every call; it's tried again once the slider moves to it anew. - In model calendars the time of day in `since` counts, and a 360- or 366-day year is laid over its real year in proportion, so daily dates (30 February, 1 March) stay distinct and in order instead of being clamped onto one. Co-Authored-By: Claude Opus 5.5 * Zarr: the layer shows the slider's step after an export and undo; the slider waits for a first read; model calendars keep their dates apart (#602 review) - time.export, and undo or redo, bring following Zarr layers back to the slider's step, not the last frame's. - layer.addZarr's time reads the first step before turning the slider on, so a store that can't be read leaves the map as it was. - A step that couldn't be read isn't read again on every call; it's tried again once the slider moves to it anew. - In model calendars the time of day in `since` counts, and a 360- or 366-day year is laid over its real year in proportion, so daily dates (30 February, 1 March) stay distinct and in order instead of being clamped onto one. Co-Authored-By: Claude Opus 5.5 * Zarr: the layer shows the slider's step after an export and undo; the slider waits for a first read; model calendars keep their dates apart (#602 review) - time.export, and undo or redo, bring following Zarr layers back to the slider's step, not the last frame's. - layer.addZarr's time reads the first step before turning the slider on, so a store that can't be read leaves the map as it was. - A step that couldn't be read isn't read again on every call; it's tried again once the slider moves to it anew. - In model calendars the time of day in `since` counts, and a 360- or 366-day year is laid over its real year in proportion, so daily dates (30 February, 1 March) stay distinct and in order instead of being clamped onto one. Co-Authored-By: Claude Opus 5.5 * Zarr: the layer shows the slider's step after an export and undo; the slider waits for a first read; model calendars keep their dates apart (#602 review) - time.export, and undo or redo, bring following Zarr layers back to the slider's step, not the last frame's. - layer.addZarr's time reads the first step before turning the slider on, so a store that can't be read leaves the map as it was. - A step that couldn't be read isn't read again on every call; it's tried again once the slider moves to it anew. - In model calendars the time of day in `since` counts, and a 360- or 366-day year is laid over its real year in proportion, so daily dates (30 February, 1 March) stay distinct and in order instead of being clamped onto one. Co-Authored-By: Claude Opus 5.5 * Zarr: a summary refuses a slice of another length rather than index past its totals (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a summary refuses a slice of another length rather than index past its totals (#603 review) Co-Authored-By: Claude Opus 5.5 * Analysis: seasons from each date's calendar year; smaller limits in the browser (#604 review) - The seasonal test in cube.trend takes each date's season from its calendar year (its month for 12, otherwise its part of the year by day), through trend::trend_seasonal, instead of the fraction of a 365.2425-day year from 1970, which drifted (the 1st of a month flipped between seasons) and broke with per decade. A monthly cube dated on the 1st tests it, per year and per decade. - In the browser, which works on one thread, a run compares at most 200 million pairs and a series has at most 2,000 values; cube.rs no longer leaves a closure unused there. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder with no variable named follows the slider with its first one; read-ahead never panics for want of a thread (#601 review) - add_following picks the variable before reading a folder's coordinates, so leaving `variable` out (its default, the store's first) no longer fails with "no time dimension". Tested. - Read-ahead starts its thread with thread::Builder, and gives back its keys and place when no thread can be had. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder with no variable named follows the slider with its first one; read-ahead never panics for want of a thread (#601 review) - add_following picks the variable before reading a folder's coordinates, so leaving `variable` out (its default, the store's first) no longer fails with "no time dimension". Tested. - Read-ahead starts its thread with thread::Builder, and gives back its keys and place when no thread can be had. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder with no variable named follows the slider with its first one; read-ahead never panics for want of a thread (#601 review) - add_following picks the variable before reading a folder's coordinates, so leaving `variable` out (its default, the store's first) no longer fails with "no time dimension". Tested. - Read-ahead starts its thread with thread::Builder, and gives back its keys and place when no thread can be had. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder with no variable named follows the slider with its first one; read-ahead never panics for want of a thread (#601 review) - add_following picks the variable before reading a folder's coordinates, so leaving `variable` out (its default, the store's first) no longer fails with "no time dimension". Tested. - Read-ahead starts its thread with thread::Builder, and gives back its keys and place when no thread can be had. Co-Authored-By: Claude Opus 5.5 * Zarr: a folder store rewritten on disk is read again, not served from the slice cache (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a folder store rewritten on disk is read again, not served from the slice cache (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a folder store rewritten on disk is read again, not served from the slice cache (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: a folder store rewritten on disk is read again, not served from the slice cache (#603 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the series chart breaks its lines at gaps, labels units only when they agree, and keeps a click on its dates (#602 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the series chart breaks its lines at gaps, labels units only when they agree, and keeps a click on its dates (#602 review) Co-Authored-By: Claude Opus 5.5 * Zarr: the series chart breaks its lines at gaps, labels units only when they agree, and keeps a click on its dates (#602 review) Co-Authored-By: Claude Opus 5.5 * Analysis: trend_seasonal refuses a season index out of range rather than allocate for it (#604 review) Co-Authored-By: Claude Opus 5.5 * Zarr: one slice key per read; a time coordinate of the dimension's length; follow off for a variable without time Co-Authored-By: Claude Opus 5.5 * Zarr reduce: read each step under its slice key, as read_step now takes it Co-Authored-By: Claude Opus 5.5 * Docs: a following Zarr layer is kept by reference for a folder or a web store; the slice rule is for a single step Co-Authored-By: Claude Opus 5.5 * Time Series: a month that isn't a number is said, not dropped; a cancelled summary stops quietly Co-Authored-By: Claude Opus 5.5 * Trend: a run no longer kept is said, not worked out again; Cancel looked at by work; only a MODIS date's A cut from a name Co-Authored-By: Claude Opus 5.5 * Zarr: a summary handed over by the app is no slice of the variable Co-Authored-By: Claude Opus 5.5 * Time Series: the summary thread catches a panic and says when it can't start Co-Authored-By: Claude Opus 5.5 * Trend: minValid above the dates is refused; the trend thread catches a panic and says when it can't start Co-Authored-By: Claude Opus 5.5 * Trend: the erfc comment gives the range the code switches at Co-Authored-By: Claude Opus 5.5 * Trend: one layer a date; a date from the file's name only; slices on one CRS; fields that aren't numbers are said Co-Authored-By: Claude Opus 5.5 * Trend: a series takes 4,000 values at most on the desktop; the correction's fallback is documented Co-Authored-By: Claude Opus 5.5 * Trend: a Zarr cube's time axis must be increasing without repeats, as for following the slider Co-Authored-By: Claude Opus 5.5 * Trend: Sen's slope is bounded on its own, not only through trend() Co-Authored-By: Claude Opus 5.5 * Trend: a cube takes 16 million values at most in the browser Co-Authored-By: Claude Opus 5.5 * Trend: a slope too steep to hold is left out of Sen's median Co-Authored-By: Claude Opus 5.5 * Trend: one band a date in a raster cube, as one layer a date in a stack Co-Authored-By: Claude Opus 5.5 * Trend: only a Zarr layer (following, or a slice named by its steps) is read as a Zarr cube; a GDAL subdataset takes the bands' way Co-Authored-By: Claude Opus 5.5 * Zarr: a web store's slice keeps the coordinate system given for it, for its time series Co-Authored-By: Claude Opus 5.5 * Zarr: Follow takes a long axis from the step picked; web reads within a total; zarr.series checks its point and needs the network permission Co-Authored-By: Claude Opus 5.5 * Zarr: zarr.reduce needs the network permission, as layer.addZarr does Co-Authored-By: Claude Opus 5.5 * Time Series: the legend's colour taken with get, as the chart's Co-Authored-By: Claude Opus 5.5 * Zarr: a long axis taken in part is said, with the period the layer takes Co-Authored-By: Claude Opus 5.5 * Trend: cube.trend needs the network permission; a cancelled run stops quietly Co-Authored-By: Claude Opus 5.5 * Zarr: a step picked past a long axis is refused; Time Series is left out of the web app's ribbon Co-Authored-By: Claude Opus 5.5 * Zarr summary: a run cancelled as it finishes adds nothing; its totals are bounded by a cell limit Co-Authored-By: Claude Opus 5.5 * Zarr summary: the params say std is the population standard deviation Co-Authored-By: Claude Opus 5.5 * Trend: slices within a thousandth of a pixel stack as one grid; two finished runs kept for hand-over Co-Authored-By: Claude Opus 5.5 * Trend: the cube's limits say what a thread's slopes take Co-Authored-By: Claude Opus 5.5 * Trend: the seasonal test's doc says what times trend() takes, and points to trend_seasonal Co-Authored-By: Claude Opus 5.5 * Trend: progress through the cells; a run cancelled as it finishes adds nothing; a season's start isn't rounded into the one before Co-Authored-By: Claude Opus 5.5 * Trend: trend_seasonal takes a season for every value, or gives none Co-Authored-By: Claude Opus 5.5 * Trend: values are taken in time order, whatever order they come in Co-Authored-By: Claude Opus 5.5 * Zarr summary: only the totals the statistic needs; the pane's messages go through i18n Co-Authored-By: Claude Opus 5.5 * Zarr summary: an axis with a step that isn't a date is refused Co-Authored-By: Claude Opus 5.5 * Time Series: the chart and the CSV read the series in place, not copied every frame Co-Authored-By: Claude Opus 5.5 * Zarr summary: its title stays over the progress; the guide says what the totals take and what count gives where there's no value Co-Authored-By: Claude Opus 5.5 * Zarr series: the time axis must be increasing without repeats, as for following the slider Co-Authored-By: Claude Opus 5.5 * Zarr series: its menu path is where it is, View › Time Series Co-Authored-By: Claude Opus 5.5 * Zarr summary: slices read without being kept, so the slider's stay; its menu path is View › Time Series Co-Authored-By: Claude Opus 5.5 * Zarr series: a test of points between cell centres Co-Authored-By: Claude Opus 5.5 * Trend: trend()'s doc says plainly when it gives no trend Co-Authored-By: Claude Opus 5.5 * Zarr summary: minValid's share isn't rounded up past a whole number of steps Co-Authored-By: Claude Opus 5.5 * Trend: a Zarr cube's slices are read without being kept, so the slider's stay; dates taken with get Co-Authored-By: Claude Opus 5.5 * Zarr summary: a step never written only lowers the counts; a summary lands only in its project; the ran hand-over keeps its crs and isn't recorded in tasks Co-Authored-By: Claude Opus 5.5 * Trend: a run lands only in the project it ran in; its ran hand-over isn't recorded in tasks Co-Authored-By: Claude Opus 5.5 * Zarr summary: every step on one grid (place and CRS too); a period with nothing written says so Co-Authored-By: Claude Opus 5.5 * Trend: the seasonal test needs as many values in seasons of two or more as any test; the block written with get_mut Co-Authored-By: Claude Opus 5.5 * Zarr: a slice with chunks refused (HTTP 403) says so, not read as fill Co-Authored-By: Claude Opus 5.5 * Trend: a full date anywhere in a name before a year alone, and a year only standing apart; alpha's range in the params Co-Authored-By: Claude Opus 5.5 * Zarr series: a chunk file there but too large or unreadable is said, not read as no value Co-Authored-By: Claude Opus 5.5 * Zarr summary: a name given is trimmed to 200 characters, as the trend's Co-Authored-By: Claude Opus 5.5 * Zarr series: files refused (HTTP 403) are said; a Time Series pick ends when the pane isn't in view Co-Authored-By: Claude Opus 5.5 * Trend: each test's doc over its own test; the limits count every thread's slopes; fewer pairs in the browser Co-Authored-By: Claude Opus 5.5 * Zarr summary: a layer's period end added with saturation Co-Authored-By: Claude Opus 5.5 * Zarr: say why a handed-over slice's subdataset only names it Co-Authored-By: Claude Opus 5.5 * Trend: say where the seasonal intercept's values come from Co-Authored-By: Claude Opus 5.5 * Zarr: a summary handed over says so; any slice keeps its subdataset, a default step included Co-Authored-By: Claude Opus 5.5 * Time Series: a summary lands whatever pane is in view, from the frame, not only from its pane Co-Authored-By: Claude Opus 5.5 * Trend: a run lands whatever pane is in view, from the frame, as the Zarr summary does Co-Authored-By: Claude Opus 5.5 * Zarr: a step never written is told apart by its error kind (DataError::NoChunks), not its message Co-Authored-By: Claude Opus 5.5 * Trend: seasons read for the seasonal test only; the docs say how detrending meets uneven times and what minValid means for the seasonal test Co-Authored-By: Claude Opus 5.5 * Trend: one slope rule for Sen's slope and the seasonal test (push_slopes) Co-Authored-By: Claude Opus 5.5 * Time Series: summarise keeps its own doc; RasterLayer::crs says it serves Zarr slices too Co-Authored-By: Claude Opus 5.5 * Time Series: a summary dropped because another project opened says so Co-Authored-By: Claude Opus 5.5 * Trend: dates and name bytes taken with get, not by index Co-Authored-By: Claude Opus 5.5 * Zarr summary: only the first slice's grid is kept through the run; dates taken with get Co-Authored-By: Claude Opus 5.5 * Trend: a Zarr step never written has no value for its cells, as in the summary, rather than stopping the run Co-Authored-By: Claude Opus 5.5 * Trend: in the web app, a Zarr cube says it's desktop-only rather than being read as bands Co-Authored-By: Claude Opus 5.5 * Trend: a cell's four outputs written through a slice pattern, not by index Co-Authored-By: Claude Opus 5.5 * Trend: the output bands read each cell's values with get Co-Authored-By: Claude Opus 5.5 * Trend and summary: one reading of months, and one grid rule (place within a thousandth of a pixel) Co-Authored-By: Claude Opus 5.5 * Trend: whether seasons is read follows the test as parsed Co-Authored-By: Claude Opus 5.5 * Trend: a run dropped because another project opened says so, as the Zarr summary does Co-Authored-By: Claude Opus 5.5 * Trend: the outputs' names cut at the date's start, keeping a product name's own digits (MOD13Q1, sentinel2_ndvi) Co-Authored-By: Claude Opus 5.5 * Trend: MAX_PAIRS says how long a run at the cap can take, and who waits for it Co-Authored-By: Claude Opus 5.5 * Trend: a trend waited for where it's called (a script, the CLI) takes a tenth of the pairs the app's threaded run does Co-Authored-By: Claude Opus 5.5 * Trend: the one-layer mode keeps its raster; the block is dropped before the outputs are made; score iterates Co-Authored-By: Claude Opus 5.5 * Trend: a stack of more layers than a run takes is refused before they're looked up Co-Authored-By: Claude Opus 5.5 * Trend: one buffer of Sen's slopes per thread, cleared cell by cell (trend_with, trend_seasonal_with) Co-Authored-By: Claude Opus 5.5 * Trend: trend_with's doc says which allocation it spares Co-Authored-By: Claude Opus 5.5 * Trend: Trend::n's doc says what it counts for the seasonal test Co-Authored-By: Claude Opus 5.5 * Zarr summary takes its axis in any order again; the time-order check is the trend's own Co-Authored-By: Claude Opus 5.5 * Trend: a run the app worked out is kept by its session, not the process; progress counted over the whole grid Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/analysis/src/lib.rs | 1 + crates/analysis/src/trend.rs | 694 +++++++++++++++++++++++++++ crates/engine/src/cmd/cube.rs | 734 +++++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/plugins.rs | 1 + crates/engine/src/cmd/zarr.rs | 107 ++--- crates/engine/src/lib.rs | 8 + crates/engine/src/tests.rs | 169 ++++++- crates/ui-egui/src/analysispane.rs | 272 ++++++++++- crates/ui-egui/src/lib.rs | 3 +- crates/ui-egui/src/panes.rs | 6 +- docs/user-guide/analysis.md | 42 ++ 12 files changed, 1975 insertions(+), 64 deletions(-) create mode 100644 crates/analysis/src/trend.rs create mode 100644 crates/engine/src/cmd/cube.rs diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index 1cf95ec82..bbd05f279 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -20,6 +20,7 @@ pub mod rasterops; pub mod tissot; mod tools; pub mod topology; +pub mod trend; pub mod warp; pub use tools::{TOOLS, holds_coordinates, simplify_geometry}; diff --git a/crates/analysis/src/trend.rs b/crates/analysis/src/trend.rs new file mode 100644 index 000000000..73eb4873d --- /dev/null +++ b/crates/analysis/src/trend.rs @@ -0,0 +1,694 @@ +//! Trends in a time series (opengeos/RustGIS#579): Sen's slope for the rate of change, and the +//! Mann–Kendall test for whether it is significant, in the variants used on autocorrelated +//! series such as NDVI and temperature. +//! +//! - **Hamed & Rao (1998):** the variance of S is corrected by the autocorrelation of the ranks of +//! the series with Sen's trend taken out, using only the lags significant at the test's level. +//! - **Yue & Wang (2004):** the variance is corrected by an effective sample size, from the lag-1 +//! autocorrelation of the series with the trend taken out (`pymannkendall` with `lag=1`, R's +//! `modifiedmk::mmky1lag`). Summing every lag instead makes white noise look correlated: the +//! lags of a short sample's autocorrelation sum to about −½. +//! - **Seasonal (Hirsch et al. 1982):** the test is made within each season (the same month, or +//! the same part of the year) and the seasons' statistics are summed; Sen's slope is the median +//! of the slopes between values of the same season. +//! - **Original:** Mann (1945) and Kendall (1975), for independent values. +//! +//! The variants follow `pymannkendall`'s definitions, so results can be checked against it. One +//! difference: the corrected tests take the trend out at each value's own time, where +//! `pymannkendall` takes it out at the value's place (0, 1, 2…); the two agree on evenly spaced +//! series, and on uneven ones (gaps, a months filter) this keeps the real spacing. Each series is +//! the values at its times; missing values (not finite) are skipped. Times are in any unit (years, +//! say): the slope is per that unit. + +/// The Mann–Kendall test to make. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Test { + /// Hamed & Rao's correction for autocorrelation (the default). + HamedRao, + /// Yue & Wang's correction for autocorrelation, from the lag-1 autocorrelation. + YueWang, + /// The seasonal test, with this many seasons in a unit of time (12: months of a year). Through + /// [`trend`], a value's season is its time's place within its unit (times in years: 2000.5 is + /// mid-2000), so times counted in steps (0, 1, 2…) put every value in one season; for real + /// dates, [`trend_seasonal`] takes each value's season from its calendar. + Seasonal(u32), + /// The test for independent values. + Original, +} + +impl Test { + /// The test named `name` (`hamedRao`, `yueWang`, `seasonal`, `original`), with `seasons` for + /// the seasonal one. + pub fn parse(name: &str, seasons: u32) -> Option { + let n = name.trim().to_ascii_lowercase().replace(['-', '_', ' ', '&'], ""); + Some(match n.as_str() { + "" | "hamedrao" | "modified" | "mmk" => Test::HamedRao, + "yuewang" => Test::YueWang, + "seasonal" => Test::Seasonal(seasons.clamp(2, 366)), + "original" | "mannkendall" | "mk" => Test::Original, + _ => return None, + }) + } + + /// Its short name, for layer names. + pub fn code(self) -> &'static str { + match self { + Test::HamedRao => "Hamed-Rao", + Test::YueWang => "Yue-Wang", + Test::Seasonal(_) => "seasonal", + Test::Original => "Mann-Kendall", + } + } +} + +/// A series' trend. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Trend { + /// The values used (finite ones; for the seasonal test, those in seasons of two or more, + /// which are the ones S and the slope come from). + pub n: usize, + /// Sen's slope, per unit of time. + pub slope: f64, + /// Sen's intercept, as `pymannkendall` and Conover (1980) give it: the median value less the + /// slope times the median time. + pub intercept: f64, + /// Mann–Kendall's S. + pub s: f64, + /// S's variance, corrected for autocorrelation by the modified tests. + pub var_s: f64, + /// The modified tests' correction, n/n* (1 for the others). Where it would be zero or below + /// (strong negative autocorrelation on a short series), which would leave S without a + /// variance, it is 1: the uncorrected test, where `pymannkendall` gives no p-value. + pub correction: f64, + /// The standard normal statistic. + pub z: f64, + /// The two-sided p-value. + pub p: f64, +} + +/// The fewest values a trend is worked out from. +pub const MIN_VALUES: usize = 4; +/// The most: Sen's slope keeps the slope of every two, so this bounds its memory (4,000 values, +/// about 64 MB, on the desktop; 2,000, 16 MB, in the browser's smaller memory). A data cube's +/// trend takes fewer still (2,000 dates). +pub const MAX_VALUES: usize = if cfg!(target_arch = "wasm32") { 2_000 } else { 4_000 }; + +/// The trend of the values `y` at times `t` (the same length, in any order: they're taken in time +/// order; missing values skipped). `alpha` is the test's level, between 0 and 1 (Hamed & Rao's +/// correction also uses it to choose its lags). `None` when fewer than [`MIN_VALUES`] or more than +/// [`MAX_VALUES`] are left, for an `alpha` outside 0 to 1, or, for the seasonal test, when no +/// season has two values. +pub fn trend(t: &[f64], y: &[f64], test: Test, alpha: f64) -> Option { + trend_with(t, y, test, alpha, &mut Vec::new()) +} + +/// [`trend`], keeping Sen's slopes in `slopes` (cleared first): a caller working out many series +/// in turn (a cube, cell by cell) keeps one buffer and spares the largest allocation (n²⁄2 +/// slopes) each time; the series' own vectors, of n values, are still made per call. +pub fn trend_with(t: &[f64], y: &[f64], test: Test, alpha: f64, slopes: &mut Vec) -> Option { + let mut kept: Vec<(f64, f64)> = t.iter().zip(y).filter(|(t, y)| t.is_finite() && y.is_finite()).map(|(t, y)| (*t, *y)).collect(); + // The test reads the values in time order (S, the autocorrelation). + kept.sort_by(|a, b| a.0.total_cmp(&b.0)); + let (t, y): (Vec, Vec) = kept.into_iter().unzip(); + let n = y.len(); + if !(MIN_VALUES..=MAX_VALUES).contains(&n) || !(alpha > 0.0 && alpha < 1.0) { + return None; + } + if let Test::Seasonal(m) = test { + return seasonal(&t, &y, m, slopes); + } + let slope = sen_slope_in(&t, &y, slopes)?; + let intercept = intercept(&t, &y, slope)?; + let s = score(&y); + let mut var_s = variance(&y); + let mut correction = 1.0; + let nf = n as f64; + match test { + Test::HamedRao => { + // The ranks of the series with the trend taken out, and their autocorrelation. + let detrended: Vec = t.iter().zip(&y).map(|(t, y)| y - slope * t).collect(); + let acf = autocorrelation(&ranks(&detrended)); + let bound = normal_quantile(1.0 - alpha / 2.0) / nf.sqrt(); + let sum: f64 = acf + .iter() + .enumerate() + .skip(1) + .filter(|(_, r)| r.abs() > bound) + .map(|(k, r)| { + let k = k as f64; + (nf - k) * (nf - k - 1.0) * (nf - k - 2.0) * r + }) + .sum(); + correction = 1.0 + 2.0 / (nf * (nf - 1.0) * (nf - 2.0)) * sum; + } + Test::YueWang => { + let detrended: Vec = t.iter().zip(&y).map(|(t, y)| y - slope * t).collect(); + let r1 = autocorrelation(&detrended).get(1).copied().unwrap_or(0.0); + correction = 1.0 + 2.0 * (1.0 - 1.0 / nf) * r1; + } + _ => {} + } + // A correction below zero (strong negative autocorrelation on a short series) would make + // the variance meaningless: it's kept positive. + if !(correction.is_finite() && correction > 0.0) { + correction = 1.0; + } + var_s *= correction; + let z = z_score(s, var_s); + Some(Trend { n, slope, intercept, s, var_s, correction, z, p: p_value(z) }) +} + +/// Sen's slope: the median of the slopes between every two values at different times; `None` +/// for more than [`MAX_VALUES`], whose slopes would take too much memory. +pub fn sen_slope(t: &[f64], y: &[f64]) -> Option { + sen_slope_in(t, y, &mut Vec::new()) +} + +/// [`sen_slope`] with its slopes kept in `slopes` (cleared first). +fn sen_slope_in(t: &[f64], y: &[f64], slopes: &mut Vec) -> Option { + let n = y.len().min(t.len()); + if n > MAX_VALUES { + return None; + } + slopes.clear(); + push_slopes(&t[..n], &y[..n], slopes); + median(slopes) +} + +/// The slopes between every two values at different times, added to `out`: the one rule for +/// Sen's slope and the seasonal test's. A slope too steep to hold (huge values, times a hair +/// apart) is left out. Its callers bound the values (at most [`MAX_VALUES`]), so `out` stays +/// within `MAX_VALUES`² ⁄ 2. +fn push_slopes(t: &[f64], y: &[f64], out: &mut Vec) { + for (i, (ti, yi)) in t.iter().zip(y).enumerate() { + for (tj, yj) in t.iter().zip(y).skip(i + 1) { + let dt = tj - ti; + let slope = (yj - yi) / dt; + if dt != 0.0 && slope.is_finite() { + out.push(slope); + } + } + } +} + +/// Sen's intercept: the median value less the slope times the median time. +fn intercept(t: &[f64], y: &[f64], slope: f64) -> Option { + Some(median(&mut y.to_vec())? - slope * median(&mut t.to_vec())?) +} + +/// The seasonal test: S and its variance summed over the seasons, and Sen's slope from pairs +/// within a season. +fn seasonal(t: &[f64], y: &[f64], seasons: u32, slopes: &mut Vec) -> Option { + let m = seasons.max(1) as usize; + // The season of a time: its place within its unit (year), in `m` equal parts. + // A hair above, so a time computed on a season's start (2000 + 3⁄12) isn't put in the one before. + let season: Vec = t.iter().map(|t| (((t - t.floor()) * m as f64 + 1e-9).floor() as usize).min(m - 1)).collect(); + seasonal_by(t, y, &season, slopes) +} + +/// The seasonal test with each value's season given (its month, or its part of the year as the +/// calendar has it): what a series of real dates should use, since a time's fraction of a year +/// drifts against the calendar from year to year. Missing values are skipped as by [`trend`]; +/// `None` as it gives one, or when the three lists aren't the same length. +pub fn trend_seasonal(t: &[f64], y: &[f64], season: &[usize], alpha: f64) -> Option { + trend_seasonal_with(t, y, season, alpha, &mut Vec::new()) +} + +/// [`trend_seasonal`], keeping the slopes in `slopes` (cleared first), as [`trend_with`] does. +pub fn trend_seasonal_with(t: &[f64], y: &[f64], season: &[usize], alpha: f64, slopes: &mut Vec) -> Option { + if y.len() != t.len() || season.len() != t.len() { + return None; + } + let mut kept: Vec<(f64, f64, usize)> = + t.iter().zip(y).zip(season).filter(|((t, y), _)| t.is_finite() && y.is_finite()).map(|((t, y), s)| (*t, *y, *s)).collect(); + kept.sort_by(|a, b| a.0.total_cmp(&b.0)); + if !(MIN_VALUES..=MAX_VALUES).contains(&kept.len()) || !(alpha > 0.0 && alpha < 1.0) { + return None; + } + let t: Vec = kept.iter().map(|k| k.0).collect(); + let y: Vec = kept.iter().map(|k| k.1).collect(); + let s: Vec = kept.iter().map(|k| k.2).collect(); + seasonal_by(&t, &y, &s, slopes) +} + +fn seasonal_by(t: &[f64], y: &[f64], season: &[usize], slopes: &mut Vec) -> Option { + // A season index past any calendar's (366 days) is a caller's mistake: no trend, not a huge allocation. + let m = season.iter().copied().max()?.checked_add(1).filter(|m| *m <= 4096)?; + let mut by: Vec<(Vec, Vec)> = vec![(Vec::new(), Vec::new()); m]; + for ((t, y), k) in t.iter().zip(y).zip(season) { + let Some(b) = by.get_mut(*k) else { continue }; + b.0.push(*t); + b.1.push(*y); + } + let (mut s, mut var_s, mut n) = (0.0, 0.0, 0); + slopes.clear(); + for (ts, ys) in by.iter().filter(|(_, ys)| ys.len() >= 2) { + s += score(ys); + var_s += variance(ys); + n += ys.len(); + push_slopes(ts, ys, slopes); + } + if n < MIN_VALUES { + return None; + } + let slope = median(slopes)?; + // The intercept from every value, those of single-value seasons too (as pymannkendall's + // seasonal test takes the medians of the whole series); `n`, S and the slope from the pairs. + let intercept = intercept(t, y, slope)?; + let z = z_score(s, var_s); + Some(Trend { n, slope, intercept, s, var_s, correction: 1.0, z, p: p_value(z) }) +} + +/// Mann–Kendall's S: the signs of every later value minus every earlier one, summed. +fn score(y: &[f64]) -> f64 { + let mut s = 0i64; + for (i, yi) in y.iter().enumerate() { + for yj in y.iter().skip(i + 1) { + s += match yj.partial_cmp(yi) { + Some(std::cmp::Ordering::Greater) => 1, + Some(std::cmp::Ordering::Less) => -1, + _ => 0, + }; + } + } + s as f64 +} + +/// S's variance for independent values, corrected for ties. +fn variance(y: &[f64]) -> f64 { + let n = y.len() as f64; + let mut sorted = y.to_vec(); + sorted.sort_by(f64::total_cmp); + let mut ties = 0.0; + let mut i = 0; + while i < sorted.len() { + let mut j = i + 1; + while j < sorted.len() && sorted[j] == sorted[i] { + j += 1; + } + let t = (j - i) as f64; + if t > 1.0 { + ties += t * (t - 1.0) * (2.0 * t + 5.0); + } + i = j; + } + (n * (n - 1.0) * (2.0 * n + 5.0) - ties) / 18.0 +} + +/// The normal statistic of S, with the continuity correction. +fn z_score(s: f64, var_s: f64) -> f64 { + if var_s <= 0.0 || !var_s.is_finite() { + return 0.0; + } + if s > 0.0 { + (s - 1.0) / var_s.sqrt() + } else if s < 0.0 { + (s + 1.0) / var_s.sqrt() + } else { + 0.0 + } +} + +/// The two-sided p-value of a standard normal statistic. +pub fn p_value(z: f64) -> f64 { + erfc(z.abs() / std::f64::consts::SQRT_2).clamp(0.0, 1.0) +} + +/// The median, reordering `v`; `None` when it's empty. +fn median(v: &mut [f64]) -> Option { + let n = v.len(); + if n == 0 { + return None; + } + let mid = n / 2; + let (_, m, _) = v.select_nth_unstable_by(mid, f64::total_cmp); + let m = *m; + if n % 2 == 1 { + return Some(m); + } + // The other middle value is the largest of the lower half. + let lower = v[..mid].iter().copied().fold(f64::NEG_INFINITY, f64::max); + Some((lower + m) / 2.0) +} + +/// Ranks from 1, ties sharing their mean rank. Values within rounding of each other tie: a +/// series detrended by Sen's slope has two values that are equal (the pair the median slope came +/// from), which arithmetic in another order leaves an ulp apart. +fn ranks(y: &[f64]) -> Vec { + let mut order: Vec = (0..y.len()).collect(); + order.sort_by(|a, b| y[*a].total_cmp(&y[*b])); + let scale = y.iter().fold(0.0f64, |m, v| m.max(v.abs())); + let tol = scale * 1e-12; + let mut out = vec![0.0; y.len()]; + let mut i = 0; + while i < order.len() { + let mut j = i + 1; + while j < order.len() && y[order[j]] - y[order[i]] <= tol { + j += 1; + } + let rank = (i + j + 1) as f64 / 2.0; + for k in &order[i..j] { + out[*k] = rank; + } + i = j; + } + out +} + +/// The autocorrelation at every lag from 0 to n − 1 (the sum of products over the whole +/// series' sum of squares, as `statsmodels` computes it). +fn autocorrelation(x: &[f64]) -> Vec { + let n = x.len(); + let mean = x.iter().sum::() / n.max(1) as f64; + let d: Vec = x.iter().map(|v| v - mean).collect(); + let denom: f64 = d.iter().map(|v| v * v).sum(); + if denom == 0.0 { + return vec![0.0; n]; + } + (0..n).map(|k| d[..n - k].iter().zip(&d[k..]).map(|(a, b)| a * b).sum::() / denom).collect() +} + +/// The complementary error function, to about 1e-15 (W. J. Cody's rational approximations, as +/// in most mathematical libraries). +pub fn erfc(x: f64) -> f64 { + if x.is_nan() { + return f64::NAN; + } + if x < 0.0 { + return 2.0 - erfc(-x); + } + if x <= 0.468_75 { + return 1.0 - erf_small(x); + } + if x > 27.0 { + return 0.0; + } + // erfc(x) = exp(−x²) · R(x), R from Cody's coefficients for 0.46875 < x < 4 and x ≥ 4. + let r = if x < 4.0 { + const P: [f64; 9] = [ + 5.641_884_969_886_701e-1, + 8.883_149_794_388_375, + 6.611_919_063_714_163e1, + 2.986_351_381_974_001e2, + 8.819_522_212_417_69e2, + 1.712_047_612_634_070_7e3, + 2.051_078_377_826_071_6e3, + 1.230_339_354_797_997_2e3, + 2.153_115_354_744_038_3e-8, + ]; + const Q: [f64; 8] = [ + 1.574_492_611_070_983_5e1, + 1.176_939_508_913_125e2, + 5.371_811_018_620_099e2, + 1.621_389_574_566_690_3e3, + 3.290_799_235_733_459_7e3, + 4.362_619_090_143_247e3, + 3.439_367_674_143_721_6e3, + 1.230_339_354_803_749_4e3, + ]; + let mut num = P[8] * x; + let mut den = x; + for i in 0..7 { + num = (num + P[i]) * x; + den = (den + Q[i]) * x; + } + (num + P[7]) / (den + Q[7]) + } else { + const P: [f64; 6] = [ + 3.053_266_349_612_323_4e-1, + 3.603_448_999_498_044_5e-1, + 1.257_817_261_112_292_6e-1, + 1.608_378_514_874_227_5e-2, + 6.587_491_615_298_378e-4, + 1.631_538_713_730_709_6e-2, + ]; + const Q: [f64; 5] = [ + 2.568_520_192_289_822, + 1.872_952_849_923_460_4, + 5.279_051_029_514_285e-1, + 6.051_834_131_244_132e-2, + 2.335_204_976_268_691_8e-3, + ]; + let z = 1.0 / (x * x); + let mut num = P[5] * z; + let mut den = z; + for i in 0..4 { + num = (num + P[i]) * z; + den = (den + Q[i]) * z; + } + let r = z * (num + P[4]) / (den + Q[4]); + (1.0 / std::f64::consts::PI.sqrt() - r) / x + }; + // exp(−x²) in two parts, so its rounding error doesn't grow with x. + let xs = (x * 16.0).trunc() / 16.0; + let del = (x - xs) * (x + xs); + (-xs * xs).exp() * (-del).exp() * r +} + +/// erf(x) for |x| ≤ 0.46875 (Cody). +fn erf_small(x: f64) -> f64 { + const A: [f64; 5] = + [3.161_123_743_870_565_6, 1.138_641_541_510_501_6e2, 3.774_852_376_853_02e2, 3.209_377_589_138_469_5e3, 1.857_777_061_846_031_5e-1]; + const B: [f64; 4] = [2.360_129_095_234_412_1e1, 2.440_246_379_344_441_7e2, 1.282_616_526_077_372_3e3, 2.844_236_833_439_171e3]; + let z = x * x; + let mut num = A[4] * z; + let mut den = z; + for i in 0..3 { + num = (num + A[i]) * z; + den = (den + B[i]) * z; + } + x * (num + A[3]) / (den + B[3]) +} + +/// The standard normal quantile (Acklam's approximation, refined by one Halley step on [`erfc`]). +pub fn normal_quantile(p: f64) -> f64 { + if !(p > 0.0 && p < 1.0) { + return if p <= 0.0 { f64::NEG_INFINITY } else { f64::INFINITY }; + } + const A: [f64; 6] = [ + -3.969_683_028_665_376e1, + 2.209_460_984_245_205e2, + -2.759_285_104_469_687e2, + 1.383_577_518_672_69e2, + -3.066_479_806_614_716e1, + 2.506_628_277_459_239, + ]; + const B: [f64; 5] = + [-5.447_609_879_822_406e1, 1.615_858_368_580_409e2, -1.556_989_798_598_866e2, 6.680_131_188_771_972e1, -1.328_068_155_288_572e1]; + const C: [f64; 6] = [ + -7.784_894_002_430_293e-3, + -3.223_964_580_411_365e-1, + -2.400_758_277_161_838, + -2.549_732_539_343_734, + 4.374_664_141_464_968, + 2.938_163_982_698_783, + ]; + const D: [f64; 4] = [7.784_695_709_041_462e-3, 3.224_671_290_700_398e-1, 2.445_134_137_142_996, 3.754_408_661_907_416]; + let low = 0.024_25; + let x = if p < low { + let q = (-2.0 * p.ln()).sqrt(); + (((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0) + } else if p <= 1.0 - low { + let q = p - 0.5; + let r = q * q; + (((((A[0] * r + A[1]) * r + A[2]) * r + A[3]) * r + A[4]) * r + A[5]) * q + / (((((B[0] * r + B[1]) * r + B[2]) * r + B[3]) * r + B[4]) * r + 1.0) + } else { + let q = (-2.0 * (1.0 - p).ln()).sqrt(); + -(((((C[0] * q + C[1]) * q + C[2]) * q + C[3]) * q + C[4]) * q + C[5]) / ((((D[0] * q + D[1]) * q + D[2]) * q + D[3]) * q + 1.0) + }; + // One Halley step: e is the error of Φ(x). + let e = 0.5 * erfc(-x / std::f64::consts::SQRT_2) - p; + let u = e * (2.0 * std::f64::consts::PI).sqrt() * (x * x / 2.0).exp(); + x - u / (1.0 + x * u / 2.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol * b.abs().max(1.0) + } + + /// The normal distribution against known values. + #[test] + fn the_normal_distribution() { + for (x, want) in [ + (0.0, 1.0), + (0.5, 0.479_500_122_186_953_5), + (1.0, 0.157_299_207_050_285_13), + (2.0, 4.677_734_981_047_266e-3), + (5.0, 1.537_459_794_428_034_8e-12), + ] { + assert!(close(erfc(x), want, 1e-13), "erfc({x}) = {}", erfc(x)); + } + assert!(close(erfc(-1.0), 2.0 - 0.157_299_207_050_285_13, 1e-14)); + assert!(close(normal_quantile(0.975), 1.959_963_984_540_054, 1e-12)); + assert!(close(normal_quantile(0.5), 0.0, 1e-12)); + assert!(close(normal_quantile(1e-6), -4.753_424_308_822_899, 1e-10)); + assert!(close(p_value(1.959_963_984_540_054), 0.05, 1e-12)); + } + + /// Sen's slope, the tie-corrected variance and the plain test on a small series worked by hand. + #[test] + fn the_original_test_by_hand() { + let t = [1.0, 2.0, 3.0, 4.0, 5.0]; + let y = [1.0, 3.0, 2.0, 5.0, 4.0]; + let r = trend(&t, &y, Test::Original, 0.05).unwrap(); + // Pairs: 8 up, 2 down. + assert_eq!((r.n, r.s), (5, 6.0)); + assert!(close(r.var_s, 5.0 * 4.0 * 15.0 / 18.0, 1e-12)); + // Slopes −1, −1, 0.33, 0.5, 0.75, 1, 1, 1.33, 2, 3: median 0.875. + assert!(close(r.slope, 0.875, 1e-12), "{}", r.slope); + // The intercept: the median value less the slope times the median time. + assert!(close(r.intercept, 3.0 - 0.875 * 3.0, 1e-12)); + assert!(close(r.z, 5.0 / (50.0f64 / 3.0).sqrt(), 1e-12)); + // Ties: two equal values take 2·1·9 out of 18·Var. + let tied = trend(&t, &[1.0, 2.0, 2.0, 3.0, 4.0], Test::Original, 0.05).unwrap(); + assert!(close(tied.var_s, (5.0 * 4.0 * 15.0 - 18.0) / 18.0, 1e-12)); + // A level outside (0, 1), or too many values for the pairs, gives no trend. + for alpha in [0.0, 1.0, f64::NAN] { + assert!(trend(&t, &y, Test::HamedRao, alpha).is_none(), "{alpha}"); + } + let seasons: Vec = (0..t.len()).map(|i| if i == 0 { usize::MAX } else { i % 2 }).collect(); + assert!(trend_seasonal(&t, &y, &seasons, 0.05).is_none(), "a season index out of range"); + let many: Vec = (0..=MAX_VALUES).map(|i| i as f64).collect(); + assert_eq!(sen_slope(&many, &many), None, "Sen's slope alone is bounded too"); + assert!( + trend_seasonal(&[2000.0, 2000.5, 2001.0, 2001.5], &[1.0, 2.0, 3.0, 4.0], &[0, 1, 0], 0.05).is_none(), + "seasons for every value" + ); + // Seasons of one value give no pair: a series with too few in seasons of two or more has no + // trend, whatever its length. + assert!(trend_seasonal(&[2000.0, 2000.1, 2000.2, 2000.3, 2001.0], &[1.0, 2.0, 3.0, 4.0, 5.0], &[0, 1, 2, 3, 0], 0.05).is_none()); + // Values in any order are taken in time order: the same trend as sorted. + let (t, y) = ([2000.0, 2001.0, 2002.0, 2003.0, 2004.0, 2005.0], [1.0, 3.0, 2.0, 5.0, 4.0, 6.0]); + let sorted = trend(&t, &y, Test::Original, 0.05).unwrap(); + let shuffled = + trend(&[2003.0, 2000.0, 2005.0, 2001.0, 2004.0, 2002.0], &[5.0, 1.0, 6.0, 3.0, 4.0, 2.0], Test::Original, 0.05).unwrap(); + assert_eq!((sorted.s, sorted.z, sorted.slope), (shuffled.s, shuffled.z, shuffled.slope)); + assert!(trend(&many, &many, Test::Original, 0.05).is_none()); + // Too few values, missing ones skipped. + assert!(trend(&t, &[1.0, f64::NAN, 2.0, f64::NAN, 3.0], Test::Original, 0.05).is_none()); + assert_eq!(trend(&t, &[1.0, f64::NAN, 2.0, 4.0, 3.0], Test::Original, 0.05).unwrap().n, 4); + } + + /// A clear trend is significant for every test; noise without one is not. + #[test] + fn trends_found_and_not() { + let t: Vec = (0..40).map(|i| 2000.0 + f64::from(i) / 4.0).collect(); + // A pseudo-random noise, the same each run. + let mut seed = 7u64; + let mut noise = || { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((seed >> 33) as f64 / (1u64 << 31) as f64) - 0.5 + }; + let rising: Vec = t.iter().map(|t| 0.02 * (t - 2000.0) + 0.3 + 0.01 * noise()).collect(); + let flat: Vec = t.iter().map(|_| 0.5 + 0.1 * noise()).collect(); + for test in [Test::HamedRao, Test::YueWang, Test::Original, Test::Seasonal(4)] { + let r = trend(&t, &rising, test, 0.05).unwrap(); + assert!(r.p < 0.01 && (r.slope - 0.02).abs() < 0.005, "{test:?}: {r:?}"); + let f = trend(&t, &flat, test, 0.05).unwrap(); + assert!(f.p > 0.05, "{test:?}: {f:?}"); + } + } + + /// The same series through `pymannkendall` 1.4.3 (`original_test`, `hamed_rao_modification_test`, + /// `yue_wang_modification_test(lag=1)`, `seasonal_test(period=12)`), with numpy's seeded noise: + /// Sen's slope and intercept, S, Var(S), Z and p agree. + #[test] + // The series are seeded noise: a value near π/6 is a coincidence. + #[allow(clippy::type_complexity, clippy::approx_constant)] + fn as_pymannkendall_gives() { + let cases: &[(&str, &[f64], f64, f64, [f64; 4], [f64; 4], [f64; 4])] = &[ + ( + "ar_rising", + &[ + 0.3996, 0.3929, 0.4141, 0.4304, 0.4602, 0.4764, 0.494, 0.4813, 0.4795, 0.4734, 0.4821, 0.5111, 0.5353, 0.5313, 0.5416, + 0.548, 0.567, 0.5235, 0.52, 0.5476, 0.5885, 0.5805, 0.6253, 0.5655, 0.5931, 0.6149, 0.6348, 0.6666, 0.7021, 0.6683, + ][..], + 0.008753333333333332, + 0.4063766666666667, + [373.0, 3141.6666666666665, 6.636863533302587, 3.204281284752142e-11], + [373.0, 2435.0322122618513, 7.538597992801056, 4.75175454539567e-14], + [373.0, 5703.607377890534, 4.925701942158268, 8.405804681199669e-07], + ), + ( + "ar_flat", + &[ + 0.4466, 0.5293, 0.4757, 0.5126, 0.5232, 0.4954, 0.5487, 0.5477, 0.4995, 0.541, 0.5504, 0.486, 0.4815, 0.4615, 0.3862, + 0.4137, 0.458, 0.5231, 0.4883, 0.4986, 0.5049, 0.5061, 0.4801, 0.5197, 0.5571, 0.5834, 0.5123, 0.4638, 0.4183, 0.4101, + 0.4064, 0.4043, 0.3711, 0.3286, 0.3564, 0.4254, 0.3774, 0.4244, 0.4869, 0.5272, + ][..], + -0.002545833333333321, + 0.5360937499999997, + [-230.0, 7366.666666666667, -2.6680869144024206, 0.007628452120383988], + [-230.0, 9218.727025114382, -2.385063821187819, 0.01707615864074641], + [-230.0, 16316.61631700942, -1.7927528917461166, 0.07301244507305826], + ), + ( + "ties_falling", + &[5.0, 4.9, 4.9, 5.2, 5.0, 4.4, 4.3, 4.8, 4.5, 4.3, 4.6, 4.0, 4.1, 4.4, 4.3, 4.4, 4.3, 4.1, 4.1, 4.0, 4.0, 3.7, 3.6, 3.9][..], + -0.05313725490196082, + 4.911078431372549, + [-197.0, 1603.6666666666667, -4.894395048339705, 9.860858256605098e-07], + [-197.0, 1603.6666666666667, -4.894395048339705, 9.860858256605098e-07], + [-197.0, 1948.515975874476, -4.440215881965339, 8.986868028193484e-06], + ), + ( + "short", + &[3.1, 2.9, 3.4, 3.8, 3.6, 4.1][..], + 0.23333333333333325, + 2.916666666666667, + [11.0, 28.333333333333332, 1.8786728732554487, 0.060289173990602185], + [11.0, 28.333333333333332, 1.8786728732554487, 0.060289173990602185], + [11.0, 3.074935400516776, 5.7027193866927695, 1.1791100540392563e-08], + ), + ]; + let same = |what: &str, got: f64, want: f64| { + assert!((got - want).abs() <= 1e-9 * want.abs().max(1e-300) + 1e-12, "{what}: {got} for {want}") + }; + for (name, x, slope, intercept, original, hamed_rao, yue_wang) in cases { + // pymannkendall's times are the values' places, from 0. + let t: Vec = (0..x.len()).map(|i| i as f64).collect(); + for (test, want) in [(Test::Original, original), (Test::HamedRao, hamed_rao), (Test::YueWang, yue_wang)] { + let r = trend(&t, x, test, 0.05).unwrap(); + let what = |k: &str| format!("{name} {test:?} {k}"); + same(&what("slope"), r.slope, *slope); + same(&what("intercept"), r.intercept, *intercept); + same(&what("S"), r.s, want[0]); + same(&what("Var(S)"), r.var_s, want[1]); + same(&what("Z"), r.z, want[2]); + // p to a part in a million, or 1e-14 where pymannkendall's 2·(1 − Φ(|z|)) has lost + // its digits to the subtraction (erfc here keeps them). + assert!((r.p - want[3]).abs() <= 1e-6 * want[3] + 1e-14, "{} p: {} for {}", what(""), r.p, want[3]); + } + } + // Monthly over four years, with a yearly cycle: the seasonal test by month. + let x = &[ + 9.783, 11.242, 11.884, 12.936, 12.886, 11.309, 10.47, 8.344, 7.923, 7.204, 7.211, 9.4, 10.11, 10.936, 13.174, 13.622, 12.772, + 12.199, 10.743, 9.111, 8.012, 9.202, 8.143, 9.333, 11.416, 12.09, 14.53, 14.224, 13.738, 12.435, 11.279, 9.175, 8.785, 7.257, + 9.35, 9.554, 11.774, 13.653, 13.892, 13.604, 14.123, 13.6, 12.068, 10.363, 9.239, 8.46, 9.288, 10.121, + ][..]; + let t: Vec = (0..x.len()).map(|k| 2000.0 + (k as f64 + 0.5) / 12.0).collect(); + let r = trend(&t, x, Test::Seasonal(12), 0.05).unwrap(); + let want = [54.0, 104.00000000000001, 5.197077581161876, 2.0244588982976097e-07]; + same("seasonal slope", r.slope, 0.56975); + same("seasonal S", r.s, want[0]); + same("seasonal Var(S)", r.var_s, want[1]); + same("seasonal Z", r.z, want[2]); + assert!((r.p - want[3]).abs() <= 1e-6 * want[3] + 1e-14, "seasonal p: {} for {}", r.p, want[3]); + } + + /// Parsing the tests' names. + #[test] + fn test_names() { + assert_eq!(Test::parse("", 12), Some(Test::HamedRao)); + assert_eq!(Test::parse("Hamed-Rao", 12), Some(Test::HamedRao)); + assert_eq!(Test::parse("yueWang", 12), Some(Test::YueWang)); + assert_eq!(Test::parse("seasonal", 23), Some(Test::Seasonal(23))); + assert_eq!(Test::parse("original", 12), Some(Test::Original)); + assert_eq!(Test::parse("ols", 12), None); + } +} diff --git a/crates/engine/src/cmd/cube.rs b/crates/engine/src/cmd/cube.rs new file mode 100644 index 000000000..4c3a3006c --- /dev/null +++ b/crates/engine/src/cmd/cube.rs @@ -0,0 +1,734 @@ +//! Data cubes (opengeos/RustGIS#579): rasters on one grid at a series of dates, whatever they come +//! from, and the trend of each cell over them. +//! +//! A [`TimeCube`] gives its dates and the slice at each one. The sources: +//! - a Zarr variable with a time dimension (#554), read a slice at a time; +//! - a stack of raster layers, each a date (from the `dates` given, or found in the layer's name +//! or file); +//! - a raster layer of several bands, each a date (from `dates`, or the bands' names). +//! +//! `cube.trend` reads the slices into one block, cell by date, and works out each cell's trend +//! with [`rustgis_analysis::trend`]: Sen's slope and the modified Mann–Kendall test. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use rustgis_analysis::trend::{self, Test}; +use rustgis_data::raster::{self, Raster}; +use serde_json::{Value, json}; + +use super::ps; +use crate::{CmdResult, CommandSpec as C, Session}; + +pub fn commands() -> Vec { + vec![C::new("cube.trend", "Trend Analysis", "Analyze › Image Analysis", trend_cmd).params( + r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer (or "path" | "url", "variable", "index", "crs" as for zarr.reduce) or a raster of several bands, a date each (default: the active layer), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "start"?, "end"?: dates, "months"?: [1–12] (one season across years), "test"?: "hamedRao" (default) | "yueWang" | "seasonal" | "original", "seasons"?: parts of a year for the seasonal test (default 12), "alpha"?: 0.05 (above 0, below 0.5), "minValid"?: dates a cell needs (default: half of them, at least 4; the seasonal test also needs 4 in seasons of two or more), "per"?: "year" (default) | "decade", "name"?: text, "ran"?: (internal: a trend the app has worked out)} → four raster layers: Sen's slope, the test's Z and p-value, and the slope where p < alpha"#, + )] +} + +/// The most cell values a cube is read into at once (cells × dates): 64 million, 256 MB, on the +/// desktop; 16 million, 64 MB, in the browser's smaller memory. A run also takes, on each of its +/// threads (up to 16 on the desktop, one in the browser), a cell's Sen slopes at a time (dates² ⁄ 2: +/// at most 16 MB at [`MAX_DATES`], so up to 256 MB more at the peak), freed cell by cell; and its +/// four outputs, four values a cell, made once the block is dropped. +pub const MAX_VALUES: u64 = if cfg!(target_arch = "wasm32") { 16_000_000 } else { 64_000_000 }; +/// The most pairs of dates a trend compares in all (cells × dates² ⁄ 2), which bounds the time +/// Sen's slope takes: far fewer in the browser, which works them out on one thread. At the cap a +/// run can take minutes on the desktop, which the app runs on threads with progress and Cancel; +/// `cube.trend` called directly (a script, the CLI) takes [`MAX_WAITED_PAIRS`]. +pub const MAX_PAIRS: u64 = if cfg!(target_arch = "wasm32") { 50_000_000 } else { 20_000_000_000 }; +/// The most pairs a trend worked out where it's called compares (`cube.trend` without `ran`: a +/// script, the CLI), which waits for it with no Cancel: a tenth of the app's on the desktop. The +/// browser works every trend out where it's called, within its own lower cap. +pub const MAX_WAITED_PAIRS: u64 = if cfg!(target_arch = "wasm32") { MAX_PAIRS } else { MAX_PAIRS / 10 }; +/// The most dates a cube is read at. +pub const MAX_DATES: usize = 2_000; + +/// Rasters on one grid at a series of dates. +pub trait TimeCube: Send + Sync { + /// The dates, in milliseconds since 1970, in the cube's order. + fn dates(&self) -> &[f64]; + /// The slice at date `i`: a raster and the band holding the values, or `None` for a date + /// with nothing written (a Zarr step never written), which has no value for any cell. + fn slice(&self, i: usize) -> Result, usize)>, String>; + /// What the cube holds, for the outputs' names. + fn label(&self) -> String; + /// The steps taken when no period is asked for (a following Zarr layer's), as a range. + fn period(&self) -> (usize, usize) { + (0, self.dates().len()) + } +} + +/// A stack of rasters (or a raster's bands), a date each. +pub struct Stack { + /// Each date's raster and band. + items: Vec<(f64, Arc, usize)>, + dates: Vec, + label: String, +} + +impl Stack { + /// Sorted by date. + fn new(mut items: Vec<(f64, Arc, usize)>, label: String) -> Stack { + items.sort_by(|a, b| a.0.total_cmp(&b.0)); + let dates = items.iter().map(|i| i.0).collect(); + Stack { items, dates, label } + } +} + +impl TimeCube for Stack { + fn dates(&self) -> &[f64] { + &self.dates + } + fn slice(&self, i: usize) -> Result, usize)>, String> { + self.items.get(i).map(|(_, r, b)| Some((r.clone(), *b))).ok_or_else(|| format!("no date {i}")) + } + fn label(&self) -> String { + self.label.clone() + } +} + +/// A Zarr variable along its time dimension. +#[cfg(not(target_arch = "wasm32"))] +struct ZarrCube(super::zarr::ZarrCube); + +#[cfg(not(target_arch = "wasm32"))] +impl TimeCube for ZarrCube { + fn dates(&self) -> &[f64] { + &self.0.zt.dates + } + fn slice(&self, i: usize) -> Result, usize)>, String> { + // A slice already kept is used; the others are read without being kept, so a trend over + // many dates doesn't push out the slices a layer following the slider holds. A step + // never written is `None`: no value that date, as the Zarr summary takes it. + let zt = &self.0.zt; + let r = match super::zarr::kept_slice(&super::zarr::slice_key(zt, i)) { + Some(r) => r, + None => match super::zarr::read_slice(zt, i)? { + Some(r) => Arc::new(r), + None => return Ok(None), + }, + }; + Ok(Some((r, 0))) + } + fn label(&self) -> String { + self.0.zt.variable.clone() + } + fn period(&self) -> (usize, usize) { + self.0.period + } +} + +/// A date written in a name: `2024-06-01` (or with `_`, `.` or nothing between), MODIS's +/// `A2024153` (year and day of the year), `2024-06`, or a year alone. Milliseconds since 1970. +pub fn date_in_text(text: &str) -> Option { + let b = text.as_bytes(); + let digits = |from: usize, n: usize| -> Option { + let s = text.get(from..from + n)?; + s.bytes().all(|c| c.is_ascii_digit()).then(|| s.parse().ok()).flatten() + }; + // Digits at `i` not run on from a digit before. + let before = |i: usize| i.checked_sub(1).and_then(|j| b.get(j)).copied(); + let starts = |i: usize| !before(i).is_some_and(|c| c.is_ascii_digit()); + let ends = |i: usize| !b.get(i).is_some_and(u8::is_ascii_digit); + let day = |y: u32, m: u32, d: u32| { + ((1800..=2200).contains(&y) && (1..=12).contains(&m) && (1..=31).contains(&d)) + .then(|| rustgis_data::time::parse_text(&format!("{y:04}-{m:02}-{d:02}"))) + .flatten() + }; + // MODIS: A + year + day of the year. + for (i, c) in b.iter().enumerate() { + if *c == b'A' + && ends(i + 8) + && let (Some(y), Some(doy)) = (digits(i + 1, 4), digits(i + 5, 3)) + && (1..=366).contains(&doy) + && let Some(jan1) = day(y, 1, 1) + { + return Some(jan1 + f64::from(doy - 1) * 86_400_000.0); + } + } + // A year alone, only once no fuller date is found anywhere in the text, and only standing + // apart (not `res2000` or `v2015`, which name something else). + let mut bare = None; + let apart = |i: usize| !before(i).is_some_and(|c| c.is_ascii_alphanumeric()) && !b.get(i + 4).is_some_and(u8::is_ascii_alphanumeric); + for i in 0..b.len() { + if !starts(i) { + continue; + } + let Some(y) = digits(i, 4) else { continue }; + // Year, month and day, with one separator or none. + let sep = b.get(i + 4).copied().filter(|c| matches!(c, b'-' | b'_' | b'.')); + let at = i + 4 + usize::from(sep.is_some()); + if let Some(m) = digits(at, 2) { + let next = at + 2; + let dsep = if sep.is_some() { b.get(next).copied().filter(|c| Some(*c) == sep) } else { None }; + let dat = next + usize::from(dsep.is_some()); + if (sep.is_none() || dsep.is_some()) + && let Some(d) = digits(dat, 2) + && ends(dat + 2) + && let Some(ms) = day(y, m, d) + { + return Some(ms); + } + if sep.is_some() + && ends(next) + && let Some(ms) = day(y, m, 1) + { + return Some(ms); + } + } + if bare.is_none() + && ends(i + 4) + && apart(i) + && let Some(ms) = day(y, 1, 1) + { + bare = Some(ms); + } + } + bare +} + +fn date_list(p: &Value) -> Result>, String> { + match p.get("dates").filter(|v| !v.is_null()) { + None => Ok(None), + Some(Value::Array(a)) => a + .iter() + .map(|v| rustgis_data::time::parse_input(v).ok_or_else(|| format!("{v} isn't a date"))) + .collect::, _>>() + .map(Some), + Some(_) => Err("dates is a list of dates".into()), + } +} + +/// The cube a command's parameters name: a stack of layers, a Zarr variable, or a raster's bands. +pub fn cube_from(s: &Session, p: &Value) -> Result, String> { + let dates = date_list(p)?; + if let Some(list) = p.get("layers").filter(|v| !v.is_null()) { + let list = list.as_array().ok_or("layers is a list of raster layers")?; + if list.len() < trend::MIN_VALUES { + return Err(format!("a stack needs {} layers or more, a date each", trend::MIN_VALUES)); + } + // At most a run's dates, refused before each is looked up and checked against the others. + if list.len() > MAX_DATES { + return Err(format!("{} layers; a trend takes {MAX_DATES} dates at most", list.len())); + } + if dates.as_ref().is_some_and(|d| d.len() != list.len()) { + return Err("dates has a date for each layer, in their order".into()); + } + let band = band_index(p)?; + let mut items = Vec::with_capacity(list.len()); + let mut label = String::new(); + for (k, v) in list.iter().enumerate() { + let id = s.layer_id(&json!({ "layer": v }))?; + let l = s.map().layer(id).ok_or("no such layer")?; + let r = l.raster.as_ref().and_then(|r| r.data.clone()).ok_or_else(|| format!("{} isn't a raster read into the map", l.name))?; + if band >= r.bands.len() { + return Err(format!("{} has {} bands", l.name, r.bands.len())); + } + // The file's own name, not its folders' (`/data/2020/ndvi_a.tif` would date them all). + let file = l + .raster + .as_ref() + .and_then(|r| r.path.as_deref()) + .and_then(|f| std::path::Path::new(f).file_name()) + .map(|f| f.to_string_lossy().into_owned()) + .unwrap_or_default(); + let date = match &dates { + Some(d) => d.get(k).copied().ok_or("dates has a date for each layer, in their order")?, + None => date_in_text(&l.name) + .or_else(|| date_in_text(&file)) + .ok_or_else(|| format!("no date in the name of {}: give \"dates\"", l.name))?, + }; + if label.is_empty() { + label = l.name.clone(); + } + // One layer a date: two on a day would count as two ordered values in the test. + if items.iter().any(|(d, ..): &(f64, _, usize)| *d == date) { + return Err(format!( + "{} and another layer have the same date ({}): one layer a date", + l.name, + rustgis_data::time::format(date, "YYYY-MM-DD") + )); + } + items.push((date, r, band)); + } + return Ok(Box::new(Stack::new(items, common_label(&label)))); + } + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + let zarr_layer = !given("path") && !given("url") && { + let id = s.layer_id(p)?; + // A Zarr layer: one following the slider, or a slice, named by its steps (`t2m[time=3]`); + // another file's subdataset (GDAL's) goes the bands' way below. + s.map() + .layer(id) + .and_then(|l| l.raster.as_ref()) + .is_some_and(|r| r.time_zarr.is_some() || r.subdataset.as_deref().is_some_and(|d| d.contains('['))) + }; + if given("path") || given("url") || zarr_layer { + // The web app reads no Zarr store along its time axis yet: said, not taken for bands. + #[cfg(target_arch = "wasm32")] + return Err("in the web app, a Zarr variable's trend can't be worked out yet (the desktop app can)".into()); + #[cfg(not(target_arch = "wasm32"))] + { + let cube = super::zarr::zarr_cube(s, p)?; + // A trend takes the steps in time order: an axis out of order (descending) or with a + // date twice would turn the test's sign or count a date twice. + if !cube.zt.dates.windows(2).all(|w| w[0] < w[1]) { + return Err(format!( + "{}'s time axis isn't in increasing order without repeats, so a trend can't be read from it", + cube.zt.variable + )); + } + return Ok(Box::new(ZarrCube(cube))); + } + } + let id = s.layer_id(p)?; + let l = s.map().layer(id).ok_or("no such layer")?; + let r = l.raster.as_ref().and_then(|r| r.data.clone()).ok_or_else(|| format!("{} isn't a raster", l.name))?; + if r.bands.len() < trend::MIN_VALUES { + return Err(format!( + "{} has {} band(s): a cube is a raster of {} bands or more (a date each), a Zarr variable with a time dimension, or \"layers\"", + l.name, + r.bands.len(), + trend::MIN_VALUES + )); + } + if dates.as_ref().is_some_and(|d| d.len() != r.bands.len()) { + return Err(format!("dates has a date for each of the {} bands", r.bands.len())); + } + let mut items = Vec::with_capacity(r.bands.len()); + for (b, band) in r.bands.iter().enumerate() { + let date = match &dates { + Some(d) => d.get(b).copied().ok_or("dates has a date for each band, in their order")?, + None => { + date_in_text(&band.name).ok_or_else(|| format!("band {} ({}) has no date in its name: give \"dates\"", b + 1, band.name))? + } + }; + // One band a date, as one layer a date in a stack. + if items.iter().any(|(d, ..): &(f64, _, usize)| *d == date) { + return Err(format!( + "band {} and another band have the same date ({}): one band a date", + b + 1, + rustgis_data::time::format(date, "YYYY-MM-DD") + )); + } + items.push((date, r.clone(), b)); + } + Ok(Box::new(Stack::new(items, l.name.clone()))) +} + +fn band_index(p: &Value) -> Result { + match p.get("band").filter(|v| !v.is_null()) { + None => Ok(0), + Some(v) => v.as_u64().filter(|b| *b >= 1).map(|b| b as usize - 1).ok_or_else(|| "band is a number from 1".into()), + } +} + +/// A name with its date taken out (`NDVI_2024-06-01` → `NDVI`, `MOD13Q1.A2024153` → `MOD13Q1`), +/// for the outputs' names: cut where the date [`date_in_text`] finds begins, so a product name with +/// digits of its own keeps them. +fn common_label(name: &str) -> String { + // The date's start: the last place from which the same date is still found (past it, the date + // is gone or another). Names are short; a long one takes the simpler cut below. + if let Some(date) = date_in_text(name).filter(|_| name.len() <= 512) { + let start = name.char_indices().map(|(i, _)| i).take_while(|i| date_in_text(&name[*i..]) == Some(date)).last().unwrap_or(0); + let cut = name[..start].trim_end_matches(['_', '-', ' ', '.']); + if !cut.is_empty() { + return cut.to_string(); + } + } + let lead: String = name.chars().take_while(|c| !c.is_ascii_digit()).collect(); + // MODIS dates its files `A2024153` (year and day of the year): that A goes with the date; any + // other final A is the name's own (`NDVI_BETA2020`). + let modis = lead.ends_with('A') && name[lead.len()..].chars().take_while(char::is_ascii_digit).count() == 7; + let cut = if modis { &lead[..lead.len() - 1] } else { lead.as_str() }; + let cut = cut.trim_end_matches(['_', '-', ' ', '.']); + if cut.is_empty() { name.to_string() } else { cut.to_string() } +} + +/// A date's season among `m` in its calendar year (UTC): its month for 12, otherwise its part of +/// the year by day, so a composite on the same day each year (16-day ones, 23 a year) keeps its +/// season from year to year (a day near a season's edge can move by one in a leap year). +pub fn season_of(ms: f64, m: u32) -> usize { + let m = m.max(1) as usize; + let month = rustgis_data::time::format(ms, "MM").parse::().unwrap_or(1); + if m == 12 { + return month.clamp(1, 12) - 1; + } + let year = rustgis_data::time::format(ms, "YYYY"); + let start = rustgis_data::time::parse_text(&format!("{year}-01-01")).unwrap_or(ms); + let next = year + .parse::() + .ok() + .and_then(|y| rustgis_data::time::parse_text(&format!("{:04}-01-01", y + 1))) + .unwrap_or(start + 365.0 * 86_400_000.0); + let fraction = ((ms - start) / (next - start).max(1.0)).clamp(0.0, 1.0); + ((fraction * m as f64).floor() as usize).min(m - 1) +} + +/// The dates of a cube a run takes: those between `start` and `end` and in `months`, or, with +/// no period given, the cube's own `period` of steps (a following Zarr layer's). +pub fn steps(dates: &[f64], period: (usize, usize), p: &Value) -> Result, String> { + let date = |k: &str| -> Result, String> { + match p.get(k).filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => rustgis_data::time::parse_input(v) + .map(Some) + .ok_or_else(|| format!("{k} is a date (2015-06-01, a year, or milliseconds since 1970)")), + } + }; + let months = months(p)?; + let (start, end) = (date("start")?, date("end")?); + let (lo, hi) = match (start, end) { + (None, None) => (period.0.min(dates.len()), period.1.min(dates.len())), + _ => (0, dates.len()), + }; + let month = |d: f64| rustgis_data::time::format(d, "MM").parse::().unwrap_or(0); + let out: Vec = (lo..hi) + .filter(|i| { + let d = dates.get(*i).copied().unwrap_or(f64::NAN); + start.is_none_or(|a| d >= a) && end.is_none_or(|z| d <= z) && (months.is_empty() || months.contains(&month(d))) + }) + .collect(); + if out.is_empty() { + return Err("no date of the cube falls in the period (and months) asked for".into()); + } + Ok(out) +} + +/// The `months` a run keeps (1 to 12; none for every month): the one reading of the parameter, +/// for the steps and for the outputs' names. +pub(crate) fn months(p: &Value) -> Result, String> { + match p.get("months").filter(|v| !v.is_null()) { + None => Ok(Vec::new()), + Some(Value::Array(a)) => a + .iter() + .map(|m| { + m.as_u64().filter(|m| (1..=12).contains(m)).map(|m| m as u32).ok_or_else(|| "months are numbers from 1 to 12".to_string()) + }) + .collect(), + Some(_) => Err("months is a list of months (1–12)".into()), + } +} + +/// What a trend writes for each cell. +const OUTPUTS: usize = 4; + +/// The parts a run's cells count for on a progress bar. +const CELL_PARTS: usize = 100; + +/// A trend over a cube, prepared on the session and computed anywhere with [`TrendRun::run`]: +/// the desktop app runs it on a thread of its own, with progress and Cancel. +pub struct TrendRun { + cube: Box, + steps: Vec, + test: Test, + alpha: f64, + min_valid: Option, + /// Years in the slope's unit (1, or 10 for a decade). + per: f64, + /// The outputs' names start with this. + pub name: String, + /// The most pairs it compares: [`MAX_PAIRS`] on the app's threads, with Cancel; fewer for a + /// call that waits for it ([`MAX_WAITED_PAIRS`]). + max_pairs: u64, +} + +impl TrendRun { + /// The work, for a progress bar: the slices read, then the cells worked out, in + /// [`CELL_PARTS`] parts. + pub fn total(&self) -> usize { + self.steps.len() + CELL_PARTS + } + + /// How many dates it reads. + pub fn dates(&self) -> usize { + self.steps.len() + } + + /// Reads the slices into one block, cell by date, then works each cell's trend out on as + /// many threads as there are cores. `done` counts the slices read, and the last unit once + /// the cells are done; `cancel` stops it between slices or rows. + pub fn run(&self, cancel: &AtomicBool, done: &AtomicUsize) -> Result, String> { + let n = self.steps.len(); + let mut grid: Option> = None; + let mut block: Vec = Vec::new(); + let mut cells = 0usize; + for (k, &i) in self.steps.iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + return Err("cancelled".into()); + } + // A date with nothing written leaves its cells without a value (the block starts empty). + let Some((r, b)) = self.cube.slice(i)? else { + done.fetch_add(1, Ordering::Relaxed); + continue; + }; + let vals = &r.bands.get(b).ok_or("the slice has no such band")?.data; + match &grid { + None => { + cells = vals.len(); + let values = (cells as u64).saturating_mul(n as u64); + if values > MAX_VALUES { + return Err(format!( + "{cells} cells × {n} dates is {values} values, more than {MAX_VALUES}: take a smaller area or fewer dates" + )); + } + let pairs = (cells as u64).saturating_mul((n * n.saturating_sub(1) / 2) as u64); + if pairs > self.max_pairs { + let how = if self.max_pairs < MAX_PAIRS { " (or run it from the Workflows pane, which can take more)" } else { "" }; + return Err(format!("{cells} cells × {n} dates is too long a run: take a smaller area or fewer dates{how}")); + } + block = vec![f32::NAN; cells * n]; + grid = Some(r.clone()); + } + Some(g) if (g.width, g.height, g.crs) != (r.width, r.height, r.crs) || !same_grid(&g.transform, &r.transform) => { + return Err(format!( + "the slice at {} is {} × {} on another grid than the first ({} × {}): put them on one grid first (Raster Functions › Resample)", + rustgis_data::time::format(self.cube.dates().get(i).copied().unwrap_or(f64::NAN), "YYYY-MM-DD"), + r.width, + r.height, + g.width, + g.height + )); + } + Some(_) => {} + } + let nodata = r.nodata.map(|v| v as f32); + for (c, v) in vals.iter().enumerate().take(cells) { + if v.is_finite() + && Some(*v) != nodata + && let Some(slot) = block.get_mut(c * n + k) + { + *slot = *v; + } + } + done.fetch_add(1, Ordering::Relaxed); + } + let grid = grid.ok_or("no date to read")?; + // Times in the slope's unit. + let year = 365.2425 * 86_400_000.0 * self.per; + let date = |i: usize| self.cube.dates().get(i).copied().unwrap_or(f64::NAN); + let times: Vec = self.steps.iter().map(|i| date(*i) / year).collect(); + // The seasonal test's seasons, from each date's place in its calendar year. + let seasons: Vec = match self.test { + Test::Seasonal(m) => self.steps.iter().map(|i| season_of(date(*i), m)).collect(), + _ => Vec::new(), + }; + let need = self.min_valid.unwrap_or((n / 2).max(trend::MIN_VALUES)).max(trend::MIN_VALUES); + let mut out = vec![f32::NAN; cells * OUTPUTS]; + let stopped = AtomicBool::new(false); + // A part of the cells, worked out in place. + // Cancel is looked at after about the same work whatever the dates: a cell compares n²⁄2 + // pairs, so every 4096 cells for a short series, every cell for a long one. + let every = (4_000_000 / (n * n).max(1)).clamp(1, 4096); + // Progress through the cells: a part of the bar every `tick` cells, on whichever thread. + let tick = cells.div_ceil(CELL_PARTS).max(1); + // `first`: the block's first cell among all of them, so the parts are counted over the + // whole grid, not again from 0 on each thread. + let work = |first: usize, block: &[f32], out: &mut [f32]| { + let mut ys = vec![0.0f64; n]; + // Sen's slopes, one buffer per thread, cleared cell by cell rather than made anew. + let mut slopes: Vec = Vec::new(); + for (c, (vals, o)) in block.chunks(n).zip(out.chunks_mut(OUTPUTS)).enumerate() { + if first + c > 0 && (first + c).is_multiple_of(tick) { + done.fetch_add(1, Ordering::Relaxed); + } + if c % every == 0 && (cancel.load(Ordering::Relaxed) || stopped.load(Ordering::Relaxed)) { + stopped.store(true, Ordering::Relaxed); + return; + } + for (y, v) in ys.iter_mut().zip(vals) { + *y = f64::from(*v); + } + if vals.iter().filter(|v| v.is_finite()).count() < need { + continue; + } + let found = match self.test { + Test::Seasonal(_) => trend::trend_seasonal_with(×, &ys, &seasons, self.alpha, &mut slopes), + test => trend::trend_with(×, &ys, test, self.alpha, &mut slopes), + }; + if let Some(t) = found + && let [slope, z, p, significant, ..] = o + { + *slope = t.slope as f32; + *z = t.z as f32; + *p = t.p as f32; + *significant = if t.p < self.alpha { t.slope as f32 } else { f32::NAN }; + } + } + }; + // On the desktop, the cells in as many parts as there are cores; the browser has no + // threads, so there they are worked out here. + #[cfg(not(target_arch = "wasm32"))] + { + let threads = std::thread::available_parallelism().map_or(1, |t| t.get()).clamp(1, 16); + let part = cells.div_ceil(threads).max(1); + std::thread::scope(|scope| { + for (k, (block, out)) in block.chunks(part * n).zip(out.chunks_mut(part * OUTPUTS)).enumerate() { + let work = &work; + scope.spawn(move || work(k * part, block, out)); + } + }); + } + #[cfg(target_arch = "wasm32")] + work(0, &block, &mut out); + if stopped.load(Ordering::Relaxed) { + return Err("cancelled".into()); + } + // The values are read: their block goes before the outputs are made from `out`. + drop(block); + done.store(self.total(), Ordering::Relaxed); + let unit = if self.per == 10.0 { "decade" } else { "year" }; + let names = [ + format!("{} Sen slope per {unit}", self.name), + format!("{} Z ({})", self.name, self.test.code()), + format!("{} p-value ({})", self.name, self.test.code()), + format!("{} significant slope per {unit} (p < {})", self.name, self.alpha), + ]; + names + .into_iter() + .enumerate() + .map(|(k, name)| { + let band: Vec = out.chunks(OUTPUTS).map(|o| o.get(k).copied().unwrap_or(f32::NAN)).collect(); + Raster::new(name, grid.width, grid.height, vec![band], grid.transform, grid.crs, None, raster::ColorHint::Gray, "Float32") + .map_err(|e| e.to_string()) + }) + .collect() + } +} + +/// Prepares `cube.trend`: the cube, the dates, the test and its options checked. +pub fn trend_start(s: &Session, p: &Value) -> Result { + // Read for the seasonal test only: another test leaves it alone, whatever it holds. + let seasonal = ps(p, "test").and_then(|t| Test::parse(t, 12)).is_some_and(|t| matches!(t, Test::Seasonal(_))); + let seasons = match p.get("seasons").filter(|v| seasonal && !v.is_null()) { + None => 12, + Some(v) => v.as_u64().filter(|m| (2..=366).contains(m)).ok_or("seasons is a number from 2 to 366")? as u32, + }; + let test = match ps(p, "test") { + None => Test::HamedRao, + Some(t) => Test::parse(t, seasons).ok_or_else(|| format!("unknown test '{t}' (hamedRao, yueWang, seasonal or original)"))?, + }; + let alpha = match p.get("alpha").filter(|v| !v.is_null()) { + None => 0.05, + Some(v) => v.as_f64().filter(|a| *a > 0.0 && *a < 0.5).ok_or("alpha is a level between 0 and 0.5, such as 0.05")?, + }; + let min_valid = match p.get("minValid").filter(|v| !v.is_null()) { + None => None, + Some(v) => Some(v.as_u64().filter(|m| *m >= 1).ok_or("minValid is a number of dates")? as usize), + }; + let per = match ps(p, "per").map(str::trim) { + None | Some("") | Some("year") => 1.0, + Some("decade") => 10.0, + Some(x) => return Err(format!("per is year or decade, not {x}")), + }; + let cube = cube_from(s, p)?; + let period = cube.period(); + let steps = steps(cube.dates(), period, p)?; + if steps.len() < trend::MIN_VALUES { + return Err(format!("{} dates; a trend needs {} or more", steps.len(), trend::MIN_VALUES)); + } + if steps.len() > MAX_DATES { + return Err(format!("{} dates; a trend takes {MAX_DATES} at most: take a shorter period", steps.len())); + } + // More valid dates asked for than there are would leave every cell out. + if let Some(m) = min_valid.filter(|m| *m > steps.len()) { + return Err(format!("minValid is {m}, more than the {} dates", steps.len())); + } + let day = |i: usize| rustgis_data::time::format(cube.dates().get(i).copied().unwrap_or(f64::NAN), "YYYY-MM-DD"); + let (a, z) = (steps.first().copied().unwrap_or(0), steps.last().copied().unwrap_or(0)); + let label = format!("{} {}–{}", cube.label(), day(a), day(z)); + let name = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or(label, |n| n.chars().take(200).collect()); + Ok(TrendRun { cube, steps, test, alpha, min_valid, per, name, max_pairs: MAX_PAIRS }) +} + +/// Whether two slices' grids are one: origins and pixel sizes within a thousandth of a pixel, so +/// files written apart (an ulp off) still stack. +pub(crate) fn same_grid(a: &rustgis_data::raster::GeoTransform, b: &rustgis_data::raster::GeoTransform) -> bool { + let (w, h) = (a.pixel_width.abs().max(f64::MIN_POSITIVE), a.pixel_height.abs().max(f64::MIN_POSITIVE)); + let near = |x: f64, y: f64, unit: f64| (x - y).abs() <= unit * 1e-3; + near(a.origin_x, b.origin_x, w) + && near(a.origin_y, b.origin_y, h) + && near(a.pixel_width, b.pixel_width, w) + && near(a.pixel_height, b.pixel_height, h) +} + +/// Keeps a trend worked out elsewhere (its layers and how many dates) in this session, for +/// `cube.trend` with `"ran"`: the id returned. The last two are kept (each is four full rasters), +/// and another project opened doesn't see them. +pub fn land(s: &mut Session, rasters: Vec, dates: usize) -> u64 { + s.next_pending = s.next_pending.wrapping_add(1); + if s.trend_results.len() >= 2 { + s.trend_results.remove(0); + } + s.trend_results.push((s.next_pending, rasters, dates)); + s.next_pending +} + +/// `cube.trend`: the trend computed here (blocking), or the one the app worked out (`ran`), and +/// its four layers added, kept in the project as GeoTIFFs. +fn trend_cmd(s: &mut Session, p: &Value) -> CmdResult { + // A run the app worked out: added if it is still kept (the last few are), never worked out + // again here, which would block. + if let Some(ran) = p.get("ran").filter(|v| !v.is_null()) { + let id = ran.as_u64().ok_or("ran is the number of a run the app worked out")?; + let at = s.trend_results.iter().position(|(k, ..)| *k == id).ok_or("that trend is no longer kept: run it again")?; + let (_, rasters, dates) = s.trend_results.remove(at); + return add_outputs(s, rasters, dates); + } + // Worked out here, waited for with no Cancel (a script, the CLI): a run of seconds, not minutes. + let mut run = trend_start(s, p)?; + run.max_pairs = MAX_WAITED_PAIRS; + let rasters = run.run(&AtomicBool::new(false), &AtomicUsize::new(0))?; + add_outputs(s, rasters, run.steps.len()) +} + +/// Adds a trend's layers, each as a GeoTIFF the project keeps. +pub fn add_outputs(s: &mut Session, rasters: Vec, dates: usize) -> CmdResult { + let mut layers = Vec::new(); + for r in rasters { + let name = r.name.clone(); + let tif = raster::geotiff::write(&r, 0).map_err(|e| e.to_string())?; + let file: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '_' }).collect(); + let layer = rustgis_map::raster::RasterLayer::from_bytes(r, format!("{file}.tif"), tif); + let out = super::raster::add_raster(s, &name, layer)?; + layers.push(out["layer"].clone()); + } + Ok(json!({ "layers": layers, "dates": dates })) +} + +#[cfg(test)] +mod tests { + /// The outputs' names: the date taken out, a MODIS date's A with it, and no other final A. + #[test] + fn labels_lose_their_date() { + assert_eq!(super::common_label("NDVI_2024-06-01"), "NDVI"); + assert_eq!(super::common_label("MOD13Q1.A2024153"), "MOD13Q1"); + assert_eq!(super::common_label("sentinel2_ndvi_2024-06-01"), "sentinel2_ndvi"); + assert_eq!(super::common_label("ndvi.A2024153.h12v10"), "ndvi"); + assert_eq!(super::common_label("NDVI_BETA2020"), "NDVI_BETA"); + assert_eq!(super::common_label("ETA_2020"), "ETA"); + } + + /// Dates in names: a full date anywhere before a year alone; a year only standing apart. + #[test] + fn dates_in_names() { + let day = |d: &str| rustgis_data::time::parse_text(d); + assert_eq!(super::date_in_text("ndvi_2015-06-01"), day("2015-06-01")); + assert_eq!(super::date_in_text("res2000_ndvi_20150601"), day("2015-06-01")); + assert_eq!(super::date_in_text("NDVI 2015"), day("2015-01-01")); + assert_eq!(super::date_in_text("ndvi_v2015"), None); + assert_eq!(super::date_in_text("MOD13Q1.A2024153"), day("2024-06-01")); + } + + /// Slices an ulp apart are one grid; half a pixel apart, another. + #[test] + fn grids_within_a_hair_are_one() { + use rustgis_data::raster::GeoTransform; + let a = GeoTransform { origin_x: -60.0, origin_y: 5.0, pixel_width: 0.25, pixel_height: -0.25 }; + let hair = GeoTransform { origin_x: -60.0 + 1e-12, ..a }; + let half = GeoTransform { origin_x: -60.125, ..a }; + assert!(super::same_grid(&a, &hair)); + assert!(!super::same_grid(&a, &half)); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3bcbd1f67..81e572493 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ pub mod catalogs; pub mod clipboard; +pub mod cube; pub mod deeplearning; pub mod design; pub mod directions; @@ -66,6 +67,7 @@ pub fn all() -> Vec { v.extend(layer_copy::commands()); v.extend(raster::commands()); v.extend(zarr::commands()); + v.extend(cube::commands()); v.extend(select::commands()); v.extend(edit::commands()); v.extend(clipboard::commands()); diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index 0fc5cc865..3e946b65f 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -298,6 +298,7 @@ const DATABASES: &[&str] = &[ "layer.addZarr", "zarr.series", "zarr.reduce", + "cube.trend", ]; /// What a command reaches beyond the project when run with some parameters. diff --git a/crates/engine/src/cmd/zarr.rs b/crates/engine/src/cmd/zarr.rs index ca417b892..a7d4eba0a 100644 --- a/crates/engine/src/cmd/zarr.rs +++ b/crates/engine/src/cmd/zarr.rs @@ -314,7 +314,7 @@ fn cached_store(url: &str) -> Result, String> { /// The key a slice is kept by: its store, selection and coordinate system. #[cfg(not(target_arch = "wasm32"))] -fn slice_key(zt: &ZarrTime, i: usize) -> String { +pub(crate) fn slice_key(zt: &ZarrTime, i: usize) -> String { // A folder store rewritten on disk (a model run adding steps) is read again: its metadata's // time of change is part of the key. let changed = if is_web(&zt.store) { @@ -426,7 +426,7 @@ fn read_waiting(zt: &ZarrTime, i: usize) -> Result Result, String> { +pub(crate) fn read_step(zt: &ZarrTime, i: usize, key: String) -> Result, String> { if let Some(r) = kept_slice(&key) { return Ok(r); } @@ -442,13 +442,13 @@ fn read_step(zt: &ZarrTime, i: usize, key: String) -> Result Option> { +pub(crate) fn kept_slice(key: &str) -> Option> { slices().lock().ok().and_then(|c| c.iter().find(|(k, _)| k == key).map(|(_, r)| r.clone())) } /// Step `i` of a following layer's variable, read from its store (no slice kept). #[cfg(not(target_arch = "wasm32"))] -fn read_slice(zt: &ZarrTime, i: usize) -> Result, String> { +pub(crate) fn read_slice(zt: &ZarrTime, i: usize) -> Result, String> { let sel = zt.selection(i); let crs = zt.crs.as_deref().map(str::trim).filter(|c| !c.is_empty()); let read = if is_web(&zt.store) { @@ -970,8 +970,13 @@ impl ReduceRun { } first = Some((r.width, r.height, r.transform, r.crs)); } - // One grid for every step: the same size, place and coordinate system. - Some(f) if *f != (r.width, r.height, r.transform, r.crs) || vals.len() != n.len() => { + // One grid for every step: the same size and coordinate system, its place within a + // thousandth of a pixel, as for a trend. + Some(f) + if (f.0, f.1, f.3) != (r.width, r.height, r.crs) + || !super::cube::same_grid(&f.2, &r.transform) + || vals.len() != n.len() => + { return Err(format!("step {} is {} × {}, unlike the first ({} × {})", i, r.width, r.height, f.0, f.1)); } Some(_) => {} @@ -1057,14 +1062,40 @@ pub fn reduce_start(s: &Session, p: &Value) -> Result { None => 0.0, Some(v) => v.as_f64().filter(|v| (0.0..=1.0).contains(v)).ok_or("minValid is a share from 0 to 1")?, }; - let months: Vec = match p.get("months").filter(|v| !v.is_null()) { - None => Vec::new(), - Some(Value::Array(a)) => a - .iter() - .map(|m| m.as_u64().filter(|m| (1..=12).contains(m)).map(|m| m as u32).ok_or("months are numbers from 1 to 12")) - .collect::>()?, - Some(_) => return Err("months is a list of months (1–12)".into()), - }; + let ZarrCube { zt, period } = zarr_cube(s, p)?; + let steps = super::cube::steps(&zt.dates, period, p)?; + let months = super::cube::months(p)?; + let (var, dates) = (zt.variable.clone(), &zt.dates); + if steps.len() > rustgis_map::raster::MAX_ZARR_STEPS { + return Err(format!( + "{} steps; a summary reads {} at most: take a shorter period", + steps.len(), + rustgis_map::raster::MAX_ZARR_STEPS + )); + } + let day = |i: usize| rustgis_data::time::format(dates.get(i).copied().unwrap_or(f64::NAN), "YYYY-MM-DD"); + let (a, z) = (steps.first().copied().unwrap_or(0), steps.last().copied().unwrap_or(0)); + let mut label = format!("{var} {} {}–{}", stat.code(), day(a), day(z)); + if !months.is_empty() { + let m: Vec = months.iter().map(u32::to_string).collect(); + label = format!("{label} (months {})", m.join(",")); + } + let name = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or(label, |n| n.chars().take(200).collect()); + let root = zt.store.clone(); + Ok(ReduceRun { zt, steps, stat, min_valid, name, root, variable: var }) +} + +/// A Zarr variable's time axis as a data cube (#579): the store and variable to read, all its +/// dates, and the steps of a following layer's period (else the whole axis). +#[cfg(not(target_arch = "wasm32"))] +pub struct ZarrCube { + pub zt: ZarrTime, + pub period: (usize, usize), +} + +/// The cube of a Zarr layer, or of the store and variable the parameters name. +#[cfg(not(target_arch = "wasm32"))] +pub fn zarr_cube(s: &Session, p: &Value) -> Result { let mut src = series_source(s, p)?; let store = if is_web(&src.root) { cached_store(&src.root)? @@ -1076,55 +1107,21 @@ pub fn reduce_start(s: &Session, p: &Value) -> Result { let coords = store_files(&src.root, &zarr::coordinate_keys(&store, &var))?; let (dim, dates) = zarr::time_axis(&store, &var, &coords) .ok_or_else(|| format!("{var} has no time dimension (a coordinate with CF units, such as \"days since 2001-01-01\")"))?; - // Every step a date, so the period and months pick the steps they say. + // Every step a date, so a period and months pick the steps they say (a summary takes them + // in any order; a trend asks for time order itself, in `cube_from`). if !dates.iter().all(|d| d.is_finite()) { return Err(format!("{var}'s time axis has a step that isn't a date")); } - let date = |k: &str| -> Result, String> { - match p.get(k).filter(|v| !v.is_null()) { - None => Ok(None), - Some(v) => rustgis_data::time::parse_input(v) - .map(Some) - .ok_or_else(|| format!("{k} is a date (2015-06-01, a year, or milliseconds since 1970)")), - } - }; - let (start, end) = (date("start")?, date("end")?); - // The layer's period, when no period is given. - let (lo, hi) = match (start, end, src.period) { - (None, None, Some((o, n))) => { + let period = match src.period { + Some((o, n)) => { let o = usize::try_from(o).unwrap_or(usize::MAX).min(dates.len()); (o, o.saturating_add(n).min(dates.len())) } - _ => (0, dates.len()), + None => (0, dates.len()), }; - let month = |d: f64| rustgis_data::time::format(d, "MM").parse::().unwrap_or(0); - let steps: Vec = (lo..hi) - .filter(|i| { - let d = dates.get(*i).copied().unwrap_or(f64::NAN); - start.is_none_or(|a| d >= a) && end.is_none_or(|z| d <= z) && (months.is_empty() || months.contains(&month(d))) - }) - .collect(); - if steps.is_empty() { - return Err("no step of the variable falls in the period (and months) asked for".into()); - } - if steps.len() > rustgis_map::raster::MAX_ZARR_STEPS { - return Err(format!( - "{} steps; a summary reads {} at most: take a shorter period", - steps.len(), - rustgis_map::raster::MAX_ZARR_STEPS - )); - } - let day = |i: usize| rustgis_data::time::format(dates.get(i).copied().unwrap_or(f64::NAN), "YYYY-MM-DD"); - let (a, z) = (steps.first().copied().unwrap_or(0), steps.last().copied().unwrap_or(0)); - let mut label = format!("{var} {} {}–{}", stat.code(), day(a), day(z)); - if !months.is_empty() { - let m: Vec = months.iter().map(u32::to_string).collect(); - label = format!("{label} (months {})", m.join(",")); - } - let name = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or(label, |n| n.chars().take(200).collect()); let zt = ZarrTime { store: src.root.clone(), - variable: var.clone(), + variable: var, index: src.sel.index.iter().filter(|(d, _)| *d != dim).cloned().collect(), dim, offset: 0, @@ -1132,7 +1129,7 @@ pub fn reduce_start(s: &Session, p: &Value) -> Result { crs: src.crs.clone(), shown: None, }; - Ok(ReduceRun { zt, steps, stat, min_valid, name, root: src.root, variable: var }) + Ok(ZarrCube { zt, period }) } /// The most cells a summary keeps running totals for (12 to 20 bytes each): 32 million, at most diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c63e8e07f..6f423ce27 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -399,6 +399,7 @@ struct PutAside { style_view: Option<(String, Camera)>, #[cfg(not(target_arch = "wasm32"))] sql_results: Vec<(u64, String, Option, rustgis_data::source::duckdb::Output)>, + trend_results: Vec<(u64, Vec, usize)>, edit_counts: std::collections::HashMap, } @@ -533,6 +534,9 @@ pub struct Session { /// the id, the query and database (label) they came from, and the result. #[cfg(not(target_arch = "wasm32"))] pub(crate) sql_results: Vec<(u64, String, Option, rustgis_data::source::duckdb::Output)>, + /// Trends the app worked out on a thread, waiting for `cube.trend {"ran"}`: the id, the + /// layers and how many dates (see [`cmd::cube::land`]). + pub(crate) trend_results: Vec<(u64, Vec, usize)>, } /// The most `layer.addUrl` requests kept for the front end at a time. @@ -662,6 +666,7 @@ impl Session { quiet_failure: false, #[cfg(not(target_arch = "wasm32"))] sql_results: Vec::new(), + trend_results: Vec::new(), } } @@ -989,6 +994,7 @@ impl Session { style_view: self.style_view.take(), #[cfg(not(target_arch = "wasm32"))] sql_results: take(&mut self.sql_results), + trend_results: take(&mut self.trend_results), edit_counts: take(&mut self.edit_counts), } } @@ -1030,6 +1036,7 @@ impl Session { style_view, #[cfg(not(target_arch = "wasm32"))] sql_results, + trend_results, edit_counts, } = *a; (self.undo, self.redo, self.back, self.forward, self.last_camera) = (undo, redo, back, forward, last_camera); @@ -1041,6 +1048,7 @@ impl Session { (self.path, self.model_run, self.style_view) = (path, model_run, style_view); (self.pending, self.plugin_runs, self.plugin_retries) = (pending, plugin_runs, plugin_retries); (self.downloading, self.downloaded, self.url_requests) = (downloading, downloaded, url_requests); + self.trend_results = trend_results; #[cfg(feature = "geolibre")] { self.started = started; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4a0214b66..207c781d6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7486,6 +7486,14 @@ fn lidar_point_clouds() { /// 31, 59) on a 3 × 4 grid, step `k`'s values `100·k + i`. #[cfg(not(target_arch = "wasm32"))] fn zarr_store_over_time(tag: &str) -> (std::path::PathBuf, String) { + zarr_store_at(tag, &[0, 31, 59]) +} + +/// A Zarr store of one variable, `t` (time × 3 × 4), with a step at each of `days` since 2001-01-01: +/// step `k`'s cells are `100 k + cell`. +#[cfg(not(target_arch = "wasm32"))] +fn zarr_store_at(tag: &str, days: &[i32]) -> (std::path::PathBuf, String) { + let n = days.len(); let dir = std::env::temp_dir().join(format!("rustgis-zarr-{tag}-{}", std::process::id())); let store = dir.join("air.zarr"); let _ = std::fs::remove_dir_all(&dir); @@ -7500,13 +7508,13 @@ fn zarr_store_over_time(tag: &str) -> (std::path::PathBuf, String) { }; let meta = json!({"metadata": { ".zgroup": {"zarr_format": 2}, - "t/.zarray": arr("[3,3,4]", "[1,3,4]", " (std::path::PathBuf, String) { let f32s = |v: &[f32]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); std::fs::write(store.join("lat/0"), f32s(&[10.0, 20.0, 30.0])).unwrap(); std::fs::write(store.join("lon/0"), f32s(&[1.0, 2.0, 3.0, 4.0])).unwrap(); - std::fs::write(store.join("time/0"), [0i32, 31, 59].iter().flat_map(|x| x.to_le_bytes()).collect::>()).unwrap(); - for t in 0..3 { + std::fs::write(store.join("time/0"), days.iter().flat_map(|x| x.to_le_bytes()).collect::>()).unwrap(); + for t in 0..n { let v: Vec = (0..12).map(|i| (t * 100 + i) as f32).collect(); std::fs::write(store.join(format!("t/{t}.0.0")), f32s(&v)).unwrap(); } @@ -7644,6 +7652,159 @@ fn a_zarr_variable_summarised_over_time() { let _ = std::fs::remove_dir_all(dir); } +/// A trend over a data cube (#579), from the three kinds of cube: a stack of raster layers dated +/// by their names, a raster whose bands are dates, and a Zarr variable along its time dimension. +/// A cell with a clear trend gets Sen's slope and a significant slope; noise gets a slope but no +/// significant one; a cell with too few values gets nothing. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_trend_over_a_data_cube() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; + let mut s = Session::new(Project::default()); + let gt = GeoTransform { origin_x: -56.0, origin_y: -12.0, pixel_width: 0.01, pixel_height: -0.01 }; + // Twelve years; cell 0 rises 0.02 a year, cell 1 is noise, cell 2 has three values only. + let years: Vec = (2013..2025).collect(); + let noise = [0.03, -0.02, 0.05, -0.04, 0.01, 0.0, -0.05, 0.04, -0.01, 0.02, -0.03, 0.03]; + let cells = |k: usize| -> Vec { + vec![0.3 + 0.02 * k as f32 + 0.002 * noise[k] as f32, 0.5 + noise[k] as f32, if k < 3 { 0.4 } else { f32::NAN }, 0.6] + }; + let mut names = Vec::new(); + for (k, y) in years.iter().enumerate() { + let name = format!("ndvi_{y}-07-01"); + let r = Raster::new(&name, 2, 2, vec![cells(k)], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let bytes = geotiff::write(&r, 0).unwrap(); + crate::cmd::raster::add_raster(&mut s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); + names.push(name); + } + let value = |s: &Session, id: &Value, cell: usize| { + s.map().layer(id.as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap().bands[0].data[cell] + }; + let r = s.run("cube.trend", &json!({"layers": names})).unwrap(); + let ids = r["layers"].as_array().unwrap().clone(); + assert_eq!((ids.len(), r["dates"].as_u64()), (4, Some(12)), "{r}"); + let (slope, p, significant) = (&ids[0], &ids[2], &ids[3]); + assert!((value(&s, slope, 0) - 0.02).abs() < 1e-3, "{}", value(&s, slope, 0)); + assert!(value(&s, p, 0) < 0.01 && (value(&s, significant, 0) - 0.02).abs() < 1e-3); + assert!(value(&s, slope, 1).is_finite() && value(&s, p, 1) > 0.05 && value(&s, significant, 1).is_nan()); + assert!(value(&s, slope, 2).is_nan() && value(&s, p, 2).is_nan()); + // A constant cell: no slope, and nothing significant. + assert_eq!(value(&s, slope, 3), 0.0); + assert!(value(&s, significant, 3).is_nan()); + let name = |id: &Value| s.map().layer(id.as_u64().unwrap() as u32).unwrap().name.clone(); + assert_eq!(name(slope), "ndvi 2013-07-01–2024-07-01 Sen slope per year"); + assert!(name(&ids[1]).ends_with("Z (Hamed-Rao)"), "{}", name(&ids[1])); + // Per decade, another test, a period, and the dates given. + let r = s.run("cube.trend", &json!({"layers": names, "per": "decade", "test": "yueWang", "start": "2016", "name": "late"})).unwrap(); + let late = r["layers"][0].clone(); + assert_eq!(r["dates"], 9); + assert!((value(&s, &late, 0) - 0.2).abs() < 1e-2, "{}", value(&s, &late, 0)); + let backwards: Vec = (0..12).map(|k| format!("{}-01-01", 2030 - k)).collect(); + let r = s.run("cube.trend", &json!({"layers": names, "dates": backwards, "test": "original"})).unwrap(); + assert!(value(&s, &r["layers"][0], 0) < 0.0, "the dates given are used, in the layers' order"); + // A raster whose bands are the dates, named by them. + let bands: Vec> = (0..12).map(cells).collect(); + let mut cube = Raster::new("stack", 2, 2, bands, gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + for (b, y) in cube.bands.iter_mut().zip(&years) { + b.name = format!("NDVI {y}"); + } + let bytes = geotiff::write(&cube, 0).unwrap(); + let id = + crate::cmd::raster::add_raster(&mut s, "stack", rustgis_map::RasterLayer::from_bytes(cube, "stack.tif", bytes)).unwrap()["layer"] + .clone(); + let r = s.run("cube.trend", &json!({"layer": id})).unwrap(); + assert!((value(&s, &r["layers"][0], 0) - 0.02).abs() < 1e-3); + // Two bands on one date are refused, as two layers are. + let same: Vec = (0..12).map(|k| format!("{}-06-01", 2000 + k.max(1))).collect(); + let e = s.run("cube.trend", &json!({"layer": id, "dates": same})).unwrap_err(); + assert!(e.contains("same date"), "{e}"); + // A Zarr variable: every cell rises 100 a step. + let (dir, path) = zarr_store_at("trend", &[0, 31, 59, 90, 120, 151, 181, 212]); + let r = s.run("cube.trend", &json!({"path": path, "variable": "t", "test": "seasonal", "seasons": 4})).unwrap(); + assert_eq!(r["dates"], 8); + assert!(value(&s, &r["layers"][0], 0) > 1000.0, "{}", value(&s, &r["layers"][0], 0)); + let _ = std::fs::remove_dir_all(dir); + // A step never written (its chunk absent) has no value; the trend goes on with the others. + let (gap_dir, gap) = zarr_store_at("trend-gap", &[0, 31, 59, 90, 120, 151, 181, 212]); + std::fs::remove_file(std::path::Path::new(&gap).join("t/3.0.0")).unwrap(); + let r = s.run("cube.trend", &json!({"path": gap, "variable": "t", "test": "original"})).unwrap(); + assert!(value(&s, &r["layers"][0], 0) > 1000.0, "{}", value(&s, &r["layers"][0], 0)); + let _ = std::fs::remove_dir_all(gap_dir); + // Refused: too few layers, no dates, dates of another length, unknown options, another grid. + let other = Raster::new("ndvi_2030-01-01", 3, 3, vec![vec![0.0; 9]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let bytes = geotiff::write(&other, 0).unwrap(); + crate::cmd::raster::add_raster(&mut s, "ndvi_2030-01-01", rustgis_map::RasterLayer::from_bytes(other, "o.tif", bytes)).unwrap(); + let mut mixed = names.clone(); + mixed.push("ndvi_2030-01-01".into()); + for bad in [ + json!({"layers": &names[..3]}), + json!({"layers": names, "dates": ["2001", "2002"]}), + json!({"layers": names, "test": "ols"}), + json!({"layers": names, "alpha": 0.7}), + json!({"layers": names, "per": "week"}), + json!({"layers": names, "start": "2030"}), + json!({"layers": mixed}), + json!({"layer": names[0]}), + ] { + assert!(s.run("cube.trend", &bad).is_err(), "{bad}"); + } + let undated = Raster::new("plain", 2, 2, vec![vec![0.0; 4]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let bytes = geotiff::write(&undated, 0).unwrap(); + crate::cmd::raster::add_raster(&mut s, "plain", rustgis_map::RasterLayer::from_bytes(undated, "plain.tif", bytes)).unwrap(); + let mut no_date = names[..4].to_vec(); + no_date[1] = "plain".into(); + let e = s.run("cube.trend", &json!({"layers": no_date})).unwrap_err(); + assert!(e.contains("no date in the name of plain"), "{e}"); + // A run the app no longer keeps is said, never worked out again here. + let e = s.run("cube.trend", &json!({"ran": 987_654_321u64})).unwrap_err(); + assert!(e.contains("no longer kept"), "{e}"); + // A run kept by one session is that session's: another one (Python, tests side by side) + // doesn't take it, and it is added once. + let kept = Raster::new("kept", 2, 2, vec![vec![0.0; 4]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let ran = crate::cmd::cube::land(&mut s, vec![kept], 12); + let mut other = Session::new(Project::default()); + assert!(other.run("cube.trend", &json!({"ran": ran})).unwrap_err().contains("no longer kept")); + assert_eq!(s.run("cube.trend", &json!({"ran": ran})).unwrap()["dates"], 12); + assert!(s.run("cube.trend", &json!({"ran": ran})).is_err()); + let e = s.run("cube.trend", &json!({"layers": names, "minValid": 99})).unwrap_err(); + assert!(e.contains("more than the"), "{e}"); + // Two layers on one day would count twice in the test: refused. + let mut twice: Vec = (0..names.len()).map(|k| format!("{}-06-01", 2010 + k)).collect(); + twice[1] = twice[0].clone(); + let e = s.run("cube.trend", &json!({"layers": names, "dates": twice})).unwrap_err(); + assert!(e.contains("same date"), "{e}"); + // Seasons from the calendar: the 1st of every month is its month in every year, leap years + // included, and a 16-day composite on the same day each year keeps its season. + use crate::cmd::cube::season_of; + let day = |d: &str| rustgis_data::time::parse_text(d).unwrap(); + for y in 2000..2010 { + for m in 1..=12 { + assert_eq!(season_of(day(&format!("{y}-{m:02}-01")), 12), m - 1, "{y}-{m}"); + } + assert_eq!(season_of(day(&format!("{y}-01-17")), 23), 1, "{y}"); + assert_eq!(season_of(day(&format!("{y}-12-19")), 23), 22, "{y}"); + } + // A monthly cube dated on the 1st, with a yearly cycle and a rise: the seasonal test finds it + // per year and per decade alike. + let mut monthly = Vec::new(); + for y in 2015..2021 { + for m in 1..=12 { + let k = (y - 2015) * 12 + (m - 1); + let v = 0.5 + 0.2 * (std::f32::consts::TAU * (m - 1) as f32 / 12.0).sin() + 0.01 * k as f32 / 12.0; + let name = format!("evi_{y}-{m:02}-01"); + let r = Raster::new(&name, 1, 1, vec![vec![v]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let bytes = geotiff::write(&r, 0).unwrap(); + crate::cmd::raster::add_raster(&mut s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); + monthly.push(name); + } + } + for (per, want) in [("year", 0.01), ("decade", 0.1)] { + let r = s.run("cube.trend", &json!({"layers": monthly, "test": "seasonal", "per": per})).unwrap(); + let slope = value(&s, &r["layers"][0], 0); + assert!((slope - want).abs() < want * 0.05, "{per}: {slope}"); + assert!(value(&s, &r["layers"][2], 0) < 0.01, "{per}: significant"); + } +} + /// A Zarr variable follows the time slider (#554): the slice drawn is the step the slider is at, /// the stretch is the same at every step, and the project keeps the layer by its store. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 24000a739..d3a288142 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -33,8 +33,44 @@ pub struct Workflow { pub error: Option, pub script: String, pub script_out: Vec<(String, bool)>, + pub trend: TrendForm, } +/// The Trend Analysis section's inputs (#579), and a run under way. +#[derive(Default)] +pub struct TrendForm { + /// The cube: a stack of layers (`true`), or one layer (a Zarr variable, or a raster of bands). + pub stack: bool, + /// The layers ticked for a stack. + pub ticked: Vec, + pub test: String, + pub seasons: u32, + pub alpha: String, + pub decade: bool, + pub start: String, + pub end: String, + pub months: String, + pub min_valid: String, + #[cfg(not(target_arch = "wasm32"))] + pub running: Option, +} + +/// A trend running on a thread of its own: Cancel, the work done, and the result. +#[cfg(not(target_arch = "wasm32"))] +pub struct Trending { + pub total: usize, + /// Its place among the project's work under way: a trend that lands after another project + /// opened is dropped. + pub id: u64, + pub params: Value, + pub cancel: std::sync::Arc, + pub done: std::sync::Arc, + pub result: TrendResult, +} + +#[cfg(not(target_arch = "wasm32"))] +type TrendResult = std::sync::Arc, usize), String>>>>; + fn heading(ui: &mut Ui, text: &str) { ui.add_space(6.0); ui.label(egui::RichText::new(tl!(text)).font(semibold(12.5))); @@ -86,6 +122,7 @@ pub const SECTIONS: &[(&str, &str)] = &[ ("models", "ModelBuilder"), ("georeference", "Georeference"), ("change", "Change Detection"), + ("trend", "Trend Analysis"), ("spectral", "Spectral Analysis"), ("classify", "Classification Wizard"), ("supervised", "Classification Tools"), @@ -150,7 +187,9 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let w = &mut app.workflow; let inputs = match w.section { "rasterFunctions" | "functionEditor" | "suitability" | "visibility" | "lineOfSight" | "georeference" | "change" | "spectral" - | "classify" | "supervised" | "deepLearning" | "hosted" | "indices" | "process" | "pixels" | "mensuration" | "reality" => &rasters, + | "classify" | "supervised" | "deepLearning" | "hosted" | "indices" | "process" | "pixels" | "mensuration" | "reality" + // The trend's one-layer mode takes a raster (a Zarr variable, or a raster of several bands). + | "trend" => &rasters, "interpolate" => &points, "network" => &lines, "simulation" => &polygons, @@ -461,6 +500,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ); } } + "trend" => trend(app, ui, &rasters), "spectral" => { heading(ui, "Band values at a point"); combo(ui, "wf_sp", &mut app.workflow.layer, &rasters); @@ -720,6 +760,236 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } } +/// The Trend Analysis section (opengeos/RustGIS#579): a cube (raster layers a date each, a Zarr +/// variable, or a raster's bands), Sen's slope and the modified Mann–Kendall test, worked out on a +/// thread with progress and Cancel, and added as four layers. +fn trend(app: &mut RustGisApp, ui: &mut Ui, rasters: &[String]) { + #[cfg(not(target_arch = "wasm32"))] + if trend_running(app, ui) { + return; + } + heading(ui, "Trend over a series of rasters"); + let f = &mut app.workflow.trend; + if f.test.is_empty() { + (f.test, f.seasons, f.alpha) = ("hamedRao".into(), 12, "0.05".into()); + } + ui.horizontal(|ui| { + ui.radio_value(&mut f.stack, true, tl!("Layers, a date each")); + ui.radio_value(&mut f.stack, false, tl!("One layer: a Zarr variable or a raster's bands")); + }); + if f.stack { + f.ticked.retain(|n| rasters.contains(n)); + ui.horizontal(|ui| { + if ui.small_button(tl!("All")).clicked() { + f.ticked = rasters.to_vec(); + } + if ui.small_button(tl!("None")).clicked() { + f.ticked.clear(); + } + }); + egui::ScrollArea::vertical().id_salt("trend_layers").max_height(160.0).show(ui, |ui| { + for n in rasters { + let mut on = f.ticked.contains(n); + let date = rustgis_engine::cmd::cube::date_in_text(n) + .map_or_else(|| tl!("no date in the name").to_string(), |d| rustgis_engine::data::time::format(d, "YYYY-MM-DD")); + if ui.checkbox(&mut on, format!("{n} ({date})")).changed() { + if on { + f.ticked.push(n.clone()); + } else { + f.ticked.retain(|x| x != n); + } + } + } + }); + } else { + combo(ui, "wf_tr_layer", &mut app.workflow.layer, rasters); + } + let f = &mut app.workflow.trend; + egui::Grid::new("trend_grid").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Test")); + let tests = [ + ("hamedRao", "Modified Mann–Kendall (Hamed & Rao)"), + ("yueWang", "Modified Mann–Kendall (Yue & Wang)"), + ("seasonal", "Seasonal Mann–Kendall"), + ("original", "Mann–Kendall"), + ]; + let shown = tests.iter().find(|t| t.0 == f.test).map_or("", |t| t.1); + egui::ComboBox::from_id_salt("trend_test").selected_text(tl!(shown)).width(240.0).show_ui(ui, |ui| { + for (code, label) in tests { + ui.selectable_value(&mut f.test, code.to_string(), tl!(label)); + } + }); + ui.end_row(); + if f.test == "seasonal" { + ui.label(tl!("Seasons a year")); + ui.add(egui::DragValue::new(&mut f.seasons).range(2..=366)); + ui.end_row(); + } + ui.label(tl!("Significance level")); + ui.add(egui::TextEdit::singleline(&mut f.alpha).desired_width(60.0)); + ui.end_row(); + ui.label(tl!("Slope per")); + ui.horizontal(|ui| { + ui.radio_value(&mut f.decade, false, tl!("year")); + ui.radio_value(&mut f.decade, true, tl!("decade")); + }); + ui.end_row(); + ui.label(tl!("From")); + ui.add(egui::TextEdit::singleline(&mut f.start).hint_text("2000-01-01").desired_width(110.0)); + ui.end_row(); + ui.label(tl!("To")); + ui.add(egui::TextEdit::singleline(&mut f.end).hint_text("2024-12-31").desired_width(110.0)); + ui.end_row(); + ui.label(tl!("Months")); + ui.add(egui::TextEdit::singleline(&mut f.months).hint_text("12, 1, 2").desired_width(110.0)); + ui.end_row(); + ui.label(tl!("Dates a cell needs")); + ui.add(egui::TextEdit::singleline(&mut f.min_valid).hint_text(tl!("half")).desired_width(60.0)); + ui.end_row(); + }); + ui.label( + egui::RichText::new(tl!("Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant.")) + .weak() + .small(), + ); + if !ui.button(tl!("Run")).clicked() { + return; + } + let mut p = json!({"test": f.test, "per": if f.decade { "decade" } else { "year" }}); + if f.stack { + p["layers"] = json!(f.ticked); + } else { + p["layer"] = json!(app.workflow.layer); + } + if f.test == "seasonal" { + p["seasons"] = json!(f.seasons); + } + // A field filled in but not read as a number is said, never dropped for the default. + let mut bad: Option = None; + match f.alpha.trim() { + "" => {} + a => match a.parse::() { + Ok(a) => p["alpha"] = json!(a), + Err(_) => bad = Some(tl!("The significance level is a number, such as 0.05").to_string()), + }, + } + match f.min_valid.trim() { + "" => {} + m => match m.parse::() { + Ok(m) => p["minValid"] = json!(m), + Err(_) => bad = Some(tl!("Dates a cell needs is a whole number, or empty for half").to_string()), + }, + } + for (k, v) in [("start", &f.start), ("end", &f.end)] { + if !v.trim().is_empty() { + p[k] = json!(v.trim()); + } + } + let typed: Vec<&str> = f.months.split([',', ' ']).map(str::trim).filter(|m| !m.is_empty()).collect(); + let months: Vec = typed.iter().filter_map(|m| m.parse::().ok()).map(|m| json!(m)).collect(); + if months.len() != typed.len() { + bad = Some(tl!("Months are numbers from 1 to 12 (12, 1, 2 for December to February)").to_string()); + } + if let Some(e) = bad { + app.workflow.error = Some(e); + return; + } + if !months.is_empty() { + p["months"] = Value::Array(months); + } + // The web app has no threads: the run is made here. + #[cfg(target_arch = "wasm32")] + run(app, "cube.trend", p); + #[cfg(not(target_arch = "wasm32"))] + match rustgis_engine::cmd::cube::trend_start(&app.session, &p) { + Ok(job) => { + let id = app.session.download_started(); + let r = Trending { + total: job.total(), + id, + params: p, + cancel: Default::default(), + done: Default::default(), + result: Default::default(), + }; + let (cancel, done, result) = (r.cancel.clone(), r.done.clone(), r.result.clone()); + // A panic on the thread (a hostile store) lands as an error, so the run always ends; + // a thread the system can't start is said. + let spawned = std::thread::Builder::new().name("trend".into()).spawn(move || { + let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + job.run(&cancel, &done).map(|rasters| (rasters, job.dates())) + })) + .unwrap_or_else(|_| Err(tl!("The trend failed unexpectedly").to_string())); + if let Ok(mut g) = result.lock() { + *g = Some(out); + } + }); + match spawned { + Ok(_) => { + app.workflow.trend.running = Some(r); + app.workflow.error = None; + } + Err(e) => { + app.session.download_failed(id); + app.workflow.error = Some(crate::i18n::fmt("The trend couldn't start: {error}", &[("error", &e.to_string())])); + } + } + } + Err(e) => app.workflow.error = Some(e), + } +} + +/// A trend that has finished, its layers added through `cube.trend` (so it's in the History): +/// called every frame, whatever pane is in view, so a run lands though the Workflows pane +/// closed. `true` while one is still running. +#[cfg(not(target_arch = "wasm32"))] +pub fn land_trend(app: &mut RustGisApp) -> bool { + use std::sync::atomic::Ordering; + let Some(r) = &app.workflow.trend.running else { return false }; + let Some(res) = r.result.lock().ok().and_then(|mut g| g.take()) else { return true }; + let mut p = r.params.clone(); + // A run the user cancelled stops quietly: no error for it. + let cancelled = r.cancel.load(Ordering::Relaxed); + // Still the project it was run in (`false` once another opened). + let current = app.session.download_done(r.id); + app.workflow.trend.running = None; + match res { + // Cancelled as it finished: nothing added. + Ok(_) if cancelled => {} + // Another project opened meanwhile: nothing added, and said. + Ok(_) if !current => { + app.workflow.error = Some(tl!("The trend finished after another project was opened, so it wasn't added").to_string()); + } + Ok((rasters, dates)) => { + p["ran"] = json!(rustgis_engine::cmd::cube::land(&mut app.session, rasters, dates)); + run(app, "cube.trend", p); + } + Err(_) if cancelled => {} + Err(e) => app.workflow.error = Some(e), + } + false +} + +/// A trend under way: its progress and Cancel (its result lands through `land_trend`). Whether +/// one is still running. +#[cfg(not(target_arch = "wasm32"))] +fn trend_running(app: &mut RustGisApp, ui: &mut Ui) -> bool { + use std::sync::atomic::Ordering; + if !land_trend(app) { + return false; + } + let Some(r) = &app.workflow.trend.running else { return false }; + let (done, total) = (r.done.load(Ordering::Relaxed), r.total.max(1)); + heading(ui, "Trend over a series of rasters"); + ui.horizontal(|ui| { + ui.add(egui::ProgressBar::new(done as f32 / total as f32).text(format!("{done} / {total}")).desired_width(200.0)); + if ui.button(tl!("Cancel")).clicked() { + r.cancel.store(true, Ordering::Relaxed); + } + }); + true +} + /// Shows a workflow's result: profiles and histograms as tables and bars, the rest as JSON. fn result_view(ui: &mut Ui, r: &Value, t: &Tokens) { ui.add_space(8.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4fcf87a7..2c40a059d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1461,7 +1461,7 @@ impl RustGisApp { // couldn't add it again. // A result handed over by the app (`ran`: a summary worked out on a thread) is used up // the same way. - if id == "layer.addDownload" || (id == "layer.addZarr" && params.get("ran").is_some_and(|r| !r.is_null())) { + if id == "layer.addDownload" || (matches!(id, "layer.addZarr" | "cube.trend") && params.get("ran").is_some_and(|r| !r.is_null())) { let text = crate::i18n::fmt( "{command} isn't recorded: the downloaded data is kept for this session only, so a task couldn't add it again.", &[("command", tl_raw!(spec.label))], @@ -3371,6 +3371,7 @@ const DEFAULTS_TO_ACTIVE_LAYER: &[&str] = &[ "share.webStyle", "share.locator", "zarr.reduce", + "cube.trend", ]; #[cfg(test)] diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 277c9b072..b56caf1f3 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -47,10 +47,10 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { if app.ui.right != Some(RightPane::TimeSeries) && app.workflow.pick.as_ref().is_some_and(|p| p.purpose == "ts_point") { app.workflow.pick = None; } - // A Zarr summary running on its thread lands whatever pane is in view, and the frames go on - // while it runs so it's taken when it ends. + // A Zarr summary or a trend running on its thread lands whatever pane is in view (both are + // looked at: `|`, not `||`), and the frames go on while one runs so it's taken when it ends. #[cfg(not(target_arch = "wasm32"))] - if crate::workspacepanes::land_summary(app) { + if crate::workspacepanes::land_summary(app) | crate::analysispane::land_trend(app) { ui.ctx().request_repaint_after(std::time::Duration::from_millis(200)); } // The pane in view always has its tab, however it was set. diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index ae6bc54e9..8ed738380 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -323,6 +323,48 @@ The Analyze tab's imagery groups open their tools in the Workflows pane: - **Mensuration**: distances along the surface, heights between two points, areas, and volumes above or below a base height, with a list of results. +### Trend analysis + +**Trend Analysis** (`cube.trend`, in the Workflows pane after Change Detection) asks of a series of +rasters whether each cell is changing, how fast, and how surely: an NDVI series greening or +browning, a temperature series warming. Each cell gets **Sen's slope**, the median of the slopes +between every two dates, which holds up against outliers such as a cloud left in an NDVI scene, +and a **Mann–Kendall** test of whether the trend is significant. + +- **The series** (a data cube) is any of: + - raster layers, a date each, dated by their names (`ndvi_2024-06-01`, MODIS's `A2024153`, + `2024-06`, a year) or by the `dates` given: `{"layers": ["ndvi_2015", "ndvi_2016", …]}`; + - a Zarr variable along its time dimension, by its layer or its store, as for Summarise over time + (see [Data](data.md)): `{"path": "air.zarr", "variable": "t2m"}`; + - a raster of several bands, a date each, as Earth Engine and openEO export, dated by the bands' + names or `dates`. + + The rasters have to be on one grid; put them on one first with Raster Functions › Resample. +- **The test:** NDVI and temperature series are autocorrelated, which makes the plain test report + too many significant cells, so the default is the **modified Mann–Kendall** test of **Hamed & + Rao (1998)**: the variance of S is corrected by the autocorrelation of the detrended series' + ranks, at the lags significant at the test's level. The others: + - **Yue & Wang (2004)**, from the lag-1 autocorrelation of the detrended series; + - **Seasonal** (Hirsch et al. 1982), for a series with a yearly cycle: the test is made within + each season (`"seasons": 12` for months, 23 for 16-day composites) and summed, and Sen's slope + comes from pairs within a season; + - **Mann–Kendall** for independent values. + + The definitions follow `pymannkendall`'s, ties included. +- **Options:** the significance level (`"alpha"`, 0.05), the slope per year or per decade (from the + real dates, so uneven spacing is handled), a period (`start`, `end`), a season across years + (`"months": [12, 1, 2]`), and the dates a cell needs (`"minValid"`, by default half of them, at + least 4). Missing values and no-data are skipped cell by cell. +- **Outputs:** four 32-bit layers, kept in the project as GeoTIFFs: Sen's slope, the test's Z, the + p-value, and the slope where p < alpha (no-data elsewhere), the one a map usually shows on a + diverging ramp centred on 0. +- **Size:** the cube is read into memory a slice at a time, up to 64 million values (cells times + dates; 16 million in the web app) and 2,000 dates. It runs in the background with progress and Cancel. + +```sh +rustgis-cli run --cmd 'cube.trend={"layers":["ndvi_2015","ndvi_2016","ndvi_2017","ndvi_2018"],"per":"decade"}' +``` + ## Deep learning and hosted imagery **Analyze › Deep Learning Tools** trains a neural network (a multilayer perceptron) on the pixels From 11715d10b3bdfde8ad54dec14daadb3a457e4432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Wed, 21 Oct 2026 19:15:34 -0500 Subject: [PATCH 011/297] feat(ui): keep a command's help by the cursor while its parameters are typed in the Script pane (#677) * feat(ui): keep a command's help by the cursor while its parameters are typed in the Script pane * fix(ui): anchor the Script help by the cursor at the line's brace, and work it out only on change * refactor(ui): name the Script suggestions' caches and group the help's state * fix(ui): let the Script help by the cursor hash the script itself --- crates/ui-egui/src/analysispane.rs | 1 + crates/ui-egui/src/scriptcomplete.rs | 306 ++++++++++++++++++++++----- docs/user-guide/analysis.md | 4 +- 3 files changed, 258 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index d3a288142..1b5e5c299 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -1109,6 +1109,7 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { .show(ui) }); let clicked = crate::scriptcomplete::show(ui, box_id, &out.inner, out.inner_rect, &app.workflow.script, &app.session); + crate::scriptcomplete::hint(ui, box_id, &out.inner, out.inner_rect, &app.workflow.script, &app.session); if let Some((offer, item)) = clicked { crate::scriptcomplete::accept(ui, box_id, &mut app.workflow.script, &offer, &item); } diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index b2e6ea02c..d56f52875 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -37,9 +37,7 @@ pub fn offer(script: &str, at: usize, ids: &[String]) -> Option { /// cursor at the end of a line's first word (comments aside), at least `MIN_TYPED` characters. /// It reads the cursor's line only, so working an offer out costs no more for a long script. pub fn word_at(script: &str, at: usize) -> Option<(usize, String)> { - // The byte the cursor is at (None past the end). - let byte = if at == 0 { Some(0) } else { script.char_indices().nth(at - 1).map(|(b, c)| b + c.len_utf8()) }?; - let (before, after) = script.split_at(byte); + let (before, after) = script.split_at(byte_at(script, at)?); // The cursor ends the word: no word character right after it. if after.chars().next().is_some_and(|c| !c.is_whitespace()) { return None; @@ -52,6 +50,57 @@ pub fn word_at(script: &str, at: usize) -> Option<(usize, String)> { Some((at - word.chars().count(), word.to_string())) } +/// The byte char `at` of `script` starts at (its end for `at` at the end); None past it. +fn byte_at(script: &str, at: usize) -> Option { + if at == 0 { Some(0) } else { script.char_indices().nth(at - 1).map(|(b, c)| b + c.len_utf8()) } +} + +/// The command whose parameters a cursor is in (`hint_at`): its line's start and its `{` (char +/// indexes), and its id. +#[derive(Clone, Debug, PartialEq)] +pub struct Hint { + pub line: usize, + pub brace: usize, + pub id: String, +} + +/// The command whose parameters the cursor at char `at` is in: the cursor inside its line's +/// `{…}` (quoted text aside: a `}` in a string doesn't close it). None outside the braces, or on +/// a line with no id. +pub fn hint_at(script: &str, at: usize) -> Option { + let before = script.get(..byte_at(script, at)?)?; + let line_start = before.rfind('\n').map_or(0, |i| i + 1); + let line = before.get(line_start..)?; + let id = crate::scriptcolor::line_id(line)?; + let brace = line.find('{')?; + let (mut depth, mut quoted, mut escaped) = (0usize, false, false); + for c in line.get(brace..)?.chars() { + let was_escaped = escaped; + escaped = c == '\\' && !was_escaped; + match c { + '"' if !was_escaped => quoted = !quoted, + '{' if !quoted => depth += 1, + '}' if !quoted => depth = depth.saturating_sub(1), + _ => {} + } + } + let chars = |s: Option<&str>| s.map_or(0, |s| s.chars().count()); + let line_chars = chars(before.get(..line_start)); + (depth > 0).then(|| Hint { line: line_chars, brace: line_chars + chars(line.get(..brace)), id: id.to_string() }) +} + +/// Where a popup of `size` (last frame's) goes by `cursor`: under it if it fits, else above if it +/// fits, else on the side with more room; kept on screen sideways. Its corner, and the height its +/// content may take (it scrolls beyond: a short window, a long help). +fn place(cursor: egui::Rect, size: egui::Vec2, screen: egui::Rect, min: f32) -> (egui::Pos2, f32) { + let (below, above) = (screen.bottom() - cursor.bottom(), cursor.top() - screen.top()); + let under = size.y <= below || (size.y > above && below >= above); + let room = (if under { below } else { above } - FRAME).max(min); + let y = if under { cursor.bottom() } else { cursor.top() - size.y.min(above) }; + let x = cursor.left().min(screen.right() - size.x).max(screen.left()); + (egui::pos2(x, y), room) +} + /// The ids in `ids` (the commands a script can run, `script_ids`) that complete `word`, typed /// from char `start` to `end`, case aside; the id typed whole isn't offered again. fn complete(start: usize, end: usize, word: &str, ids: &[String]) -> Option { @@ -173,7 +222,7 @@ struct List { /// one as long, and a change made by code comes with no input event to wait for. It reads the /// text once, as egui's own layout already does each frame (the layouter's cache key, the /// galley cache), so it adds a constant, not a new order of cost. - worked: Option<(Option, u64, u64, Option)>, + worked: Option>>, /// The list shown, and its highlighted item: what the keys act on. open: Option<(Offer, usize)>, /// The offer Escape closed: not shown again until what's typed changes. @@ -188,6 +237,41 @@ struct List { /// The highlighted item the list last scrolled to: it scrolls to a new one only (so a wheel /// can still move it). scrolled_to: Option, + /// The help by the cursor (`hint`). + hint: HintState, +} + +/// What was worked out at a cursor, and for which script (its hash) and `with` (what else it +/// depends on: the plugins' revision for the list; the language and the plugins' revision for +/// the help): worked out again only when one changes. +#[derive(Clone)] +struct Worked { + at: Option, + text: u64, + with: W, + value: T, +} + +impl Worked { + /// The value, if worked out for this cursor, script and `with`. + fn get(this: &Option, at: Option, text: u64, with: &W) -> Option { + this.as_ref().filter(|w| w.at == at && w.text == text && w.with == *with).map(|w| w.value.clone()) + } +} + +/// The help shown by the cursor while it's in a line's `{…}`. +#[derive(Clone, Default)] +struct HintState { + /// The command it's for: what Escape acts on. + shown: Option, + /// What Escape closed: not shown again until the cursor leaves those braces. + dismissed: Option, + /// Where it was drawn last frame. + rect: Option, + /// What it was worked out to show, for which cursor, script (its hash), language (the help is + /// translated) and plugins' revision (a plugin's command comes and goes with its plugin): not + /// again each frame the cursor blinks. + worked: Option>>, } fn load(ui: &Ui, box_id: Id) -> List { @@ -199,10 +283,16 @@ fn store(ui: &Ui, box_id: Id, list: List) { } /// The keys the box keeps while it has focus: code_editor's (the arrows, Tab to indent) and, with -/// the list open, Escape, which egui would otherwise take as "drop the focus" before `keys` can -/// read it as "close the list". +/// the list or the help by the cursor open, Escape, which egui would otherwise take as "drop the +/// focus" before `keys` can read it as "close it". pub fn filter(ui: &Ui, box_id: Id) -> egui::EventFilter { - egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: load(ui, box_id).open.is_some() } + let list = load(ui, box_id); + egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + escape: list.open.is_some() || list.hint.shown.is_some(), + } } /// Before the box sees the keys: with the list open and the box focused, ↑/↓ move the highlight, @@ -212,11 +302,20 @@ pub fn filter(ui: &Ui, box_id: Id) -> egui::EventFilter { /// word could be elsewhere now. pub fn keys(ui: &Ui, box_id: Id, script: &str) -> Option<(Offer, String)> { let mut list = load(ui, box_id); + // The help by the cursor (no list open): Escape closes it. + if list.open.is_none() && list.hint.shown.is_some() && ui.memory(|m| m.has_focus(box_id)) { + if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { + list.hint.dismissed = list.hint.shown.take(); + list.hint.rect = None; + store(ui, box_id, list); + } + return None; + } let (offer, selected) = list.open.clone()?; if !ui.memory(|m| m.has_focus(box_id)) { return None; } - if list.worked.as_ref().is_none_or(|w| w.1 != egui::util::hash(script)) { + if list.worked.as_ref().is_none_or(|w| w.text != egui::util::hash(script)) { list.open = None; store(ui, box_id, list); return None; @@ -278,7 +377,7 @@ pub fn show( // A cursor, not a selection: with text selected, Enter and Tab act on it as usual. let at = out.cursor_range.filter(|r| r.is_empty()).map(|r| r.primary.index.0); let (text, plugins) = (egui::util::hash(script), rustgis_engine::plugins::revision()); - let kept = list.worked.as_ref().filter(|w| (w.0, w.1, w.2) == (at, text, plugins)).map(|w| w.3.clone()); + let kept = Worked::get(&list.worked, at, text, &plugins); match kept { Some(offer) => offer, None => { @@ -286,7 +385,7 @@ pub fn show( let offer = at .and_then(|at| word_at(script, at).map(|w| (at, w))) .and_then(|(at, (start, word))| complete(start, at, &word, &script_ids_cached(ui, session))); - list.worked = Some((at, text, plugins, offer.clone())); + list.worked = Some(Worked { at, text, with: plugins, value: offer.clone() }); offer } } @@ -330,13 +429,7 @@ pub fn show( let row = ui.text_style_height(&egui::TextStyle::Monospace) + ui.spacing().item_spacing.y + 4.0; let size = list.rect.map_or(egui::vec2(MIN_WIDTH + 16.0, row * offer.items.len() as f32 + 12.0), |r| r.size()); let screen = ui.ctx().content_rect(); - // Under the cursor if it fits there, else above if it fits there, else on the side with more - // room; the list and help scroll within that room (a short window, a long help). - let (below, above) = (screen.bottom() - cursor.bottom(), cursor.top() - screen.top()); - let under = size.y <= below || (size.y > above && below >= above); - let room = (if under { below } else { above } - FRAME).max(row * 3.0); - let y = if under { cursor.bottom() } else { cursor.top() - size.y.min(above) }; - let x = cursor.left().min(screen.right() - size.x).max(screen.left()); + let (corner, room) = place(cursor, size, screen, row * 3.0); // The help beside the list in the room the screen leaves, or under it when that's too narrow // to read (a narrow window), so neither is cut off at the screen's edge. let beside = (screen.width() - MIN_WIDTH - GAP - FRAME).min(HELP_WIDTH); @@ -344,47 +437,46 @@ pub fn show( if beside >= HELP_MIN_WIDTH { (true, beside) } else { (false, (screen.width() - FRAME).clamp(0.0, HELP_WIDTH)) }; let layout = if beside { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; let mut clicked = None; - let area = - egui::Area::new(box_id.with("complete_list")).order(egui::Order::Foreground).fixed_pos(egui::pos2(x, y)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - egui::ScrollArea::vertical().max_height(room).auto_shrink([true, true]).show(ui, |ui| { - ui.with_layout(layout, |ui| { - // The help follows the item under the pointer, else the highlighted one. - let mut hovered = None; - ui.vertical(|ui| { - ui.set_min_width(MIN_WIDTH); - for (i, item) in offer.items.iter().enumerate() { - let r = ui.selectable_label(i == selected, egui::RichText::new(item).monospace()); - // Taken as the button goes down, as in a code editor: the press already - // takes the focus from the box, and a click (down, then up a frame or more - // later) could find the list gone by then. A click still counts - // (assistive technology). - if r.clicked() || (r.hovered() && ui.input(|i| i.pointer.primary_pressed())) { - clicked = Some((offer.clone(), item.clone())); - } - if r.hovered() { - hovered = Some(i); - } - if i == selected && list.scrolled_to != Some(selected) { - r.scroll_to_me(None); - list.scrolled_to = Some(selected); - } + let area = egui::Area::new(box_id.with("complete_list")).order(egui::Order::Foreground).fixed_pos(corner).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(room).auto_shrink([true, true]).show(ui, |ui| { + ui.with_layout(layout, |ui| { + // The help follows the item under the pointer, else the highlighted one. + let mut hovered = None; + ui.vertical(|ui| { + ui.set_min_width(MIN_WIDTH); + for (i, item) in offer.items.iter().enumerate() { + let r = ui.selectable_label(i == selected, egui::RichText::new(item).monospace()); + // Taken as the button goes down, as in a code editor: the press already + // takes the focus from the box, and a click (down, then up a frame or more + // later) could find the list gone by then. A click still counts + // (assistive technology). + if r.clicked() || (r.hovered() && ui.input(|i| i.pointer.primary_pressed())) { + clicked = Some((offer.clone(), item.clone())); + } + if r.hovered() { + hovered = Some(i); + } + if i == selected && list.scrolled_to != Some(selected) { + r.scroll_to_me(None); + list.scrolled_to = Some(selected); } - }); - // Worked out each frame the list shows, for one command: a registry lookup and a - // short description split. Kept, it would need the id and the language as its - // key (the help is translated), for next to nothing saved. - let shown = hovered.filter(|_| pointer_leads).unwrap_or(selected); - if let Some(help) = offer.items.get(shown).and_then(|id| help(session, id)) { - // A gap, not a separator: in a row, egui's separator takes all the height it - // can, and would stretch the list down to the bottom of the screen. - ui.add_space(GAP); - draw_help(ui, &help, help_width); } }); + // Worked out each frame the list shows, for one command: a registry lookup and a + // short description split. Kept, it would need the id and the language as its + // key (the help is translated), for next to nothing saved. + let shown = hovered.filter(|_| pointer_leads).unwrap_or(selected); + if let Some(help) = offer.items.get(shown).and_then(|id| help(session, id)) { + // A gap, not a separator: in a row, egui's separator takes all the height it + // can, and would stretch the list down to the bottom of the screen. + ui.add_space(GAP); + draw_help(ui, &help, help_width); + } }); }); }); + }); if clicked.is_some() { list.open = None; list.rect = None; @@ -396,6 +488,61 @@ pub fn show( clicked } +/// After the list: with no list open, the help of the command whose parameters the cursor is in +/// (its line's `{…}`), floating by the cursor, so the signature and parameters stay in view while +/// they're typed. Not with text selected, a cursor out of view, the window left, or once Escape +/// closed it (until the cursor leaves those braces). +pub fn hint(ui: &Ui, box_id: Id, out: &TextEditOutput, visible: egui::Rect, script: &str, session: &rustgis_engine::Session) { + let mut list = load(ui, box_id); + let focused = out.response.response.has_focus() && ui.input(|i| i.focused); + let at = out.cursor_range.filter(|r| focused && list.open.is_none() && r.is_empty()).map(|r| r.primary.index.0); + // Its own hash of the script, not the list's: it mustn't depend on how `show` ran this frame. + let text = egui::util::hash(script); + // The help is translated, and a plugin's command comes and goes with its plugin. + let with = (crate::i18n::current().code(), rustgis_engine::plugins::revision()); + let kept = Worked::get(&list.hint.worked, at, text, &with); + let found = match kept { + Some(found) => found, + None => { + let found = at.and_then(|at| hint_at(script, at)).and_then(|h| Some((h.clone(), help(session, &h.id)?))); + list.hint.worked = Some(Worked { at, text, with, value: found.clone() }); + found + } + }; + // Out of the braces, what Escape closed may show again next time. + if found.is_none() { + list.hint.dismissed = None; + } + // Anchored at the line's `{` sideways, so it doesn't slide with each key typed; on the + // cursor's line, which it doesn't cover. + let galley_rect = |i: usize| out.galley.pos_from_cursor(CCursor::new(i)).translate(out.galley_pos.to_vec2()); + let shown = found.filter(|(h, _)| list.hint.dismissed.as_ref() != Some(h)).and_then(|(h, help)| { + let cursor = galley_rect(at?); + let brace = galley_rect(h.brace).left(); + let anchor = egui::Rect::from_x_y_ranges(brace..=brace, cursor.y_range()); + visible.intersects(cursor).then_some((anchor, h, help)) + }); + let Some((cursor, at_line, help)) = shown else { + list.hint.shown = None; + list.hint.rect = None; + store(ui, box_id, list); + return; + }; + let row = ui.text_style_height(&egui::TextStyle::Monospace) + ui.spacing().item_spacing.y + 4.0; + let screen = ui.ctx().content_rect(); + let width = (screen.width() - FRAME).clamp(0.0, HELP_WIDTH); + let size = list.hint.rect.map_or(egui::vec2(width + FRAME, row * (help.params.len() + 3) as f32), |r| r.size()); + let (corner, room) = place(cursor, size, screen, row * 3.0); + let area = egui::Area::new(box_id.with("complete_hint")).order(egui::Order::Foreground).fixed_pos(corner).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(room).auto_shrink([true, true]).show(ui, |ui| draw_help(ui, &help, width)); + }); + }); + list.hint.shown = Some(at_line); + list.hint.rect = Some(area.response.rect); + store(ui, box_id, list); +} + /// Puts `item` in place of the offer's word in `script`, the cursor after it, as one undo step of /// the box (its own undo takes the pick back), and gives the box the focus again. pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &str) { @@ -802,4 +949,59 @@ mod tests { let last = h.get_by_role_and_label(egui::accesskit::Role::Button, items.last().unwrap()).rect(); assert!(last.min.y >= 0.0 && last.max.y <= height, "the last item scrolled into view: {last:?}"); } + + /// The cursor is in a command's parameters inside its line's `{…}`: not before the brace, not + /// past the closing one, and a `}` in a string doesn't close it. + #[test] + fn hint_at_finds_the_command_whose_braces_hold_the_cursor() { + use super::hint_at; + let at = |s: &str| hint_at(s, s.chars().count()).map(|h| (h.line, h.id)); + assert_eq!(at(r#"labels.style {"size": 1"#), Some((0, "labels.style".into()))); + assert_eq!(at("map.zoomFull\nlabels.style {"), Some((13, "labels.style".into())), "its line's start"); + assert_eq!(hint_at("map.zoomFull\nlabels.style {", 27).map(|h| h.brace), Some(26), "its brace"); + assert_eq!(at(r#"labels.style {"x": "a}b", "#), Some((0, "labels.style".into())), "a brace in a string"); + assert_eq!(at(r#"labels.style {"size": 1}"#), None, "past the closing brace"); + assert_eq!(at("labels.style "), None, "before the brace"); + assert_eq!(at("# labels.style {"), None, "a comment"); + assert_eq!(at(r#"labels.style {"a": {"b": 1"#), Some((0, "labels.style".into())), "nested"); + assert!(hint_at("x", 99).is_none(), "a cursor past the text"); + } + + /// Typing a command's parameters keeps its help by the cursor; past the closing brace it's + /// gone, and Escape closes it until the cursor leaves the braces. + #[test] + fn the_help_stays_by_the_cursor_in_the_braces() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + let help = super::help(&h.state().session, "labels.style").unwrap(); + let sig = format!("{}{}", help.id, help.keys); + type_in(&mut h, "labels.style {"); + h.get_by_label(&sig); + type_in(&mut h, r#""size": 12"#); + h.get_by_label(&sig); + h.key_press(egui::Key::Escape); + h.run(); + assert!(h.query_by_label(&sig).is_none(), "Escape closes it"); + assert!(h.get_by_role(egui::accesskit::Role::MultilineTextInput).is_focused(), "and the box keeps the focus"); + type_in(&mut h, ", "); + assert!(h.query_by_label(&sig).is_none(), "not again in the same braces"); + type_in(&mut h, "}"); + assert!(h.query_by_label(&sig).is_none(), "past the closing brace"); + type_in(&mut h, "\nlabels.style {"); + h.get_by_label(&sig); + } + + /// The help by the cursor stays put sideways while the parameters are typed: it's anchored at + /// the line's `{`, not at the moving cursor. + #[test] + fn the_help_by_the_cursor_doesnt_slide() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + let help = super::help(&h.state().session, "labels.style").unwrap(); + let sig = format!("{}{}", help.id, help.keys); + type_in(&mut h, "labels.style {"); + let first = h.get_by_label(&sig).rect().left(); + type_in(&mut h, r#""size": 12, "bold": true"#); + assert!((h.get_by_label(&sig).rect().left() - first).abs() < 0.5, "it didn't move with the cursor"); + } } diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 8ed738380..bd821e134 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -229,7 +229,9 @@ both themes: `#` comments in green italics, the command id, and the JSON's strin its 10,000th line; any further are dimmed, as Run doesn't read them. Typing a line's first word opens a list of the commands it starts, under the cursor: ↑/↓ to move, Enter or Tab to accept, Escape to close, or a click. Beside it, the highlighted command's help: its signature (keys with `?` -may be left out), its name, its parameters one per line, and where it sits in the interface. +may be left out), its name, its parameters one per line, and where it sits in the interface. While +you type a command's parameters (the cursor inside its `{…}`), the same help stays by the cursor; +Escape closes it. ## ModelBuilder From 23d5ba3f75ed2ae79bf3168cd6f50e70245bc8f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Wed, 21 Oct 2026 20:00:35 -0500 Subject: [PATCH 012/297] feat(ui): add a command's parameters when it's accepted in the Script pane (#679) * feat(ui): add a command's parameters when it's accepted in the Script pane * refactor(ui): keep where the Script list was drawn and its size in one field --- crates/ui-egui/src/analysispane.rs | 4 +- crates/ui-egui/src/scriptcomplete.rs | 244 ++++++++++++++++++++------- docs/user-guide/analysis.md | 7 +- 3 files changed, 187 insertions(+), 68 deletions(-) diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 1b5e5c299..348654fea 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -1095,7 +1095,7 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { let box_id = egui::Id::new("script_text"); // The suggestion list's keys (#662), taken before the box sees them. if let Some((offer, item)) = crate::scriptcomplete::keys(ui, box_id, &app.workflow.script) { - crate::scriptcomplete::accept(ui, box_id, &mut app.workflow.script, &offer, &item); + crate::scriptcomplete::accept(ui, box_id, &mut app.workflow.script, &offer, &item, &app.session); } let out = egui::ScrollArea::vertical().id_salt("script_box").max_height(room).auto_shrink([false, true]).show(ui, |ui| { egui::TextEdit::multiline(&mut app.workflow.script) @@ -1111,7 +1111,7 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { let clicked = crate::scriptcomplete::show(ui, box_id, &out.inner, out.inner_rect, &app.workflow.script, &app.session); crate::scriptcomplete::hint(ui, box_id, &out.inner, out.inner_rect, &app.workflow.script, &app.session); if let Some((offer, item)) = clicked { - crate::scriptcomplete::accept(ui, box_id, &mut app.workflow.script, &offer, &item); + crate::scriptcomplete::accept(ui, box_id, &mut app.workflow.script, &offer, &item, &app.session); } ui.horizontal(|ui| { if ui.button(tl!("Run")).clicked() { diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index d56f52875..51a1984b1 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -101,6 +101,26 @@ fn place(cursor: egui::Rect, size: egui::Vec2, screen: egui::Rect, min: f32) -> (egui::pos2(x, y), room) } +/// A popup at `corner`, its content scrolling past `room` high: where it was drawn, and its whole +/// size, the part scrolled out of view included, for `place` to choose a side next frame by what +/// it holds, not by what last fit (it would stay on a side too short for it). +fn popup(ui: &Ui, id: Id, corner: egui::Pos2, room: f32, content: impl FnOnce(&mut Ui)) -> (egui::Rect, egui::Vec2) { + let area = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(corner).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()) + .show(ui, |ui| { + // The area starts from its last size, so after a short list the scroll area would get + // only that room back, scrolling with room to spare: ask for the full height whenever + // it scrolls, as Command Search does (#255). Short content still shrinks it to fit. + let out = + egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).auto_shrink([true, true]).show(ui, content); + (out.content_size.y - out.inner_rect.height()).max(0.0) + }) + .inner + }); + let rect = area.response.rect; + (rect, rect.size() + egui::vec2(0.0, area.inner)) +} + /// The ids in `ids` (the commands a script can run, `script_ids`) that complete `word`, typed /// from char `start` to `end`, case aside; the id typed whole isn't offered again. fn complete(start: usize, end: usize, word: &str, ids: &[String]) -> Option { @@ -173,6 +193,31 @@ pub fn help(session: &rustgis_engine::Session, id: &str) -> Option { Some(Help { id: id.to_string(), keys, label, about, params: params.iter().map(|p| p.text.to_string()).collect(), location }) } +/// The parameter description of command `id`: a built-in command's, or an enabled plugin's. +fn params_of(session: &rustgis_engine::Session, id: &str) -> Option { + match session.registry.get(id) { + Some(c) => Some(c.params.to_string()), + None => rustgis_engine::plugins::command(id).map(|c| c.decl.params), + } +} + +/// What accepting a command whose parameters `desc` describes puts after its id, and where the +/// cursor goes in it (chars): its required keys, each with its value to type, the cursor at the +/// first (` {"lon": |, "lat": }`); empty braces when every key may be left out (` {|}`); nothing +/// for a command with no parameters, or a description that isn't an object (no keys to start +/// from). +pub fn template(desc: &str) -> (String, usize) { + let params = rustgis_engine::param_list(desc).unwrap_or_default(); + if params.is_empty() { + return (String::new(), 0); + } + let required: Vec = params.iter().filter(|p| !p.optional).map(|p| format!("\"{}\": ", p.key)).collect(); + let Some(first) = required.first() else { return (" {}".to_string(), 2) }; + // After ` {` and the first key. + let at = 2 + first.chars().count(); + (format!(" {{{}}}", required.join(", ")), at) +} + /// The help's widest line, so a long signature or parameter wraps rather than widen the list /// without end. const HELP_WIDTH: f32 = 360.0; @@ -228,8 +273,9 @@ struct List { /// The offer Escape closed: not shown again until what's typed changes. dismissed: Option, /// Where the list was drawn last frame: a press on it takes the focus from the box (a click - /// elsewhere), so the list stays while the pointer is on it, for the press to reach it. - rect: Option, + /// elsewhere), so the list stays while the pointer is on it, for the press to reach it. And + /// its whole size, scroll aside (`popup`): where it goes. + drawn: Option<(egui::Rect, egui::Vec2)>, /// Whether the help follows the item under the pointer (it moved last) or the highlighted one /// (an arrow key moved it last): a pointer resting on the list mustn't hold the help while /// the arrows move the highlight. @@ -266,8 +312,8 @@ struct HintState { shown: Option, /// What Escape closed: not shown again until the cursor leaves those braces. dismissed: Option, - /// Where it was drawn last frame. - rect: Option, + /// Its whole size last frame, scroll aside (`popup`): where it goes. + size: Option, /// What it was worked out to show, for which cursor, script (its hash), language (the help is /// translated) and plugins' revision (a plugin's command comes and goes with its plugin): not /// again each frame the cursor blinks. @@ -306,7 +352,7 @@ pub fn keys(ui: &Ui, box_id: Id, script: &str) -> Option<(Offer, String)> { if list.open.is_none() && list.hint.shown.is_some() && ui.memory(|m| m.has_focus(box_id)) { if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { list.hint.dismissed = list.hint.shown.take(); - list.hint.rect = None; + list.hint.size = None; store(ui, box_id, list); } return None; @@ -367,12 +413,13 @@ pub fn show( if let Some((offer, _)) = list.open.take() { list.dismissed = Some(offer); } - list.rect = None; + list.drawn = None; store(ui, box_id, list); return None; } // A press on the list: only then does it outlive the box's focus. - let on_list = list.rect.is_some_and(|r| ui.input(|i| i.pointer.any_down() && i.pointer.hover_pos().is_some_and(|p| r.contains(p)))); + let on_list = + list.drawn.is_some_and(|(r, _)| ui.input(|i| i.pointer.any_down() && i.pointer.hover_pos().is_some_and(|p| r.contains(p)))); let offer = if out.response.response.has_focus() { // A cursor, not a selection: with text selected, Enter and Tab act on it as usual. let at = out.cursor_range.filter(|r| r.is_empty()).map(|r| r.primary.index.0); @@ -399,7 +446,7 @@ pub fn show( // Closed with Escape: not again for the same word. if offer.is_some() && list.dismissed == offer { list.open = None; - list.rect = None; + list.drawn = None; store(ui, box_id, list); return None; } @@ -409,7 +456,7 @@ pub fn show( let cursor = offer.as_ref().map(|o| out.galley.pos_from_cursor(CCursor::new(o.end)).translate(out.galley_pos.to_vec2())); let (Some(offer), Some(cursor)) = (offer, cursor.filter(|c| visible.intersects(*c))) else { list.open = None; - list.rect = None; + list.drawn = None; store(ui, box_id, list); return None; }; @@ -427,7 +474,7 @@ pub fn show( // sideways. Its size is last frame's (a long plugin id, another font), estimated only on the // first frame it shows. let row = ui.text_style_height(&egui::TextStyle::Monospace) + ui.spacing().item_spacing.y + 4.0; - let size = list.rect.map_or(egui::vec2(MIN_WIDTH + 16.0, row * offer.items.len() as f32 + 12.0), |r| r.size()); + let size = list.drawn.map_or(egui::vec2(MIN_WIDTH + 16.0, row * offer.items.len() as f32 + 12.0), |(_, s)| s); let screen = ui.ctx().content_rect(); let (corner, room) = place(cursor, size, screen, row * 3.0); // The help beside the list in the room the screen leaves, or under it when that's too narrow @@ -437,52 +484,48 @@ pub fn show( if beside >= HELP_MIN_WIDTH { (true, beside) } else { (false, (screen.width() - FRAME).clamp(0.0, HELP_WIDTH)) }; let layout = if beside { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) }; let mut clicked = None; - let area = egui::Area::new(box_id.with("complete_list")).order(egui::Order::Foreground).fixed_pos(corner).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - egui::ScrollArea::vertical().max_height(room).auto_shrink([true, true]).show(ui, |ui| { - ui.with_layout(layout, |ui| { - // The help follows the item under the pointer, else the highlighted one. - let mut hovered = None; - ui.vertical(|ui| { - ui.set_min_width(MIN_WIDTH); - for (i, item) in offer.items.iter().enumerate() { - let r = ui.selectable_label(i == selected, egui::RichText::new(item).monospace()); - // Taken as the button goes down, as in a code editor: the press already - // takes the focus from the box, and a click (down, then up a frame or more - // later) could find the list gone by then. A click still counts - // (assistive technology). - if r.clicked() || (r.hovered() && ui.input(|i| i.pointer.primary_pressed())) { - clicked = Some((offer.clone(), item.clone())); - } - if r.hovered() { - hovered = Some(i); - } - if i == selected && list.scrolled_to != Some(selected) { - r.scroll_to_me(None); - list.scrolled_to = Some(selected); - } - } - }); - // Worked out each frame the list shows, for one command: a registry lookup and a - // short description split. Kept, it would need the id and the language as its - // key (the help is translated), for next to nothing saved. - let shown = hovered.filter(|_| pointer_leads).unwrap_or(selected); - if let Some(help) = offer.items.get(shown).and_then(|id| help(session, id)) { - // A gap, not a separator: in a row, egui's separator takes all the height it - // can, and would stretch the list down to the bottom of the screen. - ui.add_space(GAP); - draw_help(ui, &help, help_width); + let (area, size) = popup(ui, box_id.with("complete_list"), corner, room, |ui| { + ui.with_layout(layout, |ui| { + // The help follows the item under the pointer, else the highlighted one. + let mut hovered = None; + ui.vertical(|ui| { + ui.set_min_width(MIN_WIDTH); + for (i, item) in offer.items.iter().enumerate() { + let r = ui.selectable_label(i == selected, egui::RichText::new(item).monospace()); + // Taken as the button goes down, as in a code editor: the press already + // takes the focus from the box, and a click (down, then up a frame or more + // later) could find the list gone by then. A click still counts + // (assistive technology). + if r.clicked() || (r.hovered() && ui.input(|i| i.pointer.primary_pressed())) { + clicked = Some((offer.clone(), item.clone())); } - }); + if r.hovered() { + hovered = Some(i); + } + if i == selected && list.scrolled_to != Some(selected) { + r.scroll_to_me(None); + list.scrolled_to = Some(selected); + } + } }); + // Worked out each frame the list shows, for one command: a registry lookup and a + // short description split. Kept, it would need the id and the language as its + // key (the help is translated), for next to nothing saved. + let shown = hovered.filter(|_| pointer_leads).unwrap_or(selected); + if let Some(help) = offer.items.get(shown).and_then(|id| help(session, id)) { + // A gap, not a separator: in a row, egui's separator takes all the height it + // can, and would stretch the list down to the bottom of the screen. + ui.add_space(GAP); + draw_help(ui, &help, help_width); + } }); }); if clicked.is_some() { list.open = None; - list.rect = None; + list.drawn = None; } else { list.open = Some((offer, selected)); - list.rect = Some(area.response.rect); + list.drawn = Some((area, size)); } store(ui, box_id, list); clicked @@ -524,32 +567,33 @@ pub fn hint(ui: &Ui, box_id: Id, out: &TextEditOutput, visible: egui::Rect, scri }); let Some((cursor, at_line, help)) = shown else { list.hint.shown = None; - list.hint.rect = None; + list.hint.size = None; store(ui, box_id, list); return; }; let row = ui.text_style_height(&egui::TextStyle::Monospace) + ui.spacing().item_spacing.y + 4.0; let screen = ui.ctx().content_rect(); let width = (screen.width() - FRAME).clamp(0.0, HELP_WIDTH); - let size = list.hint.rect.map_or(egui::vec2(width + FRAME, row * (help.params.len() + 3) as f32), |r| r.size()); + let size = list.hint.size.unwrap_or(egui::vec2(width + FRAME, row * (help.params.len() + 3) as f32)); let (corner, room) = place(cursor, size, screen, row * 3.0); - let area = egui::Area::new(box_id.with("complete_hint")).order(egui::Order::Foreground).fixed_pos(corner).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - egui::ScrollArea::vertical().max_height(room).auto_shrink([true, true]).show(ui, |ui| draw_help(ui, &help, width)); - }); - }); + let (_, size) = popup(ui, box_id.with("complete_hint"), corner, room, |ui| draw_help(ui, &help, width)); list.hint.shown = Some(at_line); - list.hint.rect = Some(area.response.rect); + list.hint.size = Some(size); store(ui, box_id, list); } -/// Puts `item` in place of the offer's word in `script`, the cursor after it, as one undo step of -/// the box (its own undo takes the pick back), and gives the box the focus again. -pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &str) { +/// Puts `item` in place of the offer's word in `script`, with its parameters' `template` when +/// nothing follows the word on its line (a line that has parameters keeps them: only its id +/// changes), the cursor where the first value goes; as one undo step of the box (its own undo +/// takes the pick back), and gives the box the focus again. +pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &str, session: &rustgis_engine::Session) { let chars: Vec = script.chars().collect(); let (Some(before), Some(after)) = (chars.get(..offer.start), chars.get(offer.end..)) else { return }; + let bare = after.iter().take_while(|c| **c != '\n').all(|c| c.is_whitespace()); + let (tail, at) = if bare { params_of(session, item).map(|d| template(&d)).unwrap_or_default() } else { Default::default() }; let mut new: String = before.iter().collect(); new.push_str(item); + new.push_str(&tail); new.extend(after); let mut state = TextEditState::load(ui.ctx(), box_id).unwrap_or_default(); let mut undoer = state.undoer(); @@ -557,7 +601,7 @@ pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &st undoer.add_undo(&(was, script.clone())); state.set_undoer(undoer); *script = new; - state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(offer.start + item.chars().count())))); + state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(offer.start + item.chars().count() + at)))); state.store(ui.ctx(), box_id); ui.memory_mut(|m| m.request_focus(box_id)); // What was worked out was for the text before the pick. @@ -649,7 +693,7 @@ mod tests { h.get_by_label("labels.style"); h.key_press(egui::Key::Enter); h.run(); - assert_eq!(h.state().workflow.script, "labels.style"); + assert_eq!(h.state().workflow.script, "labels.style {}", "with its parameters' braces"); assert!(h.query_by_label("labels.style").is_none(), "the list closed"); } @@ -662,8 +706,10 @@ mod tests { h.run(); h.key_press(egui::Key::Tab); h.run(); - let second = super::offer("labels.", 7, &super::script_ids(&h.state().session)).unwrap().items[1].clone(); - assert_eq!(h.state().workflow.script, second, "the second item, and no tab"); + let session = &h.state().session; + let second = super::offer("labels.", 7, &super::script_ids(session)).unwrap().items[1].clone(); + let (template, _) = super::template(&super::params_of(session, &second).unwrap()); + assert_eq!(h.state().workflow.script, format!("{second}{template}"), "the second item, and no tab"); } /// Escape closes the list without touching the text, and it stays closed for that word. @@ -719,7 +765,57 @@ mod tests { let now = (state.cursor.char_range().unwrap(), h.state().workflow.script.clone()); let mut undoer = state.undoer(); let back = undoer.undo(&now).map(|(_, text)| text.clone()); - assert_eq!(back.as_deref(), Some("labels.sty")); + assert_eq!(back.as_deref(), Some("labels.sty"), "the id and its template together"); + } + + /// The template: the required keys, the cursor at the first one's value; empty braces when + /// every key may be left out; nothing with no keys. + #[test] + fn the_template_starts_at_the_first_required_key() { + use super::template; + let go_to = r#"{"lon" | "x": number, "lat" | "y": number, "scale"?: number}"#; + assert_eq!(template(go_to), (r#" {"lon": , "lat": }"#.to_string(), 9), "every required key, the first of its alternatives"); + assert_eq!(template(r#"{"size"?: number, "field": string}"#), (r#" {"field": }"#.to_string(), 11), "not the first key"); + assert_eq!(template(r#"{"size"?: number}"#), (" {}".to_string(), 2)); + assert_eq!(template("{}"), (String::new(), 0)); + assert_eq!(template("a path"), (String::new(), 0), "a description that isn't an object"); + assert_eq!(template(r#"{"größe": number}"#), (r#" {"größe": }"#.to_string(), 11), "chars, not bytes"); + } + + /// Accepting a command with a required key puts the cursor where its value goes, and the + /// help by the cursor shows at once; a line further on after multibyte text. + #[test] + fn accepting_leaves_the_cursor_at_the_first_value() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + type_in(&mut h, "# ñandú\nmap.goToX"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, "# ñandú\nmap.goToXY {\"lon\": , \"lat\": }"); + let help = super::help(&h.state().session, "map.goToXY").unwrap(); + h.get_by_label(&format!("{}{}", help.id, help.keys)); + type_in(&mut h, "2"); + assert_eq!(h.state().workflow.script, "# ñandú\nmap.goToXY {\"lon\": 2, \"lat\": }"); + } + + /// A command with no parameters gets nothing after its id; a line that already has + /// parameters keeps them, and only its id changes. + #[test] + fn a_line_with_parameters_keeps_them() { + let mut h = pane(); + type_in(&mut h, "map.zoomF"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, "map.zoomFull"); + let mut h = pane(); + type_in(&mut h, r#"map.goTo {"lat": 1}"#); + for _ in 0..r#" {"lat": 1}"#.len() { + h.key_press(egui::Key::ArrowLeft); + } + type_in(&mut h, "X"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, r#"map.goToXY {"lat": 1}"#); } /// Moving the cursor, with no text changed, works the list out again: into the word it @@ -950,6 +1046,26 @@ mod tests { assert!(last.min.y >= 0.0 && last.max.y <= height, "the last item scrolled into view: {last:?}"); } + /// A list that was short once (a single item) grows back for a longer one: egui lays an area + /// out in last frame's size, and the list's scroll would keep it short, scrolling. + #[test] + fn a_short_list_grows_back() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + type_in(&mut h, "labels.sty"); + h.get_by_label("labels.style"); + for _ in 0.."sty".len() { + h.key_press(egui::Key::Backspace); + h.run(); + } + let items = super::offer("labels.", 7, &super::script_ids(&h.state().session)).unwrap().items; + assert!(items.len() > 3, "a longer list: {items:?}"); + let list = h.ctx.data(|d| d.get_temp::(egui::Id::new("script_text").with("complete"))).unwrap(); + let (shown, _) = list.drawn.unwrap(); + let last = h.get_by_role_and_label(egui::accesskit::Role::Button, items.last().unwrap()).rect(); + assert!(last.max.y <= shown.max.y, "every item in view, no scroll: {last:?} in {shown:?}"); + } + /// The cursor is in a command's parameters inside its line's `{…}`: not before the brace, not /// past the closing one, and a `}` in a string doesn't close it. #[test] diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index bd821e134..5cf68af63 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -229,8 +229,11 @@ both themes: `#` comments in green italics, the command id, and the JSON's strin its 10,000th line; any further are dimmed, as Run doesn't read them. Typing a line's first word opens a list of the commands it starts, under the cursor: ↑/↓ to move, Enter or Tab to accept, Escape to close, or a click. Beside it, the highlighted command's help: its signature (keys with `?` -may be left out), its name, its parameters one per line, and where it sits in the interface. While -you type a command's parameters (the cursor inside its `{…}`), the same help stays by the cursor; +may be left out), its name, its parameters one per line, and where it sits in the interface. +Accepting a command on a line of its own adds its braces: with its required keys ready for their +values, the cursor at the first (`map.goToXY {"lon": , "lat": }`), or empty with the cursor inside +when every key may be left out; a command with no parameters gets none, and a line that has +parameters keeps them. While you type a command's parameters (the cursor inside its `{…}`), the same help stays by the cursor; Escape closes it. ## ModelBuilder From 293aeb258c49c58bc5bdc9d9feabc24b59a9efc8 Mon Sep 17 00:00:00 2001 From: quanguet0409 Date: Thu, 22 Oct 2026 10:26:50 +0700 Subject: [PATCH 013/297] =?UTF-8?q?feat(analysis):=20add=20Ordinary=20Krig?= =?UTF-8?q?ing=20interpolation=20tool=20with=20variogra=E2=80=A6=20(#660)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(analysis): add Ordinary Kriging interpolation tool with variogram models and uncertainty variance * refactor(analysis): address PR review comments for Ordinary Kriging tool * refactor(analysis): validate nugget, sill, and range parameter types and bounds in kriging * refactor(analysis): improve Kriging help text, deduplication averaging, and sample count check * refactor(analysis): normalize semivariances during Kriging solve and scan all x-near duplicates * test(analysis): verify variance band scaling and assert merged value for interleaved near-duplicates --- crates/analysis/src/kriging.rs | 526 +++++++++++++++++++++++++++++++++ crates/analysis/src/lib.rs | 1 + crates/analysis/src/tools.rs | 339 +++++++++++++++++++++ 3 files changed, 866 insertions(+) create mode 100644 crates/analysis/src/kriging.rs diff --git a/crates/analysis/src/kriging.rs b/crates/analysis/src/kriging.rs new file mode 100644 index 000000000..cec80fab5 --- /dev/null +++ b/crates/analysis/src/kriging.rs @@ -0,0 +1,526 @@ +//! Ordinary Kriging spatial interpolation. +//! +//! Ordinary Kriging calculates the best linear unbiased estimator (BLUE) of a spatial process +//! across a raster grid from scattered point measurements. It models spatial autocorrelation +//! via a theoretical semivariogram (Spherical, Exponential, or Gaussian) and solves a local +//! linear system of Kriging equations for each grid cell. +//! +//! Output is a 2-band raster: +//! - Band 1: Predicted value surface +//! - Band 2: Estimation variance (uncertainty/error surface) + +use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; +use rustgis_geom::{Coord, Extent}; + +use crate::rasterops::{MAX_PIXELS, NODATA}; + +/// Maximum cell-point pairs Kriging computes (about a second's work). +pub const MAX_KRIGING_WORK: usize = 40_000_000; + +/// Theoretical semivariogram models for spatial autocorrelation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VariogramModel { + /// Spherical model: reaches the sill at range `a`. Most commonly used in GIS. + Spherical, + /// Exponential model: approaches sill asymptotically (95% at range `a`). + Exponential, + /// Gaussian model: parabolic behavior near the origin, suitable for extremely smooth phenomena. + Gaussian, +} + +impl std::str::FromStr for VariogramModel { + type Err = (); + + fn from_str(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "spherical" => Ok(Self::Spherical), + "exponential" => Ok(Self::Exponential), + "gaussian" => Ok(Self::Gaussian), + _ => Err(()), + } + } +} + +impl VariogramModel { + pub fn parse(s: &str) -> Option { + s.parse().ok() + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Spherical => "Spherical", + Self::Exponential => "Exponential", + Self::Gaussian => "Gaussian", + } + } +} + +/// Parameters defining a theoretical semivariogram γ(h). +#[derive(Clone, Copy, Debug)] +pub struct VariogramParams { + pub model: VariogramModel, + /// Nugget effect c₀: micro-scale variance and measurement error at h -> 0. + pub nugget: f64, + /// Sill (c₀ + c): asymptotic variance at large distances. + pub sill: f64, + /// Range a: spatial correlation distance (lag) in degrees (longitude/latitude). + /// Semivariance reaches or approaches the sill at this distance. + pub range: f64, +} + +impl VariogramParams { + /// Computes semivariance γ(h) for distance h. + pub fn semivariance(&self, h: f64) -> f64 { + if h <= 0.0 || !h.is_finite() { + return 0.0; + } + let a = self.range.max(1e-9); + let c0 = self.nugget.max(0.0); + let c = (self.sill - c0).max(0.0); + + match self.model { + VariogramModel::Spherical => { + if h >= a { + c0 + c + } else { + let hr = h / a; + c0 + c * (1.5 * hr - 0.5 * hr * hr * hr) + } + } + VariogramModel::Exponential => { + let hr = h / a; + c0 + c * (1.0 - (-3.0 * hr).exp()) + } + VariogramModel::Gaussian => { + let hr = h / a; + c0 + c * (1.0 - (-3.0 * hr * hr).exp()) + } + } + } +} + +/// Preallocated reusable workspace buffers for solving the Kriging linear system. +#[derive(Clone, Debug)] +pub struct KrigingBuffers { + a_mat: Vec, + b_vec: Vec, + solution: Vec, +} + +impl Default for KrigingBuffers { + fn default() -> Self { + Self::new() + } +} + +impl KrigingBuffers { + pub fn new() -> Self { + const MAX_M: usize = 33; + Self { a_mat: vec![0.0; MAX_M * MAX_M], b_vec: vec![0.0; MAX_M], solution: vec![0.0; MAX_M] } + } +} + +/// Estimates sensible default variogram parameters from sample points and extent. +pub fn auto_variogram(points: &[(Coord, f64)], extent: &Extent, model: VariogramModel) -> VariogramParams { + let n = points.len(); + if n == 0 { + return VariogramParams { model, nugget: 0.0, sill: 1.0, range: 1.0 }; + } + let mean = points.iter().map(|(_, v)| *v).sum::() / (n as f64); + let var = points.iter().map(|(_, v)| (v - mean).powi(2)).sum::() / ((n.max(2) - 1) as f64); + let sill = if var.is_finite() && var > 1e-12 { var } else { 1.0 }; + let diag = (extent.width().powi(2) + extent.height().powi(2)).sqrt(); + let range = if diag.is_finite() && diag > 1e-6 { diag / 3.0 } else { 1.0 }; + VariogramParams { model, nugget: 0.0, sill, range } +} + +/// Solves a linear system A * x = b of size `n` with Gaussian elimination and partial pivoting. +/// `a` is stored row-major with size `n * n`. +/// Returns false if the matrix is singular or ill-conditioned. +fn solve_linear_system(n: usize, a: &mut [f64], b: &mut [f64], x: &mut [f64]) -> bool { + if n == 0 || a.len() < n * n || b.len() < n || x.len() < n { + return false; + } + for col in 0..n { + let mut pivot_row = col; + let mut max_val = a.get(col * n + col).copied().unwrap_or(0.0).abs(); + for row in (col + 1)..n { + let val = a.get(row * n + col).copied().unwrap_or(0.0).abs(); + if val > max_val { + max_val = val; + pivot_row = row; + } + } + if !max_val.is_finite() || max_val < 1e-12 { + return false; + } + if pivot_row != col { + for j in 0..n { + let idx1 = col * n + j; + let idx2 = pivot_row * n + j; + a.swap(idx1, idx2); + } + b.swap(col, pivot_row); + } + let pivot = match a.get(col * n + col).copied() { + Some(p) if p.abs() >= 1e-12 => p, + _ => return false, + }; + for row in (col + 1)..n { + let factor = a.get(row * n + col).copied().unwrap_or(0.0) / pivot; + if !factor.is_finite() { + return false; + } + for j in col..n { + let col_val = a.get(col * n + j).copied().unwrap_or(0.0); + if let Some(target) = a.get_mut(row * n + j) { + *target -= factor * col_val; + } + } + let b_col = b.get(col).copied().unwrap_or(0.0); + if let Some(b_target) = b.get_mut(row) { + *b_target -= factor * b_col; + } + } + } + // Back substitution + for i in (0..n).rev() { + let mut sum = b.get(i).copied().unwrap_or(0.0); + for j in (i + 1)..n { + let a_val = a.get(i * n + j).copied().unwrap_or(0.0); + let x_val = x.get(j).copied().unwrap_or(0.0); + sum -= a_val * x_val; + } + let diag = a.get(i * n + i).copied().unwrap_or(0.0); + if !diag.is_finite() || diag.abs() < 1e-12 { + return false; + } + if let Some(x_target) = x.get_mut(i) { + *x_target = sum / diag; + } + } + true +} + +/// Performs Ordinary Kriging interpolation for a query point given its nearest neighbors. +/// `near`: slice of `(distance, value, point_coord)` sorted or unsorted. +/// `kx`: cosine of query latitude for metric consistency across both system sides. +/// `buffers`: reusable linear system buffers to eliminate per-pixel heap allocations. +/// Returns `(predicted_value, estimation_variance)`. +pub fn ordinary_kriging_point( + query: Coord, + near: &[(f64, f64, Coord)], + params: &VariogramParams, + kx: f64, + buffers: &mut KrigingBuffers, +) -> (f64, f64) { + if near.is_empty() { + return (0.0, params.sill); + } + // Exact interpolator: if on or extremely close to a sample point, return its value exactly. + // Check both metric distance and raw coordinate proximity so high latitudes don't false-trigger. + if let Some((_, exact_val, _)) = + near.iter().find(|(d, _, p)| *d < 1e-10 && (p.x - query.x).abs() < 1e-8 && (p.y - query.y).abs() < 1e-8) + { + return (*exact_val, 0.0); + } + + let k = near.len().min(32); + let m = k + 1; // system size (k weights + 1 Lagrange multiplier) + + // Solve with gamma / scale so the pivot tolerance does not depend on the data's units. + // Weights are invariant; the Lagrange multiplier scales by `scale`. + let scale = { + let s = params.sill.max(params.nugget); + if s.is_finite() && s > 0.0 { s } else { 1.0 } + }; + + for i in 0..k { + let (di, _, pi) = match near.get(i) { + Some(n) => *n, + None => continue, + }; + for j in 0..k { + let (_, _, pj) = match near.get(j) { + Some(n) => *n, + None => continue, + }; + let dx = (pi.x - pj.x) * kx; + let dy = pi.y - pj.y; + let hij = (dx * dx + dy * dy).sqrt(); + let gamma = params.semivariance(hij); + if let Some(cell) = buffers.a_mat.get_mut(i * m + j) { + *cell = gamma / scale; + } + } + // Constraint: sum(w_i) = 1 + if let Some(cell) = buffers.a_mat.get_mut(i * m + k) { + *cell = 1.0; + } + if let Some(cell) = buffers.a_mat.get_mut(k * m + i) { + *cell = 1.0; + } + // Right hand side: gamma(h_i0) / scale + let gamma_0 = params.semivariance(di); + if let Some(b_val) = buffers.b_vec.get_mut(i) { + *b_val = gamma_0 / scale; + } + } + // A_mat[k, k] = 0.0 + if let Some(cell) = buffers.a_mat.get_mut(k * m + k) { + *cell = 0.0; + } + // b_vec[k] = 1.0 + if let Some(b_val) = buffers.b_vec.get_mut(k) { + *b_val = 1.0; + } + + if solve_linear_system(m, &mut buffers.a_mat[..m * m], &mut buffers.b_vec[..m], &mut buffers.solution[..m]) { + let mut est = 0.0; + let mut variance = buffers.solution.get(k).copied().unwrap_or(0.0) * scale; // Lagrange multiplier μ + + for i in 0..k { + let w = buffers.solution.get(i).copied().unwrap_or(0.0); + let (_, val, _) = near.get(i).copied().unwrap_or((0.0, 0.0, query)); + est += w * val; + let gamma_0 = params.semivariance(near.get(i).map(|(d, _, _)| *d).unwrap_or(0.0)); + variance += w * gamma_0; + } + + // Clamp negative numerical rounding in variance + let variance = if variance.is_finite() && variance > 0.0 { variance } else { 0.0 }; + (est, variance) + } else { + // Fallback to IDW when the matrix is singular (e.g. collinear points or duplicate coords) + let nearest = near.iter().map(|(d, _, _)| *d).fold(f64::INFINITY, f64::min); + let (num, den) = near.iter().fold((0.0, 0.0), |(n, d), (dist, val, _)| { + let wgt = if *dist > 1e-12 { (nearest / dist).powi(2) } else { 1.0 }; + (n + wgt * val, d + wgt) + }); + let fallback_est = if den > 0.0 { num / den } else { near.first().map(|(_, v, _)| *v).unwrap_or(0.0) }; + (fallback_est, params.sill) + } +} + +/// Ordinary Kriging interpolation of point values onto a longitude/latitude raster grid. +/// Returns a 2-band raster: +/// - Band 1: Prediction +/// - Band 2: Variance +pub fn kriging( + points: &[(Coord, f64)], + extent: &Extent, + cell_deg: f64, + params: &VariogramParams, + neighbours: usize, + name: &str, +) -> Result { + if points.len() < 2 { + return Err("kriging needs at least two points with values".into()); + } + if !(cell_deg.is_finite() && cell_deg > 0.0) { + return Err("the cell size must be positive".into()); + } + if extent.is_empty() { + return Err("the area to interpolate over is empty".into()); + } + let (w, h) = ((extent.width() / cell_deg).ceil().max(1.0) as usize, (extent.height() / cell_deg).ceil().max(1.0) as usize); + if w.saturating_mul(h) > MAX_PIXELS / 4 { + return Err(format!("{w} × {h} cells is too many; use a larger cell size")); + } + let k = if neighbours == 0 { 12.min(points.len()) } else { neighbours.clamp(2, points.len().min(32)) }; + let k3 = (k * k * k) / 3; + let work_per_cell = points.len().saturating_add(k3); + if w.saturating_mul(h).saturating_mul(work_per_cell) > MAX_KRIGING_WORK { + return Err(format!("{w} × {h} cells from {} points is too much work; use a larger cell size", points.len())); + } + + let mut pred_data = Vec::with_capacity(w * h); + let mut var_data = Vec::with_capacity(w * h); + let mut near: Vec<(f64, f64, Coord)> = Vec::with_capacity(points.len()); + let mut buffers = KrigingBuffers::new(); + + for row in 0..h { + let kx = (extent.max_y - (row as f64 + 0.5) * cell_deg).to_radians().cos().abs().max(1e-6); + for col in 0..w { + let c = Coord::new(extent.min_x + (col as f64 + 0.5) * cell_deg, extent.max_y - (row as f64 + 0.5) * cell_deg); + near.clear(); + near.extend(points.iter().map(|(p, v)| { + let dx = (p.x - c.x) * kx; + let dy = p.y - c.y; + let dist = (dx * dx + dy * dy).sqrt(); + (dist, *v, *p) + })); + + if k < near.len() { + near.select_nth_unstable_by(k - 1, |a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + near.truncate(k); + } + + let (val, var) = ordinary_kriging_point(c, &near, params, kx, &mut buffers); + pred_data.push(if val.is_finite() { val as f32 } else { NODATA }); + var_data.push(if var.is_finite() { var as f32 } else { NODATA }); + } + } + + let gt = GeoTransform { origin_x: extent.min_x, origin_y: extent.max_y, pixel_width: cell_deg, pixel_height: -cell_deg }; + Raster::new(name, w as u32, h as u32, vec![pred_data, var_data], gt, Crs::LonLat, Some(NODATA as f64), ColorHint::Gray, "Float32") + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exact_interpolation_at_sample_points() { + let sample_pts = vec![ + (Coord::new(0.0, 0.0), 10.0), + (Coord::new(10.0, 0.0), 20.0), + (Coord::new(0.0, 10.0), 30.0), + (Coord::new(10.0, 10.0), 40.0), + (Coord::new(5.0, 5.0), 25.0), + ]; + let extent = Extent::new(0.0, 0.0, 10.0, 10.0); + let params = auto_variogram(&sample_pts, &extent, VariogramModel::Spherical); + let mut buffers = KrigingBuffers::new(); + + for (pt, expected_val) in &sample_pts { + let mut near: Vec<(f64, f64, Coord)> = + sample_pts.iter().map(|(p, v)| (((p.x - pt.x).powi(2) + (p.y - pt.y).powi(2)).sqrt(), *v, *p)).collect(); + near.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + + let (val, var) = ordinary_kriging_point(*pt, &near, ¶ms, 1.0, &mut buffers); + assert!((val - expected_val).abs() < 1e-6, "Kriging at sample point {:?} gave {}, expected {}", pt, val, expected_val); + assert!(var.abs() < 1e-6, "Variance at sample point {:?} was {}, expected 0.0", pt, var); + } + } + + #[test] + fn test_planar_surface_reconstruction_accuracy() { + // True function: z = 5.0 + 2.0*x + 3.0*y + let f = |x: f64, y: f64| 5.0 + 2.0 * x + 3.0 * y; + + let mut pts = Vec::new(); + for i in 0..5 { + for j in 0..5 { + let x = i as f64 * 2.0; + let y = j as f64 * 2.0; + pts.push((Coord::new(x, y), f(x, y))); + } + } + let _extent = Extent::new(0.0, 0.0, 8.0, 8.0); + let params = VariogramParams { model: VariogramModel::Spherical, nugget: 0.0, sill: 50.0, range: 12.0 }; + let mut buffers = KrigingBuffers::new(); + + // Test at intermediate points not in the training set + let test_coords = vec![Coord::new(1.0, 1.0), Coord::new(3.0, 3.0), Coord::new(5.0, 1.0), Coord::new(3.0, 5.0)]; + + for query in test_coords { + let mut near: Vec<(f64, f64, Coord)> = + pts.iter().map(|(p, v)| (((p.x - query.x).powi(2) + (p.y - query.y).powi(2)).sqrt(), *v, *p)).collect(); + near.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + + // With the 4 closest symmetric neighbors, Ordinary Kriging reproduces the plane with sub-millimeter precision + let mut near_4 = near.clone(); + near_4.truncate(4); + let (val_4, _) = ordinary_kriging_point(query, &near_4, ¶ms, 1.0, &mut buffers); + let expected = f(query.x, query.y); + assert!((val_4 - expected).abs() < 1e-4, "4-neighbor query at {:?}: predicted {}, expected {}", query, val_4, expected); + + // With 12 neighbors, smoothing bias on an un-detrended ramp is within 1.5% relative error + near.truncate(12); + let (val, var) = ordinary_kriging_point(query, &near, ¶ms, 1.0, &mut buffers); + let error = (val - expected).abs(); + assert!(error < 0.75, "12-neighbor query at {:?}: predicted {}, expected {}, error {}", query, val, expected, error); + assert!(var >= 0.0, "Variance should be non-negative: {}", var); + } + } + + #[test] + fn test_high_latitude_metric_consistency() { + let lat = 60.0; + let pts = vec![(Coord::new(0.0, lat), 10.0), (Coord::new(1.0, lat), 20.0), (Coord::new(0.0, lat + 1.0), 30.0)]; + let extent = Extent::new(0.0, lat, 1.0, lat + 1.0); + let params = auto_variogram(&pts, &extent, VariogramModel::Spherical); + let r = kriging(&pts, &extent, 0.25, ¶ms, 3, "kriging_hi_lat").unwrap(); + assert_eq!(r.bands.len(), 2); + for band in &r.bands { + for v in &band.data { + assert!(r.is_valid(*v)); + } + } + } + + #[test] + fn test_variogram_models_monotonicity_and_sill() { + let models = [VariogramModel::Spherical, VariogramModel::Exponential, VariogramModel::Gaussian]; + for model in models { + let p = VariogramParams { model, nugget: 1.0, sill: 10.0, range: 5.0 }; + assert_eq!(p.semivariance(0.0), 0.0); + assert!(p.semivariance(1.0) > 0.0); + assert!(p.semivariance(3.0) >= p.semivariance(1.0)); + // Beyond range + let beyond = p.semivariance(10.0); + assert!((beyond - 10.0).abs() < 0.1, "{:?} at 10.0 was {}", model, beyond); + } + } + + #[test] + fn test_kriging_raster_output_has_two_bands() { + let pts = + vec![(Coord::new(0.0, 0.0), 10.0), (Coord::new(2.0, 0.0), 20.0), (Coord::new(0.0, 2.0), 15.0), (Coord::new(2.0, 2.0), 25.0)]; + let extent = Extent::new(0.0, 0.0, 2.0, 2.0); + let params = auto_variogram(&pts, &extent, VariogramModel::Spherical); + let r = kriging(&pts, &extent, 0.5, ¶ms, 4, "test_kriging").unwrap(); + assert_eq!(r.bands.len(), 2, "Kriging should produce Prediction and Variance bands"); + assert_eq!(r.width, 4); + assert_eq!(r.height, 4); + // All cells should be valid finite values + for band in &r.bands { + for v in &band.data { + assert!(r.is_valid(*v)); + } + } + } + + #[test] + fn test_hostile_inputs_never_panic() { + let extent = Extent::new(0.0, 0.0, 2.0, 2.0); + let params = VariogramParams { model: VariogramModel::Spherical, nugget: 0.0, sill: 1.0, range: 1.0 }; + // Less than 2 points + assert!(kriging(&[], &extent, 0.5, ¶ms, 4, "t").is_err()); + assert!(kriging(&[(Coord::new(0.0, 0.0), 1.0)], &extent, 0.5, ¶ms, 4, "t").is_err()); + // Negative / invalid cell size + let pts = vec![(Coord::new(0.0, 0.0), 1.0), (Coord::new(1.0, 1.0), 2.0)]; + assert!(kriging(&pts, &extent, -1.0, ¶ms, 4, "t").is_err()); + assert!(kriging(&pts, &extent, 0.0, ¶ms, 4, "t").is_err()); + assert!(kriging(&pts, &extent, f64::NAN, ¶ms, 4, "t").is_err()); + // Empty extent + assert!(kriging(&pts, &Extent::EMPTY, 0.5, ¶ms, 4, "t").is_err()); + } + + #[test] + fn test_kriging_scale_invariance_with_small_values() { + let pts = + vec![(Coord::new(0.0, 0.0), 10.0), (Coord::new(2.0, 0.0), 20.0), (Coord::new(0.0, 2.0), 15.0), (Coord::new(2.0, 2.0), 25.0)]; + let extent = Extent::new(0.0, 0.0, 2.0, 2.0); + let params = auto_variogram(&pts, &extent, VariogramModel::Spherical); + let r_orig = kriging(&pts, &extent, 0.5, ¶ms, 4, "orig").unwrap(); + + let scale = 1e-6_f64; + let scaled_pts: Vec<(Coord, f64)> = pts.iter().map(|(c, v)| (*c, v * scale)).collect(); + let scaled_params = auto_variogram(&scaled_pts, &extent, VariogramModel::Spherical); + let r_scaled = kriging(&scaled_pts, &extent, 0.5, &scaled_params, 4, "scaled").unwrap(); + + for (v_orig, v_scaled) in r_orig.bands[0].data.iter().zip(&r_scaled.bands[0].data) { + let expected = *v_orig * (scale as f32); + assert!((*v_scaled - expected).abs() <= 1e-10, "scaled {v_scaled} vs expected {expected}"); + } + + let scale_sq = (scale * scale) as f32; + for (v_orig, v_scaled) in r_orig.bands[1].data.iter().zip(&r_scaled.bands[1].data) { + let expected = *v_orig * scale_sq; + assert!((*v_scaled - expected).abs() <= 1e-12, "variance {v_scaled} vs expected {expected}"); + } + } +} diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index bbd05f279..37ef54706 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -14,6 +14,7 @@ pub mod convert; pub mod dggs; pub mod geodesic; pub mod georef; +pub mod kriging; pub mod network; pub mod neural; pub mod rasterops; diff --git a/crates/analysis/src/tools.rs b/crates/analysis/src/tools.rs index 33b81313c..1ffc9ec2f 100644 --- a/crates/analysis/src/tools.rs +++ b/crates/analysis/src/tools.rs @@ -11,6 +11,7 @@ use crate::geodesic::geodesic_buffer; use crate::{Inputs, Output, OutputLayer, ParamKind as K, ParamSpec as P, ToolResult, ToolSpec}; const UNITS: &[&str] = &["Kilometers", "Meters", "Miles"]; +const KRIGING_MODELS: &[&str] = &["Spherical", "Exponential", "Gaussian"]; const MATCH_OPTIONS: &[&str] = &["Intersects", "Contains", "Within"]; const JOIN_OPERATIONS: &[&str] = &["Join one to one", "Join one to many"]; const TARGET_RECORDS: &[&str] = &["Keep all", "Keep only matching"]; @@ -380,6 +381,81 @@ pub static TOOLS: &[ToolSpec] = &[ ], run: spatial_join, }, + ToolSpec { + id: "kriging", + name: "Ordinary Kriging", + toolbox: "Analysis › Interpolation", + summary: "Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging.", + params: &[ + P { + name: "input", + label: "Vector Layer", + kind: K::VectorLayer, + required: true, + default: "", + help: "Points (or bounding-box centres for lines and polygons) with values to interpolate.", + }, + P { name: "field", label: "Interpolation Attribute", kind: K::NumericField, required: true, default: "", help: "" }, + P { + name: "model", + label: "Variogram Model", + kind: K::Choice(KRIGING_MODELS), + required: false, + default: "\"Spherical\"", + help: "Spatial autocorrelation model: Spherical, Exponential, or Gaussian.", + }, + P { + name: "neighbours", + label: "Max Neighbors", + kind: K::Number, + required: false, + default: "12", + help: "Number of nearest points to solve with (default 12, max 32).", + }, + P { + name: "nugget", + label: "Nugget", + kind: K::Number, + required: false, + default: "", + help: "Micro-scale variance/measurement error at zero distance (default 0).", + }, + P { + name: "sill", + label: "Sill", + kind: K::Number, + required: false, + default: "", + help: "Total asymptotic variance (defaults to sample variance).", + }, + P { + name: "range", + label: "Range (Degrees)", + kind: K::Number, + required: false, + default: "", + help: "Spatial correlation distance in degrees (defaults to 1/3 extent diagonal).", + }, + P { + name: "extent", + label: "Extent", + kind: K::Text, + required: false, + default: "", + help: "xmin,ymin,xmax,ymax in degrees; the input's extent when empty.", + }, + P { + name: "pixelSize", + label: "Pixel Size (°)", + kind: K::Number, + required: false, + default: "", + help: "The output cell size in degrees; up to 250 cells across the extent when empty.", + }, + P { name: "output", label: "Output Name", kind: K::Text, required: false, default: "", help: "" }, + ], + run: kriging_interpolation, + }, ]; // --------------------------------------------------------------------------------------------- @@ -851,6 +927,151 @@ fn idw_auto_cell(extent: &Extent, n: usize) -> f64 { cell } +/// Ordinary Kriging interpolation: builds prediction and error surfaces from spatial autocorrelation. +fn kriging_interpolation(inp: &dyn Inputs, p: &Json) -> Result { + let ds = input(inp, p, "input")?; + if let Some(crs) = ds.crs + && crs != rustgis_data::raster::Crs::LonLat + { + return Err("kriging interpolation expects coordinates in degrees (WGS 84 / EPSG:4326)".into()); + } + let field = s(p, "field"); + let fi = ds.field_index(field).ok_or_else(|| format!("no field named '{field}'"))?; + let mut raw_pts: Vec<(Coord, f64)> = Vec::new(); + for r in rows(inp, p, "input", ds) { + let Some(g) = ds.features.get(r).and_then(|f| f.geometry.as_ref()) else { continue }; + let Some(v) = ds.value(r, fi).as_f64().filter(|v| v.is_finite()) else { continue }; + match g { + Geometry::Point(c) => { + if c.is_finite() { + raw_pts.push((*c, v)); + } + } + Geometry::MultiPoint(cs) => { + for c in cs { + if c.is_finite() { + raw_pts.push((*c, v)); + } + } + } + Geometry::Line(_) | Geometry::Polygon(_) => { + let ext = g.extent(); + if !ext.is_empty() { + let center = Coord::new((ext.min_x + ext.max_x) * 0.5, (ext.min_y + ext.max_y) * 0.5); + if center.is_finite() { + raw_pts.push((center, v)); + } + } + } + } + } + // Deduplicate sample points by coordinate to prevent singular kriging matrix + raw_pts.sort_by(|a, b| a.0.x.total_cmp(&b.0.x).then_with(|| a.0.y.total_cmp(&b.0.y))); + let mut accum: Vec<(Coord, f64, usize)> = Vec::with_capacity(raw_pts.len()); + for (c, v) in raw_pts { + let mut duplicate_idx = None; + for (i, (last_c, _, _)) in accum.iter().enumerate().rev() { + if c.x - last_c.x >= 1e-9 { + break; + } + if (c.y - last_c.y).abs() < 1e-9 { + duplicate_idx = Some(i); + break; + } + } + if let Some(i) = duplicate_idx { + if let Some((_, sum, count)) = accum.get_mut(i) { + *sum += v; + *count += 1; + } + continue; + } + accum.push((c, v, 1)); + } + let pts: Vec<(Coord, f64)> = accum.into_iter().map(|(c, sum, count)| (c, sum / count as f64)).collect(); + if pts.len() < 2 { + return Err("kriging needs at least two points with values".into()); + } + + let extent = match s(p, "extent").trim() { + "" => { + let mut e = Extent::EMPTY; + pts.iter().for_each(|(c, _)| e.expand(*c)); + if e.width() > 0.0 && e.height() > 0.0 { e } else { e.padded(0.05, 0.01) } + } + t => { + let v: Option> = t.split(',').map(|x| x.trim().parse().ok().filter(|x: &f64| x.is_finite())).collect(); + let Some([x0, y0, x1, y1]) = v.as_deref().and_then(|v| <[f64; 4]>::try_from(v).ok()) else { + return Err("the extent is xmin,ymin,xmax,ymax".into()); + }; + Extent::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)) + } + }; + let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null() && v.as_str().is_none_or(|t| !t.trim().is_empty())); + let neighbours = match num(p, "neighbours") { + Some(n) if n >= 2.0 => (n as usize).min(32), + Some(_) => return Err("neighbours must be at least 2".into()), + None if given("neighbours") => return Err("neighbours must be a number".into()), + None => 12, + }; + let cell = match num(p, "pixelSize") { + Some(c) if c > 0.0 => c, + _ if given("pixelSize") => return Err("the pixel size must be more than zero".into()), + _ => kriging_auto_cell(&extent, pts.len(), neighbours), + }; + let model = match s(p, "model") { + "" => crate::kriging::VariogramModel::Spherical, + m => m.parse::().map_err(|_| format!("unknown variogram model '{m}'"))?, + }; + let mut params = crate::kriging::auto_variogram(&pts, &extent, model); + match num(p, "nugget") { + Some(v) if v >= 0.0 => params.nugget = v, + Some(_) => return Err("nugget must be a non-negative number".into()), + None if given("nugget") => return Err("nugget must be a number".into()), + None => {} + } + match num(p, "sill") { + Some(v) if v > 0.0 => params.sill = v, + Some(_) => return Err("sill must be a positive number".into()), + None if given("sill") => return Err("sill must be a number".into()), + None => {} + } + match num(p, "range") { + Some(v) if v > 0.0 => params.range = v, + Some(_) => return Err("range must be a positive number".into()), + None if given("range") => return Err("range must be a number".into()), + None => {} + } + + let name = out_name(p, format!("{}_{field}_kriging", ds.name)); + let r = crate::kriging::kriging(&pts, &extent, cell, ¶ms, neighbours, &name)?; + let pred_bytes = rustgis_data::raster::geotiff::write(&r, 0).map_err(|e| e.to_string())?; + let var_bytes = rustgis_data::raster::geotiff::write(&r, 1).map_err(|e| e.to_string())?; + let var_name = format!("{name}_variance"); + let msg = format!("Ordinary Kriging ({}): {} × {} cells from {} points", model.as_str(), r.width, r.height, pts.len()); + Ok(ToolResult { + output: Output::Layers(vec![ + OutputLayer::Raster { file_name: format!("{name}.tif"), name, bytes: pred_bytes }, + OutputLayer::Raster { file_name: format!("{var_name}.tif"), name: var_name, bytes: var_bytes }, + ]), + messages: vec![msg], + }) +} + +fn kriging_auto_cell(extent: &Extent, n: usize, neighbours: usize) -> f64 { + let k = if neighbours == 0 { 12.min(n) } else { neighbours.clamp(2, n.clamp(2, 32)) }; + let work_per_cell = n.saturating_add((k * k * k) / 3); + let mut cell = (extent.width().max(extent.height()) / 250.0).max(1e-5); + let cells = |c: f64| ((extent.width() / c).ceil().max(1.0) * (extent.height() / c).ceil().max(1.0)) as usize; + for _ in 0..200 { + if cells(cell).saturating_mul(work_per_cell) <= crate::kriging::MAX_KRIGING_WORK { + break; + } + cell *= 1.1; + } + cell +} + fn count_points(inp: &dyn Inputs, p: &Json) -> Result { let polys = input(inp, p, "polygons")?; let pts = input(inp, p, "points")?; @@ -1629,6 +1850,124 @@ mod tests { } } + #[test] + fn kriging_interpolation_writes_prediction_and_variance_rasters() { + let m = inputs(); + let r = crate::run( + "kriging", + &m, + &serde_json::json!({ + "input": "pts", + "field": "v", + "model": "Spherical", + "extent": "0,0,2,2", + "pixelSize": 0.5 + }), + ) + .unwrap_or_else(|e| panic!("{e}")); + let Output::Layers(out) = r.output else { panic!("expected layers") }; + assert_eq!(out.len(), 2, "Expected prediction and variance rasters"); + let (pred_layer, var_layer) = match (&out[0], &out[1]) { + ( + OutputLayer::Raster { name: p_name, file_name: p_file, bytes: p_bytes }, + OutputLayer::Raster { name: v_name, file_name: v_file, bytes: v_bytes }, + ) => { + assert_eq!(p_name, "pts_v_kriging"); + assert_eq!(v_name, "pts_v_kriging_variance"); + ( + rustgis_data::raster::read_bytes(p_file, p_bytes).unwrap_or_else(|e| panic!("{e}")), + rustgis_data::raster::read_bytes(v_file, v_bytes).unwrap_or_else(|e| panic!("{e}")), + ) + } + _ => panic!("expected two raster output layers: {out:?}"), + }; + assert_eq!((pred_layer.width, pred_layer.height), (4, 4)); + assert_eq!((var_layer.width, var_layer.height), (4, 4)); + // Values in prediction band + let d = &pred_layer.bands[0].data; + assert!(d.iter().all(|v| (0.5..=3.5).contains(v)), "{d:?}"); + // Variance in variance raster is non-negative + let var = &var_layer.bands[0].data; + assert!(var.iter().all(|v| *v >= 0.0), "{var:?}"); + + // Invalid models or parameters return errors cleanly + assert!(crate::run("kriging", &m, &serde_json::json!({"input": "pts", "field": "v", "model": "UnknownModel"})).is_err()); + assert!(crate::run("kriging", &m, &serde_json::json!({"input": "pts", "field": "v", "neighbours": 1})).is_err()); + assert!(crate::run("kriging", &m, &serde_json::json!({"input": "pts", "field": "v", "nugget": -1})).is_err()); + assert!(crate::run("kriging", &m, &serde_json::json!({"input": "pts", "field": "v", "nugget": "abc"})).is_err()); + assert!(crate::run("kriging", &m, &serde_json::json!({"input": "pts", "field": "v", "sill": 0})).is_err()); + assert!(crate::run("kriging", &m, &serde_json::json!({"input": "pts", "field": "v", "sill": "xyz"})).is_err()); + assert!(crate::run("kriging", &m, &serde_json::json!({"input": "pts", "field": "v", "range": -5})).is_err()); + assert!(crate::run("kriging", &m, &serde_json::json!({"input": "pts", "field": "v", "range": "none"})).is_err()); + + // Projected layer with non-degrees CRS returns error + let Some(pts_layer) = m.0.get("pts") else { panic!("missing pts") }; + let mut proj_pts = pts_layer.clone(); + proj_pts.crs = Some(rustgis_data::raster::Crs::WebMercator); + let m_proj = Map(HashMap::from([("proj_pts".to_string(), proj_pts)])); + let err = crate::run("kriging", &m_proj, &serde_json::json!({"input": "proj_pts", "field": "v"})).unwrap_err(); + assert!(err.contains("degrees")); + + // Datasets with fewer than two points are cleanly rejected + let mut single_pt = Dataset::new("single", GeometryType::Point, vec![Field::new("v", FieldType::Double)]); + single_pt.push(Some(Geometry::Point(Coord::new(0.5, 0.5))), vec![Value::Float(1.0)]); + let m_single = Map(HashMap::from([("single".to_string(), single_pt)])); + assert_eq!( + crate::run("kriging", &m_single, &serde_json::json!({"input": "single", "field": "v"})).unwrap_err(), + "kriging needs at least two points with values" + ); + + // Deduplication averages coincident points with true arithmetic mean + let mut dup_pts = Dataset::new("dup", GeometryType::Point, vec![Field::new("v", FieldType::Double)]); + dup_pts.push(Some(Geometry::Point(Coord::new(0.5, 0.5))), vec![Value::Float(10.0)]); + dup_pts.push(Some(Geometry::Point(Coord::new(0.5, 0.5))), vec![Value::Float(20.0)]); + dup_pts.push(Some(Geometry::Point(Coord::new(0.5, 0.5))), vec![Value::Float(30.0)]); + dup_pts.push(Some(Geometry::Point(Coord::new(1.5, 1.5))), vec![Value::Float(20.0)]); + let m_dup = Map(HashMap::from([("dup".to_string(), dup_pts)])); + let r_dup = crate::run( + "kriging", + &m_dup, + &serde_json::json!({ + "input": "dup", + "field": "v", + "extent": "0,0,2,2", + "pixelSize": 0.5 + }), + ) + .unwrap_or_else(|e| panic!("{e}")); + let Output::Layers(dup_layers) = r_dup.output else { panic!("expected layers") }; + let pred = match &dup_layers[0] { + OutputLayer::Raster { file_name, bytes, .. } => rustgis_data::raster::read_bytes(file_name, bytes).unwrap(), + _ => panic!("expected raster"), + }; + assert!(pred.bands[0].data.iter().all(|v| (19.99..=20.01).contains(v)), "{:?}", pred.bands[0].data); + + // Deduplication merges near-duplicate samples across x-windows even when separated by different y + let mut inter_pts = Dataset::new("inter", GeometryType::Point, vec![Field::new("v", FieldType::Double)]); + inter_pts.push(Some(Geometry::Point(Coord::new(0.5, 0.5))), vec![Value::Float(10.0)]); + inter_pts.push(Some(Geometry::Point(Coord::new(0.5 + 5e-13, 10.0))), vec![Value::Float(100.0)]); + inter_pts.push(Some(Geometry::Point(Coord::new(0.5 + 9e-13, 0.5))), vec![Value::Float(30.0)]); + inter_pts.push(Some(Geometry::Point(Coord::new(1.5, 1.5))), vec![Value::Float(20.0)]); + let m_inter = Map(HashMap::from([("inter".to_string(), inter_pts)])); + let r_inter = crate::run( + "kriging", + &m_inter, + &serde_json::json!({ + "input": "inter", + "field": "v", + "extent": "0.25,0.25,0.75,0.75", + "pixelSize": 0.5 + }), + ) + .unwrap_or_else(|e| panic!("{e}")); + let Output::Layers(inter_layers) = r_inter.output else { panic!("expected layers") }; + let pred_inter = match &inter_layers[0] { + OutputLayer::Raster { file_name, bytes, .. } => rustgis_data::raster::read_bytes(file_name, bytes).unwrap(), + _ => panic!("expected raster"), + }; + assert!((pred_inter.bands[0].data[0] - 20.0).abs() < 1e-4, "expected 20.0 at (0.5, 0.5), got {}", pred_inter.bands[0].data[0]); + } + #[test] fn xy_table_to_point_places_rows_by_their_coordinates() { let fields = vec![Field::new("name", FieldType::Text), Field::new("lon", FieldType::Text), Field::new("lat", FieldType::Double)]; From cfeef0c62105efffe3bca4e0099d781792bd00db Mon Sep 17 00:00:00 2001 From: yongyin-leon Date: Thu, 22 Oct 2026 11:31:16 +0800 Subject: [PATCH 014/297] feat(map): bound streamed viewport and geometry caches by bytes (#680) * feat(map): budget streamed viewport and geometry caches * fix(map): address viewport memory budget review findings --------- Co-authored-by: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> --- crates/data/src/lib.rs | 7 + crates/data/src/source/memory.rs | 104 +++++++ crates/data/src/source/mod.rs | 40 +++ crates/data/tests/view_memory.rs | 36 +++ crates/map/src/lib.rs | 98 ++++++- crates/map/src/lod.rs | 51 ++++ crates/map/src/source.rs | 63 +++- crates/map/src/source/window.rs | 466 ++++++++++++++++++++++++++++++ crates/map/src/view_memory.rs | 162 +++++++++++ crates/render/src/budget_tests.rs | 38 +++ crates/render/src/lib.rs | 3 + docs/user-guide/data.md | 23 ++ 12 files changed, 1077 insertions(+), 14 deletions(-) create mode 100644 crates/data/src/source/memory.rs create mode 100644 crates/data/tests/view_memory.rs create mode 100644 crates/map/src/source/window.rs create mode 100644 crates/map/src/view_memory.rs create mode 100644 crates/render/src/budget_tests.rs diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 8c77d3c5f..9e1b69b34 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -74,6 +74,13 @@ impl Features { std::sync::Arc::ptr_eq(&self.0, &other.0) } + /// A non-owning allocation identity for derived caches. Mutable access dissociates weak + /// references through `Arc::make_mut`, even without another strong owner, so an identity + /// stops matching after any row, geometry or attribute edit. It does not retain the rows. + pub fn allocation_key(&self) -> std::sync::Weak> { + std::sync::Arc::downgrade(&self.0) + } + /// The rows, taking them out of the share (copying them only when another copy holds them). pub fn into_vec(self) -> Vec { std::sync::Arc::unwrap_or_clone(self.0) diff --git a/crates/data/src/source/memory.rs b/crates/data/src/source/memory.rs new file mode 100644 index 000000000..432afff7b --- /dev/null +++ b/crates/data/src/source/memory.rs @@ -0,0 +1,104 @@ +//! Allocation accounting and bounded sampling for disposable view windows. Full reads are unchanged. +use crate::{Feature, Geometry, Value}; +use std::mem::size_of; + +/// Allocated vector storage owned by a geometry, excluding the enum in its containing array. +pub fn geometry_bytes(g: &Geometry) -> usize { + let ring = |r: &Vec| r.capacity().saturating_mul(size_of::()); + let parts = |p: &Vec>| { + p.iter().fold(p.capacity().saturating_mul(size_of::>()), |n, r| n.saturating_add(ring(r))) + }; + match g { + Geometry::Point(_) => 0, + Geometry::MultiPoint(p) => ring(p), + Geometry::Line(p) => parts(p), + Geometry::Polygon(p) => { + p.iter().fold(p.capacity().saturating_mul(size_of::>>()), |n, r| n.saturating_add(parts(r))) + } + } +} + +/// Heap storage owned by a row, including unused capacity and attribute strings. +pub fn feature_bytes(f: &Feature) -> usize { + let attrs = f.attrs.iter().fold(f.attrs.capacity().saturating_mul(size_of::()), |n, v| { + n.saturating_add(match v { + Value::Text(s) => s.capacity(), + _ => 0, + }) + }); + attrs.saturating_add(f.geometry.as_ref().map_or(0, geometry_bytes)) +} + +/// Actual allocated window storage; shared owners count the allocation once. +pub fn features_bytes(features: &Vec) -> usize { + features.iter().fold(features.capacity().saturating_mul(size_of::()), |n, f| n.saturating_add(feature_bytes(f))) +} + +/// Keep every other candidate, in file order. The source remains authoritative: this changes +/// only a disposable display sample, never coordinates, attributes, or full/edit/export reads. +pub(crate) fn halve(features: &mut Vec) { + let mut i = 0usize; + features.retain(|_| { + let keep = i.is_multiple_of(2); + i += 1; + keep + }); + features.shrink_to_fit(); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn allocation_accounting_includes_spare_capacity_nested_parts_and_text() { + let mut ring = Vec::with_capacity(100); + ring.push(crate::Coord::new(0.0, 0.0)); + let g = Geometry::Polygon(vec![vec![ring]]); + let mut text = String::with_capacity(1024); + text.push('x'); + let mut rows = Vec::with_capacity(8); + rows.push(Feature { id: 1, geometry: Some(g), attrs: vec![Value::Text(text)] }); + let expected = rows.capacity() * size_of::() + + 100 * size_of::() + + size_of::>>() + + size_of::>() + + size_of::() + + 1024; + assert_eq!(features_bytes(&rows), expected); + } + fn source() -> Box { + let mut ds = crate::Dataset::new("budget", crate::GeometryType::Polyline, vec![crate::Field::new("text", crate::FieldType::Text)]); + for i in 0..300 { + ds.push( + Some(Geometry::Line(vec![(0..50).map(|n| crate::Coord::new(i as f64 * 0.001, n as f64 * 0.001)).collect()])), + vec![Value::Text("x".repeat(400))], + ); + } + let (bytes, _) = crate::io::write_bytes(&ds, "fgb").unwrap(); + crate::source::open_bytes("budget.fgb", bytes, None).unwrap() + } + + #[test] + fn budgeted_windows_preserve_ids_attributes_order_and_full_materialization() { + let src = source(); + let request = crate::source::Request::all(); + let full = src.read(&request).unwrap(); + let bytes = 32 * 1024; + let sample = src.read_budgeted(&request, bytes).unwrap(); + assert!(features_bytes(&sample.features) <= bytes); + assert!(!sample.complete); + assert_eq!(sample.matched, full.matched); + assert_eq!(sample.skipped, full.skipped); + let mut previous = None; + for feature in &sample.features { + let position = full.features.iter().position(|f| f == feature).unwrap(); + assert!(previous.is_none_or(|old| old < position)); + previous = Some(position); + } + assert_eq!(src.read(&request).unwrap().features, full.features); + assert_eq!(crate::source::materialize(src.as_ref()).unwrap().0.features.len(), 300); + assert!(src.read_budgeted(&request, 0).is_err()); + let empty = src.read_budgeted(&crate::source::Request { limit: 0, ..request }, bytes).unwrap(); + assert!(empty.features.is_empty()); + } +} diff --git a/crates/data/src/source/mod.rs b/crates/data/src/source/mod.rs index 5416bfb62..a670c0774 100644 --- a/crates/data/src/source/mod.rs +++ b/crates/data/src/source/mod.rs @@ -22,6 +22,7 @@ pub mod gpkg; mod gpkg_blob; pub mod gpkg_mem; pub mod lidar; +pub mod memory; pub mod msconn; pub mod myconn; #[cfg(not(target_arch = "wasm32"))] @@ -175,6 +176,45 @@ pub trait FeatureSource: Send + Sync { /// [`remote::NEED`] until the bytes the read needs are fetched: run it with /// [`remote::drive`] (see [`FeatureSource::remote`]). fn read(&self, req: &Request) -> Result; + /// A disposable view window bounded by allocated row/geometry/attribute bytes. Providers + /// can enforce this during decoding; the fallback probes a small uniform sample before + /// sizing the actual read. Full materialization deliberately continues to use `read`. + fn read_budgeted(&self, req: &Request, bytes: usize) -> Result { + let mut request = *req; + request.limit = clamp_limit(req.limit).min(128); + let mut batch = self.read(&request)?; + if req.limit == 0 { + batch.features.clear(); + batch.features.shrink_to_fit(); + batch.complete = batch.matched == 0; + return Ok(batch); + } + let used = memory::features_bytes(&batch.features); + if !batch.complete && used < bytes { + // A small probe can hit only unreadable/deleted candidates. An incomplete + // empty answer is not proof that the requested extent contains no features. + let count = if batch.features.is_empty() { + bytes / (std::mem::size_of::() * 4) + } else { + batch.features.len().saturating_mul(bytes / 2) / used.max(1) + }; + let next = clamp_limit(req.limit).min(count).max(1); + if next > request.limit { + request.limit = next; + drop(batch); + batch = self.read(&request)?; + } + } + batch.features.shrink_to_fit(); + while memory::features_bytes(&batch.features) > bytes { + if batch.features.len() <= 1 { + return Err(DataError::Format("one feature exceeds the view memory budget; load it explicitly for full processing".into())); + } + memory::halve(&mut batch.features); + batch.complete = false; + } + Ok(batch) + } /// The file a source reads by its web address, a range at a time. fn remote(&self) -> Option> { None diff --git a/crates/data/tests/view_memory.rs b/crates/data/tests/view_memory.rs new file mode 100644 index 000000000..f2f2751c1 --- /dev/null +++ b/crates/data/tests/view_memory.rs @@ -0,0 +1,36 @@ +//! The default provider adapter bounds retained views without changing full-source reads. +#![cfg(not(target_arch = "wasm32"))] + +use rustgis_data::{ + Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value, + source::{Request, memory}, +}; + +#[test] +fn budgeted_views_keep_full_reads_for_geopackage_geoparquet_and_geojson() { + let mut data = Dataset::new("budget", GeometryType::Polyline, vec![Field::new("text", FieldType::Text)]); + for i in 0..150 { + data.push( + Some(Geometry::Line(vec![(0..80).map(|n| Coord::new(i as f64 * 0.001, n as f64 * 0.001)).collect()])), + vec![Value::Text("x".repeat(300))], + ); + } + let folder = tempfile::tempdir().unwrap(); + for extension in ["gpkg", "parquet", "geojson"] { + let path = folder.path().join(format!("budget.{extension}")); + rustgis_data::io::write_path(&data, &path).unwrap(); + let source = rustgis_data::source::open_path(&path, None).unwrap(); + let request = Request { extent: Some(rustgis_geom::Extent::new(-1.0, -1.0, 1.0, 1.0)), ..Request::all() }; + let full = source.read(&request).unwrap(); + let sample = source.read_budgeted(&request, 32 * 1024).unwrap(); + assert!(memory::features_bytes(&sample.features) <= 32 * 1024, "{extension}"); + assert!(!sample.complete, "{extension}"); + assert!(!sample.features.is_empty(), "{extension}"); + assert_eq!(sample.matched, full.matched, "{extension}"); + for feature in &sample.features { + assert!(full.features.contains(feature), "{extension}: sampled feature was changed"); + } + assert_eq!(source.read(&request).unwrap().features, full.features, "{extension}"); + assert_eq!(rustgis_data::source::materialize(source.as_ref()).unwrap().0.features.len(), 150); + } +} diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index cc4495425..15b8f7283 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -21,6 +21,7 @@ pub mod symbology; pub mod tiles; pub mod time; pub mod view; +mod view_memory; pub mod world; pub use camera::Camera; @@ -174,6 +175,9 @@ pub struct Layer { /// The projection the cache is in. #[serde(skip)] projected_for: String, + /// Allocation identities and byte totals, shared with render snapshots without retaining rows. + #[serde(skip)] + view_memory: view_memory::Cache, /// Bumped whenever geometry changes so caches rebuild. #[serde(default)] pub geometry_rev: u64, @@ -219,6 +223,7 @@ impl Layer { projected_rev: u64::MAX, projected_for: String::new(), geometry_rev: 0, + view_memory: Default::default(), } } @@ -292,6 +297,7 @@ impl Layer { self.dataset.fields = info.fields.clone(); } let Some(src) = &mut self.source else { return false }; + src.window_fields = Some(self.dataset.fields.len()); // A failed read leaves nothing drawn for it (its error shows in Layers), not the // previous view's features. match src.poll(block) { @@ -315,12 +321,20 @@ impl Layer { /// Replaces a streamed layer's features, keeping the selection (by feature id). fn install(&mut self, features: Vec) { - let ids: std::collections::HashSet = + let mut ids: std::collections::HashSet = self.selection.iter().filter_map(|r| self.dataset.features.get(*r)).map(|f| f.id).collect(); + if let Some(src) = &mut self.source { + ids.extend(std::mem::take(&mut src.saved_selection)); + } let fields = self.dataset.fields.len(); + self.clear_cache(); self.dataset.features = features.into(); + let mut padded = false; for f in &mut self.dataset.features { - f.attrs.resize(fields, rustgis_data::Value::Null); + if f.attrs.len() != fields { + f.attrs.resize(fields, rustgis_data::Value::Null); + padded = true; + } } self.dataset.windowed = true; if let Some(src) = &mut self.source { @@ -332,6 +346,23 @@ impl Layer { self.dataset.features.iter().enumerate().filter(|(_, f)| ids.contains(&f.id)).map(|(i, _)| i).collect() }; self.touch_geometry(); + if let Some(cache) = self.source.as_mut().and_then(|s| s.ready_cache.take()) { + self.projected = cache.projected; + self.projected_extents = cache.extents; + self.projected_vertices = cache.vertices; + self.projected_for = cache.key; + self.projected_rev = self.geometry_rev; + let feature_bytes = + if padded { rustgis_data::source::memory::features_bytes(&self.dataset.features) } else { cache.feature_bytes }; + self.view_memory.seed(&self.dataset.features, &self.projected, feature_bytes, cache.projected_bytes); + } + } + + /// Allocated row, projected-geometry, extent and optional display storage for this layer. + /// Snapshots report the same shared allocations; totals are memoized until mutable access + /// changes an allocation identity, including edits through the public dataset API. + pub fn view_cache_bytes(&self) -> usize { + self.view_memory.total(&self.dataset.features, &self.projected, &self.projected_extents, &self.thinned) } /// Every feature of a streamed layer's source (up to the read limit), for tools and for @@ -406,6 +437,10 @@ impl Layer { /// geometry itself for a small layer. Read it back with [`Layer::thinned_cache`]. pub fn thinned_in(&mut self, flat: &projection::Flat, key: &str, tol: f64) -> &[Option] { self.projected_in(flat, key); + let budget = self.source.as_ref().map_or(lod::CACHE_BYTES, |s| { + let base = self.view_memory.base(&self.dataset.features, &self.projected, &self.projected_extents); + s.budget().saturating_sub(base).min(lod::CACHE_BYTES) + }); let Some(level) = lod::level(tol).filter(|_| self.projected_vertices > lod::THIN_ABOVE) else { return &self.projected }; let levels = std::sync::Arc::make_mut(&mut self.thinned); match levels.iter().position(|(l, _)| *l == level) { @@ -415,13 +450,24 @@ impl Layer { } None => { let t = lod::tolerance(level); - let copy: Vec> = self.projected.iter().map(|g| g.as_ref().map(|g| lod::thin(g, t))).collect(); + let copy = lod::thin_budgeted(&self.projected, t, budget); // The saving counts against every vertex the copy holds, points included. let all: usize = self.projected.iter().flatten().map(lod::vertices).sum(); - let kept: usize = copy.iter().flatten().map(lod::vertices).sum(); - let worth = (kept as f64) <= all as f64 * (1.0 - lod::MIN_SAVING); - levels.insert(0, (level, worth.then(|| std::sync::Arc::new(copy)))); + let copy = copy.filter(|c| { + let kept: usize = c.iter().flatten().map(lod::vertices).sum(); + (kept as f64) <= all as f64 * (1.0 - lod::MIN_SAVING) + }); + levels.insert(0, (level, copy.map(std::sync::Arc::new))); levels.truncate(lod::LEVELS_KEPT); + let mut bytes = 0usize; + levels.retain(|(_, copy)| { + let extra = copy.as_ref().map_or(0, |c| lod::cache_bytes(c)); + if bytes.saturating_add(extra) > budget { + return false; + } + bytes += extra; + true + }); } } self.thinned_cache(tol) @@ -464,7 +510,13 @@ impl Layer { pub fn adopt_cache(&mut self, other: &Layer) -> bool { let fits = other.dataset.features.shares(&self.dataset.features) && other.geometry_rev == self.geometry_rev - && other.is_cached(&other.projected_for); + && other.is_cached(&other.projected_for) + && self.source.as_ref().is_none_or(|source| { + !source.is_pending() + && other.source.as_ref().is_some_and(|old| { + old.budget() <= source.budget() && old.projection == source.projection && old.full_read == source.full_read + }) + }); if fits { self.projected = other.projected.clone(); self.projected_extents = other.projected_extents.clone(); @@ -1179,6 +1231,38 @@ impl Map { pub fn sync_sources(&mut self, view: &Extent, scale: f64, block: bool) -> bool { let mut pending = false; let held = self.held_by_group(scale); + // Hidden resident layers share the budget too; toggling visibility cannot multiply it. + let sources = self.layers.iter().filter(|l| l.source.is_some()).count().max(1); + let bytes = source::VIEW_BYTES / sources; + for layer in &mut self.layers { + if let Some(src) = &mut layer.source { + let changed = src.projection.as_ref().is_some_and(|p| p != &self.projection); + let mode_changed = src.full_read != block; + let budget_changed = src.byte_budget != bytes; + src.projection = Some(self.projection.clone()); + src.byte_budget = bytes; + src.full_read = block; + if changed || mode_changed || budget_changed { + let selected = std::mem::take(&mut src.saved_selection); + src.reset(); + src.saved_selection = selected; + } + if changed || (!block && layer.view_cache_bytes() > bytes) { + // Preserve ids while discarding a window that no longer fits. The next + // installed window resolves the selection against its authoritative ids. + let mut selected: Vec<_> = + layer.selection.iter().filter_map(|r| layer.dataset.features.get(*r)).map(|f| f.id).collect(); + if let Some(src) = &mut layer.source { + selected.append(&mut src.saved_selection); + src.reset(); + } + layer.install(Vec::new()); + if let Some(src) = &mut layer.source { + src.saved_selection = selected; + } + } + } + } for l in self.layers.iter_mut().filter(|l| l.is_streamed() && l.visible && l.in_scale_range(scale) && !held.contains(&l.id)) { pending |= l.sync_source(view, block); } diff --git a/crates/map/src/lod.rs b/crates/map/src/lod.rs index c1ab86583..54da620e3 100644 --- a/crates/map/src/lod.rs +++ b/crates/map/src/lod.rs @@ -21,6 +21,40 @@ pub const MIN_SAVING: f64 = 0.25; /// How many levels a layer keeps. pub const LEVELS_KEPT: usize = 3; +/// Additional geometry retained for display levels per layer, excluding original/projected data. +pub const CACHE_BYTES: usize = 64 * 1024 * 1024; + +/// Heap allocation held by a geometry's vectors (the enum itself is in its containing array). +fn heap_bytes(g: &Geometry) -> usize { + rustgis_data::source::memory::geometry_bytes(g) +} + +pub(crate) fn cache_bytes(geoms: &Vec>) -> usize { + geoms + .iter() + .flatten() + .fold(geoms.capacity().saturating_mul(std::mem::size_of::>()), |n, g| n.saturating_add(heap_bytes(g))) +} + +/// Build one level without first allocating a whole window's extra geometry. Running out of +/// budget discards only this optional display cache; drawing still uses the full projection. +pub(crate) fn thin_budgeted(geoms: &[Option], tol: f64, budget: usize) -> Option>> { + let mut bytes = geoms.len().checked_mul(std::mem::size_of::>())?; + if bytes > budget { + return None; + } + let mut copy = Vec::with_capacity(geoms.len()); + for g in geoms { + let thinned = g.as_ref().map(|g| thin(g, tol)); + bytes = bytes.checked_add(thinned.as_ref().map_or(0, heap_bytes))?; + if bytes > budget { + return None; + } + copy.push(thinned); + } + Some(copy) +} + /// The level for a tolerance in projection units: the largest whose tolerance (2^level) is at /// most `tol`. `None` for a tolerance that is not a positive number. pub fn level(tol: f64) -> Option { @@ -99,6 +133,23 @@ pub fn vertices(g: &Geometry) -> usize { mod tests { use super::*; + #[test] + fn optional_levels_respect_heap_budget_and_leave_original_geometry_intact() { + let original = vec![Some(Geometry::Polygon(vec![vec![vec![ + Coord::new(0.0, 0.0), + Coord::new(10.0, 0.0), + Coord::new(10.0, 10.0), + Coord::new(0.0, 0.0), + ]]]))]; + let before = original.clone(); + assert!(thin_budgeted(&original, 0.1, 0).is_none()); + assert!(thin_budgeted(&original, 0.1, std::mem::size_of::>()).is_none()); + let copy = thin_budgeted(&original, 0.1, 1024).unwrap(); + assert!(cache_bytes(©) <= 1024); + assert_eq!(copy, before); + assert_eq!(original, before); + } + #[test] fn thinning_keeps_ends_and_far_vertices() { let run: Vec = (0..=100).map(|i| Coord::new(i as f64 * 0.1, 0.0)).collect(); diff --git a/crates/map/src/source.rs b/crates/map/src/source.rs index 4474ac0fd..ffa9e54dc 100644 --- a/crates/map/src/source.rs +++ b/crates/map/src/source.rs @@ -18,6 +18,11 @@ use rustgis_data::source::{Batch, FeatureSource, Request, SourceInfo}; use rustgis_geom::Extent; use serde::{Deserialize, Serialize}; +mod window; + +/// Retained streamed rows and geometry caches per map; excludes scratch and render/export copies. +pub const VIEW_BYTES: usize = if cfg!(target_arch = "wasm32") { 48 * 1024 * 1024 } else { 128 * 1024 * 1024 }; + /// Sources with more features than this are streamed when added; smaller ones are loaded whole. pub const STREAM_ABOVE: u64 = 100_000; @@ -48,6 +53,26 @@ pub struct SourceLayer { window: Option, #[serde(skip)] pending: Option>, + /// Projection used for this disposable window's geometry cache. + #[serde(skip)] + pub(crate) projection: Option, + #[serde(skip)] + pub(crate) ready_cache: Option, + /// Allocated row/projection/display bytes; zero selects the standalone default. + #[serde(skip)] + pub byte_budget: usize, + #[serde(skip)] + estimated_row_bytes: usize, + /// Blocking render/export reads preserve the original row limit rather than the interaction budget. + #[serde(skip)] + pub(crate) full_read: bool, + /// IDs parked only while budget/projection eviction leaves this same source without rows. + /// Hidden layers retain them until the next read; success consumes them, failure or an + /// explicit source reset discards them. Display-only resets preserve them in `sync_sources`. + #[serde(skip)] + pub(crate) saved_selection: Vec, + #[serde(skip)] + pub(crate) window_fields: Option, /// A source by its web address being opened (its header or footer being fetched). #[serde(skip)] opening: Option, @@ -282,7 +307,7 @@ pub struct Window { /// A read in flight. struct Pending { extent: Extent, - result: Mutex>>, + result: Mutex>>, } /// A source being opened off the frame. @@ -309,6 +334,11 @@ pub enum Polled { } impl SourceLayer { + /// Retained interaction bytes assigned by the map, or the standalone default. + pub fn budget(&self) -> usize { + if self.byte_budget == 0 { VIEW_BYTES } else { self.byte_budget } + } + /// A source read from a file on disk. pub fn from_path(path: impl Into, table: Option, handle: Box) -> Self { SourceLayer { path: Some(path.into()), table, handle: Some(Arc::from(handle)), ..SourceLayer::default() } @@ -552,6 +582,9 @@ impl SourceLayer { pub fn reset(&mut self) { self.window = None; self.pending = None; + self.ready_cache = None; + self.estimated_row_bytes = 0; + self.saved_selection.clear(); } /// The extent to read for a view (longitude/latitude), or `None` when the window serves it. @@ -583,9 +616,15 @@ impl SourceLayer { /// Starts reading an extent: on a background thread unless `block` (or in the browser). pub fn start(&mut self, extent: Extent, block: bool) { + self.ready_cache = None; let Some(h) = self.handle.clone() else { return }; let most = if h.remote().is_some() { REMOTE_VIEW_LIMIT } else { VIEW_LIMIT }; let req = Request { extent: Some(extent), limit: if self.limit == 0 { most } else { self.limit } }; + let budget = self.budget(); + let projection = self.projection.clone().unwrap_or(crate::Projection::WebMercator); + let estimate = self.estimated_row_bytes; + let full_read = self.full_read; + let fields = self.window_fields; let pending = Arc::new(Pending { extent, result: Mutex::new(None) }); let p = pending.clone(); // A source by its web address: the read runs as its bytes land (on the desktop and in @@ -593,8 +632,10 @@ impl SourceLayer { if let Some(file) = h.remote() { let (h2, p2) = (h.clone(), p.clone()); let work = Arc::new(move || { - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| h2.read(&req))) - .unwrap_or_else(|_| Err(rustgis_data::DataError::Format("the read failed unexpectedly".into()))) + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + window::read_mode(h2.as_ref(), &req, budget, &projection, estimate, full_read, fields) + })) + .unwrap_or_else(|_| Err(rustgis_data::DataError::Format("the read failed unexpectedly".into()))) }); rustgis_data::source::remote::drive( file, @@ -611,9 +652,11 @@ impl SourceLayer { return; } let job = move || { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| h.read(&req))) - .unwrap_or_else(|_| Err(rustgis_data::DataError::Format("the read failed unexpectedly".into()))) - .map_err(|e| e.to_string()); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + window::read_mode(h.as_ref(), &req, budget, &projection, estimate, full_read, fields) + })) + .unwrap_or_else(|_| Err(rustgis_data::DataError::Format("the read failed unexpectedly".into()))) + .map_err(|e| e.to_string()); if let Ok(mut slot) = p.result.lock() { *slot = Some(r); } @@ -631,6 +674,7 @@ impl SourceLayer { Ok(_) => self.pending = Some(pending), // No thread to read on: say so, and don't ask again until the view moves on. Err(e) => { + self.saved_selection.clear(); self.error = Some(format!("couldn't start reading the layer: {e}")); self.window = Some(Window { extent, complete: true, matched: 0, kept: 0 }); } @@ -649,8 +693,10 @@ impl SourceLayer { loop { let done = p.result.lock().ok().and_then(|mut r| r.take()); match done { - Some(Ok(b)) => { + Some(Ok((b, cache))) => { self.pending = None; + self.estimated_row_bytes = cache.mean_bytes; + self.ready_cache = Some(cache); let w = Window { extent: p.extent, complete: b.complete, matched: b.matched, kept: b.features.len() }; self.window = Some(w); self.error = None; @@ -658,6 +704,8 @@ impl SourceLayer { } Some(Err(e)) => { self.pending = None; + self.ready_cache = None; + self.saved_selection.clear(); self.error = Some(e.clone()); // Not read again until the view moves past it. self.window = Some(Window { extent: p.extent, complete: true, matched: 0, kept: 0 }); @@ -670,6 +718,7 @@ impl SourceLayer { // there as if it were the view. (The read carries on; the next view takes it.) None if block && cfg!(not(target_arch = "wasm32")) => { let e = "reading the layer took more than a minute".to_string(); + self.saved_selection.clear(); self.error = Some(e.clone()); return Polled::Failed(e); } diff --git a/crates/map/src/source/window.rs b/crates/map/src/source/window.rs new file mode 100644 index 000000000..3c7d247e8 --- /dev/null +++ b/crates/map/src/source/window.rs @@ -0,0 +1,466 @@ +//! A view is decoded and projected on the source worker. The UI installs Arc-backed caches. +use rustgis_data::source::{Batch, FeatureSource, Request, memory}; +use rustgis_data::{DataError, Geometry, Result}; +use rustgis_geom::Extent; +use std::sync::Arc; + +#[derive(Clone)] +pub(crate) struct Cache { + pub projected: Arc>>, + pub extents: Arc>, + pub vertices: usize, + pub key: String, + pub mean_bytes: usize, + pub feature_bytes: usize, + pub projected_bytes: usize, +} + +fn base_bytes(batch: &Batch, projected: &Vec>) -> usize { + memory::features_bytes(&batch.features) + .saturating_add(crate::lod::cache_bytes(projected)) + .saturating_add(projected.len().saturating_mul(std::mem::size_of::())) +} + +fn retain_evenly(items: &mut Vec, kept: usize) { + let total = items.len(); + let mut carry = total.saturating_sub(kept); + items.retain(|_| { + carry += kept; + if carry >= total { + carry -= total; + true + } else { + false + } + }); + items.shrink_to_fit(); +} + +pub(super) fn read_mode( + source: &dyn FeatureSource, + req: &Request, + bytes: usize, + projection: &crate::Projection, + estimate: usize, + full: bool, + fields: Option, +) -> Result<(Batch, Cache)> { + if !full { + return read_with_fields(source, req, bytes, projection, estimate, fields); + } + let batch = source.read(req)?; + let flat = crate::projection::Flat::of(projection); + let projected: Vec<_> = batch.features.iter().map(|f| f.geometry.as_ref().map(|g| flat.project(g))).collect(); + let cache = Cache { + extents: Arc::new(projected.iter().map(|g| g.as_ref().map_or(Extent::EMPTY, Geometry::extent)).collect()), + vertices: projected.iter().flatten().map(crate::lod::thinnable).sum(), + mean_bytes: 0, + feature_bytes: memory::features_bytes(&batch.features), + projected_bytes: crate::lod::cache_bytes(&projected), + projected: Arc::new(projected), + key: projection.code(), + }; + Ok((batch, cache)) +} + +#[cfg(test)] +fn read( + source: &dyn FeatureSource, + req: &Request, + bytes: usize, + projection: &crate::Projection, + estimate: usize, +) -> Result<(Batch, Cache)> { + read_with_fields(source, req, bytes, projection, estimate, None) +} + +fn read_with_fields( + source: &dyn FeatureSource, + req: &Request, + bytes: usize, + projection: &crate::Projection, + estimate: usize, + fields: Option, +) -> Result<(Batch, Cache)> { + let flat = crate::projection::Flat::of(projection); + let base_budget = bytes.saturating_mul(3) / 4; + let count_for = |cost: usize| req.limit.min(base_budget.saturating_mul(9) / 10 / cost.max(1)).max(usize::from(req.limit > 0)); + let mut request = Request { limit: if estimate == 0 { req.limit.min(64) } else { count_for(estimate) }, ..*req }; + let pad = |batch: &mut Batch| -> Result<()> { + if let Some(fields) = fields { + let row = fields + .saturating_mul(std::mem::size_of::()) + .saturating_add(std::mem::size_of::()); + let kept = bytes / row; + if !batch.features.is_empty() && kept == 0 { + return Err(DataError::Format( + "one attribute row exceeds the view memory budget; load it explicitly for full processing".into(), + )); + } + if batch.features.len() > kept { + retain_evenly(&mut batch.features, kept); + batch.complete = false; + } + for feature in &mut batch.features { + feature.attrs.resize(fields, rustgis_data::Value::Null); + } + } + Ok(()) + }; + let mut batch = if estimate == 0 || req.limit == 0 { source.read_budgeted(&request, bytes)? } else { source.read(&request)? }; + // Trusted row sizes avoid a second probe, but never exempt a newly oversized row. + if batch.features.iter().any(|f| std::mem::size_of::().saturating_add(memory::feature_bytes(f)) > bytes) { + return Err(DataError::Format("one feature exceeds the view memory budget; load it explicitly for full processing".into())); + } + pad(&mut batch)?; + let project = |batch: &Batch| batch.features.iter().map(|f| f.geometry.as_ref().map(|g| flat.project(g))).collect::>(); + let mut projected = project(&batch); + // The first small uniform sample measures real geometry and trimmed attributes rather + // than padded DBF widths. Later windows reuse the measured bytes per row, avoiding a + // second probe. This fills the available budget instead of leaving most of it unused. + if estimate == 0 && !batch.complete { + // An initial sample may hit only deleted/unreadable rows. It is not proof that + // the matching extent is empty; let the bounded provider try a wider sample. + let mean = if batch.features.is_empty() { + std::mem::size_of::().saturating_mul(4) + } else { + base_bytes(&batch, &projected).div_ceil(batch.features.len()) + }; + let target = count_for(mean); + if target > request.limit { + drop(projected); + drop(batch); + request.limit = target; + batch = source.read_budgeted(&request, bytes)?; + pad(&mut batch)?; + projected = project(&batch); + } + } + // Keep room for display levels. Actual allocation accounting also handles projections + // that split at a world seam and create more parts than the original geometry. + loop { + let used = base_bytes(&batch, &projected); + // The display-cache reservation is optional: one indivisible feature may + // use the entire assigned budget, leaving no room for display copies. + if used <= base_budget || (batch.features.len() <= 1 && used <= bytes) { + break; + } + if batch.features.len() <= 1 { + return Err(DataError::Format( + "one projected feature exceeds the view memory budget; load it explicitly for full processing".into(), + )); + } + let kept = batch.features.len().saturating_mul(base_budget) / used.max(1); + let kept = kept.max(1).min(batch.features.len() - 1); + retain_evenly(&mut batch.features, kept); + retain_evenly(&mut projected, kept); + batch.complete = false; + } + let extents = Arc::new(projected.iter().map(|g| g.as_ref().map_or(Extent::EMPTY, Geometry::extent)).collect()); + let vertices = projected.iter().flatten().map(crate::lod::thinnable).sum(); + let mean_bytes = base_bytes(&batch, &projected).div_ceil(batch.features.len().max(1)); + let feature_bytes = memory::features_bytes(&batch.features); + let projected_bytes = crate::lod::cache_bytes(&projected); + Ok(( + batch, + Cache { projected: Arc::new(projected), extents, vertices, key: projection.code(), mean_bytes, feature_bytes, projected_bytes }, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustgis_data::{Dataset, Field, FieldType, GeometryType, Value}; + use rustgis_geom::Coord; + + fn source() -> Box { + let mut ds = Dataset::new("complex", GeometryType::Polyline, vec![Field::new("text", FieldType::Text)]); + for i in 0..600 { + ds.push( + Some(Geometry::Line(vec![(0..80).map(|j| Coord::new(i as f64 * 0.01, j as f64 * 0.001)).collect()])), + vec![Value::Text("x".repeat(300))], + ); + } + let (bytes, _) = rustgis_data::io::write_bytes(&ds, "fgb").unwrap(); + rustgis_data::source::open_bytes("complex.fgb", bytes, None).unwrap() + } + + #[test] + fn byte_budget_prepares_aligned_projection_and_preserves_full_processing() { + let source = source(); + let budget = 128 * 1024; + let projection = crate::Projection::Custom("robinson +lon_0=150".into()); + let (batch, cache) = read(source.as_ref(), &Request::all(), budget, &projection, 0).unwrap(); + assert!(!batch.complete); + assert!(batch.features.len() < 600 && !batch.features.is_empty()); + assert_eq!(batch.features.len(), cache.projected.len()); + let bytes = memory::features_bytes(&batch.features) + + crate::lod::cache_bytes(&cache.projected) + + cache.extents.capacity() * std::mem::size_of::(); + assert!(bytes <= budget * 3 / 4, "{bytes}"); + assert!(bytes >= budget / 2, "available memory was wasted by an overly thin sample: {bytes}"); + let flat = crate::projection::Flat::of(&projection); + for (row, projected) in batch.features.iter().zip(cache.projected.iter()) { + assert_eq!(*projected, row.geometry.as_ref().map(|g| flat.project(g))); + } + assert_eq!(source.read(&Request::all()).unwrap().features.len(), 600); + } + + #[test] + fn installed_windows_share_the_map_budget_and_reuse_prepared_geometry() { + let mut map = crate::Map::new("budget"); + let mut ids = Vec::new(); + for _ in 0..2 { + let mut s = super::super::SourceLayer::from_path("memory.fgb", None, source()); + s.limit = 100; + ids.push(map.add_streamed("complex", s)); + } + let extent = Extent::new(-10.0, -10.0, 10.0, 10.0); + settle(&mut map, &extent); + let flat = map.flat(); + let key = map.projection.code(); + for id in ids { + let layer = map.layer_mut(id).unwrap(); + assert_eq!(layer.source.as_ref().unwrap().budget(), super::super::VIEW_BYTES / 2); + assert!(layer.is_cached(&key), "installing a window left projection on the UI thread"); + let cache = layer.projected.clone(); + for tol in [100.0, 10.0, 1.0, 0.1] { + layer.thinned_in(&flat, &key, tol); + } + assert!(Arc::ptr_eq(&cache, &layer.projected), "first zoom reprojected the window"); + assert!(layer.view_cache_bytes() <= layer.source.as_ref().unwrap().budget()); + let mut older = layer.clone(); + older.source.as_mut().unwrap().byte_budget *= 2; + assert!(!layer.adopt_cache(&older), "a worker using an older, larger budget must not restore its cache"); + } + map.set_projection(crate::Projection::Custom("robinson".into())); + settle(&mut map, &extent); + assert!(map.layers.iter().all(|l| l.is_cached(&map.projection.code()))); + } + fn settle(map: &mut crate::Map, extent: &Extent) { + map.sync_sources(extent, 1000.0, false); + for layer in map.layers.iter_mut().filter(|l| l.visible) { + assert!(!layer.sync_source(extent, true)); + } + } + + #[test] + fn zero_limits_and_oversized_features_preserve_read_contracts() { + let source = source(); + let projection = crate::Projection::WebMercator; + for estimate in [0, 100] { + let request = Request { extent: None, limit: 0 }; + let (batch, cache) = read(source.as_ref(), &request, 4096, &projection, estimate).unwrap(); + assert!(batch.features.is_empty()); + assert!(cache.projected.is_empty()); + assert_eq!(batch.matched, 600); + } + assert!(read(source.as_ref(), &Request::all(), 1, &projection, 0).is_err()); + let (full, _) = read_mode(source.as_ref(), &Request::all(), 1, &projection, 0, true, None).unwrap(); + assert!(full.complete); + assert_eq!(full.features.len(), 600); + } + + #[test] + fn padded_project_fields_are_included_in_the_budget() { + let source = source(); + let bytes = 64 * 1024; + let (batch, cache) = + read_with_fields(source.as_ref(), &Request::all(), bytes, &crate::Projection::WebMercator, 0, Some(200)).unwrap(); + assert!(batch.features.iter().all(|f| f.attrs.len() == 200)); + assert!(base_bytes(&batch, &cache.projected) <= bytes * 3 / 4); + assert!(!batch.complete); + assert!(read_with_fields(source.as_ref(), &Request::all(), bytes, &crate::Projection::WebMercator, 0, Some(usize::MAX)).is_err()); + } + + #[test] + fn export_mode_replaces_an_interaction_sample_and_preserves_selection() { + let mut src = super::super::SourceLayer::from_path("memory.fgb", None, source()); + src.byte_budget = 64 * 1024; + src.projection = Some(crate::Projection::WebMercator); + let mut layer = crate::Layer::new_streamed(1, src); + let extent = Extent::new(-10.0, -10.0, 10.0, 10.0); + assert!(!layer.sync_source(&extent, true)); + assert!(layer.dataset.features.len() < 600); + let id = layer.dataset.features.first().unwrap().id; + layer.selection.push(0); + assert_eq!(layer.load_all().unwrap().features.len(), 600); + let mut map = crate::Map::new("export"); + map.layers.push(layer); + assert!(!map.sync_sources(&extent, 1000.0, true)); + let layer = &map.layers[0]; + assert_eq!(layer.dataset.features.len(), 600); + assert_eq!(layer.dataset.features[layer.selection[0]].id, id); + assert!(layer.source.as_ref().unwrap().window().unwrap().complete); + let shared = map.clone(); + assert!(Arc::ptr_eq(&layer.projected, &shared.layers[0].projected)); + } + + #[test] + fn hidden_resident_windows_share_budget_and_release_excess_capacity() { + let mut map = crate::Map::new("resident"); + let first = map.add_streamed("first", super::super::SourceLayer::from_path("memory.fgb", None, source())); + let extent = Extent::new(-10.0, -10.0, 10.0, 10.0); + settle(&mut map, &extent); + let layer = map.layer_mut(first).unwrap(); + let selected = layer.dataset.features.first().unwrap().id; + layer.selection.push(0); + layer.visible = false; + layer.projected = Arc::new(Vec::with_capacity(super::super::VIEW_BYTES / std::mem::size_of::>() * 3 / 4)); + map.add_streamed("second", super::super::SourceLayer::from_path("memory.fgb", None, source())); + settle(&mut map, &extent); + let layer = map.layer_mut(first).unwrap(); + assert!(layer.dataset.features.is_empty()); + assert!(layer.view_cache_bytes() <= super::super::VIEW_BYTES / 2); + assert!(!layer.source.as_ref().unwrap().is_pending()); + // A hidden window can be reset repeatedly before it is visible again. Its IDs + // belong to this same source and must survive those display-only changes. + map.set_projection(crate::Projection::Custom("robinson".into())); + settle(&mut map, &extent); + map.set_projection(crate::Projection::WebMercator); + settle(&mut map, &extent); + let layer = map.layer_mut(first).unwrap(); + assert_eq!(layer.source.as_ref().unwrap().saved_selection, vec![selected]); + layer.visible = true; + settle(&mut map, &extent); + let layer = map.layer(first).unwrap(); + assert_eq!(layer.dataset.features[layer.selection[0]].id, selected); + assert!(map.layers.iter().map(crate::Layer::view_cache_bytes).sum::() <= super::super::VIEW_BYTES); + // Capacity can also grow without a budget/projection change. Eviction must + // invalidate the old complete-window marker, or no replacement read starts. + map.layer_mut(first).unwrap().projected = + Arc::new(Vec::with_capacity(super::super::VIEW_BYTES / std::mem::size_of::>() * 3 / 4)); + settle(&mut map, &extent); + assert!(!map.layer(first).unwrap().dataset.features.is_empty()); + } + #[test] + fn failed_reads_cannot_publish_an_old_projection_cache() { + use std::sync::atomic::{AtomicBool, Ordering}; + struct Failing { + inner: Box, + fail: Arc, + } + impl FeatureSource for Failing { + fn info(&self) -> &rustgis_data::source::SourceInfo { + self.inner.info() + } + fn read(&self, req: &Request) -> Result { + if self.fail.load(Ordering::Relaxed) { + return Err(DataError::Format("injected failure".into())); + } + self.inner.read(req) + } + } + let fail = Arc::new(AtomicBool::new(false)); + let mut src = super::super::SourceLayer::from_path("memory.fgb", None, Box::new(Failing { inner: source(), fail: fail.clone() })); + let extent = Extent::new(-10.0, -10.0, 10.0, 10.0); + src.start(extent, true); + assert!(matches!(src.poll(true), super::super::Polled::Done(..))); + assert!(src.ready_cache.is_some()); + src.saved_selection = vec![42]; + fail.store(true, Ordering::Relaxed); + src.start(extent, true); + assert!(matches!(src.poll(true), super::super::Polled::Failed(..))); + assert!(src.ready_cache.is_none()); + assert!(src.saved_selection.is_empty()); + fail.store(false, Ordering::Relaxed); + let mut layer = crate::Layer::new_streamed(1, src); + layer.source.as_mut().unwrap().reset(); + assert!(!layer.sync_source(&extent, true)); + assert!(!layer.dataset.features.is_empty()); + assert!(layer.selection.is_empty(), "an unrelated successful read restored failed selection IDs"); + layer.source.as_mut().unwrap().saved_selection = vec![42]; + layer.source.as_mut().unwrap().reset(); + assert!(layer.source.as_ref().unwrap().saved_selection.is_empty(), "explicit reset retained parked selection"); + } + #[test] + fn an_empty_incomplete_probe_does_not_hide_the_source() { + struct Sparse(Box); + impl FeatureSource for Sparse { + fn info(&self) -> &rustgis_data::source::SourceInfo { + self.0.info() + } + fn read(&self, req: &Request) -> Result { + if req.limit <= 128 { + return Ok(Batch { features: Vec::with_capacity(128), matched: 600, complete: false, ..Batch::default() }); + } + self.0.read(req) + } + } + let source = Sparse(source()); + // Exercise the default adapter directly: the map's wider retry must not hide a + // provider probe whose empty Vec still has allocated capacity. + let probe = source.read_budgeted(&Request::all(), 128 * 1024).unwrap(); + assert!(!probe.features.is_empty()); + let (batch, cache) = read(&source, &Request::all(), 128 * 1024, &crate::Projection::WebMercator, 0).unwrap(); + assert!(!batch.features.is_empty()); + assert_eq!(batch.features.len(), cache.projected.len()); + assert!(base_bytes(&batch, &cache.projected) <= 96 * 1024); + assert_eq!(source.read(&Request::all()).unwrap().features.len(), 600); + } + #[test] + fn one_feature_can_use_the_optional_display_reservation() { + let source = source(); + let req = Request { extent: None, limit: 1 }; + let projection = crate::Projection::WebMercator; + let (mut full, cache) = read_mode(source.as_ref(), &req, 1, &projection, 0, true, None).unwrap(); + full.features.shrink_to_fit(); + let budget = base_bytes(&full, &cache.projected); + let (batch, cache) = read(source.as_ref(), &req, budget, &projection, 0).unwrap(); + assert_eq!(batch.features, full.features); + assert!(base_bytes(&batch, &cache.projected) <= budget); + } + #[test] + fn measured_rows_read_once_and_still_reject_newly_oversized_features() { + use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }; + struct Counted { + inner: Box, + limits: Mutex>, + large: AtomicBool, + } + impl FeatureSource for Counted { + fn info(&self) -> &rustgis_data::source::SourceInfo { + self.inner.info() + } + fn read(&self, req: &Request) -> Result { + self.limits.lock().unwrap().push(req.limit); + let mut batch = self.inner.read(req)?; + if self.large.load(Ordering::Relaxed) + && let Some(f) = batch.features.first_mut() + { + f.attrs.push(Value::Text("x".repeat(256 * 1024))); + } + Ok(batch) + } + } + let mut ds = Dataset::new("points", GeometryType::Point, vec![]); + for i in 0..1000 { + ds.push(Some(Geometry::Point(Coord::new(i as f64 * 0.01, 0.0))), vec![]); + } + let (bytes, _) = rustgis_data::io::write_bytes(&ds, "fgb").unwrap(); + let source = Counted { + inner: rustgis_data::source::open_bytes("points.fgb", bytes, None).unwrap(), + limits: Mutex::new(vec![]), + large: AtomicBool::new(false), + }; + let budget = 128 * 1024; + let projection = crate::Projection::WebMercator; + let (batch, cache) = read(&source, &Request::all(), budget, &projection, 0).unwrap(); + assert!(!batch.features.is_empty()); + source.limits.lock().unwrap().clear(); + let (batch, _) = read(&source, &Request::all(), budget, &projection, cache.mean_bytes).unwrap(); + let limits = source.limits.lock().unwrap(); + assert_eq!(limits.len(), 1, "a trusted estimate still triggered provider probes"); + assert!(limits[0] > 128, "fixture must cover the old adapter's redundant-probe path"); + drop(limits); + assert!(!batch.features.is_empty()); + source.large.store(true, Ordering::Relaxed); + source.limits.lock().unwrap().clear(); + let result = read(&source, &Request::all(), budget, &projection, cache.mean_bytes); + assert!(matches!(result, Err(DataError::Format(s)) if s.contains("one feature exceeds"))); + assert_eq!(source.limits.lock().unwrap().len(), 1); + } +} diff --git a/crates/map/src/view_memory.rs b/crates/map/src/view_memory.rs new file mode 100644 index 000000000..5ee71326d --- /dev/null +++ b/crates/map/src/view_memory.rs @@ -0,0 +1,162 @@ +//! Memoized allocation accounting. Weak identities invalidate on copy-on-write mutation without +//! retaining old windows or forcing a deep clone of their rows. +use rustgis_data::{Feature, Features, Geometry, source::memory}; +use rustgis_geom::Extent; +use std::sync::{Arc, Mutex, Weak}; + +type Levels = Vec<(i32, Option>>>)>; + +#[derive(Debug)] +struct Allocation { + key: Weak>, + bytes: usize, +} + +impl Default for Allocation { + fn default() -> Self { + Self { key: Weak::new(), bytes: 0 } + } +} + +impl Allocation { + fn get(&mut self, key: Weak>, measure: impl FnOnce() -> usize) -> usize { + if !self.key.ptr_eq(&key) { + self.bytes = measure(); + self.key = key; + } + self.bytes + } +} + +#[derive(Debug, Default)] +struct State { + rows: Allocation, + projected: Allocation>, + levels: Vec>>, +} + +#[derive(Clone, Debug, Default)] +pub(super) struct Cache(Arc>); + +impl Cache { + fn with(&self, f: impl FnOnce(&mut State) -> usize) -> usize { + // A poisoned memo contains no authoritative data: recover and remeasure as needed. + let mut state = self.0.lock().unwrap_or_else(|e| e.into_inner()); + f(&mut state) + } + + pub(super) fn seed(&self, rows: &Features, projected: &Arc>>, row_bytes: usize, projected_bytes: usize) { + self.with(|s| { + s.rows = Allocation { key: rows.allocation_key(), bytes: row_bytes }; + s.projected = Allocation { key: Arc::downgrade(projected), bytes: projected_bytes }; + 0 + }); + } + + pub(super) fn base(&self, rows: &Features, projected: &Arc>>, extents: &Vec) -> usize { + self.with(|s| s.base(rows, projected, extents)) + } + + pub(super) fn total( + &self, + rows: &Features, + projected: &Arc>>, + extents: &Vec, + levels: &Arc, + ) -> usize { + self.with(|s| { + let mut bytes = s.base(rows, projected, extents); + for c in levels.iter().filter_map(|(_, c)| c.as_ref()) { + let key = Arc::downgrade(c); + let extra = if let Some(old) = s.levels.iter().find(|old| old.key.ptr_eq(&key)) { + old.bytes + } else { + let bytes = crate::lod::cache_bytes(c); + s.levels.push(Allocation { key, bytes }); + bytes + }; + bytes = bytes.saturating_add(extra); + } + // LRU reordering changes the outer Vec but not the geometry allocations. Measure + // only new copies, and keep a bounded set of weak entries for the current levels. + s.levels.retain(|old| levels.iter().filter_map(|(_, c)| c.as_ref()).any(|c| old.key.ptr_eq(&Arc::downgrade(c)))); + bytes + }) + } +} + +impl State { + fn base(&mut self, rows: &Features, projected: &Arc>>, extents: &Vec) -> usize { + self.rows + .get(rows.allocation_key(), || memory::features_bytes(rows)) + .saturating_add(self.projected.get(Arc::downgrade(projected), || crate::lod::cache_bytes(projected))) + .saturating_add(extents.capacity().saturating_mul(std::mem::size_of::())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unchanged_allocations_reuse_totals_and_weak_keys_do_not_retain_rows() { + let mut rows: Features = vec![Feature { id: 1, geometry: None, attrs: vec![] }].into(); + let key = rows.allocation_key(); + let mut measured = Allocation::default(); + assert_eq!(measured.get(key.clone(), || 123), 123); + for _ in 0..100 { + assert_eq!(measured.get(rows.allocation_key(), || panic!("unchanged rows were walked again")), 123); + } + // There is no strong snapshot here: even an in-place edit must invalidate the memo. + rows[0].attrs.push(rustgis_data::Value::Text("changed".into())); + assert!(!key.ptr_eq(&rows.allocation_key())); + assert!(key.upgrade().is_none()); + let used = memory::features_bytes(&rows); + assert_eq!(measured.get(rows.allocation_key(), || used), used); + let key = rows.allocation_key(); + drop(rows); + assert!(key.upgrade().is_none(), "accounting must not keep an evicted window alive"); + } + #[test] + fn accounting_tracks_attributes_geometry_lod_snapshots_and_eviction() { + let mut ds = rustgis_data::Dataset::new( + "memory", + rustgis_data::GeometryType::Polyline, + vec![rustgis_data::Field::new("text", rustgis_data::FieldType::Text)], + ); + ds.push(Some(Geometry::Line(vec![vec![rustgis_geom::Coord::new(0.0, 0.0); 100]])), vec![rustgis_data::Value::Text("x".into())]); + let mut layer = crate::Layer::new(1, ds); + let exact = |l: &crate::Layer| { + memory::features_bytes(&l.dataset.features) + + crate::lod::cache_bytes(&l.projected) + + l.projected_extents.capacity() * std::mem::size_of::() + + l.thinned.iter().filter_map(|(_, g)| g.as_ref()).map(|g| crate::lod::cache_bytes(g)).sum::() + }; + layer.projected(); + let original = layer.view_cache_bytes(); + assert_eq!(original, exact(&layer)); + let old = layer.clone(); + layer.dataset.features[0].attrs[0] = rustgis_data::Value::Text("x".repeat(4096)); + let edited = layer.view_cache_bytes(); + assert!(edited > original); + assert_eq!(edited, exact(&layer)); + assert_eq!(old.view_cache_bytes(), original); + assert_eq!(layer.view_cache_bytes(), edited); + // Public row mutation is detected without requiring a geometry revision bump. + layer.dataset.features[0].geometry = Some(Geometry::Line(vec![vec![rustgis_geom::Coord::new(0.0, 0.0); 500]])); + assert_eq!(layer.view_cache_bytes(), exact(&layer)); + Arc::make_mut(&mut layer.projected).reserve(100); + Arc::make_mut(&mut layer.projected_extents).reserve(100); + assert_eq!(layer.view_cache_bytes(), exact(&layer)); + let copy = Arc::new(vec![Some(Geometry::Line(vec![vec![rustgis_geom::Coord::new(0.0, 0.0); 30]]))]); + Arc::make_mut(&mut layer.thinned).push((5, Some(copy))); + assert_eq!(layer.view_cache_bytes(), exact(&layer)); + Arc::make_mut(&mut layer.thinned).insert(0, (6, None)); + assert_eq!(layer.view_cache_bytes(), exact(&layer)); + layer.clear_cache(); + assert_eq!(layer.view_cache_bytes(), memory::features_bytes(&layer.dataset.features)); + layer.install(Vec::new()); + assert_eq!(layer.view_cache_bytes(), 0); + assert_eq!(old.view_cache_bytes(), original); + } +} diff --git a/crates/render/src/budget_tests.rs b/crates/render/src/budget_tests.rs new file mode 100644 index 000000000..bd38a9862 --- /dev/null +++ b/crates/render/src/budget_tests.rs @@ -0,0 +1,38 @@ +//! A small interaction budget must not change a full-resolution map export. +use super::*; +use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}; + +#[test] +fn export_after_budgeted_interaction_matches_the_original_full_source() { + let mut data = Dataset::new("budget export", GeometryType::Point, vec![Field::new("label", FieldType::Text)]); + for i in 0..80 { + data.push(Some(Geometry::Point(Coord::new((i % 10) as f64 * 0.01, (i / 10) as f64 * 0.01))), vec![Value::Text(format!("p{i}"))]); + } + let (bytes, _) = rustgis_data::io::write_bytes(&data, "fgb").unwrap(); + let original = rustgis_data::source::open_bytes("export.fgb", bytes, None).unwrap(); + let mut source = rustgis_map::source::SourceLayer::from_path("memory.fgb", None, original); + source.byte_budget = 1024; + let mut streamed = Map::new("budget export"); + let id = streamed.add_streamed("budget export", source); + let extent = Extent::new(-1.0, -1.0, 1.0, 1.0); + assert!(!streamed.layer_mut(id).unwrap().sync_source(&extent, true)); + assert!(streamed.layer(id).unwrap().dataset.features.len() < 80); + let mut expected = streamed.clone(); + expected.layer_mut(id).unwrap().source = None; + expected.layer_mut(id).unwrap().dataset = streamed.layer(id).unwrap().load_all().unwrap(); + for map in [&mut streamed, &mut expected] { + map.camera = Camera::new(rustgis_geom::lonlat_to_merc(Coord::new(0.045, 0.035)), 55.0); + let layer = map.layer_mut(id).unwrap(); + layer.labels.enabled = true; + layer.labels.default.field = "label".into(); + layer.transparency = 0.25; + layer.definition_query = Some("label <> 'p3'".into()); + } + let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: streamed.camera, sky: false }; + let reference = render_complete(&mut expected, &view).unwrap(); + assert!(!reference.labels.is_empty(), "the reference must exercise real label placement"); + let actual = render_complete(&mut streamed, &view).unwrap(); + assert_eq!(streamed.layer(id).unwrap().dataset.features.len(), 80); + assert!(reference.pixmap.data() == actual.pixmap.data(), "interaction sampling changed export pixels"); + assert_eq!(reference.labels.iter().map(|l| &l.text).collect::>(), actual.labels.iter().map(|l| &l.text).collect::>()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5ac4c43e7..940c553e8 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -4876,3 +4876,6 @@ mod tests { assert_eq!(tilt_sky(&m), (space, space)); } } + +#[cfg(test)] +mod budget_tests; diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index bb070aabc..15062c6cc 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -374,6 +374,29 @@ open; a browser upload is kept in the project. | GeoPackage, the same grid (344 MB) | 1.6 million polygons | 183,000 in 0.18 s | | GeoParquet, global grid (190 MB) | 6.5 million polygons | a sample of 136,000 in 0.27 s | +### Streamed view memory + +Interaction windows share a retained-memory budget across all source layers in a map, +including hidden layers: 128 MiB on the desktop and 48 MiB in the browser. The budget +counts allocated row capacity, attribute strings, nested geometry buffers, projected +geometry, extent arrays and optional display geometry. Complex records can therefore +reduce the interaction sample even below the existing feature-count limit. Source ids, +attributes and file order are retained; the sample is marked as incomplete. An individual +feature that cannot fit produces a layer error instead of silently disappearing. + +Projection is prepared with the window on the source worker. Optional thinned geometry +uses the remaining budget, up to 64 MiB per layer; when it cannot fit, drawing uses the +original projected geometry. Explicitly loaded layers retain their full source data and +limit only their optional display caches. + +This is a budget for installed interaction caches, not a process-RSS or GPU-memory cap. +Provider decoding scratch, in-flight reads, shared snapshots and explicit full reads are +outside it. The generic provider adapter probes a small sample and trims the returned +window; it does not bound the provider's transient decoding allocations. Blocking map +exports retain their original row limits and reread instead of reusing a budget-reduced +interaction window. Full-source loading, analysis, editing, data exports and project +serialization continue to use the original source data and existing read limits. + ## Rasters
From 9d47ee6d4d7ec1b183e7ab8f991f4f1227c40d85 Mon Sep 17 00:00:00 2001 From: yongyin-leon Date: Thu, 22 Oct 2026 11:35:20 +0800 Subject: [PATCH 015/297] feat(data): cache streamed Shapefile spatial indexes on disk (#668) * feat(data): cache streamed Shapefile spatial indexes on disk * fix(data): secure Unix cache roots and test contended cleanup * test(data): make cache fixture permissions independent of umask * test(data): secure cleanup fixture leases under group-writable umasks * test(data): give simulated cache payloads private Unix permissions * test(data): create Shapefile index fixtures with private Unix directories --------- Co-authored-by: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> --- Cargo.lock | 1 + crates/data/Cargo.toml | 4 + crates/data/src/source/shp.rs | 113 ++--- crates/data/src/source/shp/cache.rs | 466 +++++++++++++++++++ crates/data/src/source/shp/cache/tests.rs | 528 ++++++++++++++++++++++ crates/data/src/source/shp/index.rs | 259 +++++++++++ crates/data/src/source/shp/tests.rs | 238 ++++++++++ crates/data/src/source/tests.rs | 16 +- docs/user-guide/data.md | 29 +- 9 files changed, 1576 insertions(+), 78 deletions(-) create mode 100644 crates/data/src/source/shp/cache.rs create mode 100644 crates/data/src/source/shp/cache/tests.rs create mode 100644 crates/data/src/source/shp/index.rs create mode 100644 crates/data/src/source/shp/tests.rs diff --git a/Cargo.lock b/Cargo.lock index a5db5a9ee..ec259165d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4521,6 +4521,7 @@ dependencies = [ "roxmltree", "rusqlite", "rustgis-geom", + "rustix 1.1.5", "rustls", "ruzstd", "serde", diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index 01228f6ef..cad9f3d08 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -55,6 +55,10 @@ webpki-roots = { workspace = true } # Private temporary workspaces for downloaded JPEG 2000 conversion through GDAL. tempfile = "3" +[target.'cfg(all(unix, not(target_arch = "wasm32")))'.dependencies] +# Safe effective-UID and no-follow operations for private Shapefile index caches. +rustix = { version = "1.1", features = ["process", "fs"] } + [dev-dependencies] # Writing sample GeoParquet files. parquet = { workspace = true, features = ["arrow"] } diff --git a/crates/data/src/source/shp.rs b/crates/data/src/source/shp.rs index 52275cdeb..95024f0ba 100644 --- a/crates/data/src/source/shp.rs +++ b/crates/data/src/source/shp.rs @@ -1,8 +1,8 @@ //! Shapefiles read in place: a large `.shp` with its `.dbf` and `.prj`, a view at a time. //! -//! Opening walks the `.shp` once, keeping each record's place and bounding box (24 bytes a -//! record); a read picks the records whose boxes meet the request, then reads just those shapes -//! and `.dbf` rows by their offsets. Smaller Shapefiles are still read whole by `io::read_path`, +//! Opening builds or reuses a disk-backed R-tree and record directory. Queries use a bounded +//! SQLite page cache, then read matching shapes and `.dbf` rows by their offsets. +//! Smaller Shapefiles are still read whole by `io::read_path`, //! so they can be edited. //! //! The files are hostile input: record lengths, part and point counts are checked against the @@ -12,7 +12,10 @@ use std::fs::File; use std::io::{BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; -use super::{Batch, FeatureSource, Reproject, Request, SourceInfo, clamp_limit, stride}; +mod cache; +mod index; + +use super::{Batch, FeatureSource, Reproject, Request, SourceInfo, clamp_limit}; use crate::io::group_rings; use crate::{DataError, Feature, Field, FieldType, Geometry, GeometryType, Result, Value}; use rustgis_geom::{Coord, Extent}; @@ -56,9 +59,7 @@ pub struct ShpSource { info: SourceInfo, shp: PathBuf, dbf: PathBuf, - places: Vec, - /// Each record's box in the file's coordinates, rounded outward (NaN for a null shape). - boxes: Vec<[f32; 4]>, + index: index::Index, table: Table, reproject: Reproject, } @@ -98,36 +99,10 @@ impl ShpSource { le_f64(&header, 52).unwrap_or(0.0), le_f64(&header, 60).unwrap_or(0.0), ); - // Walk the records by their headers: each one's place and box. - let (mut places, mut boxes) = (Vec::new(), Vec::new()); - let mut pos = 100u64; - let mut head = [0u8; 8 + 36]; - while pos + 8 <= shp_len { - let want = (shp_len - pos).min(head.len() as u64) as usize; - let Some(buf) = head.get_mut(..want) else { break }; - if f.read_exact(buf).is_err() { - break; - } - let len = u64::try_from(be_i32(&head, 4).unwrap_or(-1)).unwrap_or(u64::MAX).saturating_mul(2); - let content = pos + 8; - if len < 4 || content.saturating_add(len) > shp_len { - notes.push(format!("the .shp ends partway through record {}; the rest are left out", places.len() + 1)); - break; - } - // Only this record's bytes: a short one (a null shape, or a damaged one) mustn't read - // its box from the next record's header. - let c = head.get(8..(want as u64).min(8 + len) as usize).unwrap_or(&[]); - boxes.push(record_box(c)); - places.push(Place { offset: content as u32, len: len as u32 }); - pos = content + len; - // On to the next record's header, past what was read of this one. - let read = (want as u64).saturating_sub(8); - if f.seek_relative(len as i64 - read as i64).is_err() { - break; - } - } - if table.rows != places.len() as u64 { - notes.push(format!("the .shp holds {} shapes and the .dbf {} rows; rows are matched by position", places.len(), table.rows)); + drop(f); + let index = index::Index::open(path, shp_len, &mut notes)?; + if table.rows != index.count { + notes.push(format!("the .shp holds {} shapes and the .dbf {} rows; rows are matched by position", index.count, table.rows)); } let reproject = shp_reproject(crate::raster::read_prj(path).as_deref(), &mut notes); let extent = if native.is_empty() || !native.min_x.is_finite() { Extent::EMPTY } else { reproject.extent_to_lonlat(&native) }; @@ -144,27 +119,12 @@ impl ShpSource { geometry_type, fields, extent, - count: Some(places.len() as u64), + count: Some(index.count), crs: reproject.label(), - indexed: false, + indexed: index.indexed(), notes, }; - Ok(ShpSource { info, shp: path.to_path_buf(), dbf, places, boxes, table, reproject }) - } - - /// The records a request matches, in file order: every one without an extent; otherwise - /// those whose boxes meet it (null shapes never do). - fn matches(&self, req: &Request) -> impl Iterator + '_ { - // Rounded to the nearest f32: never past a box rounded outward that meets the extent. - let e = req - .extent - .map(|e| self.reproject.extent_from_lonlat(&e)) - .map(|e| (e.is_empty(), [e.min_x as f32, e.min_y as f32, e.max_x as f32, e.max_y as f32])); - self.boxes.iter().enumerate().filter_map(move |(i, b)| match e { - None => Some(i as u32), - Some((true, _)) => None, - Some((false, [x0, y0, x1, y1])) => (b[0] <= x1 && b[2] >= x0 && b[1] <= y1 && b[3] >= y0).then_some(i as u32), - }) + Ok(ShpSource { info, shp: path.to_path_buf(), dbf, index, table, reproject }) } } @@ -175,54 +135,55 @@ impl FeatureSource for ShpSource { fn read(&self, req: &Request) -> Result { let limit = clamp_limit(req.limit); - // Counted first, then the sample taken: no list of every match. - let matched = self.matches(req).count(); - let step = stride(matched as u64, limit).max(1) as usize; - let wanted: Vec = self.matches(req).step_by(step).take(limit).collect(); + let extent = req.extent.map(|e| self.reproject.extent_from_lonlat(&e)); + if extent.is_some_and(|e| e.is_empty()) { + return Ok(Batch { complete: super::stride(0, limit) == 1, ..Batch::default() }); + } + let bounds = extent.map(|e| [e.min_x as f32, e.min_y as f32, e.max_x as f32, e.max_y as f32]); let mut shp = Cursor::open(&self.shp)?; let mut dbf = Cursor::open(&self.dbf)?; let mut buf = Vec::new(); let mut row = vec![0u8; self.table.row_len]; - let mut features = Vec::with_capacity(wanted.len()); + let mut features = Vec::new(); let mut skipped = 0; - for i in wanted { - let Some(place) = self.places.get(i as usize) else { continue }; + let (matched, complete) = self.index.visit(bounds, limit, |i, place| { buf.resize(place.len as usize, 0); if shp.read_at(u64::from(place.offset), &mut buf).is_err() { skipped += 1; - continue; + return Ok(()); } let geometry = match parse_shape(&buf) { Ok(Some(g)) => match self.reproject.geometry(g) { Some(g) => Some(g), None => { skipped += 1; - continue; + return Ok(()); } }, Ok(None) => None, Err(()) => { skipped += 1; - continue; + return Ok(()); } }; let attrs = if u64::from(i) < self.table.rows { let at = self.table.header_len + u64::from(i) * self.table.row_len as u64; if dbf.read_at(at, &mut row).is_err() { skipped += 1; - continue; + return Ok(()); } // A deleted row: its shape is left out too. if row.first() == Some(&b'*') { - continue; + return Ok(()); } self.table.columns.iter().map(|c| value(c, row.get(1 + c.offset..1 + c.offset + c.len).unwrap_or(&[]))).collect() } else { vec![Value::Null; self.table.columns.len()] }; features.push(Feature { id: u64::from(i) + 1, geometry, attrs }); - } - Ok(Batch { features, matched: matched as u64, complete: step == 1, skipped }) + Ok(()) + })?; + Ok(Batch { features, matched, complete, skipped }) } } @@ -480,3 +441,17 @@ fn shp_reproject(prj: Option<&str>, notes: &mut Vec) -> Reproject { }; Reproject::from_crs(crs, notes) } + +#[cfg(test)] +mod tests; + +#[cfg(test)] +pub(super) fn with_test_cache(root: PathBuf, run: impl FnOnce() -> T) -> T { + cache::with_index_dir(root.clone(), || { + let result = run(); + if root.exists() { + assert_eq!(cache::maintain(&root, 0).unwrap().remaining_bytes, 0); + } + result + }) +} diff --git a/crates/data/src/source/shp/cache.rs b/crates/data/src/source/shp/cache.rs new file mode 100644 index 000000000..981a5182e --- /dev/null +++ b/crates/data/src/source/shp/cache.rs @@ -0,0 +1,466 @@ +//! Native disposable caches. A directory lock serializes publication and eviction; +//! per-entry leases protect readers/builders across processes, including PID reuse. +use std::collections::BinaryHeap; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Seek, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MIB: u64 = 1024 * 1024; +const CANDIDATES: usize = 256; +pub const INDEX_DEFAULT_BYTES: u64 = 2048 * MIB; + +pub fn index_budget() -> u64 { + std::env::var("RUSTGIS_SHP_CACHE_MIB").ok().and_then(|s| s.parse::().ok()).unwrap_or(INDEX_DEFAULT_BYTES / MIB).clamp(64, 65536) + * MIB +} +pub fn index_dir() -> PathBuf { + #[cfg(test)] + if let Some(root) = TEST_INDEX_DIR.with(|root| root.borrow().clone()) { + return root; + } + crate::source::external::cache_dir("RUSTGIS_SHP_CACHE", "shp-index").unwrap_or_else(index_fallback_dir) +} + +// Test-local routing avoids process-wide environment mutation and interference with +// parallel tests or the user's persistent indexes. Production always uses the path above. +#[cfg(test)] +thread_local! { + static TEST_INDEX_DIR: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} +#[cfg(test)] +pub(crate) fn with_index_dir(root: PathBuf, run: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + TEST_INDEX_DIR.with(|root| *root.borrow_mut() = self.0.take()); + } + } + let _restore = Restore(TEST_INDEX_DIR.with(|current| current.replace(Some(root)))); + run() +} +/// Separate Unix users even when the system temporary directory is shared. +pub fn index_fallback_dir() -> PathBuf { + #[cfg(unix)] + let name = format!("rustgis-shp-index-{}", rustix::process::geteuid().as_raw()); + #[cfg(not(unix))] + let name = "rustgis-shp-index"; + std::env::temp_dir().join(name) +} + +/// Never follow a lease symlink or open a special file, including after a rename race. +fn lease_file(path: &Path, create: bool) -> io::Result { + if std::fs::symlink_metadata(path).is_ok_and(|m| !m.file_type().is_file()) { + return Err(io::Error::other("cache lease is not a regular file")); + } + let mut options = OpenOptions::new(); + options.read(true).write(true).create(create).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags((rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::NONBLOCK).bits() as i32); + } + let file = options.open(path)?; + let metadata = file.metadata()?; + if !metadata.is_file() { + return Err(io::Error::other("cache lease is not a regular file")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != rustix::process::geteuid().as_raw() || metadata.mode() & 0o022 != 0 { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "cache lease is not private to this user")); + } + } + Ok(file) +} +fn open_lock(path: &Path) -> io::Result { + lease_file(path, true) +} + +/// Resolve once, validate the actual storage, and never use an unstable alias again. +fn validate_directory(root: &Path) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let uid = rustix::process::geteuid().as_raw(); + // Strip trailing slash/dot spellings before lstat: those can otherwise follow + // a final symlink even though symlink_metadata normally does not follow it. + let absolute: PathBuf = std::path::absolute(root)?.components().collect(); + let metadata = std::fs::symlink_metadata(&absolute)?; + if !metadata.file_type().is_dir() || metadata.uid() != uid || metadata.mode() & 0o022 != 0 { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "cache root is not private to this user")); + } + let resolved = absolute.canonicalize()?; + for (depth, parent) in resolved.ancestors().enumerate() { + let metadata = std::fs::symlink_metadata(parent)?; + let untrusted_owner = metadata.uid() != uid && (depth == 0 || metadata.uid() != 0); + let writable = metadata.mode() & 0o022 != 0; + if !metadata.file_type().is_dir() || untrusted_owner || (writable && (depth == 0 || metadata.mode() & 0o1000 == 0)) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "cache path is not trusted")); + } + } + Ok(resolved) + } + #[cfg(not(unix))] + { + // Preserve the platform's existing directory/junction behavior outside Unix. + if !std::fs::metadata(root)?.is_dir() { + return Err(io::Error::other("cache root is not a directory")); + } + Ok(root.to_path_buf()) + } +} + +#[derive(Debug)] +struct Directory { + root: PathBuf, + file: File, +} + +/// Shared validation for blocking checkout and nonblocking release paths. +fn directory_file(root: &Path) -> io::Result { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(root)?; + let root = validate_directory(root)?; + let file = open_lock(&root.join(".rustgis-cache-v1.lock"))?; + Ok(Directory { root, file }) +} +fn directory_lock(root: &Path) -> io::Result { + let directory = directory_file(root)?; + directory.file.lock()?; + Ok(directory) +} +fn lease_path(path: &Path) -> PathBuf { + path.with_extension("lease") +} +fn state(file: &mut File) -> io::Result { + file.rewind()?; + let mut value = [0u8]; + file.read_exact(&mut value)?; + Ok(value[0]) +} +fn mark(file: &mut File, value: u8) -> io::Result<()> { + file.rewind()?; + file.write_all(&[value])?; + file.set_len(1)?; + file.flush()?; + file.set_modified(SystemTime::now()) +} +fn unique() -> String { + static SERIAL: AtomicU64 = AtomicU64::new(0); + format!( + "{}-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(), + SERIAL.fetch_add(1, Ordering::Relaxed) + ) +} +fn regular(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|m| { + if !m.file_type().is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if m.uid() != rustix::process::geteuid().as_raw() || m.mode() & 0o022 != 0 { + return false; + } + } + true + }) +} +fn owned(name: &str) -> bool { + if let Some(key) = name.strip_prefix("rg-index-v1-").and_then(|s| s.strip_suffix(".sqlite")) { + return key.len() == 33 && key.bytes().enumerate().all(|(i, c)| if i == 16 { c == b'-' } else { c.is_ascii_hexdigit() }); + } + let id = name.strip_prefix("rg-index-work-v1-").and_then(|s| s.strip_suffix(".sqlite")); + let Some(id) = id else { return false }; + let mut parts = id.split('-'); + parts.next().is_some_and(|s| s.parse::().is_ok_and(|p| p > 0)) + && parts.next().is_some_and(|s| s.parse::().is_ok()) + && parts.next().is_some_and(|s| s.parse::().is_ok()) + && parts.next().is_none() +} +fn existing_lock(path: &Path) -> io::Result { + if !regular(path) { + return Err(io::Error::other("missing regular cache lease")); + } + lease_file(path, false) +} + +/// Kept after the SQLite connection. The sidecar stays locked for the +/// whole lifetime, rather than just during I/O. No PID-based assumption for new caches. +pub struct Lease { + path: PathBuf, + lock: Option, + temporary: bool, + ready: bool, + budget: u64, +} +impl Lease { + pub fn path(&self) -> &Path { + &self.path + } + pub fn commit(&mut self) -> io::Result<()> { + if !self.temporary && self.path.metadata()?.len() > self.budget { + self.temporary = true; + } + let root = self.path.parent().ok_or_else(|| io::Error::other("missing cache directory"))?; + let directory = directory_lock(root)?; + // SQLite uses disposable build settings. Flush its completed pages before + // recording readiness, so a power loss cannot publish an unflushed index. + if !self.temporary { + File::options().read(true).write(true).open(&self.path)?.sync_all()?; + } + let file = self.lock.as_mut().ok_or_else(|| io::Error::other("missing cache lease"))?; + mark(file, if self.temporary { b'T' } else { b'C' })?; + if !self.temporary { + file.sync_all()?; + } + // The directory lock closes the downgrade/open/eviction race on Unix as well. + file.unlock()?; + file.lock_shared()?; + self.ready = true; + drop(directory); + let _ = maintain(root, self.budget); + Ok(()) + } +} +impl Drop for Lease { + fn drop(&mut self) { + let Some(root) = self.path.parent() else { return }; + // A release during another sweep must not deadlock. That sweep, the next + // checkout, or the next startup will reclaim any now-unlocked temporary file. + if self.ready + && !self.temporary + && let Some(file) = &self.lock + { + let _ = file.set_modified(SystemTime::now()); + } + self.lock.take(); + let Ok(directory) = directory_file(root) else { return }; + if directory.file.try_lock().is_err() { + retry_maintenance(root, self.budget); + return; + } + if self.temporary || !self.ready { + let _ = remove_unpinned(&self.path); + } + let _ = prune_locked(&directory.root, self.budget); + } +} + +// Releases racing a sweep need one later pass: the sweep may already have counted +// that entry as pinned. Coalesce roots on one bounded worker, never one thread per drop. +fn retry_maintenance(root: &Path, budget: u64) { + use std::collections::BTreeMap; + use std::sync::{Arc, Condvar, Mutex, OnceLock}; + type Jobs = Arc<(Mutex>, Condvar)>; + static WORKER: OnceLock> = OnceLock::new(); + let jobs = WORKER.get_or_init(|| { + let jobs: Jobs = Arc::new((Mutex::new(BTreeMap::new()), Condvar::new())); + let worker = jobs.clone(); + std::thread::Builder::new() + .name("rustgis-cache-retry".into()) + .spawn(move || { + loop { + let job = { + let (queue, wake) = &*worker; + let mut queue = queue.lock().unwrap_or_else(|e| e.into_inner()); + while queue.is_empty() { + queue = wake.wait(queue).unwrap_or_else(|e| e.into_inner()); + } + queue.pop_first() + }; + if let Some((root, budget)) = job + && root.is_dir() + { + let _ = maintain(&root, budget); + } + } + }) + .ok() + .map(|_| jobs) + }); + if let Some(jobs) = jobs { + let (queue, wake) = &**jobs; + let mut queue = queue.lock().unwrap_or_else(|e| e.into_inner()); + if queue.contains_key(root) || queue.len() < 64 { + queue.entry(root.to_path_buf()).and_modify(|b| *b = (*b).min(budget)).or_insert(budget); + wake.notify_one(); + } + } +} + +/// Called under the directory lock; excludes symlinks and active readers/builders. +fn remove_unpinned(path: &Path) -> io::Result { + if !regular(path) { + return Ok(false); + } + let sidecar = lease_path(path); + let lock = existing_lock(&sidecar)?; + if lock.try_lock().is_err() { + return Ok(false); + } + std::fs::remove_file(path)?; + drop(lock); + let _ = std::fs::remove_file(sidecar); + Ok(true) +} + +fn create_locked(path: PathBuf, temporary: bool, budget: u64) -> io::Result<(Lease, File)> { + let mut lock = open_lock(&lease_path(&path))?; + lock.try_lock().map_err(io::Error::other)?; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + // Establish ownership before creating the payload. A missing or invalid lease + // never authorizes deleting a similarly named unrelated file during a sweep. + mark(&mut lock, if temporary { b'T' } else { b'B' })?; + let file = options.open(&path)?; + Ok((Lease { path, lock: Some(lock), temporary, ready: false, budget }, file)) +} + +/// Acquire a ready index before opening SQLite. Only names from this protocol are managed. +pub fn pin_index(root: &Path, key: &str, budget: u64) -> io::Result> { + let directory = directory_lock(root)?; + let path = index_path(&directory.root, key)?; + if !regular(&path) { + return Ok(None); + } + let mut lock = open_lock(&lease_path(&path))?; + if lock.try_lock_shared().is_err() || state(&mut lock).ok() != Some(b'C') { + return Ok(None); + } + lock.set_modified(SystemTime::now())?; + Ok(Some(Lease { path, lock: Some(lock), temporary: false, ready: true, budget })) +} +fn index_path(root: &Path, key: &str) -> io::Result { + if key.len() != 33 || !key.bytes().enumerate().all(|(i, c)| if i == 16 { c == b'-' } else { c.is_ascii_hexdigit() }) { + return Err(io::Error::other("invalid cache key")); + } + Ok(root.join(format!("rg-index-v1-{key}.sqlite"))) +} +pub fn reserve_index(root: &Path, key: &str, budget: u64) -> io::Result { + let directory = directory_lock(root)?; + let path = index_path(&directory.root, key)?; + // A valid generation pinned by another reader is never replaced. Concurrent + // builders can use a private temporary index without losing functionality. + let mut lock = open_lock(&lease_path(&path))?; + let available = lock.try_lock().is_ok(); + let managed = available && matches!(state(&mut lock).ok(), Some(b'C' | b'B' | b'T')); + if available && (!path.exists() || (managed && regular(&path) && std::fs::remove_file(&path).is_ok())) { + drop(lock); + let (lease, file) = create_locked(path, false, budget)?; + drop(file); + return Ok(lease); + } + drop(lock); + let (lease, file) = create_locked(directory.root.join(format!("rg-index-work-v1-{}.sqlite", unique())), true, budget)?; + drop(file); + Ok(lease) +} + +#[derive(Default, Debug)] +pub struct PruneStats { + pub removed: u64, + pub remaining_bytes: u64, + pub pinned_bytes: u64, +} +pub fn maintain(root: &Path, budget: u64) -> io::Result { + let directory = directory_lock(root)?; + prune_locked(&directory.root, budget) +} +fn prune_locked(root: &Path, budget: u64) -> io::Result { + let mut stats = PruneStats::default(); + loop { + stats.remaining_bytes = 0; + stats.pinned_bytes = 0; + // Bounded metadata even if a cache folder contains millions of files. + let mut oldest = BinaryHeap::new(); + for entry in std::fs::read_dir(root)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(stem) = name.strip_suffix(".lease") { + let payload = root.join(format!("{stem}.sqlite")); + if owned(&payload.file_name().unwrap_or_default().to_string_lossy()) + && std::fs::symlink_metadata(&payload).is_err_and(|e| e.kind() == io::ErrorKind::NotFound) + && let Ok(lock) = open_lock(&entry.path()) + && lock.try_lock().is_ok() + { + drop(lock); + let _ = std::fs::remove_file(entry.path()); + } + continue; + } + if !owned(&name) { + continue; + } + let path = entry.path(); + let bytes = entry.metadata()?.len(); + let Ok(mut lock) = existing_lock(&lease_path(&path)) else { + stats.remaining_bytes = stats.remaining_bytes.saturating_add(bytes); + continue; + }; + if lock.try_lock().is_err() { + stats.remaining_bytes = stats.remaining_bytes.saturating_add(bytes); + stats.pinned_bytes = stats.pinned_bytes.saturating_add(bytes); + continue; + } + let flag = state(&mut lock).ok(); + if !matches!(flag, Some(b'C' | b'B' | b'T')) { + stats.remaining_bytes = stats.remaining_bytes.saturating_add(bytes); + continue; + } + let temporary = flag != Some(b'C') || !name.starts_with("rg-index-v1-"); + if temporary && std::fs::remove_file(&path).is_ok() { + drop(lock); + let _ = std::fs::remove_file(lease_path(&path)); + stats.removed += 1; + continue; + } + stats.remaining_bytes = stats.remaining_bytes.saturating_add(bytes); + oldest.push((lock.metadata()?.modified().unwrap_or(UNIX_EPOCH), name, bytes)); + if oldest.len() > CANDIDATES { + oldest.pop(); + } + } + if stats.remaining_bytes <= budget { + break; + } + let mut removed = false; + for (_, name, bytes) in oldest.into_sorted_vec() { + if stats.remaining_bytes <= budget { + break; + } + if remove_unpinned(&root.join(name)).unwrap_or(false) { + stats.remaining_bytes = stats.remaining_bytes.saturating_sub(bytes); + stats.removed += 1; + removed = true; + } + } + if !removed || stats.remaining_bytes <= budget { + break; + } + } + Ok(stats) +} + +#[cfg(test)] +mod tests; diff --git a/crates/data/src/source/shp/cache/tests.rs b/crates/data/src/source/shp/cache/tests.rs new file mode 100644 index 000000000..32cf7369e --- /dev/null +++ b/crates/data/src/source/shp/cache/tests.rs @@ -0,0 +1,528 @@ +use super::*; +use crate::source::FeatureSource; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +struct Folder(PathBuf); +impl Folder { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("rustgis-cache-test-{}", unique())); + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&path).unwrap(); + Self(path) + } +} +impl Drop for Folder { + fn drop(&mut self) { + // Only the unique direct child of the test temp directory belongs to this fixture. + if self.0.parent() == Some(std::env::temp_dir().as_path()) { + let _ = std::fs::remove_dir_all(&self.0); + } + } +} +fn key(n: usize) -> String { + format!("{n:016x}-0000000000000001") +} + +#[test] +fn cache_test_index_roots_are_scoped_and_thread_local() { + let first = Folder::new(); + let second = Folder::new(); + let original = index_dir(); + with_index_dir(first.0.clone(), || { + assert_eq!(index_dir(), first.0); + let outer = first.0.clone(); + std::thread::spawn(move || assert_ne!(index_dir(), outer)).join().unwrap(); + let result = std::panic::catch_unwind(|| with_index_dir(second.0.clone(), || panic!("test unwind"))); + assert!(result.is_err()); + assert_eq!(index_dir(), first.0); + }); + assert_eq!(index_dir(), original); +} +fn ready(root: &Path, n: usize, bytes: u64, budget: u64) -> Lease { + let mut lease = reserve_index(root, &key(n), budget).unwrap(); + File::options().write(true).open(lease.path()).unwrap().set_len(bytes).unwrap(); + lease.commit().unwrap(); + lease +} + +#[test] +fn cache_lru_touches_reopened_indexes_and_evicts_oldest_idle() { + let root = Folder::new(); + for n in 1..=3 { + let lease = ready(&root.0, n, 10, 4096); + let path = lease.path().to_path_buf(); + drop(lease); + File::options().write(true).open(lease_path(&path)).unwrap().set_modified(UNIX_EPOCH + Duration::from_secs(n as u64)).unwrap(); + } + let hot = pin_index(&root.0, &key(1), 4096).unwrap().unwrap(); + drop(hot); + let stats = maintain(&root.0, 20).unwrap(); + assert_eq!(stats.remaining_bytes, 20); + assert_eq!(stats.removed, 1); + assert!(index_path(&root.0, &key(1)).unwrap().exists()); + assert!(!index_path(&root.0, &key(2)).unwrap().exists()); + assert!(index_path(&root.0, &key(3)).unwrap().exists()); +} + +#[test] +fn cache_shared_readers_and_oversized_indexes_are_protected_until_release() { + let root = Folder::new(); + let first = ready(&root.0, 1, 10, 4096); + let second = pin_index(&root.0, &key(1), 4096).unwrap().unwrap(); + let path = first.path().to_path_buf(); + assert_eq!(maintain(&root.0, 0).unwrap().pinned_bytes, 10); + drop(first); + assert!(path.exists()); + assert_eq!(maintain(&root.0, 0).unwrap().remaining_bytes, 10); + drop(second); + assert_eq!(maintain(&root.0, 0).unwrap().remaining_bytes, 0); + assert!(!path.exists()); + let oversized = ready(&root.0, 2, 40, 20); + let path = oversized.path().to_path_buf(); + assert_eq!(maintain(&root.0, 20).unwrap().pinned_bytes, 40); + assert!(path.exists()); + // A concurrent sweep can defer release cleanup on Unix as well as Windows. + let sweep = directory_lock(&root.0).unwrap(); + drop(oversized); + assert!(path.exists(), "release must not mutate a directory owned by a sweep"); + drop(sweep); + wait_removed(&path); +} + +fn wait_removed(path: &Path) { + let deadline = Instant::now() + Duration::from_secs(10); + while path.exists() { + assert!(Instant::now() < deadline, "release cleanup was never retried: {}", path.display()); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn cache_release_racing_a_sweep_is_retried_without_another_checkout() { + let root = Folder::new(); + let (lease, file) = temporary_index(&root.0); + drop(file); + let path = lease.path().to_path_buf(); + let sweep = directory_lock(&root.0).unwrap(); + drop(lease); + assert!(path.exists(), "a busy sweep owns directory mutations"); + drop(sweep); + wait_removed(&path); +} + +#[test] +fn cache_concurrent_builder_uses_private_index_without_replacing_reader() { + let root = Folder::new(); + let reader = ready(&root.0, 1, 10, 4096); + let mut private = reserve_index(&root.0, &key(1), 4096).unwrap(); + assert_ne!(reader.path(), private.path()); + File::options().write(true).open(private.path()).unwrap().set_len(30).unwrap(); + private.commit().unwrap(); + assert_eq!(std::fs::metadata(reader.path()).unwrap().len(), 10); + let path = private.path().to_path_buf(); + drop(private); + assert!(!path.exists()); + assert!(reader.path().exists()); +} + +#[test] +fn cache_cleanup_is_bounded_and_preserves_unknown_files_and_orphans_in_use() { + let root = Folder::new(); + std::fs::write(root.0.join("customer.sqlite"), b"keep").unwrap(); + let unleased = root.0.join("rg-index-work-v1-1-2-4.sqlite"); + std::fs::write(&unleased, b"unverified").unwrap(); + assert!(!owned("rg-index-work-v1-abc.sqlite")); + std::fs::write(root.0.join("0000000000000000.sqlite"), b"legacy index: ownership uncertain").unwrap(); + for n in 0..CANDIDATES + 3 { + let path = index_path(&root.0, &key(n)).unwrap(); + std::fs::write(&path, [1]).unwrap(); + std::fs::write(lease_path(&path), b"C").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + std::fs::set_permissions(lease_path(&path), std::fs::Permissions::from_mode(0o600)).unwrap(); + } + } + let orphan = root.0.join("rg-index-work-v1-1-2-3.lease"); + let lock = open_lock(&orphan).unwrap(); + lock.lock().unwrap(); + let stats = maintain(&root.0, 2).unwrap(); + assert_eq!(stats.remaining_bytes, 10); + assert_eq!(stats.removed, (CANDIDATES + 3) as u64); + assert_eq!(std::fs::read(unleased).unwrap(), b"unverified"); + assert!(orphan.exists()); + drop(lock); + maintain(&root.0, 2).unwrap(); + assert!(!orphan.exists()); + assert_eq!(std::fs::read(root.0.join("customer.sqlite")).unwrap(), b"keep"); + assert!(root.0.join("0000000000000000.sqlite").exists()); + assert!(reserve_index(&root.0, "../customer", 2).is_err()); +} + +struct Process(Child, PathBuf); +impl Drop for Process { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +fn spawn(root: &Path, mode: &str) -> Process { + let marker = root.join(format!("ready-{}", unique())); + let child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "source::shp::cache::tests::cache_child_fixture", "--nocapture"]) + .env("RUSTGIS_CACHE_TEST_READY", &marker) + .env("RUSTGIS_CACHE_TEST_ROOT", root) + .env("RUSTGIS_CACHE_TEST_MODE", mode) + .env("RUSTGIS_SHP_CACHE", if matches!(mode, "fallback" | "scan") { root.join("blocked") } else { root.to_path_buf() }) + .env("TMP", root) + .env("TEMP", root) + .env("TMPDIR", root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + Process(child, marker) +} +fn wait_ready(child: &mut Process, root: &Path) -> PathBuf { + let marker = &child.1; + assert!(marker.starts_with(root)); + let deadline = Instant::now() + Duration::from_secs(20); + while !marker.exists() { + assert!(child.0.try_wait().unwrap().is_none(), "cache child exited before holding its lease"); + assert!(Instant::now() < deadline, "cache child did not become ready"); + std::thread::sleep(Duration::from_millis(10)); + } + PathBuf::from(std::fs::read_to_string(marker).unwrap()) +} + +/// Real process death releases OS leases without running any Rust destructors. +#[test] +fn cache_killed_process_is_reclaimed_at_checkout_but_live_instance_is_preserved() { + let root = Folder::new(); + let mut live = spawn(&root.0, "temporary"); + let live_path = wait_ready(&mut live, &root.0); + for mode in ["temporary", "building", "index"] { + let mut dead = spawn(&root.0, mode); + let dead_path = wait_ready(&mut dead, &root.0); + assert!(maintain(&root.0, 0).unwrap().pinned_bytes >= 2048); + if mode == "index" { + let reader = pin_index(&root.0, &key(dead.0.id() as usize), 4096).unwrap().unwrap(); + assert_eq!(reader.path(), dead_path); + } + dead.0.kill().unwrap(); + dead.0.wait().unwrap(); + let mut cleaner = spawn(&root.0, "sweep"); + assert!(cleaner.0.wait().unwrap().success()); + if mode == "index" { + assert!(dead_path.exists(), "a ready index below budget must survive restart"); + maintain(&root.0, 0).unwrap(); + } + assert!(!dead_path.exists()); + assert!(live_path.exists(), "checkout deleted another running instance's temporary index"); + } + live.0.kill().unwrap(); + live.0.wait().unwrap(); + let mut cleaner = spawn(&root.0, "sweep"); + assert!(cleaner.0.wait().unwrap().success()); + assert!(!live_path.exists()); +} + +#[test] +fn cache_child_fixture() { + let Some(root) = std::env::var_os("RUSTGIS_CACHE_TEST_ROOT").map(PathBuf::from) else { return }; + let mode = std::env::var("RUSTGIS_CACHE_TEST_MODE").unwrap(); + if mode == "reject-root" { + for result in [ + directory_lock(&root).map(|_| ()), + pin_index(&root, &key(1), 4096).map(|_| ()), + reserve_index(&root, &key(1), 4096).map(|_| ()), + maintain(&root, 4096).map(|_| ()), + ] { + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); + } + return; + } + if mode == "sweep" { + checkout().unwrap(); + return; + } + if mode == "scan" { + std::fs::write(index_fallback_dir(), b"keep fallback").unwrap(); + let mut ds = crate::Dataset::new("scan", crate::GeometryType::Point, vec![]); + ds.push(Some(crate::Geometry::Point(crate::Coord::new(1.0, 2.0))), vec![]); + let path = root.join("source.shp"); + crate::io::write_path(&ds, &path).unwrap(); + let source = super::super::ShpSource::open_path(&path, "scan").unwrap(); + assert!(!source.info().indexed); + assert!(source.info().notes.iter().any(|n| n.contains("scanning record headers"))); + for limit in [0, 1, 100] { + let batch = source.read(&crate::source::Request { extent: Some(crate::Extent::new(0.0, 0.0, 3.0, 3.0)), limit }).unwrap(); + assert_eq!(batch.matched, 1); + assert_eq!(batch.features.len(), limit.min(1)); + } + assert_eq!(std::fs::read(root.join("blocked")).unwrap(), b"keep"); + assert_eq!(std::fs::read(index_fallback_dir()).unwrap(), b"keep fallback"); + return; + } + if mode == "fallback" { + let mut ds = crate::Dataset::new("fallback", crate::GeometryType::Point, vec![]); + ds.push(Some(crate::Geometry::Point(crate::Coord::new(1.0, 2.0))), vec![]); + let path = root.join("source.shp"); + crate::io::write_path(&ds, &path).unwrap(); + let source = crate::source::open_path(&path, None).unwrap(); + assert_eq!(source.read(&crate::source::Request::all()).unwrap().features.len(), 1); + drop(source); + let fallback = index_fallback_dir(); + let index = std::fs::read_dir(&fallback) + .unwrap() + .flatten() + .map(|e| e.path()) + .find(|p| { + p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("rg-index-v1-")) + && p.extension().is_some_and(|e| e == "sqlite") + }) + .unwrap(); + let db = rusqlite::Connection::open(&index).unwrap(); + db.execute_batch("CREATE TABLE reuse_probe(value INTEGER)").unwrap(); + drop(db); + drop(crate::source::open_path(&path, None).unwrap()); + let db = rusqlite::Connection::open(&index).unwrap(); + assert_eq!(db.query_row("SELECT count(*) FROM sqlite_master WHERE name='reuse_probe'", [], |r| r.get::<_, i64>(0)).unwrap(), 1); + drop(db); + let abandoned = fallback.join("rg-index-work-v1-1-2-3.sqlite"); + std::fs::write(&abandoned, b"partial").unwrap(); + std::fs::write(lease_path(&abandoned), b"T").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(lease_path(&abandoned), std::fs::Permissions::from_mode(0o600)).unwrap(); + } + checkout().unwrap(); + assert!(!abandoned.exists(), "checkout must sweep the fallback cache too"); + assert_eq!(std::fs::read(root.join("blocked")).unwrap(), b"keep"); + return; + } + let lease = if mode == "temporary" { + let (lease, file) = temporary_index(&root); + file.set_len(1024).unwrap(); + lease + } else if mode == "index" { + ready(&root, std::process::id() as usize, 1024, 4096) + } else { + let lease = reserve_index(&root, &key(std::process::id() as usize), 4096).unwrap(); + File::options().write(true).open(lease.path()).unwrap().set_len(1024).unwrap(); + lease + }; + let marker = PathBuf::from(std::env::var_os("RUSTGIS_CACHE_TEST_READY").unwrap()); + let scratch = marker.with_extension("tmp"); + std::fs::write(&scratch, lease.path().to_string_lossy().as_bytes()).unwrap(); + std::fs::rename(scratch, marker).unwrap(); + std::thread::sleep(Duration::from_secs(60)); + drop(lease); +} + +#[test] +fn cache_symlinks_never_authorize_deleting_or_modifying_their_targets() { + let root = Folder::new(); + let other = Folder::new(); + let sentinel = other.0.join("sentinel"); + std::fs::write(&sentinel, b"private").unwrap(); + let link = root.0.join("rg-index-work-v1-1-2-3.sqlite"); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_file(&sentinel, &link).is_ok(); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(&sentinel, &link).is_ok(); + #[cfg(not(any(windows, unix)))] + let linked = false; + if linked { + maintain(&root.0, 0).unwrap(); + assert!(std::fs::symlink_metadata(link).is_ok()); + } + assert_eq!(std::fs::read(sentinel).unwrap(), b"private"); +} + +#[test] +fn cache_unwritable_primary_reuses_and_cleans_the_fallback_directory() { + let root = Folder::new(); + std::fs::write(root.0.join("blocked"), b"keep").unwrap(); + let mut child = spawn(&root.0, "fallback"); + assert!(child.0.wait().unwrap().success(), "fallback cache was not reused or cleaned"); +} + +fn temporary_index(root: &Path) -> (Lease, File) { + let directory = directory_lock(root).unwrap(); + create_locked(directory.root.join(format!("rg-index-work-v1-{}.sqlite", unique())), true, 4096).unwrap() +} +fn checkout() -> io::Result<()> { + for root in [index_dir(), index_fallback_dir()] { + if root.is_dir() { + maintain(&root, index_budget())?; + } + } + Ok(()) +} + +#[test] +fn cache_unwritable_locations_keep_read_only_shapefile_loading_available() { + let root = Folder::new(); + std::fs::write(root.0.join("blocked"), b"keep").unwrap(); + let mut child = spawn(&root.0, "scan"); + assert!(child.0.wait().unwrap().success(), "scan fallback failed"); +} + +#[test] +fn cache_reservation_preserves_unverified_payload_even_at_the_requested_key() { + let root = Folder::new(); + let path = index_path(&root.0, &key(17)).unwrap(); + std::fs::write(&path, b"unrelated").unwrap(); + let lease = reserve_index(&root.0, &key(17), 4096).unwrap(); + assert_ne!(lease.path(), path); + assert_eq!(std::fs::read(&path).unwrap(), b"unrelated"); + drop(lease); + maintain(&root.0, 0).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b"unrelated"); +} + +/// A predictable per-UID name still needs ownership checks before any blocking lock. +#[cfg(unix)] +#[test] +fn cache_unix_unsafe_root_is_rejected_before_an_attacker_lock() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let root = Folder::new(); + let lock_path = root.0.join(".rustgis-cache-v1.lock"); + std::fs::write(&lock_path, b"sentinel").unwrap(); + std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let lock = open_lock(&lock_path).unwrap(); + lock.lock().unwrap(); + std::fs::set_permissions(&root.0, std::fs::Permissions::from_mode(0o777)).unwrap(); + let mut child = spawn(&root.0, "reject-root"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + assert!(status.success(), "unsafe root was accepted"); + break; + } + assert!(Instant::now() < deadline, "unsafe root reached the attacker's blocking lock"); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(std::fs::read(lock_path).unwrap(), b"sentinel"); + assert_eq!(root.0.metadata().unwrap().mode() & 0o777, 0o777, "do not repair untrusted roots"); +} + +#[cfg(unix)] +#[test] +fn cache_unix_roots_are_private_and_legacy_owned_roots_still_work() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let folder = Folder::new(); + let root = folder.0.join("new/nested"); + drop(directory_lock(&root).unwrap()); + for dir in [&root, &folder.0.join("new")] { + let metadata = dir.metadata().unwrap(); + assert_eq!(metadata.mode() & 0o777, 0o700); + assert_eq!(metadata.uid(), rustix::process::geteuid().as_raw()); + } + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap(); + let lease = ready(&root, 1, 10, 4096); + let reopened = pin_index(&root, &key(1), 4096).unwrap().unwrap(); + assert_eq!(lease.path(), reopened.path()); + assert_eq!(index_fallback_dir().file_name().unwrap(), format!("rustgis-shp-index-{}", rustix::process::geteuid().as_raw()).as_str()); +} + +#[cfg(unix)] +#[test] +fn cache_unix_release_does_not_mutate_a_root_that_became_unsafe() { + use std::os::unix::fs::PermissionsExt; + let root = Folder::new(); + let lease = ready(&root.0, 1, 40, 20); + let path = lease.path().to_path_buf(); + std::fs::set_permissions(&root.0, std::fs::Permissions::from_mode(0o777)).unwrap(); + drop(lease); + assert!(path.exists(), "Drop must validate the same boundary as checkout"); + std::fs::set_permissions(&root.0, std::fs::Permissions::from_mode(0o700)).unwrap(); + maintain(&root.0, 20).unwrap(); + assert!(!path.exists()); +} + +#[cfg(unix)] +#[test] +fn cache_unix_symlink_roots_and_lock_files_are_rejected() { + let folder = Folder::new(); + let other = Folder::new(); + let root = folder.0.join("linked"); + std::os::unix::fs::symlink(&other.0, &root).unwrap(); + for alias in [root.clone(), root.join("."), PathBuf::from(format!("{}/", root.display()))] { + assert!(directory_lock(&alias).is_err(), "symlink root accepted: {}", alias.display()); + } + assert!(!other.0.join(".rustgis-cache-v1.lock").exists()); + let sentinel = other.0.join("sentinel"); + std::fs::write(&sentinel, b"unchanged").unwrap(); + std::os::unix::fs::symlink(&sentinel, folder.0.join(".rustgis-cache-v1.lock")).unwrap(); + assert!(directory_lock(&folder.0).is_err()); + assert_eq!(std::fs::read(sentinel).unwrap(), b"unchanged"); +} + +#[cfg(unix)] +#[test] +fn cache_unix_writable_ancestors_cannot_redirect_private_roots() { + use std::os::unix::fs::PermissionsExt; + let folder = Folder::new(); + let root = folder.0.join("private"); + std::fs::set_permissions(&folder.0, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert!(directory_lock(&root).is_err()); + assert!(!root.join(".rustgis-cache-v1.lock").exists()); + std::fs::set_permissions(&folder.0, std::fs::Permissions::from_mode(0o1777)).unwrap(); + drop(directory_lock(&root).unwrap()); + std::fs::set_permissions(&folder.0, std::fs::Permissions::from_mode(0o700)).unwrap(); + drop(directory_lock(&root).unwrap()); +} + +#[cfg(unix)] +#[test] +fn cache_unix_foreign_owned_roots_are_rejected() { + let uid = rustix::process::geteuid(); + if uid.as_raw() != 0 { + assert_eq!(validate_directory(Path::new("/")).unwrap_err().kind(), io::ErrorKind::PermissionDenied); + } else { + let root = Folder::new(); + rustix::fs::chown(&root.0, Some(rustix::process::Uid::from_raw(65534)), None).unwrap(); + assert_eq!(directory_lock(&root.0).unwrap_err().kind(), io::ErrorKind::PermissionDenied); + assert!(!root.0.join(".rustgis-cache-v1.lock").exists()); + } +} + +/// A hidden, mutable intermediate link must not redirect a pinned SQLite pathname. +#[cfg(unix)] +#[test] +fn cache_unix_ancestor_symlink_retargeting_cannot_redirect_a_lease() { + let folder = Folder::new(); + let original = Folder::new(); + let replacement = Folder::new(); + let bridge = folder.0.join("bridge"); + let alias = folder.0.join("alias"); + std::os::unix::fs::symlink(&original.0, &bridge).unwrap(); + std::os::unix::fs::symlink(&bridge, &alias).unwrap(); + let root = alias.join("index"); + let reader = ready(&root, 1, 10, 4096); + let pinned = pin_index(&root, &key(1), 4096).unwrap().unwrap(); + let replacement_root = replacement.0.join("index"); + std::fs::create_dir(&replacement_root).unwrap(); + let forged = replacement_root.join(reader.path().file_name().unwrap()); + std::fs::write(forged, b"forged cache").unwrap(); + std::fs::remove_file(&bridge).unwrap(); + std::os::unix::fs::symlink(&replacement.0, &bridge).unwrap(); + assert_eq!(std::fs::read(reader.path()).unwrap(), vec![0; 10]); + assert_eq!(std::fs::read(pinned.path()).unwrap(), vec![0; 10]); + assert_eq!(reader.path().parent().unwrap(), original.0.join("index").canonicalize().unwrap()); + drop(pinned); + drop(reader); + maintain(&original.0.join("index"), 0).unwrap(); +} diff --git a/crates/data/src/source/shp/index.rs b/crates/data/src/source/shp/index.rs new file mode 100644 index 000000000..bc250769d --- /dev/null +++ b/crates/data/src/source/shp/index.rs @@ -0,0 +1,259 @@ +//! Disk-backed record directory and R-tree. Neither grows the provider's resident heap. + +use super::{BUFFER, Place, bad, be_i32, record_box}; +use crate::Result; +use rusqlite::{Connection, OpenFlags, params}; +use std::fs::File; +use std::io::{BufReader, Read}; +use std::path::Path; +use std::sync::Mutex; + +struct DiskIndex { + db: Mutex, + pub count: u64, + covered: u32, + bounds: Option<[f32; 4]>, + _lease: super::cache::Lease, +} + +fn sql(e: rusqlite::Error) -> crate::DataError { + bad(format!("spatial index: {e}")) +} + +fn connect(path: &Path, write: bool) -> Result { + let flags = if write { OpenFlags::SQLITE_OPEN_READ_WRITE } else { OpenFlags::SQLITE_OPEN_READ_ONLY }; + let db = Connection::open_with_flags(path, flags | OpenFlags::SQLITE_OPEN_NO_MUTEX).map_err(sql)?; + db.execute_batch("PRAGMA trusted_schema=OFF; PRAGMA cache_size=-8192; PRAGMA mmap_size=0; PRAGMA temp_store=FILE;").map_err(sql)?; + Ok(db) +} + +impl DiskIndex { + pub fn open(path: &Path, len: u64, notes: &mut Vec) -> Result { + use super::cache; + use crate::source::external::{fnv1a, stamp}; + let canonical = path.canonicalize()?; + let signature = format!("shp-index-v2:{canonical:?}:{:?}", stamp(path)?); + let mut dir = cache::index_dir(); + let budget = cache::index_budget(); + let key = format!("{:016x}-{:016x}", fnv1a(format!("shp-index-v2:{canonical:?}").as_bytes()), fnv1a(signature.as_bytes())); + let pinned = cache::pin_index(&dir, &key, budget).or_else(|_| { + dir = cache::index_fallback_dir(); + cache::pin_index(&dir, &key, budget) + }); + if let Ok(Some(lease)) = pinned + && let Ok(db) = connect(lease.path(), false) + && let Ok((saved, count, note, covered, bounds)) = + db.query_row("SELECT signature, count, note, covered, bounds FROM metadata", [], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, u32>(1)?, r.get::<_, String>(2)?, r.get::<_, u32>(3)?, r.get::<_, String>(4)?)) + }) + && saved == signature + && covered <= count + && let Ok(bounds) = serde_json::from_str::>(&bounds) + && db.prepare("SELECT id, offset, len FROM records LIMIT 0").is_ok() + && db.prepare("SELECT id FROM bounds LIMIT 0").is_ok() + { + if !note.is_empty() { + notes.push(note); + } + let _ = cache::maintain(&dir, budget); + return Ok(Self { db: Mutex::new(db), count: u64::from(count), covered, bounds, _lease: lease }); + } + let _ = cache::maintain(&dir, budget); + let mut lease = + cache::reserve_index(&dir, &key, budget).or_else(|_| cache::reserve_index(&cache::index_fallback_dir(), &key, budget))?; + let mut db = connect(lease.path(), true)?; + // A disposable cache: no journal allocation proportional to the source is necessary. + db.execute_batch( + "PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; + CREATE TABLE records(id INTEGER PRIMARY KEY, offset INTEGER NOT NULL, len INTEGER NOT NULL); + CREATE VIRTUAL TABLE bounds USING rtree(id, x0, x1, y0, y1); + CREATE TABLE metadata(signature TEXT, count INTEGER, note TEXT, covered INTEGER, bounds TEXT);", + ) + .map_err(sql)?; + let tx = db.transaction().map_err(sql)?; + let mut covered = 0u32; + let mut all_bounds: Option<[f32; 4]> = None; + let (count, note) = { + let mut record = tx.prepare("INSERT INTO records VALUES(?1,?2,?3)").map_err(sql)?; + let mut bounds = tx.prepare("INSERT INTO bounds VALUES(?1,?2,?3,?4,?5)").map_err(sql)?; + scan(path, len, |id, place, b| { + record.execute(params![id, place.offset, place.len]).map_err(sql)?; + let [x0, y0, x1, y1] = b; + if b.iter().all(|v| v.is_finite()) && x0 <= x1 && y0 <= y1 { + bounds.execute(params![id, x0, x1, y0, y1]).map_err(sql)?; + covered += 1; + all_bounds = Some(match all_bounds { + Some([a, b, x, y]) => [a.min(x0), b.min(y0), x.max(x1), y.max(y1)], + None => [x0, y0, x1, y1], + }); + } + Ok(()) + })? + }; + // Do not retain an index built while the original file was being replaced/edited. + if signature != format!("shp-index-v2:{canonical:?}:{:?}", stamp(path)?) { + return Err(bad("the .shp changed while its spatial index was being built")); + } + let bounds_json = serde_json::to_string(&all_bounds).map_err(|e| bad(e.to_string()))?; + tx.execute("INSERT INTO metadata VALUES(?1,?2,?3,?4,?5)", params![signature, count, note, covered, bounds_json]).map_err(sql)?; + tx.commit().map_err(sql)?; + drop(db); + lease.commit()?; + let db = connect(lease.path(), false)?; + if !note.is_empty() { + notes.push(note); + } + Ok(Self { db: Mutex::new(db), count: u64::from(count), covered, bounds: all_bounds, _lease: lease }) + } + + pub fn covers_bbox(&self, extent: [f32; 4]) -> bool { + self.bounds.is_some_and(|b| extent[0] <= b[0] && extent[1] <= b[1] && extent[2] >= b[2] && extent[3] >= b[3]) + } + + /// Visit in file order without allocating a list of all matches. SQLite's page cache is + /// budgeted at 8 MiB; a large ordering operation spills to disk. + pub fn visit(&self, extent: Option<[f32; 4]>, limit: usize, mut visit: impl FnMut(u32, Place) -> Result<()>) -> Result<(u64, bool)> { + // The index's own envelope (not the untrusted file header) proves that every box + // matches. Skip the R-tree and its global ordering operation for overview requests. + let extent = extent.filter(|e| u64::from(self.covered) != self.count || !self.covers_bbox(*e)); + let db = self.db.lock().map_err(|_| bad("spatial index lock is poisoned"))?; + let (from, args) = if let Some([x0, y0, x1, y1]) = extent { + ("bounds CROSS JOIN records USING(id) WHERE x0<=?1 AND x1>=?2 AND y0<=?3 AND y1>=?4", vec![x1, x0, y1, y0]) + } else { + ("records", Vec::new()) + }; + let matched = if extent.is_none() { + self.count + } else { + let n: u32 = + db.query_row(&format!("SELECT count(*) FROM {from}"), rusqlite::params_from_iter(&args), |r| r.get(0)).map_err(sql)?; + u64::from(n) + }; + if limit == 0 { + return Ok((matched, false)); + } + let step = crate::source::stride(matched, limit).max(1); + // A thin overview sample must not scan the whole on-disk directory either. Its + // contiguous ids let us seek the exact same sample directly, with bounded work. + if extent.is_none() && step >= 16 { + let mut stmt = db.prepare("SELECT offset, len FROM records WHERE id=?1").map_err(sql)?; + for id in (0..matched).step_by(step as usize).take(limit) { + let place = stmt.query_row([id as i64], |r| Ok(Place { offset: r.get(0)?, len: r.get(1)? })).map_err(sql)?; + visit(id as u32, place)?; + } + return Ok((matched, false)); + } + // Record ids are contiguous and zero based. Apply the overview sample in SQL rather + // than transferring every skipped record through the SQLite/Rust boundary. + let query = if extent.is_none() { + format!("SELECT id, offset, len FROM records WHERE id % {step}=0 ORDER BY id LIMIT {limit}") + } else { + format!("SELECT id, offset, len FROM {from} ORDER BY id") + }; + let mut stmt = db.prepare(&query).map_err(sql)?; + let mut rows = stmt.query(rusqlite::params_from_iter(&args)).map_err(sql)?; + let (mut ordinal, mut kept) = (0u64, 0usize); + while let Some(r) = rows.next().map_err(sql)? { + if extent.is_none() || ordinal.is_multiple_of(step) { + visit(r.get(0).map_err(sql)?, Place { offset: r.get(1).map_err(sql)?, len: r.get(2).map_err(sql)? })?; + kept += 1; + if kept >= limit { + break; + } + } + ordinal += 1; + } + Ok((matched, step == 1)) + } +} + +/// Keep read-only deployments working when neither cache location is writable. +/// The fallback scans record headers in constant space; it never changes source files. +pub(super) struct Index { + storage: Storage, + pub count: u64, +} +enum Storage { + Disk(DiskIndex), + Scan { path: std::path::PathBuf, len: u64 }, +} +impl Index { + pub fn open(path: &Path, len: u64, notes: &mut Vec) -> Result { + match DiskIndex::open(path, len, notes) { + Ok(disk) => Ok(Self { count: disk.count, storage: Storage::Disk(disk) }), + Err(error) => { + let (count, note) = scan(path, len, |_, _, _| Ok(()))?; + notes.push(format!("spatial index unavailable ({error}); reading the .shp by scanning record headers")); + if !note.is_empty() { + notes.push(note); + } + Ok(Self { count: u64::from(count), storage: Storage::Scan { path: path.to_path_buf(), len } }) + } + } + } + pub fn indexed(&self) -> bool { + matches!(self.storage, Storage::Disk(_)) + } + pub fn visit(&self, extent: Option<[f32; 4]>, limit: usize, mut visit: impl FnMut(u32, Place) -> Result<()>) -> Result<(u64, bool)> { + match &self.storage { + Storage::Disk(disk) => disk.visit(extent, limit, visit), + Storage::Scan { path, len } => { + let matches = |b: [f32; 4]| { + extent.is_none_or(|[x0, y0, x1, y1]| { + let [a, b, x, y] = b; + a <= x1 && x >= x0 && b <= y1 && y >= y0 + }) + }; + let mut matched = 0u64; + scan(path, *len, |_, _, b| { + matched += u64::from(matches(b)); + Ok(()) + })?; + let step = crate::source::stride(matched, limit); + let mut ordinal = 0u64; + let mut kept = 0usize; + scan(path, *len, |id, place, b| { + if matches(b) { + if ordinal.is_multiple_of(step) && kept < limit { + visit(id, place)?; + kept += 1; + } + ordinal += 1; + } + Ok(()) + })?; + Ok((matched, step == 1)) + } + } + } +} + +fn scan(path: &Path, len: u64, mut visit: impl FnMut(u32, Place, [f32; 4]) -> Result<()>) -> Result<(u32, String)> { + let mut f = BufReader::with_capacity(BUFFER, File::open(path)?); + f.seek_relative(100)?; + let mut pos = 100u64; + let mut count = 0u32; + let mut head = [0u8; 44]; + let mut note = String::new(); + while pos + 8 <= len { + let want = (len - pos).min(head.len() as u64) as usize; + let Some(buf) = head.get_mut(..want) else { break }; + if f.read_exact(buf).is_err() { + break; + } + let bytes = u64::try_from(be_i32(&head, 4).unwrap_or(-1)).unwrap_or(u64::MAX).saturating_mul(2); + let content = pos + 8; + if bytes < 4 || content.saturating_add(bytes) > len { + note = format!("the .shp ends partway through record {}; the rest are left out", count + 1); + break; + } + let b = record_box(head.get(8..(want as u64).min(8 + bytes) as usize).unwrap_or(&[])); + visit(count, Place { offset: content as u32, len: bytes as u32 }, b)?; + count += 1; + pos = content + bytes; + if f.seek_relative(bytes as i64 - (want as u64).saturating_sub(8) as i64).is_err() { + break; + } + } + Ok((count, note)) +} diff --git a/crates/data/src/source/shp/tests.rs b/crates/data/src/source/shp/tests.rs new file mode 100644 index 000000000..87740c03e --- /dev/null +++ b/crates/data/src/source/shp/tests.rs @@ -0,0 +1,238 @@ +use super::*; +use crate::Dataset; +use crate::source::open_path; + +fn dir() -> PathBuf { + static SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let path = std::env::temp_dir().join(format!( + "rustgis-shp-index-test-{}-{}", + std::process::id(), + SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&path).unwrap(); + path +} +fn write_shapefile(ds: &Dataset, path: &Path) { + crate::io::write_path(ds, path).unwrap(); +} +fn points_shapefile(path: &Path) { + let mut ds = Dataset::new("grid", GeometryType::Point, vec![]); + for x in 0..30 { + for y in 0..20 { + ds.push(Some(Geometry::Point(Coord::new(x as f64, y as f64))), vec![]); + } + } + write_shapefile(&ds, path); +} +#[test] +fn shapefile_disk_index_reopens_and_invalidates_without_changing_source() { + let fixture = dir(); + cache::with_index_dir(fixture.join("cache"), || { + let p = fixture.join("indexed-grid.shp"); + points_shapefile(&p); + let original = std::fs::read(&p).unwrap(); + let req = Request { extent: Some(Extent::new(10.0, 10.0, 40.0, 30.0)), limit: 1000 }; + let src = open_path(&p, None).unwrap(); + let expected = src.read(&req).unwrap(); + let reopened = open_path(&p, None).unwrap(); + assert!(reopened.info().indexed); + assert_eq!(reopened.read(&req).unwrap().features, expected.features); + assert_eq!(std::fs::read(&p).unwrap(), original); + drop((src, reopened)); + // Replacing the same path must never reuse stale offsets, boxes or record counts. + let mut replacement = Dataset::new("new", GeometryType::Point, vec![]); + replacement.push(Some(Geometry::Point(Coord::new(-75.0, -30.0))), vec![]); + write_shapefile(&replacement, &p); + let changed = open_path(&p, None).unwrap(); + assert_eq!(changed.info().count, Some(1)); + assert!(changed.read(&req).unwrap().features.is_empty()); + assert_eq!(changed.read(&Request::all()).unwrap().features.len(), 1); + }); + std::fs::remove_dir_all(fixture).unwrap(); +} + +#[test] +fn shapefile_disk_index_keeps_file_order_and_query_boundaries() { + let fixture = dir(); + cache::with_index_dir(fixture.join("cache"), || { + let p = fixture.join("indexed-order.shp"); + let mut ds = Dataset::new("ordered", GeometryType::Point, vec![]); + // Deliberately reverse spatial order. The index must not change painter/attribute order. + for x in (0..1000).rev() { + ds.push(Some(Geometry::Point(Coord::new(x as f64 / 100.0, 0.0))), vec![]); + } + write_shapefile(&ds, &p); + // A stale file-header envelope cannot prove that a view contains the whole source. + let mut bytes = std::fs::read(&p).unwrap(); + for (at, value) in [(36, 1.5f64), (44, 0.0), (52, 1.5), (60, 0.0)] { + bytes[at..at + 8].copy_from_slice(&value.to_le_bytes()); + } + std::fs::write(&p, bytes).unwrap(); + let src = open_path(&p, None).unwrap(); + let batch = src.read(&Request { extent: Some(Extent::new(1.0, -1.0, 2.0, 1.0)), limit: 1000 }).unwrap(); + assert_eq!(batch.matched, 101); + assert_eq!(batch.features.len(), 101); + assert!(batch.complete); + assert!(batch.features.windows(2).all(|p| p[0].id < p[1].id)); + assert_eq!(batch.features.first().unwrap().geometry, Some(Geometry::Point(Coord::new(2.0, 0.0)))); + assert_eq!(batch.features.last().unwrap().geometry, Some(Geometry::Point(Coord::new(1.0, 0.0)))); + let sample = src.read(&Request { extent: None, limit: 10 }).unwrap(); + assert_eq!(sample.matched, 1000); + assert_eq!(sample.features.len(), 10); + assert!(!sample.complete); + assert_eq!(sample.features.iter().map(|f| f.id).collect::>(), (0..10).map(|i| i * 100 + 1).collect::>()); + }); + std::fs::remove_dir_all(fixture).unwrap(); +} + +#[test] +fn shapefile_overview_index_shortcut_does_not_include_null_shapes() { + let fixture = dir(); + cache::with_index_dir(fixture.join("cache"), || { + let p = fixture.join("indexed-null.shp"); + let mut ds = Dataset::new("point", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(1.0, 1.0))), vec![]); + write_shapefile(&ds, &p); + let mut bytes = std::fs::read(&p).unwrap(); + bytes.extend_from_slice(&2i32.to_be_bytes()); + bytes.extend_from_slice(&2i32.to_be_bytes()); + bytes.extend_from_slice(&0i32.to_le_bytes()); + let words = bytes.len() as i32 / 2; + bytes[24..28].copy_from_slice(&words.to_be_bytes()); + std::fs::write(&p, bytes).unwrap(); + let src = open_path(&p, None).unwrap(); + assert_eq!(src.read(&Request::all()).unwrap().features.len(), 2); + let view = src.read(&Request { extent: Some(Extent::new(-10.0, -10.0, 10.0, 10.0)), limit: 100 }).unwrap(); + assert_eq!(view.matched, 1); + assert_eq!(view.features.len(), 1); + assert!(view.features.iter().all(|f| f.geometry.is_some())); + }); + std::fs::remove_dir_all(fixture).unwrap(); +} + +#[test] +fn disk_index_is_reused_and_corrupt_cache_is_rebuilt() { + let folder = dir(); + let source = folder.join("points.shp"); + points_shapefile(&source); + let cache_root = folder.join("cache"); + cache::with_index_dir(cache_root.clone(), || { + drop(ShpSource::open_path(&source, "points").unwrap()); + let path = std::fs::read_dir(&cache_root) + .unwrap() + .flatten() + .map(|e| e.path()) + .find(|p| p.extension().is_some_and(|e| e == "sqlite")) + .unwrap(); + let db = rusqlite::Connection::open(&path).unwrap(); + db.execute_batch("CREATE TABLE reuse_probe(value INTEGER)").unwrap(); + drop(db); + drop(ShpSource::open_path(&source, "points").unwrap()); + let db = rusqlite::Connection::open(&path).unwrap(); + assert_eq!(db.query_row("SELECT count(*) FROM sqlite_master WHERE name='reuse_probe'", [], |r| r.get::<_, i64>(0)).unwrap(), 1); + drop(db); + std::fs::write(&path, b"interrupted or corrupted cache").unwrap(); + let reopened = ShpSource::open_path(&source, "points").unwrap(); + assert_eq!(reopened.read(&Request::all()).unwrap().features.len(), 600); + drop(reopened); + cache::maintain(&cache_root, 0).unwrap(); + }); + std::fs::remove_dir_all(folder).unwrap(); +} + +#[test] +fn disk_queries_match_legacy_scan_sampling_and_edges() { + let folder = dir(); + let source = folder.join("points.shp"); + let mut ds = Dataset::new("points", GeometryType::Point, vec![]); + for i in 0..401 { + let x = ((i * 71) % 401) as f64 / 13.0 - 15.0; + let y = ((i * 37) % 401) as f64 / 17.0 - 12.0; + ds.push(Some(Geometry::Point(Coord::new(x, y))), vec![]); + } + write_shapefile(&ds, &source); + cache::with_index_dir(folder.join("cache"), || { + let src = ShpSource::open_path(&source, "points").unwrap(); + for extent in [ + None, + Some(Extent::new(-180.0, -90.0, 180.0, 90.0)), + Some(Extent::new(-4.5, -3.0, 7.5, 4.0)), + Some(Extent::new(80.0, 50.0, 90.0, 60.0)), + ] { + let ids: Vec = ds + .features + .iter() + .enumerate() + .filter_map(|(i, f)| { + let Some(Geometry::Point(p)) = &f.geometry else { return None }; + let mut raw = Vec::new(); + raw.extend_from_slice(&1i32.to_le_bytes()); + raw.extend_from_slice(&p.x.to_le_bytes()); + raw.extend_from_slice(&p.y.to_le_bytes()); + let b = record_box(&raw); + extent + .is_none_or(|e| { + b[0] <= e.max_x as f32 && b[2] >= e.min_x as f32 && b[1] <= e.max_y as f32 && b[3] >= e.min_y as f32 + }) + .then_some(i as u64 + 1) + }) + .collect(); + for limit in [0, 1, 7, 29, 1000] { + let step = crate::source::stride(ids.len() as u64, limit).max(1) as usize; + let expected: Vec<_> = ids.iter().copied().step_by(step).take(limit).collect(); + let batch = src.read(&Request { extent, limit }).unwrap(); + assert_eq!(batch.matched, ids.len() as u64); + assert_eq!(batch.complete, step == 1); + assert_eq!(batch.features.iter().map(|f| f.id).collect::>(), expected); + } + } + }); + std::fs::remove_dir_all(folder).unwrap(); +} + +#[test] +#[ignore = "manual cold/warm index benchmark; creates 100,000 synthetic records"] +fn shapefile_disk_index_benchmark() { + let folder = dir(); + let path = folder.join("benchmark.shp"); + let mut ds = Dataset::new("benchmark", GeometryType::Point, vec![]); + for i in 0..100_000 { + ds.push(Some(Geometry::Point(Coord::new((i % 1000) as f64 / 10.0 - 50.0, (i / 1000) as f64 / 10.0 - 5.0))), vec![]); + } + write_shapefile(&ds, &path); + cache::with_index_dir(folder.join("cache"), || { + let clock = std::time::Instant::now(); + let first = ShpSource::open_path(&path, "benchmark").unwrap(); + let cold = clock.elapsed(); + drop(first); + let clock = std::time::Instant::now(); + let source = ShpSource::open_path(&path, "benchmark").unwrap(); + let warm = clock.elapsed(); + let request = Request { extent: Some(Extent::new(-1.0, -1.0, 1.0, 1.0)), limit: 1000 }; + let clock = std::time::Instant::now(); + let indexed = source.read(&request).unwrap(); + let query = clock.elapsed(); + let clock = std::time::Instant::now(); + let mut matched = 0u64; + let bytes = std::fs::read(&path).unwrap(); + for raw in bytes.get(100..).unwrap().as_chunks::<28>().0 { + let [x0, y0, x1, y1] = record_box(raw.get(8..).unwrap()); + if x0 <= 1.0 && x1 >= -1.0 && y0 <= 1.0 && y1 >= -1.0 { + matched += 1; + } + } + let scan = clock.elapsed(); + assert_eq!(indexed.matched, matched); + eprintln!( + "100000 points: cold={cold:?}, warm={warm:?}, indexed-query={query:?}, record-header scan including file read={scan:?}, matches={matched}" + ); + }); + std::fs::remove_dir_all(folder).unwrap(); +} diff --git a/crates/data/src/source/tests.rs b/crates/data/src/source/tests.rs index cd9bce648..036f717cf 100644 --- a/crates/data/src/source/tests.rs +++ b/crates/data/src/source/tests.rs @@ -808,7 +808,7 @@ fn shapefile_read_in_place() { points_shapefile(&p); let src = open_path(&p, None).unwrap(); assert_eq!(src.info().format, "Shapefile"); - check(src.as_ref(), false); + check(src.as_ref(), true); assert_eq!(shp::record_count(&p), Some(600)); } @@ -873,10 +873,16 @@ fn damaged_shapefiles_are_errors_or_skips_not_panics() { let (shp, dbf) = (std::fs::read(&good).unwrap(), std::fs::read(good.with_extension("dbf")).unwrap()); let p = d.join("cut.shp"); let read_all = |p: &Path| { - if let Ok(s) = open_path(p, None) { - let _ = s.read(&Request::all()); - let _ = s.read(&Request { extent: Some(Extent::new(-10.0, -10.0, 10.0, 10.0)), limit: 1 }); - } + let read = || { + if let Ok(s) = open_path(p, None) { + let _ = s.read(&Request::all()); + let _ = s.read(&Request { extent: Some(Extent::new(-10.0, -10.0, 10.0, 10.0)), limit: 1 }); + } + }; + #[cfg(not(target_arch = "wasm32"))] + shp::with_test_cache(d.join("indexes"), read); + #[cfg(target_arch = "wasm32")] + read(); }; std::fs::write(p.with_extension("dbf"), &dbf).unwrap(); for n in 0..shp.len() { diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 15062c6cc..a784abf95 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -347,14 +347,35 @@ database is kept without its password. The commands `sources.list`, `sources.sav ## Large files GeoPackage, GeoParquet, FlatGeobuf and (on the desktop) Shapefile files with more than 100,000 -features are read in place rather than loaded whole, so files far larger than memory open in a -moment. The layer reads the features in view, using the file's own spatial index (the GeoPackage -R-tree, the FlatGeobuf packed R-tree, GeoParquet row-group bounding boxes; for a Shapefile, the -shapes' bounding boxes, read once when it opens), in the background as the map moves. When a +features are read in place rather than loaded whole, avoiding an in-memory copy of the entire +dataset. The layer reads features in view in the background as the map moves, using spatial +indexes: the GeoPackage R-tree, the FlatGeobuf packed R-tree, GeoParquet row-group bounding +boxes, or a cached Shapefile R-tree built from the shapes' bounding boxes. When a view holds more than 250,000 features (100,000 in the web app), the layer draws an evenly spread sample of them and its tooltip in Layers says so: zoom in to see them all. Smaller files load whole; `layer.addData` with `"stream": true` or `false` overrides the choice. +On the desktop, opening a large Shapefile for the first time scans its record headers to build +a disposable SQLite index outside the dataset directory. Reopening an unchanged file reuses +that index; its canonical path, size and modification time identify the cached generation. +The record directory and R-tree stay on disk, with an 8 MiB SQLite page-cache target per open +index and memory mapping disabled. Query results retain file order and the same sampling +rules. Attributes and the `.prj` are still read from the source files. + +Set `RUSTGIS_SHP_CACHE` to choose the cache directory and `RUSTGIS_SHP_CACHE_MIB` to choose +the retained index budget (2048 MiB by default, clamped to 64–65536 MiB). Idle indexes are +evicted in least-recently-used order. Open readers and builders hold operating-system leases, +so another instance cannot evict their indexes; active files can temporarily exceed the +budget. An index larger than the budget is session-only. Cache checkout and release reclaim +abandoned builds and temporary indexes. A corrupt cache is rebuilt, and an unavailable cache +directory falls back to the system temporary directory (a per-user directory on Unix). +New Unix cache directories are created with mode `0700`. Existing roots must belong to +the effective user, forbid group/other writes, and have trusted parents; symlink roots +are rejected. These checks also apply to configured locations. Unsafe locations fall +back rather than changing their permissions. If neither location works, the +Shapefile remains readable by scanning record headers without retaining a full in-memory +index. Deleting idle cache files does not modify the dataset. + On the desktop, a GeoJSON FeatureCollection larger than 32 MB is read the same way, with no size limit: opening reads the file once, keeping each feature's place in it and its bounding box (a file over 256 MB through a buffer, so it is never held whole), and each view parses just the From caa125a5f933a964792537e3a23dfd4979b63c36 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Thu, 22 Oct 2026 00:36:29 -0300 Subject: [PATCH 016/297] Layouts: a font family for text, sans or serif, from the installed fonts (#517) (#675) * Layout text: a font family, sans or serif, from the fonts installed (#517) Text and Dynamic Text elements take "font": "sans" | "serif". The serif is an installed one (Georgia, Times New Roman, Noto, DejaVu or Liberation Serif), with its own bold and italic faces; none is bundled. Where none is installed the text is drawn in the sans, and the Format pane and the commands say so. Co-Authored-By: Claude Opus 5.5 * Serif text: a character the serif lacks is drawn from the sans, not left out Co-Authored-By: Claude Opus 5.5 * Serif text: a glyph from the sans leans in a real italic; a variable Noto Serif is found; the serif path tested with faces of its own Co-Authored-By: Claude Opus 5.5 * Layout: a font given for an element that isn't a text is refused, not ignored Co-Authored-By: Claude Opus 5.5 * Serif discovery: Debian's liberation2 folder; a missing bold face said; letterSpacing's kinds kept in the params; font in the hostile parameters Co-Authored-By: Claude Opus 5.5 * FontFamily: lowercase names read on import; the font paragraph rewrapped Co-Authored-By: Claude Opus 5.5 * Variable Noto Serif: folders and files taken in name order; the update warning's comment says when it's given Co-Authored-By: Claude Opus 5.5 * Variable Noto Serif: its bytes kept once, the bold faces parsed from them; the update warning's comment matches what it does Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 62 ++++++-- crates/engine/src/tests.rs | 55 +++++++ crates/map/src/layout.rs | 60 +++++++- crates/render/src/fonts.rs | 178 ++++++++++++++++++++- crates/render/src/layout.rs | 24 +-- crates/render/src/text.rs | 229 +++++++++++++++++++++++----- crates/ui-egui/src/graphicformat.rs | 1 + crates/ui-egui/src/layoutview.rs | 18 ++- crates/ui-egui/src/textform.rs | 30 +++- docs/user-guide/layouts.md | 11 +- 10 files changed, 603 insertions(+), 65 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index ab2a4601f..6139417e5 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -6,9 +6,9 @@ use std::path::Path; use rustgis_geom::{Coord, Extent}; use rustgis_map::Rgba; use rustgis_map::layout::{ - ArrowStyle, CoordFormat, ElementKind, FrameShape, Grid, GridEdges, GridStyle, Guides, Layout, LineDir, LineHead, MAX_COLORBAR_TICKS, - MAX_GRID_WIDTH, MAX_GUIDES, MAX_SCALE_DIVISIONS, MAX_TICK_MM, MIN_ELEMENT_MM, MapSeries, Orientation, PAGES, ScaleBarStyle, ScaleUnits, - TextAlign, TickSide, fit_scale, line_angle, line_at_angle, line_length, + ArrowStyle, CoordFormat, ElementKind, FontFamily, FrameShape, Grid, GridEdges, GridStyle, Guides, Layout, LineDir, LineHead, + MAX_COLORBAR_TICKS, MAX_GRID_WIDTH, MAX_GUIDES, MAX_SCALE_DIVISIONS, MAX_TICK_MM, MIN_ELEMENT_MM, MapSeries, Orientation, PAGES, + ScaleBarStyle, ScaleUnits, TextAlign, TickSide, fit_scale, line_angle, line_at_angle, line_length, }; use serde_json::{Value, json}; @@ -82,10 +82,10 @@ pub fn commands() -> Vec { 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), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "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), "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 (0 picks one), "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" (text, dynamicText; an installed serif, else the sans with a warning; refused for other kinds), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "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), "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 (0 picks one), "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}"##, ), 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"? (text), "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), "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), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif" (text; 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), "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)}"#, @@ -466,6 +466,11 @@ fn add_element(s: &mut Session, p: &Value) -> CmdResult { if kind != "legend" && p.get("exclude").is_some_and(|v| !v.is_null()) { return Err("only legends leave layers out".into()); } + // A family is a text's: refused elsewhere, as a legend's left-out layers are, so a script + // doesn't take it as applied. + if !matches!(kind, "text" | "dynamicText") && p.get("font").is_some_and(|v| !v.is_null()) { + return Err(ONLY_TEXT_FONT.into()); + } let i = index(s, p)?; let image = if kind == "picture" { Some(super::graphics::picture(p)?) } else { None }; let layer_name = |s: &Session, l: &Layout, frame: u32| -> Result { @@ -528,6 +533,7 @@ fn add_element(s: &mut Session, p: &Value) -> CmdResult { let fonts = rustgis_map::graphics::FONT_SIZE; let size = f(p, "size").unwrap_or(12.0).clamp(*fonts.start(), *fonts.end()); let (italic, align, letter_spacing) = text_style(p, false, TextAlign::Left, 0.0)?; + let font = font_family(p, FontFamily::Sans)?; let k = ElementKind::Text { text, size, @@ -536,6 +542,7 @@ fn add_element(s: &mut Session, p: &Value) -> CmdResult { italic, align, letter_spacing, + font, }; (if kind == "text" { "Text" } else { "Dynamic Text" }, place(p, l, (15.0, 12.0, 120.0, (size * 0.5).max(8.0)))?, k) } @@ -664,9 +671,14 @@ fn add_element(s: &mut Session, p: &Value) -> CmdResult { } o => return Err(format!("unknown element kind '{o}'")), }; + let serif = matches!(k, ElementKind::Text { font: FontFamily::Serif, .. }); let l = s.project.layouts.get_mut(i).ok_or("no layout")?; let id = l.add(name, rect, k); - Ok(json!({"element": id})) + let mut out = json!({"element": id}); + if serif && let Some(w) = no_serif() { + out["warning"] = json!(w); + } + Ok(out) } /// The raster layer a colorbar on `frame` shows: `name` checked against the frame's map, or the @@ -809,6 +821,24 @@ pub(crate) fn text_style(p: &Value, italic: bool, align: TextAlign, spacing: f64 Ok((italic, align, spacing)) } +/// What a `font` given for an element that isn't a text is told. +const ONLY_TEXT_FONT: &str = "only text elements take a font"; + +/// A text element's family from `font` in `p`, the current one when not given (#517). +pub(crate) fn font_family(p: &Value, current: FontFamily) -> Result { + match ps(p, "font") { + Some(n) => FontFamily::from_name(n).ok_or_else(|| format!("unknown font '{n}' (sans or serif)")), + None if p.get("font").is_some_and(|v| !v.is_null()) => Err("font must be sans or serif".into()), + None => Ok(current), + } +} + +/// What a text asking for a serif is told where none is installed (the web app, a bare Linux +/// box): it is drawn in the sans, so an export never differs from what was asked unsaid. +fn no_serif() -> Option<&'static str> { + (!rustgis_render::fonts::serif_available()).then_some("no serif font is installed here, so the text is drawn in the sans-serif") +} + /// A legend's `exclude` from `p`: the names of layers in its frame's map, as the map spells them; /// `None` when not given. Not a list of names, or a name the map hasn't, is an error. fn legend_exclude(s: &Session, l: &Layout, frame: u32, p: &Value) -> Result>, String> { @@ -1059,7 +1089,10 @@ fn update(s: &mut Session, p: &Value) -> CmdResult { }; // A text element's italic, alignment and spacing, checked before anything changes. let new_text_style = match s.project.layouts.get(li).and_then(|l| l.element(id)).map(|e| &e.kind) { - Some(ElementKind::Text { italic, align, letter_spacing, .. }) => Some(text_style(p, *italic, *align, *letter_spacing)?), + Some(ElementKind::Text { italic, align, letter_spacing, font, .. }) => { + Some((text_style(p, *italic, *align, *letter_spacing)?, font_family(p, *font)?)) + } + _ if p.get("font").is_some_and(|v| !v.is_null()) => return Err(ONLY_TEXT_FONT.into()), _ => None, }; // A legend's left-out layers, checked against its frame's map before anything changes. @@ -1114,9 +1147,9 @@ fn update(s: &mut Session, p: &Value) -> CmdResult { } } match &mut e.kind { - ElementKind::Text { text, size, color, bold, italic, align, letter_spacing } => { - if let Some((i, a, l)) = new_text_style { - (*italic, *align, *letter_spacing) = (i, a, l); + ElementKind::Text { text, size, color, bold, italic, align, letter_spacing, font } => { + if let Some(((i, a, l), f)) = new_text_style { + (*italic, *align, *letter_spacing, *font) = (i, a, l, f); } if let Some(t) = ps(p, "text") { *text = t.chars().take(5000).collect(); @@ -1210,6 +1243,15 @@ fn update(s: &mut Session, p: &Value) -> CmdResult { } _ => {} } + // A serif asked for where none is installed: said whenever the call asks for one (again too, + // as `layout.addElement` says it), so a script is told what it gets; a call that changes + // something else on a serif text isn't told again. + if ps(p, "font").is_some() + && matches!(new_text_style, Some((_, FontFamily::Serif))) + && let Some(w) = no_serif() + { + return Ok(json!({"warning": w})); + } Ok(Value::Null) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 207c781d6..a9b784dc9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1079,6 +1079,7 @@ fn hostile_params_never_panic() { json!({"kind": "colorbar", "ticks": -1e308, "tickSide": 7, "orientation": "x", "layer": "", "reverse": "maybe"}), json!({"type": "albers", "lon_0": f64::MAX, "lat_1": "x", "lat_2": -1e308}), json!({"kind": "legend", "element": 1, "exclude": [null, 7, "", {"x": 1}]}), + json!({"kind": "text", "element": 1, "font": {"family": "serif"}, "italic": [], "align": 7}), // MySQL addresses that can't be read, so nothing connects. json!({"database": "mysql://u:p#@[::1/x?ssl-mode=nope", "password": 7, "layer": ":", "crs": "EPSG:-1"}), json!({"database": "MARIADB://u@h:99999999/%zz?ssl-mode=VERIFY_CA&ssl-ca=/nonexistent", "sql": "SELECT 1"}), @@ -11821,6 +11822,60 @@ fn text_elements_take_italic_alignment_and_spacing() { assert_eq!(style(&s, ocean), (false, TextAlign::Center, MAX_LETTER_SPACING)); } +/// Text elements take a font family (#517): sans by default and not written then, serif when +/// added or later, kept through a save, sans again on request; a bad value is an error that +/// changes nothing. Where no serif is installed the text is drawn in the sans, and the command +/// says so. +#[test] +fn text_elements_take_a_font_family() { + use rustgis_map::layout::{ElementKind, FontFamily}; + let mut s = Session::new(sample::world_project()); + s.run("layout.new", &json!({"name": "Type"})).unwrap(); + let font = |s: &Session, id: u32| match s.project.layouts[0].element(id).map(|e| &e.kind) { + Some(ElementKind::Text { font, .. }) => *font, + _ => panic!("a text element"), + }; + let plain = s.run("layout.addElement", &json!({"kind": "text", "text": "Brasil"})).unwrap()["element"].as_u64().unwrap() as u32; + assert_eq!(font(&s, plain), FontFamily::Sans); + assert!(!serde_json::to_string(&s.project.layouts[0]).unwrap().contains("font"), "the sans isn't written"); + let r = s.run("layout.addElement", &json!({"kind": "dynamicText", "font": "Serif"})).unwrap(); + let title = r["element"].as_u64().unwrap() as u32; + assert_eq!(font(&s, title), FontFamily::Serif); + // Said only where the text can't be drawn as asked. + let installed = rustgis_render::fonts::serif_available(); + assert_eq!(r.get("warning").is_some(), !installed, "{r}"); + let r = s.run("layout.update", &json!({"element": plain, "font": "serif"})).unwrap(); + assert_eq!(font(&s, plain), FontFamily::Serif); + assert_eq!(r.get("warning").is_some(), !installed, "{r}"); + // Another change leaves the family, and says nothing of it. + let r = s.run("layout.update", &json!({"element": plain, "italic": true})).unwrap(); + assert_eq!((font(&s, plain), r.get("warning")), (FontFamily::Serif, None)); + let back: rustgis_map::layout::Layout = serde_json::from_str(&serde_json::to_string(&s.project.layouts[0]).unwrap()).unwrap(); + assert!(back.element(plain).is_some_and(|e| matches!(&e.kind, ElementKind::Text { font: FontFamily::Serif, .. }))); + s.run("layout.update", &json!({"element": plain, "font": "sans-serif"})).unwrap(); + assert_eq!(font(&s, plain), FontFamily::Sans); + // Saved as the other layout enums are; the command's lowercase names read too (a layout + // written by hand). + assert!(serde_json::to_string(&s.project.layouts[0]).unwrap().contains(r#""font":"Serif""#)); + for (name, f) in [("\"serif\"", FontFamily::Serif), ("\"Serif\"", FontFamily::Serif), ("\"sans\"", FontFamily::Sans)] { + assert_eq!(serde_json::from_str::(name).unwrap(), f); + } + for bad in [json!("comic"), json!(2)] { + let err = s.run("layout.update", &json!({"element": title, "font": bad})).unwrap_err(); + assert!(err.contains("font"), "{err}"); + assert!(s.run("layout.addElement", &json!({"kind": "text", "font": bad})).is_err()); + } + assert_eq!(font(&s, title), FontFamily::Serif); + // Only a text takes a family: another element refuses it rather than ignore it. + let arrow = s.run("layout.addElement", &json!({"kind": "northArrow"})).unwrap()["element"].clone(); + let err = s.run("layout.update", &json!({"element": arrow, "font": "serif"})).unwrap_err(); + assert!(err.contains("only text elements"), "{err}"); + assert!(s.run("layout.addElement", &json!({"kind": "northArrow", "font": "serif"})).unwrap_err().contains("only text elements")); + // The page draws either way. + assert!(s.run("layout.update", &json!({"element": title, "text": "Mapa do Brasil", "bold": true, "italic": true})).is_ok()); + assert!(!crate::cmd::layout::layout_bytes(&mut s, 0, false, 72.0, false).unwrap().is_empty()); +} + /// Each undo step remembers who made it, through undo and redo: the user (no actor) or the /// agent the MCP relay named. #[test] diff --git a/crates/map/src/layout.rs b/crates/map/src/layout.rs index ec9059780..a5fa4d64f 100644 --- a/crates/map/src/layout.rs +++ b/crates/map/src/layout.rs @@ -305,6 +305,52 @@ impl TextAlign { } } +/// The family a text element is drawn in (#517): a generic one, not a file, so a project opens +/// anywhere and each machine draws it in a face it has. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum FontFamily { + /// The interface's sans-serif. Saved as the other layout enums are (`Sans`, as `TextAlign` + /// saves `Center`); the command's lowercase names are read too, so a layout written by hand + /// with `"font": "sans"` imports. + #[default] + #[serde(alias = "sans", alias = "sans-serif")] + Sans, + /// An installed serif (Georgia, Times, Noto Serif…); the sans where there is none. + #[serde(alias = "serif")] + Serif, +} + +impl FontFamily { + pub const ALL: [FontFamily; 2] = [FontFamily::Sans, FontFamily::Serif]; + + pub fn code(self) -> &'static str { + match self { + FontFamily::Sans => "sans", + FontFamily::Serif => "serif", + } + } + + pub fn label(self) -> &'static str { + match self { + FontFamily::Sans => "Sans-serif", + FontFamily::Serif => "Serif", + } + } + + /// `sans` or `serif` (`sans-serif` too), in any case. + pub fn from_name(s: &str) -> Option { + let s = s.trim(); + FontFamily::ALL + .into_iter() + .find(|v| v.code().eq_ignore_ascii_case(s)) + .or(s.eq_ignore_ascii_case("sans-serif").then_some(FontFamily::Sans)) + } +} + +fn is_sans(f: &FontFamily) -> bool { + *f == FontFamily::Sans +} + /// The most letter spacing a text element takes, either way (points). pub const MAX_LETTER_SPACING: f64 = 50.0; pub const MIN_LETTER_SPACING: f64 = -5.0; @@ -757,6 +803,9 @@ pub enum ElementKind { /// Space added between letters, in points (spaced-out ocean names); negative tightens. #[serde(default, skip_serializing_if = "is_zero")] letter_spacing: f64, + /// Sans-serif or serif; left out when sans, so layouts saved before keep their bytes. + #[serde(default, skip_serializing_if = "is_sans")] + font: FontFamily, }, Picture { /// Base64 PNG or JPEG. @@ -802,7 +851,16 @@ fn rows() -> usize { impl ElementKind { /// A text element in the plain style: upright, left-aligned, normally spaced. pub fn text(text: impl Into, size: f64, color: Rgba, bold: bool) -> ElementKind { - ElementKind::Text { text: text.into(), size, color, bold, italic: false, align: TextAlign::Left, letter_spacing: 0.0 } + ElementKind::Text { + text: text.into(), + size, + color, + bold, + italic: false, + align: TextAlign::Left, + letter_spacing: 0.0, + font: FontFamily::Sans, + } } /// A north arrow for `frame`, in the default style. diff --git a/crates/render/src/fonts.rs b/crates/render/src/fonts.rs index 78db0c20b..f12728c48 100644 --- a/crates/render/src/fonts.rs +++ b/crates/render/src/fonts.rs @@ -1,6 +1,7 @@ //! Font discovery: a good installed sans-serif when there is one (Segoe UI, SF, Inter, Noto Sans, -//! Open Sans, DejaVu Sans), else the Ubuntu Light face that ships with egui. Fonts are read at -//! run time and never bundled. +//! Open Sans, DejaVu Sans), else the Ubuntu Light face that ships with egui; and an installed +//! serif for the layout texts that ask for one (#517). Fonts are read at run time and never +//! bundled. use std::sync::OnceLock; @@ -64,6 +65,158 @@ pub fn ui_fonts() -> &'static UiFonts { }) } +/// Installed serif families: (regular, bold, italic, bold italic) files, in order of preference. +/// Full paths, as the sans candidates are; a variable Noto Serif is looked for after these (see +/// [`variable_noto_serif`]), then [`SERIF_LAST`]. +const SERIF_CANDIDATES: &[[&str; 4]] = &[ + [ + "C:\\Windows\\Fonts\\georgia.ttf", + "C:\\Windows\\Fonts\\georgiab.ttf", + "C:\\Windows\\Fonts\\georgiai.ttf", + "C:\\Windows\\Fonts\\georgiaz.ttf", + ], + [ + "C:\\Windows\\Fonts\\times.ttf", + "C:\\Windows\\Fonts\\timesbd.ttf", + "C:\\Windows\\Fonts\\timesi.ttf", + "C:\\Windows\\Fonts\\timesbi.ttf", + ], + [ + "/System/Library/Fonts/Supplemental/Georgia.ttf", + "/System/Library/Fonts/Supplemental/Georgia Bold.ttf", + "/System/Library/Fonts/Supplemental/Georgia Italic.ttf", + "/System/Library/Fonts/Supplemental/Georgia Bold Italic.ttf", + ], + [ + "/System/Library/Fonts/Supplemental/Times New Roman.ttf", + "/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf", + "/System/Library/Fonts/Supplemental/Times New Roman Italic.ttf", + "/System/Library/Fonts/Supplemental/Times New Roman Bold Italic.ttf", + ], + [ + "/usr/share/fonts/noto/NotoSerif-Regular.ttf", + "/usr/share/fonts/noto/NotoSerif-Bold.ttf", + "/usr/share/fonts/noto/NotoSerif-Italic.ttf", + "/usr/share/fonts/noto/NotoSerif-BoldItalic.ttf", + ], + [ + "/usr/share/fonts/truetype/noto/NotoSerif-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSerif-Bold.ttf", + "/usr/share/fonts/truetype/noto/NotoSerif-Italic.ttf", + "/usr/share/fonts/truetype/noto/NotoSerif-BoldItalic.ttf", + ], + [ + "/usr/share/fonts/google-noto/NotoSerif-Regular.ttf", + "/usr/share/fonts/google-noto/NotoSerif-Bold.ttf", + "/usr/share/fonts/google-noto/NotoSerif-Italic.ttf", + "/usr/share/fonts/google-noto/NotoSerif-BoldItalic.ttf", + ], +]; + +/// Serif families taken when none of the above (nor a variable Noto Serif) is installed. +const SERIF_LAST: &[[&str; 4]] = &[ + [ + "/usr/share/fonts/TTF/DejaVuSerif.ttf", + "/usr/share/fonts/TTF/DejaVuSerif-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSerif-Italic.ttf", + "/usr/share/fonts/TTF/DejaVuSerif-BoldItalic.ttf", + ], + [ + "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Italic.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSerif-BoldItalic.ttf", + ], + [ + "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSerif-BoldItalic.ttf", + ], + [ + "/usr/share/fonts/truetype/liberation2/LiberationSerif-Regular.ttf", + "/usr/share/fonts/truetype/liberation2/LiberationSerif-Bold.ttf", + "/usr/share/fonts/truetype/liberation2/LiberationSerif-Italic.ttf", + "/usr/share/fonts/truetype/liberation2/LiberationSerif-BoldItalic.ttf", + ], + [ + "/usr/share/fonts/liberation-serif/LiberationSerif-Regular.ttf", + "/usr/share/fonts/liberation-serif/LiberationSerif-Bold.ttf", + "/usr/share/fonts/liberation-serif/LiberationSerif-Italic.ttf", + "/usr/share/fonts/liberation-serif/LiberationSerif-BoldItalic.ttf", + ], +]; + +/// An installed serif family (#517): its regular face, and its bold, italic and bold italic ones +/// where it has them. A style it lacks is drawn from the regular: slanted for italic, but not +/// made heavier, so bold in a family with no bold file looks as the regular does. +pub struct SerifFonts { + pub regular: Vec, + pub bold: Option>, + pub italic: Option>, + pub bold_italic: Option>, + /// One file for every weight (a variable Noto Serif): `bold` and `bold_italic` are left empty + /// and drawn from `regular` and `italic` at the bold weight, so its bytes are kept once. + pub variable: bool, + /// File the regular face came from. + pub source: String, +} + +/// The installed serif, found once: `None` when there is none (the web app, a bare Linux box), +/// or with `RUSTGIS_BUILTIN_FONTS` set, so tests draw alike on every machine. Text asking for a +/// serif is then drawn in the sans. +pub fn serif_fonts() -> Option<&'static SerifFonts> { + static F: OnceLock> = OnceLock::new(); + F.get_or_init(|| { + if std::env::var_os("RUSTGIS_BUILTIN_FONTS").is_some() { + return None; + } + let family = |[r, b, i, bi]: &[&str; 4]| { + let regular = read(r)?; + Some(SerifFonts { regular, bold: read(b), italic: read(i), bold_italic: read(bi), variable: false, source: (*r).to_string() }) + }; + SERIF_CANDIDATES.iter().find_map(family).or_else(|| variable_noto_serif(&FONT_ROOTS)).or_else(|| SERIF_LAST.iter().find_map(family)) + }) + .as_ref() +} + +/// The folders under which distributions put each font package's own folder. +const FONT_ROOTS: [&str; 3] = ["/usr/share/fonts", "/usr/share/fonts/truetype", "/usr/share/fonts/opentype"]; + +/// Noto Serif as recent distributions ship it: a variable font (`NotoSerif[wdth,wght].ttf`, or +/// `[wght]`), with its italic beside it, in a package folder under one of `roots` ([`FONT_ROOTS`]). +/// Found by its name's start, whatever axes it lists; its bold is the same file (kept once), +/// drawn at the bold weight. +fn variable_noto_serif(roots: &[&str]) -> Option { + let named = |files: &[std::path::PathBuf], stem: &str| { + files.iter().find(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with(stem) && n.ends_with(".ttf"))).cloned() + }; + // Folders and files in name order, so the same one is picked on every machine whatever order + // the system lists them in; loose files don't count against the folders taken. + let listed = |dir: &std::path::Path| { + let mut paths: Vec = + std::fs::read_dir(dir).map(|e| e.flatten().take(10_000).map(|e| e.path()).collect()).unwrap_or_default(); + paths.sort(); + paths + }; + for root in roots { + for dir in listed(std::path::Path::new(root)).into_iter().filter(|p| p.is_dir()).take(1000) { + let files = listed(&dir); + let Some(r) = named(&files, "NotoSerif[") else { continue }; + let Some(regular) = r.to_str().and_then(read) else { continue }; + let italic = named(&files, "NotoSerif-Italic[").and_then(|p| p.to_str().and_then(read)); + let source = r.display().to_string(); + return Some(SerifFonts { regular, bold: None, italic, bold_italic: None, variable: true, source }); + } + } + None +} + +/// Whether a serif is installed: text asking for one is drawn in it, not in the sans. +pub fn serif_available() -> bool { + serif_fonts().is_some() +} + /// Regional CJK glyph styles (the same code point is drawn differently by region). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CjkRegion { @@ -197,6 +350,27 @@ pub fn script_font_url(script: ScriptFont) -> &'static str { #[cfg(test)] mod tests { + /// A variable Noto Serif (one file for every weight, its italic beside it) is found in a + /// package folder by its name's start, whatever axes the name lists. + #[test] + fn a_variable_noto_serif_is_found() { + let root = std::env::temp_dir().join(format!("rustgis-noto-{}", std::process::id())); + let dir = root.join("google-noto-serif-vf"); + std::fs::create_dir_all(&dir).unwrap(); + // Any valid face stands in for the font. + std::fs::write(dir.join("NotoSerif[wdth,wght].ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); + std::fs::write(dir.join("NotoSerif-Italic[wdth,wght].ttf"), epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + std::fs::write(dir.join("NotoSerifDisplay[wdth,wght].ttf"), epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + let found = super::variable_noto_serif(&[root.to_str().unwrap(), "/no/such/folder"]); + let _ = std::fs::remove_dir_all(&root); + let f = found.expect("found"); + assert!(f.source.ends_with("NotoSerif[wdth,wght].ttf"), "{}", f.source); + assert_eq!(f.regular, epaint_default_fonts::HACK_REGULAR); + assert!(f.variable && f.bold.is_none() && f.bold_italic.is_none(), "the bold is the same file, kept once"); + assert_eq!(f.italic.as_deref(), Some(epaint_default_fonts::UBUNTU_LIGHT)); + assert!(super::variable_noto_serif(&["/no/such/folder"]).is_none()); + } + #[test] fn an_arabic_face_needs_persian_and_latin_letters() { // Ubuntu Light has Latin but no Arabic; junk is no font at all. diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 735d0ab35..d1ce6b3eb 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -170,10 +170,11 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O None => placeholder(&mut pm, r, "Colorbar", ppp), } } - ElementKind::Text { text: t, size, color, bold, italic, align, letter_spacing } => { + ElementKind::Text { text: t, size, color, bold, italic, align, letter_spacing, font } => { let first = layout.first_frame().and_then(frame); let s = expand(t, layout, info, first.and_then(|f| maps.get(f.1).map(|m| (m, f.2)))); - let style = TextStyle { bold: *bold, italic: *italic, align: *align, spacing: *letter_spacing as f32 }; + let face = text::Face { bold: *bold, italic: *italic, serif: *font == rustgis_map::layout::FontFamily::Serif }; + let style = TextStyle { face, align: *align, spacing: *letter_spacing as f32 }; text_block(&mut pm, r, &s, *size as f32, *color, style, ppp); } ElementKind::Picture { image } => { @@ -1014,8 +1015,8 @@ fn colorbar(pm: &mut Pixmap, r: Rect, scale: &ColorScale, bar: &Bar, ppp: f64) { /// How a text element's lines are drawn, beyond their size and colour. #[derive(Clone, Copy)] struct TextStyle { - bold: bool, - italic: bool, + /// Bold, italic and the family. + face: text::Face, align: TextAlign, /// Letter spacing in points. spacing: f32, @@ -1039,12 +1040,12 @@ fn text_block(pm: &mut Pixmap, r: Rect, s: &str, size: f32, color: Rgba, style: let left = match style.align { TextAlign::Left => r.x(), aligned => { - let (ink_left, ink_right) = text::styled_extent(line, px, style.bold, style.italic, spacing); + let (ink_left, ink_right) = text::styled_extent(line, px, style.face, spacing); let free = r.width() - (ink_right - ink_left); r.x() + if aligned == TextAlign::Center { free / 2.0 } else { free } - ink_left } }; - text::styled_at(pm, line, left, y, px, color, style.bold, style.italic, spacing); + text::styled_at(pm, line, left, y, px, color, style.face, spacing); y += px * 1.25; } } @@ -1371,7 +1372,7 @@ mod tests { /// as before. #[test] fn text_takes_italic_alignment_and_letter_spacing() { - let plain = TextStyle { bold: false, italic: false, align: TextAlign::Left, spacing: 0.0 }; + let plain = TextStyle { face: text::Face::default(), align: TextAlign::Left, spacing: 0.0 }; let base = styled_line(plain); let (l, r, ..) = text_ink(&base); assert!(l < 4, "left-aligned starts at the left: {l}"); @@ -1397,7 +1398,7 @@ mod tests { let mut pm = Pixmap::new(120, 120).unwrap(); pm.fill(tiny_skia::Color::WHITE); let r = Rect::from_xywh(40.0, 0.0, 80.0, 120.0).unwrap(); - text_block(&mut pm, r, "l", 60.0, Rgba::rgb(0, 0, 0), TextStyle { italic, ..plain }, 1.0); + text_block(&mut pm, r, "l", 60.0, Rgba::rgb(0, 0, 0), TextStyle { face: text::Face { italic, ..plain.face }, ..plain }, 1.0); let (.., top, bottom) = text_ink(&pm); (top as i32 - bottom as i32, text_height(&pm)) }; @@ -1405,9 +1406,10 @@ mod tests { assert!(upright.abs() <= 1, "upright stem: {upright}"); assert!((lean as f32 - 0.2126 * height as f32).abs() <= 3.0, "lean {lean} over {height} px"); // Right-aligned, slanted or tightened past the letters' width, the ink stays in the box. - for style in - [TextStyle { align: TextAlign::Right, italic: true, ..plain }, TextStyle { align: TextAlign::Right, spacing: -5.0, ..plain }] - { + for style in [ + TextStyle { align: TextAlign::Right, face: text::Face { italic: true, ..plain.face }, ..plain }, + TextStyle { align: TextAlign::Right, spacing: -5.0, ..plain }, + ] { let mut pm = Pixmap::new(400, 60).unwrap(); pm.fill(tiny_skia::Color::WHITE); let r = Rect::from_xywh(0.0, 0.0, 300.0, 60.0).unwrap(); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index d211ff507..1433dce62 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -27,6 +27,73 @@ fn font(bold: bool) -> Option<&'static FontRef<'static>> { } } +/// The family and style a line is drawn in: a layout text's serif (#517), with its bold and +/// italic, or the label font's. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Face { + pub bold: bool, + pub italic: bool, + /// The installed serif; the sans where there is none. + pub serif: bool, +} + +/// The installed serif's faces, parsed once: (regular, bold, italic, bold italic). +type SerifFaces = (FontRef<'static>, Option>, Option>, Option>); + +/// The weight a serif's bold faces are drawn at where the file is a variable font (a variable +/// Noto Serif is one file for every weight); static faces ignore it. +const SERIF_BOLD_WEIGHT: f32 = 700.0; + +fn serif() -> Option<&'static SerifFaces> { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| { + let f = crate::fonts::serif_fonts()?; + let parse = |b: Option<&'static [u8]>| b.and_then(|b| FontRef::try_from_slice(b).ok()); + let heavy = |b: Option<&'static [u8]>| { + let mut face = parse(b)?; + face.set_variation(b"wght", SERIF_BOLD_WEIGHT); + Some(face) + }; + // A variable family's bold faces are its regular and italic bytes parsed again (a + // `FontRef` only borrows them), drawn at the bold weight. + let (bold, bold_italic) = + if f.variable { (Some(f.regular.as_slice()), f.italic.as_deref()) } else { (f.bold.as_deref(), f.bold_italic.as_deref()) }; + Some((FontRef::try_from_slice(&f.regular).ok()?, heavy(bold), parse(f.italic.as_deref()), heavy(bold_italic))) + }) + .as_ref() +} + +/// The face `face` is drawn in from `serif`'s faces, and whether it is slanted by hand: a real +/// italic face where the family has one (bold italic, else italic), else the bold or regular +/// slanted, as the sans always is. Without a serif, the label font. +fn face_in(face: Face, serif: Option<&'static SerifFaces>) -> Option<(&'static FontRef<'static>, bool)> { + let Some((regular, bold, italic, bold_italic)) = serif.filter(|_| face.serif) else { + return font(face.bold).map(|f| (f, face.italic)); + }; + let upright = if face.bold { bold.as_ref().unwrap_or(regular) } else { regular }; + let real_italic = if face.bold { bold_italic.as_ref() } else { italic.as_ref() }; + Some(match real_italic.filter(|_| face.italic) { + Some(f) => (f, false), + None => (upright, face.italic), + }) +} + +fn face_font(face: Face) -> Option<(&'static FontRef<'static>, bool)> { + face_in(face, serif()) +} + +/// The label font of `face`'s weight, for the characters `f` lacks: `None` when `f` is it. +fn sans_beside(f: &'static FontRef<'static>, face: Face) -> Option<&'static FontRef<'static>> { + font(face.bold).filter(|s| !std::ptr::eq(*s, f)) +} + +/// Whether a glyph drawn from `g` leans: the whole line does when its face is slanted by hand +/// (`slanted`), and in a real italic face `f` a glyph taken from another face (the sans or a +/// CJK face, for a character the italic lacks) is slanted to match it. +fn leans(face: Face, f: &FontRef<'static>, slanted: bool, g: &FontRef<'static>) -> bool { + slanted || (face.italic && !std::ptr::eq(f, g)) +} + /// The scale that draws `f` with an em `px` pixels tall, as CSS sizes a font and the map view /// (egui) draws it. (ab_glyph's own pixel scale is the height from descender to ascender, /// a good third more than the em in most faces.) @@ -66,14 +133,20 @@ fn cjk_order(c: char) -> [CjkRegion; 3] { } } -/// The face and glyph that draw `c`: the label font when it has the glyph, else the first -/// installed CJK font that does. `None` when nothing covers it, so no missing-glyph box is drawn. -fn glyph(c: char, bold: bool) -> Option<(&'static FontRef<'static>, GlyphId)> { - let f = font(bold)?; +/// The face and glyph that draw `c`: `f` when it has the glyph, else `sans` (the label font a +/// serif text falls back to: an arrow or a script the serif lacks), else the first installed CJK +/// font that does. `None` when nothing covers it, so no missing-glyph box is drawn. +fn glyph(c: char, f: &'static FontRef<'static>, sans: Option<&'static FontRef<'static>>) -> Option<(&'static FontRef<'static>, GlyphId)> { let id = f.glyph_id(c); if id.0 != 0 || invisible(c) { return (id.0 != 0).then_some((f, id)); } + if let Some(s) = sans { + let id = s.glyph_id(c); + if id.0 != 0 { + return Some((s, id)); + } + } cjk_order(c).into_iter().filter_map(cjk).find_map(|f| { let id = f.glyph_id(c); (id.0 != 0).then_some((f, id)) @@ -83,20 +156,24 @@ fn glyph(c: char, bold: bool) -> Option<(&'static FontRef<'static>, GlyphId)> { /// Whether the label fonts (`bold` or regular, with the installed CJK fallbacks) have a glyph for /// every character of `text`, invisible characters aside. pub fn covers(text: &str, bold: bool) -> bool { - if font(bold).is_none() { - return true; - } - text.chars().all(|c| invisible(c) || glyph(c, bold).is_some()) -} - -/// Lays out `text` at `px` pixels: each drawable glyph with its face and pen position (from 0), -/// and the total advance. Characters no font covers are left out. -fn shape(text: &str, px: f32, bold: bool) -> (Vec<(&'static FontRef<'static>, GlyphId, f32)>, f32) { + let Some(f) = font(bold) else { return true }; + text.chars().all(|c| invisible(c) || glyph(c, f, None).is_some()) +} + +/// Lays out `text` at `px` pixels in `face` (with `sans` for the characters it lacks, as +/// [`glyph`] takes them): each drawable glyph with its face and pen position (from 0), and the +/// total advance. Characters no font covers are left out. +fn shape( + text: &str, + px: f32, + face: &'static FontRef<'static>, + sans: Option<&'static FontRef<'static>>, +) -> (Vec<(&'static FontRef<'static>, GlyphId, f32)>, f32) { let mut out = Vec::new(); let mut x = 0.0; let mut prev: Option<(&'static FontRef<'static>, GlyphId)> = None; for ch in text.chars().take(500) { - let Some((f, id)) = glyph(ch, bold) else { continue }; + let Some((f, id)) = glyph(ch, face, sans) else { continue }; let sf = f.as_scaled(em(f, px)); if let Some((pf, pid)) = prev && std::ptr::eq(pf, f) @@ -126,10 +203,10 @@ pub fn line_count(text: &str) -> usize { /// Text width in points at `size` (font px ≈ size × 1.33 for point sizes), drawn on one line. pub fn measure(text: &str, size: f32, bold: bool) -> f32 { - if font(bold).is_none() { + let Some(f) = font(bold) else { return text.chars().count() as f32 * size * 0.6; - } - shape(text, size * 1.33, bold).1 + }; + shape(text, size * 1.33, f, None).1 } /// How wide `text` is on one line at `size` with `spacing` points between letters, in points. @@ -235,7 +312,7 @@ fn line_width(l: &PlacedLabel, line: &str, ppp: f32) -> f32 { return advance; } let letters = match font(l.bold) { - Some(_) => shape(line, l.size * 1.33 * ppp, l.bold).0.len(), + Some(f) => shape(line, l.size * 1.33 * ppp, f, None).0.len(), None => line.chars().count(), }; // Letters drawn tighter than their own width (the most negative spacing) don't make it less @@ -278,7 +355,7 @@ fn blend(pm: &mut Pixmap, x: i32, y: i32, c: Rgba, cov: f32) { fn draw_text(pm: &mut Pixmap, text: &str, cx: f32, cy: f32, px_size: f32, color: Rgba, bold: bool, offset: (f32, f32)) { let Some(f) = font(bold) else { return }; let sf = f.as_scaled(em(f, px_size)); - let (glyphs, width) = shape(text, px_size, bold); + let (glyphs, width) = shape(text, px_size, f, None); let x0 = cx - width / 2.0 + offset.0; let base = cy + (sf.ascent() + sf.descent()) / 2.0 + offset.1; for (f, id, x) in glyphs { @@ -294,18 +371,20 @@ fn draw_text(pm: &mut Pixmap, text: &str, cx: f32, cy: f32, px_size: f32, color: /// slant an oblique. const SLANT: f32 = 0.2126; -/// The ink `text` draws at `px` with `spacing` and `italic`, from the pen's start: its leftmost +/// The ink `text` draws at `px` in `face` with `spacing`, from the pen's start: its leftmost /// and rightmost pixel offsets. With a negative spacing the letters can run backwards past the /// pen, and a slant moves their tops right and their descenders left; (0, 0) when nothing draws. -pub fn styled_extent(text: &str, px: f32, bold: bool, italic: bool, spacing: f32) -> (f32, f32) { - let Some(f) = font(bold) else { return (0.0, 0.0) }; +pub fn styled_extent(text: &str, px: f32, face: Face, spacing: f32) -> (f32, f32) { + let Some((f, slanted)) = face_font(face) else { return (0.0, 0.0) }; let sf = f.as_scaled(em(f, px)); let base = (sf.ascent() + sf.descent()) / 2.0; - let (glyphs, _) = shape(text, px, bold); + let (glyphs, _) = shape(text, px, f, sans_beside(f, face)); let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY); - for (i, (f, id, x)) in glyphs.into_iter().enumerate() { - let g = id.with_scale_and_position(em(f, px), ab_glyph::point(x + spacing * i as f32, base)); - let Some(og) = f.outline_glyph(g) else { continue }; + for (i, (gf, id, x)) in glyphs.into_iter().enumerate() { + // Measured as `styled_at` draws it. + let italic = leans(face, f, slanted, gf); + let g = id.with_scale_and_position(em(gf, px), ab_glyph::point(x + spacing * i as f32, base)); + let Some(og) = gf.outline_glyph(g) else { continue }; let bb = og.px_bounds(); let (lean_top, lean_bottom) = if italic { (SLANT * (base - bb.min.y), SLANT * (base - bb.max.y)) } else { (0.0, 0.0) }; lo = lo.min(bb.min.x + lean_top.min(lean_bottom)); @@ -314,21 +393,22 @@ pub fn styled_extent(text: &str, px: f32, bold: bool, italic: bool, spacing: f32 if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 0.0) } } -/// Draws `text` from `left` (pixels), its line centred on `cy`, at `px` with `spacing` pixels -/// between letters, slanted when `italic`. Plain (no spacing, upright) it draws as -/// [`text_at`] does from the left. +/// Draws `text` from `left` (pixels), its line centred on `cy`, at `px` in `face` with `spacing` +/// pixels between letters: italic in the family's italic face where it has one, else slanted. +/// Plain (sans, no spacing, upright) it draws as [`text_at`] does from the left. #[allow(clippy::too_many_arguments)] -pub fn styled_at(pm: &mut Pixmap, text: &str, left: f32, cy: f32, px: f32, color: Rgba, bold: bool, italic: bool, spacing: f32) { +pub fn styled_at(pm: &mut Pixmap, text: &str, left: f32, cy: f32, px: f32, color: Rgba, face: Face, spacing: f32) { if !(left.is_finite() && cy.is_finite() && px.is_finite() && spacing.is_finite()) || px <= 0.0 { return; } - let Some(f) = font(bold) else { return }; + let Some((f, slanted)) = face_font(face) else { return }; let sf = f.as_scaled(em(f, px)); let base = cy + (sf.ascent() + sf.descent()) / 2.0; - let (glyphs, _) = shape(text, px, bold); - for (i, (f, id, x)) in glyphs.into_iter().enumerate() { - let g = id.with_scale_and_position(em(f, px), ab_glyph::point(left + x + spacing * i as f32, base)); - let Some(og) = f.outline_glyph(g) else { continue }; + let (glyphs, _) = shape(text, px, f, sans_beside(f, face)); + for (i, (gf, id, x)) in glyphs.into_iter().enumerate() { + let italic = leans(face, f, slanted, gf); + let g = id.with_scale_and_position(em(gf, px), ab_glyph::point(left + x + spacing * i as f32, base)); + let Some(og) = gf.outline_glyph(g) else { continue }; let bb = og.px_bounds(); og.draw(|gx, gy, cov| { let (px_x, px_y) = (bb.min.x + gx as f32, bb.min.y + gy as f32); @@ -408,7 +488,7 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { let (styled, spacing) = (styled(l), l.spacing * 1.33 * ppp); let put = |pm: &mut Pixmap, &(line, left, lw, y): &(&str, f32, f32, f32), color: Rgba, (dx, dy): (f32, f32)| { if styled { - styled_at(pm, line, left + dx, y + dy, px, color, l.bold, l.italic, spacing); + styled_at(pm, line, left + dx, y + dy, px, color, Face { bold: l.bold, italic: l.italic, serif: false }, spacing); } else { draw_text(pm, line, left + lw / 2.0, y, px, color, l.bold, (dx, dy)); } @@ -569,4 +649,81 @@ mod coverage_tests { super::text_at(&mut pm, &ch.to_string(), 40.0, 20.0, 24.0, rustgis_map::Rgba::rgb(0, 0, 0), false, super::Align::Center, false); assert!(pm.pixels().iter().any(|p| p.alpha() > 0), "the fallback glyph was not drawn"); } + + /// A text asking for a serif (#517) where none is installed is drawn in the label font, slanted + /// as before for italic: nothing goes blank, and the result is the same on every machine. + #[test] + fn a_missing_serif_draws_in_the_sans() { + use super::{Face, face_in, font}; + for bold in [false, true] { + for italic in [false, true] { + let (f, slant) = face_in(Face { bold, italic, serif: true }, None).unwrap(); + assert!(std::ptr::eq(f, font(bold).unwrap()) && slant == italic); + } + } + } + + /// The serif's faces picked for each style, a character it lacks taken from the sans, and that + /// glyph slanted in a real italic, with faces of the test's own (egui's bundled Hack as the + /// serif's regular, Ubuntu Light as its italic): run on every machine, serif installed or not. + #[test] + fn serif_faces_are_picked_and_fall_back() { + use super::{Face, SerifFaces, face_in, leans, shape}; + use ab_glyph::{Font, FontRef}; + let parse = |b: &'static [u8]| FontRef::try_from_slice(b).unwrap(); + let faces: &'static SerifFaces = + Box::leak(Box::new((parse(epaint_default_fonts::HACK_REGULAR), None, Some(parse(epaint_default_fonts::UBUNTU_LIGHT)), None))); + let (regular, italic) = (&faces.0, faces.2.as_ref().unwrap()); + let pick = |bold, it| face_in(Face { bold, italic: it, serif: true }, Some(faces)).unwrap(); + let is = |(f, slant): (&FontRef, bool), g: &FontRef, s: bool| std::ptr::eq(f, g) && slant == s; + assert!(is(pick(false, false), regular, false)); + assert!(is(pick(false, true), italic, false), "the real italic, not slanted"); + // No bold file: the regular; no bold italic: the regular, slanted. + assert!(is(pick(true, false), regular, false)); + assert!(is(pick(true, true), regular, true)); + // A sans text never takes them. + assert!(!std::ptr::eq(face_in(Face::default(), Some(faces)).unwrap().0, regular)); + // A character the serif lacks and the sans has: drawn from the sans. + let sans = italic; + let c = ('\u{100}'..='\u{2FFF}').find(|&c| regular.glyph_id(c).0 == 0 && sans.glyph_id(c).0 != 0).expect("a character Hack lacks"); + let (glyphs, _) = shape(&format!("a{c}"), 24.0, regular, Some(sans)); + assert!(glyphs.len() == 2 && std::ptr::eq(glyphs[1].0, sans), "{c:?} left out"); + // In a real italic, a glyph from another face leans; the italic's own don't. + let it = Face { italic: true, serif: true, ..Face::default() }; + assert!(!leans(it, italic, false, italic) && leans(it, italic, false, regular)); + assert!(!leans(Face { serif: true, ..Face::default() }, regular, false, sans), "upright text stays upright"); + } + + /// With a serif installed (Georgia on Windows, Times on macOS, Noto or DejaVu Serif on Linux), + /// a text asking for it is drawn in it, and italic in the family's italic face, not slanted + /// by hand; the sans is untouched. + #[test] + fn an_installed_serif_is_drawn() { + use super::{Face, face_font, font}; + let Some(serif) = super::serif() else { return }; + let (f, slant) = face_font(Face { serif: true, ..Face::default() }).unwrap(); + assert!(std::ptr::eq(f, &serif.0) && !slant); + let (sans, _) = face_font(Face::default()).unwrap(); + assert!(std::ptr::eq(sans, font(false).unwrap())); + if let Some(italic) = &serif.2 { + let (it, slant) = face_font(Face { italic: true, serif: true, ..Face::default() }).unwrap(); + assert!(std::ptr::eq(it, italic) && !slant); + } + // The same words in the serif and the sans draw differently. + let draw = |face: Face| { + let mut pm = tiny_skia::Pixmap::new(300, 40).unwrap(); + super::styled_at(&mut pm, "Rio Amazonas", 2.0, 20.0, 24.0, rustgis_map::Rgba::rgb(0, 0, 0), face, 0.0); + pm.data().to_vec() + }; + assert_ne!(draw(Face { serif: true, ..Face::default() }), draw(Face::default())); + // A character the serif lacks and the label font has is drawn from the label font, not + // left out. + use ab_glyph::Font; + let sans = font(false).unwrap(); + let lacking = ('\u{2190}'..='\u{2BFF}').find(|&c| serif.0.glyph_id(c).0 == 0 && sans.glyph_id(c).0 != 0); + if let Some(c) = lacking { + let (glyphs, _) = super::shape(&c.to_string(), 24.0, &serif.0, super::sans_beside(&serif.0, Face::default())); + assert!(glyphs.len() == 1 && std::ptr::eq(glyphs[0].0, sans), "{c:?} left out"); + } + } } diff --git a/crates/ui-egui/src/graphicformat.rs b/crates/ui-egui/src/graphicformat.rs index fd168872a..647af2a8f 100644 --- a/crates/ui-egui/src/graphicformat.rs +++ b/crates/ui-egui/src/graphicformat.rs @@ -125,6 +125,7 @@ pub fn show(app: &mut RustGisApp, ui: &mut egui::Ui, t: &Tokens) { italic: Some(&mut italic), align: Some(&mut align), letter_spacing: Some(&mut spacing), + font: None, select, }; let settled = crate::textform::section(ui, "Text", |ui| crate::textform::show(ui, ("graphic_format", k), &[graphics::TOKENS], fields)) diff --git a/crates/ui-egui/src/layoutview.rs b/crates/ui-egui/src/layoutview.rs index b0f5303f8..26284fc01 100644 --- a/crates/ui-egui/src/layoutview.rs +++ b/crates/ui-egui/src/layoutview.rs @@ -1538,11 +1538,11 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { ); }); // A layout's text: the form a text graphic has too (#516, #535), with the page's dynamic text. - if let ElementKind::Text { text, size, color, bold, italic, align, letter_spacing } = &e.kind { + if let ElementKind::Text { text, size, color, bold, italic, align, letter_spacing, font } = &e.kind { let key = (li, id, "text"); let mut buf = app.layout.typing.remove(&key).unwrap_or_else(|| text.clone()); let (mut sz, mut c, mut bb) = (*size, crate::theme::c32(*color), *bold); - let (mut it, mut al, mut ls) = (*italic, *align, *letter_spacing); + let (mut it, mut al, mut ls, mut fam) = (*italic, *align, *letter_spacing, *font); let tokens = [rustgis_engine::map::graphics::TOKENS, rustgis_engine::map::layout::PAGE_TOKENS]; let fields = crate::textform::Fields { text: &mut buf, @@ -1552,6 +1552,7 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { italic: Some(&mut it), align: Some(&mut al), letter_spacing: Some(&mut ls), + font: Some(&mut fam), select: false, }; let settled = crate::textform::section(ui, "Text", |ui| crate::textform::show(ui, ("layout_text", li, id), &tokens, fields)) @@ -1579,6 +1580,9 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { if settled.letter_spacing { update(app, json!({"letterSpacing": ls})); } + if settled.font { + update(app, json!({"font": fam.code()})); + } } // The layers a legend lists: a checkbox for each vector layer of its frame's map, ticked when // listed. Names gone from the map drop out of the list as it's written back. @@ -2235,6 +2239,16 @@ mod tests { matches!(kind, ElementKind::Text { italic: true, align: TextAlign::Left, letter_spacing, .. } if *letter_spacing == 3.5), "{kind:?}" ); + // The family (#517): Serif picked from the Font list is stored. + h.get_by_role_and_label(Role::ComboBox, "Font").click(); + h.run_steps(2); + h.get_by_label("Serif").click(); + h.run_steps(3); + let kind = &h.state().session.project.layouts[li].element(title).unwrap().kind; + assert!(matches!(kind, ElementKind::Text { font: rustgis_engine::map::layout::FontFamily::Serif, .. }), "{kind:?}"); + // Said where no serif is installed, and only there. + let said = h.query_by_label_contains("No serif font is installed").is_some(); + assert_eq!(said, !rustgis_engine::render::fonts::serif_available()); } /// A number dragged in the Format pane and let go is applied (#607): X moves the element by diff --git a/crates/ui-egui/src/textform.rs b/crates/ui-egui/src/textform.rs index d32dd2fa5..36f7e7f27 100644 --- a/crates/ui-egui/src/textform.rs +++ b/crates/ui-egui/src/textform.rs @@ -1,11 +1,11 @@ //! The text form shared by a text graphic (Add Text) and a layout's text (the Format pane, #516): //! its text, dynamic text, size, colour, boldness, italic, alignment and letter spacing (#535), //! laid out and limited the same in both, so the same text can look the same on the map and on -//! the page. It edits the values it is lent +//! the page; a layout's text also has its font family (#517). It edits the values it is lent //! and says what was settled; each caller runs its own command. use egui::{Color32, Ui, vec2}; -use rustgis_engine::map::layout::{MAX_LETTER_SPACING, MIN_LETTER_SPACING, TextAlign}; +use rustgis_engine::map::layout::{FontFamily, MAX_LETTER_SPACING, MIN_LETTER_SPACING, TextAlign}; use crate::widgets::Dragged; @@ -20,6 +20,8 @@ pub struct Fields<'a> { pub align: Option<&'a mut TextAlign>, /// Points between letters, from −5 (tighter) to 50. pub letter_spacing: Option<&'a mut f64>, + /// Sans-serif or serif: a layout text's (a text graphic is drawn in the map's sans). + pub font: Option<&'a mut FontFamily>, /// The text is focused and selected whole, so typing replaces it (a text just placed). pub select: bool, } @@ -39,6 +41,8 @@ pub struct Settled { pub align: bool, /// The letter spacing changed and was let go, as the size. pub letter_spacing: bool, + /// Another family was picked. + pub font: bool, } /// A section of the Format pane ("Element", "Text"), folding as its other sections do (a frame's @@ -149,6 +153,27 @@ pub fn show(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, tokens: &[& out.letter_spacing = drag.settled(ui, &r, ls) && (*ls - saved).abs() > 1e-6; ui.end_row(); } + if let Some(fam) = f.font { + ui.label(tl!("Font")); + let list = egui::ComboBox::from_id_salt(id.with("font")).selected_text(tl!(fam.label())).show_ui(ui, |ui| { + for v in FontFamily::ALL { + if ui.selectable_label(*fam == v, tl!(v.label())).clicked() && *fam != v { + *fam = v; + out.font = true; + } + } + }); + // Named for assistive technology, as its label sits in the other column. + list.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, tl!("Font"))); + ui.end_row(); + // A serif asked for where none is installed is drawn in the sans: said here, so the + // page never differs from what was asked unsaid. + if *fam == FontFamily::Serif && !rustgis_engine::render::fonts::serif_available() { + ui.label(""); + ui.label(egui::RichText::new(tl!("No serif font is installed, so this text is drawn in the sans-serif")).weak().small()); + ui.end_row(); + } + } }); out } @@ -190,6 +215,7 @@ mod tests { italic: f.styled.then_some(&mut f.italic), align: f.styled.then_some(&mut f.align), letter_spacing: f.styled.then_some(&mut spacing), + font: None, select: false, }, ); diff --git a/docs/user-guide/layouts.md b/docs/user-guide/layouts.md index 65923e9d0..c75c5e44b 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -89,7 +89,16 @@ the maps; while one is open, its groups lead the Publish tab: **Alignment** (left, center or right within the element) and **Letter spacing** in points (spaced-out names such as O C E A N O, or a negative value to tighten) are the same form a text on the map has in the pane; Enter ends the text and Shift+Enter starts a new line. Scripts set them with `italic`, `align` and - `letterSpacing` on `layout.addElement` and `layout.update`. **Picture** places a PNG or JPEG. + `letterSpacing` on `layout.addElement` and `layout.update`. A layout's text also takes a **Font**: + Sans-serif (the default) or Serif, for a title or a classic atlas look. The serif is one installed + on the computer (Georgia or Times New Roman on Windows and macOS, Noto, DejaVu or Liberation Serif + on Linux), with its own bold and italic faces, so italic there is a true italic rather than + slanted letters (a family with no bold or italic file draws that style from its regular face: + italic slanted, bold as the regular); RustGIS bundles no font. The project saves only the family, + so it opens anywhere. Where no serif is installed (the web app, a minimal Linux), the text is + drawn in the sans-serif and the pane says so, as `layout.addElement` and `layout.update` do in a + `warning`. Scripts set it with `"font": "serif"` or `"font": "sans"`. **Picture** places a PNG or + JPEG. - **Map Series**: one page per feature of an index layer, with the frame zoomed to each feature; step through pages, refresh, and export every page into one PDF. - Click an element to select it, drag to move, drag its handles to resize, and edit it in the From cc5ef6ce5c32ff4ecbe67da62a8982136c5a5b4e Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Thu, 22 Oct 2026 01:36:10 -0300 Subject: [PATCH 017/297] Analysis: trend over a STAC collection's band, read in place in pieces (#579) (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Analysis: trend over a STAC collection's band, read in place in pieces (#579) cube.trend takes a STAC collection (collection, asset, bbox, start, end, as stac.addCube searches it): each date's files are put on one grid, scaled as their metadata says, read at the overview nearest cellSize or averaged from the full images, and an area too large for a run's block is read in pieces of rows. The seasonal test's time limit counts pairs within a season; an end given as a year or month takes all of it. Co-Authored-By: Claude Opus 5.5 * STAC trend: Cancel stops the run after the readers stop; the COG value read counts and indexes its tiles in checked arithmetic Co-Authored-By: Claude Opus 5.5 * STAC trend: averaged cells refuse a file whose corner isn't on a cell's; a STAC source in the hostile parameters Co-Authored-By: Claude Opus 5.5 * STAC trend: a piece's slice reads at most 4 million of the files' pixels (averaging's factor² counted) Co-Authored-By: Claude Opus 5.5 * COG value read: the window's size is checked before its values are allocated Co-Authored-By: Claude Opus 5.5 * STAC trend: outputs filled band by band, piece by piece; one reader in the browser; a boundary tile's two pieces said; end as a year tested on a stack Co-Authored-By: Claude Opus 5.5 * STAC trend: a slice reads at most 1 million pixels, its memory said; the output bands reserved; the piece-size hook test-only Co-Authored-By: Claude Opus 5.5 * STAC trend test: each file's offset too, the refused resampling adds nothing, a numeric end; the pieces' tile reads said when a tile row doesn't fit Co-Authored-By: Claude Opus 5.5 * STAC: the signed-address refusal says why for a trend too; average's grid requirement documented Co-Authored-By: Claude Opus 5.5 * Trend: a number from 1000 to 9999 is a year, said in the parameters and docs Co-Authored-By: Claude Opus 5.5 * Docs: a number from 1000 to 9999 is a year in a trend's period Co-Authored-By: Claude Opus 5.5 * STAC trend: pieces checked for one grid (origin and pixel size), an overview that isn't the full image divided evenly said, tile reads wait two minutes; the read's bound documented Co-Authored-By: Claude Opus 5.5 * Hostile parameters reach the STAC trend's junk past a valid band and period; Cancel's wait on a stalled read said Co-Authored-By: Claude Opus 5.5 * StacCube::slice refuses a cube read in pieces rather than return its top rows; the header cache said where it's read Co-Authored-By: Claude Opus 5.5 * STAC: each file's address checked against the API it came from; a piece's top checked to follow the rows above Co-Authored-By: Claude Opus 5.5 * STAC trend: the asset's no-data skipped too; one piece when the area fits; average needs an exact overview; the signed-address refusal and the average's requirement said plainly Co-Authored-By: Claude Opus 5.5 * TimeCube::readers says how many slices it keeps alive and what that asks of a cube's slices Co-Authored-By: Claude Opus 5.5 * STAC trend: a resampling that isn't text is refused, not read as nearest Co-Authored-By: Claude Opus 5.5 * docs: a STAC trend averages overlapping tiles on a date Co-Authored-By: Claude Opus 5.5 * cube.trend: test full end dates and an area wider than its file; count the queued slices' memory Co-Authored-By: Claude Opus 5.5 * cube.trend: refuse a STAC area no file covers, rather than four empty rasters Co-Authored-By: Claude Opus 5.5 * cube.trend: probe at most eight tiles' headers for the STAC area's cover, stopping at a failed one Co-Authored-By: Claude Opus 5.5 * cube.trend: refuse a STAC run whose files all missed the area once read; say it waits Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/cube.rs | 302 +++++++++++--- crates/engine/src/cmd/stac.rs | 732 ++++++++++++++++++++++++++++------ crates/engine/src/tests.rs | 164 ++++++++ crates/tiles/src/cog.rs | 82 ++++ crates/tiles/src/stac.rs | 8 +- docs/user-guide/analysis.md | 32 +- docs/user-guide/web-data.md | 3 + 7 files changed, 1135 insertions(+), 188 deletions(-) diff --git a/crates/engine/src/cmd/cube.rs b/crates/engine/src/cmd/cube.rs index 4c3a3006c..a64faa92f 100644 --- a/crates/engine/src/cmd/cube.rs +++ b/crates/engine/src/cmd/cube.rs @@ -22,7 +22,7 @@ use crate::{CmdResult, CommandSpec as C, Session}; pub fn commands() -> Vec { vec![C::new("cube.trend", "Trend Analysis", "Analyze › Image Analysis", trend_cmd).params( - r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer (or "path" | "url", "variable", "index", "crs" as for zarr.reduce) or a raster of several bands, a date each (default: the active layer), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "start"?, "end"?: dates, "months"?: [1–12] (one season across years), "test"?: "hamedRao" (default) | "yueWang" | "seasonal" | "original", "seasons"?: parts of a year for the seasonal test (default 12), "alpha"?: 0.05 (above 0, below 0.5), "minValid"?: dates a cell needs (default: half of them, at least 4; the seasonal test also needs 4 in seasons of two or more), "per"?: "year" (default) | "decade", "name"?: text, "ran"?: (internal: a trend the app has worked out)} → four raster layers: Sen's slope, the test's Z and p-value, and the slope where p < alpha"#, + r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer (or "path" | "url", "variable", "index", "crs" as for zarr.reduce) or a raster of several bands, a date each (default: the active layer) | "collection": a STAC collection (desktop: "url"?: its API, "asset": a band, "bbox"?: [w, s, e, n] | "view", "start", "end", "maxCloud"? as for stac.addCube, "cellSize"?: in the files' units, the overview level nearest it is read, "resampling"?: "nearest" (default: that overview's pixels as stored) | "average" (each cell the mean of the full images' pixels in it, which reads them); a large area is read in pieces), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "start"?, "end"?: dates (a whole number from 1000 to 9999 is a year; an end given as a year or a month takes all of it), "months"?: [1–12] (one season across years), "test"?: "hamedRao" (default) | "yueWang" | "seasonal" | "original", "seasons"?: parts of a year for the seasonal test (default 12), "alpha"?: 0.05 (above 0, below 0.5), "minValid"?: dates a cell needs (default: half of them, at least 4; the seasonal test also needs 4 in seasons of two or more), "per"?: "year" (default) | "decade", "name"?: text, "ran"?: (internal: a trend the app has worked out)} → four raster layers: Sen's slope, the test's Z and p-value, and the slope where p < alpha"#, )] } @@ -57,6 +57,34 @@ pub trait TimeCube: Send + Sync { fn period(&self) -> (usize, usize) { (0, self.dates().len()) } + /// The pieces it is read in, each a band of rows of the whole grid from the top: one for a cube + /// read whole. A cube too large for one block of [`MAX_VALUES`] (a STAC collection over a + /// state) is worked out piece by piece, each within it. + fn pieces(&self) -> usize { + 1 + } + /// Date `i`'s slice, piece `piece` of it (the whole slice for a cube read whole). + fn slice_of(&self, i: usize, piece: usize) -> Result, usize)>, String> { + let _ = piece; + self.slice(i) + } + /// The whole grid's columns and rows, when known before any slice is read (a STAC cube's area), + /// so a run too long is refused before it reads. + fn size(&self) -> Option<(usize, usize)> { + None + } + /// How many dates are read at once: files read over the network wait mostly on it. Up to + /// about twice as many slices are held at a time (one in each reader's hands, as many waiting + /// to be placed), so a cube with more than one reader keeps its slices small: a STAC cube's are a + /// million pixels at most, about 4 MB each. + fn readers(&self) -> usize { + 1 + } + /// Why the run read nothing, asked once every slice is read: a cube none of whose files met + /// the area (a STAC cube's), so its outputs would be empty. `None` when something was read. + fn nothing_read(&self) -> Option { + None + } } /// A stack of rasters (or a raster's bands), a date each. @@ -255,6 +283,13 @@ pub fn cube_from(s: &Session, p: &Value) -> Result, String> { return Ok(Box::new(Stack::new(items, common_label(&label)))); } let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + // A STAC collection's band, read from its files over an area (#579): named by its collection. + if given("collection") { + #[cfg(target_arch = "wasm32")] + return Err("in the web app, a STAC collection's trend can't be worked out yet (the desktop app can)".into()); + #[cfg(not(target_arch = "wasm32"))] + return Ok(Box::new(super::stac::stac_cube(s, p)?)); + } let zarr_layer = !given("path") && !given("url") && { let id = s.layer_id(p)?; // A Zarr layer: one following the slider, or a slice, named by its steps (`t2m[time=3]`); @@ -378,7 +413,13 @@ pub fn steps(dates: &[f64], period: (usize, usize), p: &Value) -> Result Some(after - 1.0), + None => date("end")?, + }; + let start = date("start")?; let (lo, hi) = match (start, end) { (None, None) => (period.0.min(dates.len()), period.1.min(dates.len())), _ => (0, dates.len()), @@ -435,10 +476,26 @@ pub struct TrendRun { } impl TrendRun { - /// The work, for a progress bar: the slices read, then the cells worked out, in - /// [`CELL_PARTS`] parts. + /// The work, for a progress bar: the slices read (every piece's), then the cells worked out, + /// in [`CELL_PARTS`] parts. pub fn total(&self) -> usize { - self.steps.len() + CELL_PARTS + self.steps.len() * self.cube.pieces().max(1) + CELL_PARTS + } + + /// The pairs of dates one cell compares: all of them, or for the seasonal test only those of + /// a season (`seasons`, a step's season each), which is what bounds its time. + fn pairs_per_cell(&self, seasons: &[usize]) -> u64 { + let n = self.steps.len() as u64; + match self.test { + Test::Seasonal(_) => { + let mut by: std::collections::HashMap = std::collections::HashMap::new(); + for s in seasons { + *by.entry(*s).or_default() += 1; + } + by.values().map(|k| k * k.saturating_sub(1) / 2).sum() + } + _ => n * n.saturating_sub(1) / 2, + } } /// How many dates it reads. @@ -447,21 +504,119 @@ impl TrendRun { } /// Reads the slices into one block, cell by date, then works each cell's trend out on as - /// many threads as there are cores. `done` counts the slices read, and the last unit once - /// the cells are done; `cancel` stops it between slices or rows. + /// many threads as there are cores; a cube read in pieces ([`TimeCube::pieces`]) does so piece + /// by piece, the outputs put together row by row. `done` counts the slices read, and the last + /// unit once the cells are done; `cancel` stops it between slices or rows. pub fn run(&self, cancel: &AtomicBool, done: &AtomicUsize) -> Result, String> { + let n = self.steps.len(); + // Times in the slope's unit. + let year = 365.2425 * 86_400_000.0 * self.per; + let date = |i: usize| self.cube.dates().get(i).copied().unwrap_or(f64::NAN); + let times: Vec = self.steps.iter().map(|i| date(*i) / year).collect(); + // The seasonal test's seasons, from each date's place in its calendar year. + let seasons: Vec = match self.test { + Test::Seasonal(m) => self.steps.iter().map(|i| season_of(date(*i), m)).collect(), + _ => Vec::new(), + }; + let per_cell = self.pairs_per_cell(&seasons); + let too_long = |cells: usize| { + let pairs = (cells as u64).saturating_mul(per_cell); + (pairs > self.max_pairs).then(|| { + let how = if self.max_pairs < MAX_PAIRS { " (or run it from the Workflows pane, which can take more)" } else { "" }; + format!("{cells} cells × {n} dates is too long a run: take a smaller area, a coarser cell size or fewer dates{how}") + }) + }; + // A cube that knows its size is refused before it reads anything. + let whole = self.cube.size().map(|(w, h)| w.saturating_mul(h)); + if let Some(e) = whole.and_then(too_long) { + return Err(e); + } + let need = self.min_valid.unwrap_or((n / 2).max(trend::MIN_VALUES)).max(trend::MIN_VALUES); + let mut first_piece: Option> = None; + let mut rows = 0usize; + // The four outputs, a band each, filled piece by piece (never held interleaved whole). + let mut bands: [Vec; OUTPUTS] = Default::default(); + // Their full length up front when the cube knows it, so they don't grow by doubling. + if let Some(cells) = whole { + for band in &mut bands { + band.reserve_exact(cells); + } + } + let mut cells_done = 0usize; + for piece in 0..self.cube.pieces().max(1) { + let (grid, block, cells) = self.read_piece(piece, cancel, done, whole.is_none().then_some(&too_long))?; + // Stitched under the first piece's georeferencing: every piece on its columns, pixel + // size and coordinate system (its rows follow on from the one above). + if let Some(f) = &first_piece { + let near = |a: f64, b: f64| (a - b).abs() <= f.transform.pixel_width.abs() * 1e-3; + let (a, b) = (&f.transform, &grid.transform); + // Its top on the row after the pieces above it. + let top = a.origin_y + rows as f64 * a.pixel_height; + if (f.width, f.crs) != (grid.width, grid.crs) + || !near(a.origin_x, b.origin_x) + || !near(top, b.origin_y) + || !near(a.pixel_width, b.pixel_width) + || !near(a.pixel_height, b.pixel_height) + { + return Err("the cube's pieces aren't one grid".into()); + } + } + // Progress through all the cells: a part of the bar every `tick` cells. + let tick = whole.unwrap_or(cells).div_ceil(CELL_PARTS).max(1); + let part = self.work_out(&block, cells, cells_done, tick, need, ×, &seasons, cancel, done)?; + // The values are read: their block goes before the next piece's is read. + drop(block); + for (k, band) in bands.iter_mut().enumerate() { + band.extend(part.chunks(OUTPUTS).map(|o| o.get(k).copied().unwrap_or(f32::NAN))); + } + cells_done += cells; + rows += grid.height as usize; + first_piece.get_or_insert(grid); + } + let grid = first_piece.ok_or("no date to read")?; + if let Some(e) = self.cube.nothing_read() { + return Err(e); + } + done.store(self.total(), Ordering::Relaxed); + let unit = if self.per == 10.0 { "decade" } else { "year" }; + let names = [ + format!("{} Sen slope per {unit}", self.name), + format!("{} Z ({})", self.name, self.test.code()), + format!("{} p-value ({})", self.name, self.test.code()), + format!("{} significant slope per {unit} (p < {})", self.name, self.alpha), + ]; + let height = u32::try_from(rows).map_err(|_| "too many rows")?; + names + .into_iter() + .zip(bands) + .map(|(name, band)| { + Raster::new(name, grid.width, height, vec![band], grid.transform, grid.crs, None, raster::ColorHint::Gray, "Float32") + .map_err(|e| e.to_string()) + }) + .collect() + } + + /// One piece's slices read into a block, cell by date: the piece's first slice (its grid), the + /// block, and its cells. Dates are read [`TimeCube::readers`] at a time; `too_long` checks the + /// run's length on the first slice when the cube didn't know its size. + #[allow(clippy::type_complexity)] + fn read_piece( + &self, + piece: usize, + cancel: &AtomicBool, + done: &AtomicUsize, + too_long: Option<&dyn Fn(usize) -> Option>, + ) -> Result<(Arc, Vec, usize), String> { let n = self.steps.len(); let mut grid: Option> = None; let mut block: Vec = Vec::new(); let mut cells = 0usize; - for (k, &i) in self.steps.iter().enumerate() { - if cancel.load(Ordering::Relaxed) { - return Err("cancelled".into()); - } - // A date with nothing written leaves its cells without a value (the block starts empty). - let Some((r, b)) = self.cube.slice(i)? else { + // Step `k`'s slice put in the block (a date with nothing written leaves its cells empty). + let mut place = |k: usize, slice: Option<(Arc, usize)>| -> Result<(), String> { + let i = self.steps.get(k).copied().unwrap_or(0); + let Some((r, b)) = slice else { done.fetch_add(1, Ordering::Relaxed); - continue; + return Ok(()); }; let vals = &r.bands.get(b).ok_or("the slice has no such band")?.data; match &grid { @@ -473,10 +628,8 @@ impl TrendRun { "{cells} cells × {n} dates is {values} values, more than {MAX_VALUES}: take a smaller area or fewer dates" )); } - let pairs = (cells as u64).saturating_mul((n * n.saturating_sub(1) / 2) as u64); - if pairs > self.max_pairs { - let how = if self.max_pairs < MAX_PAIRS { " (or run it from the Workflows pane, which can take more)" } else { "" }; - return Err(format!("{cells} cells × {n} dates is too long a run: take a smaller area or fewer dates{how}")); + if let Some(e) = too_long.and_then(|f| f(cells)) { + return Err(e); } block = vec![f32::NAN; cells * n]; grid = Some(r.clone()); @@ -503,27 +656,84 @@ impl TrendRun { } } done.fetch_add(1, Ordering::Relaxed); + Ok(()) + }; + // One at a time in the browser, which has no threads to read on. + let readers = if cfg!(target_arch = "wasm32") { 1 } else { self.cube.readers().clamp(1, 16).min(n.max(1)) }; + if readers == 1 { + for (k, &i) in self.steps.iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + return Err("cancelled".into()); + } + place(k, self.cube.slice_of(i, piece)?)?; + } + } else { + // Several dates read at once, placed here as they come; a few wait at most, so the + // slices held stay a handful. An error (or Cancel) drops the receiver, which stops the + // readers at their next date. A reader in a read when that happens finishes it first: + // on a stalled server the scope waits out its timeout (two minutes, `cog`'s blocking + // reads have no cancel hook), so Cancel can take that long to return. + #[cfg(not(target_arch = "wasm32"))] + { + let next = AtomicUsize::new(0); + std::thread::scope(|scope| -> Result<(), String> { + type Read = Result, usize)>, String>; + let (tx, rx) = std::sync::mpsc::sync_channel::<(usize, Read)>(readers); + for _ in 0..readers { + let (tx, next) = (tx.clone(), &next); + scope.spawn(move || { + loop { + let k = next.fetch_add(1, Ordering::Relaxed); + let Some(&i) = self.steps.get(k) else { break }; + if cancel.load(Ordering::Relaxed) || tx.send((k, self.cube.slice_of(i, piece))).is_err() { + break; + } + } + }); + } + drop(tx); + for (k, read) in rx { + if cancel.load(Ordering::Relaxed) { + return Err("cancelled".into()); + } + place(k, read?)?; + } + Ok(()) + })?; + } + } + // Cancel stops the readers, which ends their dates early without an error: the block is + // only partly read, so the run stops here rather than work out what it has. + if cancel.load(Ordering::Relaxed) { + return Err("cancelled".into()); } let grid = grid.ok_or("no date to read")?; - // Times in the slope's unit. - let year = 365.2425 * 86_400_000.0 * self.per; - let date = |i: usize| self.cube.dates().get(i).copied().unwrap_or(f64::NAN); - let times: Vec = self.steps.iter().map(|i| date(*i) / year).collect(); - // The seasonal test's seasons, from each date's place in its calendar year. - let seasons: Vec = match self.test { - Test::Seasonal(m) => self.steps.iter().map(|i| season_of(date(*i), m)).collect(), - _ => Vec::new(), - }; - let need = self.min_valid.unwrap_or((n / 2).max(trend::MIN_VALUES)).max(trend::MIN_VALUES); + Ok((grid, block, cells)) + } + + /// Each cell's trend in `block` (cells × dates), worked out on as many threads as there are + /// cores: its four outputs a cell. `first` is the block's first cell among all of the run's, + /// so progress is counted over the whole grid. + #[allow(clippy::too_many_arguments)] + fn work_out( + &self, + block: &[f32], + cells: usize, + first: usize, + tick: usize, + need: usize, + times: &[f64], + seasons: &[usize], + cancel: &AtomicBool, + done: &AtomicUsize, + ) -> Result, String> { + let n = self.steps.len(); let mut out = vec![f32::NAN; cells * OUTPUTS]; let stopped = AtomicBool::new(false); - // A part of the cells, worked out in place. // Cancel is looked at after about the same work whatever the dates: a cell compares n²⁄2 // pairs, so every 4096 cells for a short series, every cell for a long one. let every = (4_000_000 / (n * n).max(1)).clamp(1, 4096); - // Progress through the cells: a part of the bar every `tick` cells, on whichever thread. - let tick = cells.div_ceil(CELL_PARTS).max(1); - // `first`: the block's first cell among all of them, so the parts are counted over the + // `first`: the part's first cell among all of them, so the parts are counted over the // whole grid, not again from 0 on each thread. let work = |first: usize, block: &[f32], out: &mut [f32]| { let mut ys = vec![0.0f64; n]; @@ -544,8 +754,8 @@ impl TrendRun { continue; } let found = match self.test { - Test::Seasonal(_) => trend::trend_seasonal_with(×, &ys, &seasons, self.alpha, &mut slopes), - test => trend::trend_with(×, &ys, test, self.alpha, &mut slopes), + Test::Seasonal(_) => trend::trend_seasonal_with(times, &ys, seasons, self.alpha, &mut slopes), + test => trend::trend_with(times, &ys, test, self.alpha, &mut slopes), }; if let Some(t) = found && let [slope, z, p, significant, ..] = o @@ -566,34 +776,16 @@ impl TrendRun { std::thread::scope(|scope| { for (k, (block, out)) in block.chunks(part * n).zip(out.chunks_mut(part * OUTPUTS)).enumerate() { let work = &work; - scope.spawn(move || work(k * part, block, out)); + scope.spawn(move || work(first + k * part, block, out)); } }); } #[cfg(target_arch = "wasm32")] - work(0, &block, &mut out); + work(first, block, &mut out); if stopped.load(Ordering::Relaxed) { return Err("cancelled".into()); } - // The values are read: their block goes before the outputs are made from `out`. - drop(block); - done.store(self.total(), Ordering::Relaxed); - let unit = if self.per == 10.0 { "decade" } else { "year" }; - let names = [ - format!("{} Sen slope per {unit}", self.name), - format!("{} Z ({})", self.name, self.test.code()), - format!("{} p-value ({})", self.name, self.test.code()), - format!("{} significant slope per {unit} (p < {})", self.name, self.alpha), - ]; - names - .into_iter() - .enumerate() - .map(|(k, name)| { - let band: Vec = out.chunks(OUTPUTS).map(|o| o.get(k).copied().unwrap_or(f32::NAN)).collect(); - Raster::new(name, grid.width, grid.height, vec![band], grid.transform, grid.crs, None, raster::ColorHint::Gray, "Float32") - .map_err(|e| e.to_string()) - }) - .collect() + Ok(out) } } diff --git a/crates/engine/src/cmd/stac.rs b/crates/engine/src/cmd/stac.rs index a4d4fd074..244a8e2e5 100644 --- a/crates/engine/src/cmd/stac.rs +++ b/crates/engine/src/cmd/stac.rs @@ -242,6 +242,573 @@ pub const MAX_CUBE_ITEMS: usize = 2000; /// The most pages of a search a data cube reads, whatever they hold. pub const MAX_CUBE_PAGES: usize = 100; +/// A STAC collection's band as files: by grid tile, then by day, the address of the first item's +/// asset found (the search's order), with the asset's scale and offset. What `stac.addCube` adds +/// as tile layers and `cube.trend` reads as a data cube (#559, #579). +#[cfg(not(target_arch = "wasm32"))] +pub struct CubeFiles { + pub tiles: std::collections::BTreeMap>, + /// The first asset's (scale, offset), from `raster:bands` or `eo:bands`. + pub scale: Option<(f64, f64)>, + /// Each file's own (scale, offset), by its address: a trend reads each date's values as its + /// own metadata says to (#579). + pub scales: std::collections::HashMap, + /// Each file's no-data value from its asset's metadata, where it names one (MODIS NDVI's + /// -3000 at the Brazil Data Cube), skipped as the file's own header no-data is. + pub nodata: std::collections::HashMap, + /// The items found, and those left out (no date, or no such asset). + pub items: usize, + pub without: usize, + /// Whether any item names its grid tile (otherwise each footprint counts as one). + pub named_tiles: bool, + /// Items left out because their tile and day already had a file. + pub duplicates: usize, + /// Whether the assets' scale or offset differ from the first one's. + pub scales_differ: bool, + pub collection: String, + pub asset: String, +} + +/// The first moment after a period given as a year (`"2024"`) or a month (`"2024-03"`), so an +/// end given so takes all of it; `None` for a date (an end on that day) or anything else. +pub(crate) fn period_after(v: &Value) -> Option { + let given = v.as_str().map_or_else(|| v.to_string(), |s| s.trim().to_string()); + let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()); + let next = match given.split_once('-') { + None if given.len() == 4 && digits(&given) => given.parse::().ok().map(|y| format!("{}-01-01", y + 1)), + Some((y, m)) if y.len() == 4 && m.len() == 2 && digits(y) && digits(m) => match (y.parse::(), m.parse::()) { + (Ok(y), Ok(12)) => Some(format!("{}-01-01", y + 1)), + (Ok(y), Ok(m)) if (1..12).contains(&m) => Some(format!("{y:04}-{:02}-01", m + 1)), + _ => None, + }, + _ => None, + }; + next.and_then(|n| rustgis_data::time::parse_text(&n)) +} + +/// Searches a collection over an area and period, every page up to [`MAX_CUBE_ITEMS`], and +/// groups the items' asset by tile and day: `{"url"?, "collection", "asset", "bbox"? | "view", +/// "start", "end", "maxCloud"?}`. +#[cfg(not(target_arch = "wasm32"))] +pub fn cube_files(s: &Session, p: &Value) -> Result { + let api = stac::api(ps(p, "url"))?; + if stac::catalog::is_static(&api) { + return Err("a data cube needs a STAC API's search; a static catalog can't be searched".into()); + } + let collection = ps(p, "collection").map(str::trim).filter(|c| !c.is_empty()).ok_or("collection is required")?; + let asset = ps(p, "asset").map(str::trim).filter(|a| !a.is_empty()).ok_or("asset is required (a band, such as NDVI)")?; + let date = |k: &str| { + p.get(k) + .and_then(rustgis_data::time::parse_input) + .ok_or_else(|| format!("{k} is a date (2024-06-01, a year, or milliseconds since 1970)")) + }; + let (start, mut end) = (date("start")?, date("end")?); + // An end given as a year or a month is the end of it: "end": "2024" takes all of 2024. + if let Some(after) = p.get("end").and_then(period_after) { + end = after - 86_400_000.0; + } + if end < start { + return Err("end is before start".into()); + } + let day = |ms: f64| rustgis_data::time::format(ms, "YYYY-MM-DD"); + // RFC 3339, as STAC asks: the whole of the last day included. + let mut q = json!({"collections": collection, "datetime": format!("{}T00:00:00Z/{}T23:59:59Z", day(start), day(end)), "limit": 200}); + for k in ["bbox", "maxCloud"] { + if let Some(v) = p.get(k).filter(|v| !v.is_null()) { + q[k] = v.clone(); + } + } + if q.get("bbox").is_none() { + q["bbox"] = json!("view"); + } + let search = search_params(s, &q)?; + // Every page of the search, up to the cap. + let mut items: Vec = Vec::new(); + // An empty page, or a next link back to a page already read, ends it; so does the page cap. + let mut next = Some(stac::search_url(&api, &search)?); + let mut read: Vec = Vec::new(); + // Pages are read from the API's own host: a next link elsewhere ends the search. + // Scheme, user part, host and port compared: `https://host:443`, never another host or http. + let host = |u: &str| { + u.split_once("://").map(|(scheme, r)| { + let authority = r.split(['/', '?', '#']).next().unwrap_or(""); + let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h).to_ascii_lowercase(); + let scheme = scheme.to_ascii_lowercase(); + // The scheme's own port written out is the same host. + let default = if scheme == "https" { ":443" } else { ":80" }; + format!("{scheme}://{}", host.strip_suffix(default).unwrap_or(&host)) + }) + }; + // An address with no scheme has no host to match: a next link is never taken on it. + let api_host = host(&api); + while let Some(url) = next.take() { + if read.contains(&url) || (!read.is_empty() && (api_host.is_none() || host(&url) != api_host)) { + break; + } + if read.len() >= MAX_CUBE_PAGES { + return Err(format!("more than {MAX_CUBE_PAGES} pages of results: take a shorter period or a smaller area")); + } + let bytes = crate::render::rustgis_tiles::cache::download_blocking(&url)?; + let (page, more) = stac::parse_items(&bytes, &url)?; + read.push(url); + if page.is_empty() { + break; + } + items.extend(page); + if items.len() > MAX_CUBE_ITEMS { + return Err(format!("more than {MAX_CUBE_ITEMS} items: take a shorter period or a smaller area")); + } + next = more; + } + if items.is_empty() { + return Err(format!("no item of {collection} in the area and period")); + } + // By tile, then by day: the first item found for a tile and day (the search's order). + let mut tiles: std::collections::BTreeMap> = Default::default(); + let mut scale: Option<(f64, f64)> = None; + let mut scales: std::collections::HashMap = Default::default(); + let mut nodata: std::collections::HashMap = Default::default(); + let mut scales_differ = false; + let mut duplicates = 0usize; + let mut without = 0usize; + for i in &items { + let Some(ms) = rustgis_data::time::parse_text(&i.datetime) else { + without += 1; + continue; + }; + let Some(a) = i.assets.iter().find(|a| a.key.eq_ignore_ascii_case(asset)) else { + without += 1; + continue; + }; + let key = i.tile.clone().unwrap_or_else(|| match i.bbox { + Some([w, so, e, n]) => format!("{w:.2},{so:.2},{e:.2},{n:.2}"), + None => i.id.clone(), + }); + let d = (ms / 86_400_000.0).floor() as i64; + let by_day = tiles.entry(key).or_default(); + if by_day.contains_key(&d) { + // A tile and day already have a file; this one is left out (and counted), its scale too. + duplicates += 1; + continue; + } + // The file's address without a fragment of its own, so the range look is the only one. + let href = a.href.split('#').next().unwrap_or(&a.href).to_string(); + // No scale or offset in the metadata reads as 1 and 0, the values as stored. + let this = (a.scale.unwrap_or(1.0), a.offset.unwrap_or(0.0)); + scales.insert(href.clone(), this); + if let Some(n) = a.nodata { + nodata.insert(href.clone(), n); + } + by_day.insert(d, href); + scales_differ |= scale.is_some_and(|s| s != this); + scale.get_or_insert(this); + } + if tiles.is_empty() { + return Err(format!("no item of {collection} has an asset {asset}")); + } + // Files reached through signed addresses (Planetary Computer's) expire within hours, so a + // cube of them couldn't be kept in a project or read a day later. + // The files' addresses come from the API's answer: each is checked as one that answer points + // to (http or https, never a forbidden address, and not into the local network from a public + // API), before a cube on the map or a trend reads it. + if let Some(e) = tiles + .values() + .flat_map(|by_day| by_day.values()) + .find_map(|href| crate::render::rustgis_tiles::address::check(href, Some(&api)).err()) + { + return Err(e); + } + if tiles.values().flat_map(|by_day| by_day.values()).any(|href| stac::needs_signing(href)) { + return Err(format!( + "{collection}'s files are reached through signed addresses that expire within hours, which neither a data cube on the map nor a trend reads: use an API with public files (Earth Search, the Brazil Data Cube)" + )); + } + Ok(CubeFiles { + tiles, + scale, + scales, + nodata, + items: items.len(), + named_tiles: items.iter().any(|i| i.tile.is_some()), + without, + duplicates, + scales_differ, + collection: collection.to_string(), + asset: asset.to_string(), + }) +} + +/// The most cells a STAC collection's trend covers: its four outputs take 16 bytes a cell, 400 MB +/// at the cap (the dates are read in pieces, each within [`super::cube::MAX_VALUES`]). +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_TREND_CELLS: usize = 25_000_000; + +/// Dates a STAC collection's trend reads at once: its files are read over the network. +#[cfg(not(target_arch = "wasm32"))] +const STAC_READERS: usize = 8; + +/// Tiles' headers a STAC collection's trend reads, beyond the first file's, looking for one that +/// covers the area: each can take up to two minutes on a stalled server. +#[cfg(not(target_arch = "wasm32"))] +const COVER_PROBES: usize = 8; + +/// The most pixels of the files one slice of a piece reads: 1 million. A reader holds them (4 +/// bytes each) with its cells' sums, counts and values (16 bytes a cell, as many cells as pixels +/// at most), about 20 MB, so the [`STAC_READERS`] hold about 160 MB together; the slices read and +/// waiting to go into the block (as many as readers) keep only their values, 4 MB each, 32 MB +/// more. The block itself is within [`super::cube::MAX_VALUES`]. A trend over few +/// dates would otherwise make pieces of many cells, and averaging reads `factor²` pixels a cell. +#[cfg(not(target_arch = "wasm32"))] +const MAX_PIECE_PIXELS: u64 = 1_000_000; + +#[cfg(test)] +thread_local! { + /// For tests: the values a piece holds at most, in place of [`super::cube::MAX_VALUES`], so a + /// small cube is read in several pieces. + pub(crate) static PIECE_VALUES: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// A STAC collection's band as a data cube for `cube.trend` (#579): each date's files (a grid +/// tile each, MODIS's h13v09 and h13v10 for one date), read in place from their tiled GeoTIFFs +/// over an area, at one level of their overviews, and put together on one grid. A large area is +/// read in pieces, bands of rows each within a run's block, so a state's 25 years of 16-day +/// composites can be worked out whole. +#[cfg(not(target_arch = "wasm32"))] +pub struct StacCube { + dates: Vec, + /// Each date's files, with their (scale, offset) and the no-data value their metadata names. + files: Vec)>>, + /// The level read (0: the full images), and how many of its pixels a cell's side takes: 1 + /// when the cells are that level's own pixels (`"resampling": "nearest"`, an overview as the + /// file stores it), more when a cell is the mean of the full images' pixels in it. + level: usize, + factor: u32, + /// The area on that level's grid: its georeferencing (from its top-left corner), columns and + /// rows, in the files' coordinate system. + transform: rustgis_data::raster::GeoTransform, + width: u32, + height: u32, + crs: rustgis_data::raster::Crs, + /// Rows a piece reads. + rows: u32, + /// Whether any file met a piece read: the tiles past the cover probes may all miss the area. + met: std::sync::atomic::AtomicBool, + label: String, +} + +/// Where a grid's columns and rows fall in a file's: the offsets (in the file's pixels) of the +/// grid's top-left corner, when both are on one grid of pixels (the same size, corners a whole +/// number of pixels apart); `None` otherwise. +#[cfg(not(target_arch = "wasm32"))] +fn offset_in(grid: &rustgis_data::raster::GeoTransform, file: &rustgis_data::raster::GeoTransform) -> Option<(i64, i64)> { + let same = |a: f64, b: f64| (a - b).abs() <= a.abs().max(b.abs()) * 1e-6; + if !same(grid.pixel_width, file.pixel_width) || !same(grid.pixel_height, file.pixel_height) { + return None; + } + let dx = (grid.origin_x - file.origin_x) / file.pixel_width; + let dy = (grid.origin_y - file.origin_y) / file.pixel_height; + let whole = |v: f64| v.is_finite() && (v - v.round()).abs() <= 1e-3 && v.abs() < 1e9; + (whole(dx) && whole(dy)).then(|| (dx.round() as i64, dy.round() as i64)) +} + +/// The rows a piece reads: as many as `max` values hold over `width` columns and `dates` dates, +/// a whole number of the files' tile rows when that many fit (so a tile is read by at most the two +/// pieces its rows straddle, the area's top not being on a tile's), at least one. When fewer rows +/// than a tile's fit (Piauí at 1 km over 575 dates: ~170 rows against 512), each tile row is read +/// by every piece within it, for each date: the cost of the area's size. `None` when a single row is too many. +#[cfg(not(target_arch = "wasm32"))] +pub fn piece_rows(width: u32, height: u32, dates: usize, tile_height: u32, max: u64) -> Option { + let per_row = u64::from(width.max(1)).saturating_mul(dates.max(1) as u64); + let fit = max / per_row; + if fit == 0 { + return None; + } + // The whole area in one piece when it fits, however its rows fall on the tiles. + let height = height.max(1); + if fit >= u64::from(height) { + return Some(height); + } + let fit = u32::try_from(fit).unwrap_or(u32::MAX); + let th = tile_height.max(1); + Some(if fit >= th { fit / th * th } else { fit }) +} + +/// Prepares a STAC collection's band as a cube: the search (as `stac.addCube` makes it), the +/// overview level for `cellSize`, the area's grid, and the pieces it is read in. +#[cfg(not(target_arch = "wasm32"))] +pub fn stac_cube(s: &Session, p: &Value) -> Result { + use crate::render::rustgis_tiles::cog; + let CubeFiles { tiles, scales, nodata, collection, asset, .. } = cube_files(s, p)?; + // Every tile's file for a date, by day. + let mut by_day: std::collections::BTreeMap)>> = Default::default(); + for days in tiles.values() { + for (d, href) in days { + let scale = scales.get(href).copied().unwrap_or((1.0, 0.0)); + by_day.entry(*d).or_default().push((href.clone(), scale, nodata.get(href).copied())); + } + } + if by_day.len() > super::cube::MAX_DATES { + return Err(format!("{} dates; a trend takes {} at most: take a shorter period", by_day.len(), super::cube::MAX_DATES)); + } + // The first file's header gives the coordinate system, the grid and its overviews. + let first = by_day.values().flatten().next().map(|(h, ..)| h.clone()).ok_or("no file to read")?; + let info = cog::info_blocking(&first)?; + let layout = &info.layout; + if layout.transform.pixel_height > 0.0 { + return Err(format!("{first} is stored bottom up, which a trend doesn't read yet")); + } + // The level whose cells are nearest the size asked, by ratio (`1000` takes MODIS's 926.6 m + // overview, not its 463 m one); the full images by default. + let level = match p.get("cellSize").filter(|v| !v.is_null()) { + None => 0, + Some(v) => { + let size = + v.as_f64().filter(|c| c.is_finite() && *c > 0.0).ok_or("cellSize is a size in the files' units (metres for most)")?; + let off = |l: &rustgis_data::raster::cog::Level| (layout.transform_of(l).pixel_width.abs() / size).ln().abs(); + (0..layout.levels.len()).min_by(|a, b| off(&layout.levels[*a]).total_cmp(&off(&layout.levels[*b]))).unwrap_or(0) + } + }; + let lv = layout.levels.get(level).ok_or("the file has no image")?; + let lt = layout.transform_of(lv); + // An overview is often made by taking one pixel in each block (the Brazil Data Cube's are, + // GDAL's NEAREST), so a cell of it is one of the full images' pixels, which can differ much + // from the place's mean: "average" reads the full images and takes the mean of each cell's. + let average = match p.get("resampling").filter(|v| !v.is_null()) { + None => false, + Some(v) => match v.as_str().map(str::trim) { + Some("") | Some("nearest") => false, + Some("average") => true, + Some(x) => return Err(format!("resampling is nearest or average, not {x}")), + // A number or anything else isn't read as the default: said. + None => return Err("resampling is nearest or average".into()), + }, + }; + let full = layout.levels.first().ok_or("the file has no image")?; + let (read, factor) = if average && level > 0 { + let f = (f64::from(full.width) / f64::from(lv.width.max(1))).round(); + // Averaging takes `f` × `f` full pixels a cell: the overview has to be exactly the full + // image's size divided by `f` (rounded up), or its cells aren't those blocks. + let fits = |full: u32, ov: u32| ov.checked_mul(f as u32) == Some(full); + if !(1.0..=64.0).contains(&f) || !fits(full.width, lv.width) || !fits(full.height, lv.height) { + return Err(format!( + "{first}'s overview ({} × {}) isn't its full image ({} × {}) divided by a whole number, so its cells can't be averaged from it: take \"resampling\": \"nearest\"", + lv.width, lv.height, full.width, full.height + )); + } + (0, f as u32) + } else { + (level, 1) + }; + let read_tile_rows = layout.levels.get(read).map_or(1, |l| l.tile_height).div_ceil(factor).max(1); + // The area, in the files' coordinates: the box's edges, each at 32 points, as a curved grid + // (MODIS's sinusoidal) bends them. + let bbox = match p.get("bbox").filter(|v| !v.is_null()) { + Some(v) if v.as_str().is_some_and(|t| t.trim().eq_ignore_ascii_case("view")) => view(s)?, + None => view(s)?, + Some(v) => { + let b: Option> = + v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite())).collect()); + match b.as_deref() { + Some([w, so, e, n]) if w < e && so < n => [*w, *so, *e, *n], + _ => return Err("bbox is [west, south, east, north] in degrees, or \"view\"".into()), + } + } + }; + let tr = layout.crs.transformer()?; + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + let [w, so, e, n] = bbox; + for k in 0..=32 { + let t = f64::from(k) / 32.0; + for (lon, lat) in [(w + (e - w) * t, so), (w + (e - w) * t, n), (w, so + (n - so) * t), (e, so + (n - so) * t)] { + if let Some(c) = tr.from_lonlat(rustgis_geom::Coord::new(lon, lat)) { + (x0, y0, x1, y1) = (x0.min(c.x), y0.min(c.y), x1.max(c.x), y1.max(c.y)); + } + } + } + if !(x0 < x1 && y0 < y1) { + return Err("the area is outside the files' coordinate system".into()); + } + // Snapped outwards onto the level's pixels. + let (pw, ph) = (lt.pixel_width, lt.pixel_height); + let c0 = ((x0 - lt.origin_x) / pw).floor(); + let c1 = ((x1 - lt.origin_x) / pw).ceil(); + let r0 = ((y1 - lt.origin_y) / ph).floor(); + let r1 = ((y0 - lt.origin_y) / ph).ceil(); + let (width, height) = (c1 - c0, r1 - r0); + if !(width >= 1.0 && height >= 1.0) || width * height > MAX_TREND_CELLS as f64 { + return Err(format!( + "the area is {width:.0} × {height:.0} cells at {:.0}-unit cells; a trend covers {MAX_TREND_CELLS} at most: take a smaller area or a larger cellSize", + pw.abs() + )); + } + let (width, height) = (width as u32, height as u32); + let transform = rustgis_data::raster::GeoTransform { origin_x: lt.origin_x + c0 * pw, origin_y: lt.origin_y + r0 * ph, ..lt }; + // Some file has to cover part of the area, or every cell would come out empty: each tile's + // first file in turn until one does (the first file's header, read already, most often), at + // most [`COVER_PROBES`] more headers, a header that can't be read ending it as reading would. + let span = |a: f64, b: f64| (a.min(b), a.max(b)); + let (ax, ay) = (span(c0 * pw, c1 * pw), span(r0 * ph, r1 * ph)); + let (ax, ay) = ((ax.0 + lt.origin_x, ax.1 + lt.origin_x), (ay.0 + lt.origin_y, ay.1 + lt.origin_y)); + let covers = |i: &cog::Info| { + let (t, Some(full)) = (&i.layout.transform, i.layout.levels.first()) else { return false }; + let x = span(t.origin_x, t.origin_x + f64::from(full.width) * t.pixel_width); + let y = span(t.origin_y, t.origin_y + f64::from(full.height) * t.pixel_height); + i.layout.crs == layout.crs && x.0 < ax.1 && ax.0 < x.1 && y.0 < ay.1 && ay.0 < y.1 + }; + let mut others = tiles.values().filter_map(|days| days.values().next()).filter(|h| **h != first); + let mut covered = covers(&info); + let mut probed = 0; + while !covered && probed < COVER_PROBES { + let Some(h) = others.next() else { break }; + covered = covers(&*cog::info_blocking(h)?); + probed += 1; + } + // Tiles beyond the probes are left to the reading, which leaves what none covers empty. + if !covered && others.next().is_none() { + return Err(format!("no file of {collection}'s {asset} covers the area, only comes near it: take another bbox")); + } + // Within a run's block, and each slice within what a reader holds. + let per_slice = (MAX_PIECE_PIXELS / u64::from(factor).pow(2)).max(1); + #[cfg(test)] + let block = PIECE_VALUES.with(|v| v.get()).unwrap_or(super::cube::MAX_VALUES); + #[cfg(not(test))] + let block = super::cube::MAX_VALUES; + let max = block.min(per_slice.saturating_mul(by_day.len() as u64)); + let rows = piece_rows(width, height, by_day.len(), read_tile_rows, max).ok_or_else(|| { + format!( + "a row of {width} cells × {} dates is more than a run's block: take a smaller area, a larger cellSize or fewer dates", + by_day.len() + ) + })?; + let dates = by_day.keys().map(|d| *d as f64 * 86_400_000.0).collect(); + let files = by_day.into_values().collect(); + Ok(StacCube { + dates, + files, + level: read, + factor, + transform, + width, + height, + crs: layout.crs, + rows, + met: Default::default(), + label: format!("{collection} {asset}"), + }) +} + +#[cfg(not(target_arch = "wasm32"))] +impl super::cube::TimeCube for StacCube { + fn dates(&self) -> &[f64] { + &self.dates + } + /// The whole slice, when the area is one piece; read in pieces, a date's whole slice would be + /// the block the pieces exist to avoid, so it is refused rather than cut to the top rows. + fn slice(&self, i: usize) -> Result, usize)>, String> { + if self.pieces() > 1 { + return Err(format!("this STAC cube is read in {} pieces: read it piece by piece (slice_of)", self.pieces())); + } + self.slice_of(i, 0) + } + fn label(&self) -> String { + self.label.clone() + } + fn pieces(&self) -> usize { + self.height.div_ceil(self.rows.max(1)) as usize + } + fn size(&self) -> Option<(usize, usize)> { + Some((self.width as usize, self.height as usize)) + } + fn readers(&self) -> usize { + STAC_READERS + } + fn nothing_read(&self) -> Option { + let met = self.met.load(std::sync::atomic::Ordering::Relaxed); + (!met).then(|| format!("no file of {} covers the area, only comes near it: take another bbox", self.label)) + } + /// Rows `piece × rows` on of the area at date `i`: each file's part of them, its values + /// scaled as its metadata says, a cell the mean of the pixels read in it (one, or a block of + /// the full images'); a cell no file covers (or none has a value for) is NaN. + fn slice_of(&self, i: usize, piece: usize) -> Result, usize)>, String> { + use crate::render::rustgis_tiles::cog; + let files = self.files.get(i).ok_or_else(|| format!("no date {i}"))?; + let top = u32::try_from(piece).ok().and_then(|p| p.checked_mul(self.rows)).filter(|t| *t < self.height).ok_or("no such piece")?; + let h = self.rows.min(self.height - top); + let w = self.width; + let f = self.factor.max(1); + // Each cell's sum and count of the pixels read in it. + let mut sum = vec![0.0f64; w as usize * h as usize]; + let mut count = vec![0u32; w as usize * h as usize]; + // The area's grid at the level read: a cell is `f` × `f` of its pixels. + let grid = rustgis_data::raster::GeoTransform { + pixel_width: self.transform.pixel_width / f64::from(f), + pixel_height: self.transform.pixel_height / f64::from(f), + ..self.transform + }; + let (wf, top_f, hf) = (i64::from(w) * i64::from(f), i64::from(top) * i64::from(f), i64::from(h) * i64::from(f)); + for (href, (k, o), fill) in files { + let fill = fill.map(|v| v as f32); + // Cached per address for the process (`with_info`, a failure too, for a while), so a + // file's header is fetched once however many pieces and dates read it. + let info = cog::info_blocking(href)?; + if info.layout.crs != self.crs { + return Err(format!("{href} is in another coordinate system than the first file")); + } + let lv = info.layout.levels.get(self.level).ok_or_else(|| format!("{href} has fewer overviews than the first file"))?; + let lt = info.layout.transform_of(lv); + let (dx, dy) = offset_in(&grid, <).ok_or_else(|| format!("{href} is on another grid of pixels than the first file"))?; + // Averaged, a cell is a block of `f` × `f` of the file's pixels: the file's corner + // has to fall on a block's, or the blocks would straddle cells. + if dx.rem_euclid(i64::from(f)) != 0 || dy.rem_euclid(i64::from(f)) != 0 { + return Err(format!("{href}'s pixels don't fall in whole cells of the area: take \"resampling\": \"nearest\"")); + } + // The piece's columns and rows in the file's pixels, within the file. + let (fc0, fc1) = (dx.max(0), (dx + wf).min(i64::from(lv.width))); + let (fr0, fr1) = ((dy + top_f).max(0), (dy + top_f + hf).min(i64::from(lv.height))); + if fc1 <= fc0 || fr1 <= fr0 { + continue; + } + self.met.store(true, std::sync::atomic::Ordering::Relaxed); + let cols = (fc0 as u32, fc1 as u32); + let rows = (fr0 as u32, fr1 as u32); + let read = cog::read_values_blocking(&info, href, self.level, 0, cols, rows)?; + let across = (fc1 - fc0) as usize; + for (r, line) in read.chunks(across).enumerate() { + let row = ((fr0 - dy - top_f) as usize + r) / f as usize; + for (c, v) in line.iter().enumerate() { + let col = ((fc0 - dx) as usize + c) / f as usize; + let cell = row * w as usize + col; + if v.is_finite() + && Some(*v) != fill + && let (Some(s), Some(n)) = (sum.get_mut(cell), count.get_mut(cell)) + { + *s += f64::from(*v) * k + o; + *n += 1; + } + } + } + } + let vals: Vec = sum.iter().zip(&count).map(|(s, n)| if *n > 0 { (s / f64::from(*n)) as f32 } else { f32::NAN }).collect(); + let transform = rustgis_data::raster::GeoTransform { + origin_y: self.transform.origin_y + f64::from(top) * self.transform.pixel_height, + ..self.transform + }; + let r = rustgis_data::raster::Raster::new( + &self.label, + w, + h, + vec![vals], + transform, + self.crs, + None, + rustgis_data::raster::ColorHint::Gray, + "Float32", + ) + .map_err(|e| e.to_string())?; + Ok(Some((std::sync::Arc::new(r), 0))) + } +} + /// `stac.addCube` (opengeos/RustGIS#559): a STAC collection's band as a data cube on the time /// slider. The items found are grouped by the grid tile they cover (the tile their properties /// name, else their bounding box) and by day; each tile becomes a tile layer that reads a file @@ -255,36 +822,6 @@ fn add_cube(s: &mut Session, p: &Value) -> CmdResult { } #[cfg(not(target_arch = "wasm32"))] { - let api = stac::api(ps(p, "url"))?; - if stac::catalog::is_static(&api) { - return Err("a data cube needs a STAC API's search; a static catalog can't be searched".into()); - } - let collection = ps(p, "collection").map(str::trim).filter(|c| !c.is_empty()).ok_or("collection is required")?; - let asset = ps(p, "asset").map(str::trim).filter(|a| !a.is_empty()).ok_or("asset is required (a band, such as NDVI)")?; - let date = |k: &str| { - p.get(k) - .and_then(rustgis_data::time::parse_input) - .ok_or_else(|| format!("{k} is a date (2024-06-01, a year, or milliseconds since 1970)")) - }; - let (start, mut end) = (date("start")?, date("end")?); - // An end given as a year or a month is the end of it: "end": "2024" takes all of 2024. - let given = p.get("end").map(|v| v.as_str().map_or_else(|| v.to_string(), |s| s.trim().to_string())).unwrap_or_default(); - let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit()); - let next = match given.split_once('-') { - None if given.len() == 4 && digits(&given) => given.parse::().ok().map(|y| format!("{}-01-01", y + 1)), - Some((y, m)) if y.len() == 4 && m.len() == 2 && digits(y) && digits(m) => match (y.parse::(), m.parse::()) { - (Ok(y), Ok(12)) => Some(format!("{}-01-01", y + 1)), - (Ok(y), Ok(m)) if (1..12).contains(&m) => Some(format!("{y:04}-{:02}-01", m + 1)), - _ => None, - }, - _ => None, - }; - if let Some(after) = next.and_then(|n| rustgis_data::time::parse_text(&n)) { - end = after - 86_400_000.0; - } - if end < start { - return Err("end is before start".into()); - } let range = match p.get("range").filter(|v| !v.is_null()) { None => None, Some(v) => { @@ -297,103 +834,8 @@ fn add_cube(s: &mut Session, p: &Value) -> CmdResult { } } }; - let day = |ms: f64| rustgis_data::time::format(ms, "YYYY-MM-DD"); - // RFC 3339, as STAC asks: the whole of the last day included. - let mut q = - json!({"collections": collection, "datetime": format!("{}T00:00:00Z/{}T23:59:59Z", day(start), day(end)), "limit": 200}); - for k in ["bbox", "maxCloud"] { - if let Some(v) = p.get(k).filter(|v| !v.is_null()) { - q[k] = v.clone(); - } - } - if q.get("bbox").is_none() { - q["bbox"] = json!("view"); - } - let search = search_params(s, &q)?; - // Every page of the search, up to the cap. - let mut items: Vec = Vec::new(); - // An empty page, or a next link back to a page already read, ends it; so does the page cap. - let mut next = Some(stac::search_url(&api, &search)?); - let mut read: Vec = Vec::new(); - // Pages are read from the API's own host: a next link elsewhere ends the search. - // Scheme, user part, host and port compared: `https://host:443`, never another host or http. - let host = |u: &str| { - u.split_once("://").map(|(scheme, r)| { - let authority = r.split(['/', '?', '#']).next().unwrap_or(""); - let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h).to_ascii_lowercase(); - let scheme = scheme.to_ascii_lowercase(); - // The scheme's own port written out is the same host. - let default = if scheme == "https" { ":443" } else { ":80" }; - format!("{scheme}://{}", host.strip_suffix(default).unwrap_or(&host)) - }) - }; - // An address with no scheme has no host to match: a next link is never taken on it. - let api_host = host(&api); - while let Some(url) = next.take() { - if read.contains(&url) || (!read.is_empty() && (api_host.is_none() || host(&url) != api_host)) { - break; - } - if read.len() >= MAX_CUBE_PAGES { - return Err(format!("more than {MAX_CUBE_PAGES} pages of results: take a shorter period or a smaller area")); - } - let bytes = crate::render::rustgis_tiles::cache::download_blocking(&url)?; - let (page, more) = stac::parse_items(&bytes, &url)?; - read.push(url); - if page.is_empty() { - break; - } - items.extend(page); - if items.len() > MAX_CUBE_ITEMS { - return Err(format!("more than {MAX_CUBE_ITEMS} items: take a shorter period or a smaller area")); - } - next = more; - } - if items.is_empty() { - return Err(format!("no item of {collection} in the area and period")); - } - // By tile, then by day: the first item found for a tile and day (the search's order). - let mut tiles: std::collections::BTreeMap> = Default::default(); - let mut scale: Option<(f64, f64)> = None; - let mut scales_differ = false; - let mut duplicates = 0usize; - let mut without = 0usize; - for i in &items { - let Some(ms) = rustgis_data::time::parse_text(&i.datetime) else { - without += 1; - continue; - }; - let Some(a) = i.assets.iter().find(|a| a.key.eq_ignore_ascii_case(asset)) else { - without += 1; - continue; - }; - let key = i.tile.clone().unwrap_or_else(|| match i.bbox { - Some([w, so, e, n]) => format!("{w:.2},{so:.2},{e:.2},{n:.2}"), - None => i.id.clone(), - }); - let d = (ms / 86_400_000.0).floor() as i64; - let by_day = tiles.entry(key).or_default(); - if by_day.contains_key(&d) { - // A tile and day already have a file; this one is left out (and counted), its scale too. - duplicates += 1; - continue; - } - // The file's address without a fragment of its own, so the range look is the only one. - by_day.insert(d, a.href.split('#').next().unwrap_or(&a.href).to_string()); - // No scale or offset in the metadata reads as 1 and 0, the values as stored. - let this = (a.scale.unwrap_or(1.0), a.offset.unwrap_or(0.0)); - scales_differ |= scale.is_some_and(|s| s != this); - scale.get_or_insert(this); - } - if tiles.is_empty() { - return Err(format!("no item of {collection} has an asset {asset}")); - } - // Files reached through signed addresses (Planetary Computer's) expire within hours, so a - // cube of them couldn't be kept in a project or read a day later. - if tiles.values().flat_map(|by_day| by_day.values()).any(|href| stac::needs_signing(href)) { - return Err(format!( - "{collection}'s files are reached through signed addresses that expire, so they can't make a data cube kept in the project: use an API with public files (Earth Search, the Brazil Data Cube)" - )); - } + let CubeFiles { tiles, scale, scales: _, nodata: _, items, named_tiles, without, duplicates, scales_differ, collection, asset } = + cube_files(s, p)?; // One range can't hold if the files store their values alike no longer (a processing // change of scale or offset partway). if range.is_some() && scales_differ { @@ -435,7 +877,7 @@ fn add_cube(s: &mut Session, p: &Value) -> CmdResult { let room = rustgis_map::tiles::MAX_TILE_LAYERS.saturating_sub(s.map().tiles.len()); if tiles.len() > room { // Items naming no grid tile are told apart by footprint, which can differ scene by scene. - let why = if items.iter().all(|i| i.tile.is_none()) { + let why = if !named_tiles { format!("{collection}'s items name no grid tile, so each footprint counts as one") } else { "take a smaller area".to_string() @@ -529,7 +971,7 @@ fn add_cube(s: &mut Session, p: &Value) -> CmdResult { undo(s); return Err(e); } - let mut out = json!({"items": items.len(), "tiles": tiles.len(), "dates": dates.len(), "layers": added.len()}); + let mut out = json!({"items": items, "tiles": tiles.len(), "dates": dates.len(), "layers": added.len()}); if without > 0 { out["skipped"] = json!(without); } @@ -539,3 +981,35 @@ fn add_cube(s: &mut Session, p: &Value) -> CmdResult { Ok(out) } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use rustgis_data::raster::GeoTransform; + + /// A piece's rows: as many as fit, whole tiles of rows when one fits, at least one; none when + /// a single row is too many. Piauí at ~1 km over 25 years of 16-day composites (645 × 984 + /// cells, 576 dates) takes about a sixth of the rows a piece. + #[test] + fn pieces_fit_a_block_in_whole_tiles() { + assert_eq!(super::piece_rows(512, 512, 6, 256, 512 * 300 * 6), Some(256)); + assert_eq!(super::piece_rows(512, 512, 6, 256, 512 * 600 * 6), Some(512), "never more than the area's rows"); + assert_eq!(super::piece_rows(512, 512, 6, 256, 512 * 100 * 6), Some(100), "fewer than a tile's rows: as many as fit"); + assert_eq!(super::piece_rows(512, 512, 6, 256, 100), None); + let rows = super::piece_rows(645, 984, 576, 512, crate::cmd::cube::MAX_VALUES).unwrap(); + assert!(rows < 984 && (rows.is_multiple_of(512) || rows < 512), "{rows}"); + assert_eq!(984u32.div_ceil(rows), 6); + } + + /// Grids on one lattice of pixels line up by whole pixels (MODIS's tiles on its sinusoidal + /// grid); another pixel size, or corners a fraction of a pixel apart, don't. + #[test] + fn files_line_up_on_one_grid() { + let file = GeoTransform { origin_x: -5559752.598, origin_y: 0.0, pixel_width: 926.625433, pixel_height: -926.625433 }; + let grid = GeoTransform { origin_x: file.origin_x + 10.0 * 926.625433, origin_y: -3.0 * 926.625433, ..file }; + assert_eq!(super::offset_in(&grid, &file), Some((10, 3))); + let above = GeoTransform { origin_y: 2.0 * 926.625433, ..grid }; + assert_eq!(super::offset_in(&above, &file), Some((10, -2)), "a grid above the file starts before its first row"); + assert_eq!(super::offset_in(&GeoTransform { origin_x: grid.origin_x + 400.0, ..grid }, &file), None); + assert_eq!(super::offset_in(&GeoTransform { pixel_width: 463.3, ..grid }, &file), None); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a9b784dc9..aa868f808 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1080,6 +1080,10 @@ fn hostile_params_never_panic() { json!({"type": "albers", "lon_0": f64::MAX, "lat_1": "x", "lat_2": -1e308}), json!({"kind": "legend", "element": 1, "exclude": [null, 7, "", {"x": 1}]}), json!({"kind": "text", "element": 1, "font": {"family": "serif"}, "italic": [], "align": 7}), + // A STAC trend's source: a valid band and period so the junk after them is reached, and an + // API on a closed local port, so nothing is read. + json!({"collection": "c", "asset": "NDVI", "url": "http://127.0.0.1:9/v1", "bbox": "nowhere", "cellSize": -1e308, "resampling": 3, "start": "2020", "end": "2021"}), + json!({"collection": "c", "asset": "NDVI", "url": "http://127.0.0.1:9/v1", "bbox": [1e308, -91, 7, "x"], "start": "2020", "end": "2021"}), // MySQL addresses that can't be read, so nothing connects. json!({"database": "mysql://u:p#@[::1/x?ssl-mode=nope", "password": 7, "layer": ":", "crs": "EPSG:-1"}), json!({"database": "MARIADB://u@h:99999999/%zz?ssl-mode=VERIFY_CA&ssl-ca=/nonexistent", "sql": "SELECT 1"}), @@ -7246,6 +7250,152 @@ fn a_stac_data_cube_on_the_time_slider() { assert_eq!((s.map().tiles.len(), s.map().layers.len()), (tiles, layers)); } +/// A trend over a STAC collection's band (#579), read in place from its files over an area: each +/// date's values scaled as its own metadata says (here date k's scale is k + 1 ten-thousandths, +/// so every cell grows in a straight line), Sen's slope as worked out by hand; the same outputs +/// read whole or in pieces of rows; a coarser cell size read from the files' overviews; and the +/// requests a trend can't take refused. +#[test] +fn a_trend_over_a_stac_collection() { + use crate::cmd::stac::PIECE_VALUES; + let (cog, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); + let dates = ["2024-01-01", "2024-01-17", "2024-02-02", "2024-02-18", "2024-03-05", "2024-03-21"]; + let features: Vec = dates + .iter() + .enumerate() + .map(|(k, d)| { + format!( + r#"{{"type": "Feature", "id": "n{k}", "collection": "mod", "bbox": [10, 45, 11, 46], "geometry": null, + "properties": {{"datetime": "{d}T00:00:00Z", "bdc:tiles": ["h13v09"]}}, + "assets": {{"NDVI": {{"href": "{cog}/n{k}.tif", "eo:bands": [{{"scale": {}, "offset": {}}}]}}}}}}"#, + (k + 1) as f64 * 1e-4, + k as f64 * 0.01 + ) + }) + .collect(); + let search = format!(r#"{{"type": "FeatureCollection", "features": [{}]}}"#, features.join(",")); + let base = serve_routes(vec![("/v1/search", search)], 16); + let api = format!("{base}/v1"); + let params = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-03", "test": "original"}); + let mut s = Session::new(Project::default()); + let run = |s: &mut Session, p: &Value| { + let r = s.run("cube.trend", p).unwrap(); + let ids: Vec = r["layers"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap() as u32).collect(); + let raster = |id: u32| s.map().layer(id).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); + (r["dates"].as_u64(), ids.iter().map(|id| raster(*id)).collect::>()) + }; + let (n, whole) = run(&mut s, ¶ms); + assert_eq!(n, Some(6)); + let slope = &whole[0]; + assert_eq!((slope.width, slope.height), (512, 512), "the area on the full images' grid"); + // Pixel (x, y) stores (x + y) % 256; with date k's scale (k + 1) ten-thousandths and offset + // k hundredths, its value grows by that many ten-thousandths and a hundredth a date, 16 days + // apart: each file's own scale and offset taken. + let per_year = |x: usize, y: usize| (((x + y) % 256) as f64 * 1e-4 + 0.01) * 365.2425 / 16.0; + for (x, y) in [(10, 20), (300, 100), (0, 0), (511, 511)] { + let got = f64::from(slope.bands[0].data[y * 512 + x]); + assert!((got - per_year(x, y)).abs() < 1e-4, "({x}, {y}): {got} vs {}", per_year(x, y)); + } + // A straight rise over six dates is significant, (0, 0)'s too: its stored 0 rises by the offsets. + assert!(whole[3].bands[0].data[20 * 512 + 10].is_finite() && whole[3].bands[0].data[0].is_finite()); + // An area wider than the file: half a degree more to the west, 256 columns no file covers + // (NaN), and the file's own beside them, in their places. + let wider = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [9.5, 45, 11, 46], "start": "2024-01-01", "end": "2024-03", "test": "original"}); + let (_, wide) = run(&mut s, &wider); + let ws = &wide[0]; + assert_eq!((ws.width, ws.height), (768, 512)); + assert!(ws.bands[0].data[20 * 768 + 255].is_nan() && ws.bands[0].data[0].is_nan()); + for (x, y) in [(10, 20), (511, 511)] { + let got = f64::from(ws.bands[0].data[y * 768 + 256 + x]); + assert!((got - per_year(x, y)).abs() < 1e-4, "({x}, {y}): {got} vs {}", per_year(x, y)); + } + // An area no file covers (the items only near it) is refused, not four empty rasters. + let away = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [20, 45, 21, 46], "start": "2024-01-01", "end": "2024-03"}); + let e = s.run("cube.trend", &away).unwrap_err(); + assert!(e.contains("covers the area"), "{e}"); + // Ten tiles, more than the cover probes reach, none covering it: refused once read, not four + // empty rasters. + let many: Vec = (0..10) + .flat_map(|t| { + let cog = cog.clone(); + dates.iter().enumerate().map(move |(k, d)| { + format!( + r#"{{"type": "Feature", "id": "t{t}n{k}", "collection": "mod", "bbox": [20, 45, 21, 46], "geometry": null, + "properties": {{"datetime": "{d}T00:00:00Z", "bdc:tiles": ["t{t}"]}}, + "assets": {{"NDVI": {{"href": "{cog}/t{t}n{k}.tif"}}}}}}"# + ) + }) + }) + .collect(); + let search = format!(r#"{{"type": "FeatureCollection", "features": [{}]}}"#, many.join(",")); + let tiles_api = format!("{}/v1", serve_routes(vec![("/v1/search", search)], 16)); + let e = s.run("cube.trend", &json!({"url": tiles_api, "collection": "mod", "asset": "NDVI", "bbox": [20, 45, 21, 46], "start": "2024-01-01", "end": "2024-03"})).unwrap_err(); + assert!(e.contains("covers the area"), "{e}"); + // Read in two pieces (a quarter of the rows' values each would make four; 256-row tiles make + // it two), the outputs are the same, cell for cell. + PIECE_VALUES.with(|v| v.set(Some(512 * 300 * 6))); + let (_, pieces) = run(&mut s, ¶ms); + PIECE_VALUES.with(|v| v.set(None)); + for (a, b) in whole.iter().zip(&pieces) { + assert_eq!((a.width, a.height), (b.width, b.height)); + assert!(a.bands[0].data.iter().zip(&b.bands[0].data).all(|(x, y)| x == y || (x.is_nan() && y.is_nan())), "{}", a.name); + assert!(crate::cmd::cube::same_grid(&a.transform, &b.transform)); + } + // Cancelled, the readers stop early and the run says so, rather than work out a block only + // partly read. + let job = crate::cmd::cube::trend_start(&s, ¶ms).unwrap(); + let e = job.run(&std::sync::atomic::AtomicBool::new(true), &std::sync::atomic::AtomicUsize::new(0)).unwrap_err(); + assert_eq!(e, "cancelled"); + // A row too wide for a piece is said. + PIECE_VALUES.with(|v| v.set(Some(100))); + let e = s.run("cube.trend", ¶ms).unwrap_err(); + PIECE_VALUES.with(|v| v.set(None)); + assert!(e.contains("more than a run's block"), "{e}"); + // A coarser cell size reads the overview nearest it: 256 × 256 cells of 1/256°. + let mut coarse = params.clone(); + coarse["cellSize"] = json!(0.004); + let (_, half) = run(&mut s, &coarse); + assert_eq!((half[0].width, half[0].height), (256, 256)); + // Averaged, each 1/256° cell is the mean of its 2 × 2 full pixels, (2x + 2y + 1) where the + // overview's (nearest) pixel is 2x + 2y. + coarse["resampling"] = json!("average"); + let (_, mean) = run(&mut s, &coarse); + assert_eq!((mean[0].width, mean[0].height), (256, 256)); + for (x, y) in [(5, 7), (30, 40)] { + let (near, avg) = (f64::from(half[0].bands[0].data[y * 256 + x]), f64::from(mean[0].bands[0].data[y * 256 + x])); + let (unit, offsets) = (1e-4 * 365.2425 / 16.0, 0.01 * 365.2425 / 16.0); + assert!((near - (2 * x + 2 * y) as f64 * unit - offsets).abs() < 1e-4, "nearest ({x}, {y}): {near}"); + assert!((avg - (2 * x + 2 * y + 1) as f64 * unit - offsets).abs() < 1e-4, "average ({x}, {y}): {avg}"); + } + let before = s.map().layers.len(); + for bad in [json!("bilinear"), json!(3)] { + coarse["resampling"] = bad; + assert!(s.run("cube.trend", &coarse).unwrap_err().contains("resampling")); + } + assert_eq!(s.map().layers.len(), before, "refused, nothing added"); + coarse["resampling"] = Value::Null; + // The level nearest the size asked: 0.0025 is nearer the full images' 1/512° than 1/256°. + coarse["cellSize"] = json!(0.0025); + assert_eq!(run(&mut s, &coarse).1[0].width, 512); + // What can't be read is refused before anything is added. + let layers = s.map().layers.len(); + for (bad, said) in [ + (json!({"cellSize": -1}), "cellSize"), + (json!({"cellSize": "fine"}), "cellSize"), + (json!({"bbox": [11, 45, 10, 46]}), "bbox"), + (json!({"asset": null}), "asset"), + (json!({"bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-01-20"}), "dates"), + ] { + let mut p = params.clone(); + for (k, v) in bad.as_object().unwrap() { + p[k] = v.clone(); + } + let e = s.run("cube.trend", &p).unwrap_err(); + assert!(e.contains(said), "{bad}: {e}"); + } + assert_eq!(s.map().layers.len(), layers); +} + /// A cloud-optimised GeoTIFF by its address is read in place: its header, then only the tiles a /// map tile needs, by range; a GeoTIFF in strips is downloaded whole as a raster layer. #[test] @@ -7699,6 +7849,20 @@ fn a_trend_over_a_data_cube() { let late = r["layers"][0].clone(); assert_eq!(r["dates"], 9); assert!((value(&s, &late, 0) - 0.2).abs() < 1e-2, "{}", value(&s, &late, 0)); + // An end given as a year takes all of it: 2013 to 2020's July, eight dates (read as 1 January + // 2020, it would leave 2020's out). + let r = s.run("cube.trend", &json!({"layers": names, "end": "2020", "name": "to2020"})).unwrap(); + assert_eq!(r["dates"], 8, "{r}"); + let r = s.run("cube.trend", &json!({"layers": names, "end": "2020-06", "name": "toJune"})).unwrap(); + assert_eq!(r["dates"], 7, "a month ends with it: 2020's July date is after June: {r}"); + // A year given as a number reads the same. + let r = s.run("cube.trend", &json!({"layers": names, "end": 2020, "name": "to2020n"})).unwrap(); + assert_eq!(r["dates"], 8, "{r}"); + // A full date is that day, as before: 1 January 2020 leaves 2020's July out, its 1 July keeps it. + let r = s.run("cube.trend", &json!({"layers": names, "end": "2020-01-01", "name": "toNewYear"})).unwrap(); + assert_eq!(r["dates"], 7, "{r}"); + let r = s.run("cube.trend", &json!({"layers": names, "end": "2020-07-01", "name": "toJuly"})).unwrap(); + assert_eq!(r["dates"], 8, "{r}"); let backwards: Vec = (0..12).map(|k| format!("{}-01-01", 2030 - k)).collect(); let r = s.run("cube.trend", &json!({"layers": names, "dates": backwards, "test": "original"})).unwrap(); assert!(value(&s, &r["layers"][0], 0) < 0.0, "the dates given are used, in the layers' order"); diff --git a/crates/tiles/src/cog.rs b/crates/tiles/src/cog.rs index e8e73d725..6d8153b44 100644 --- a/crates/tiles/src/cog.rs +++ b/crates/tiles/src/cog.rs @@ -1045,6 +1045,88 @@ pub fn probe(url: &str, then: Box) + Send>) { ); } +/// The most tiles of a file one read of values takes. +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_VALUE_TILES: usize = 4096; + +/// The most values one read returns: 64 million, 256 MB, refused before anything is allocated. +#[cfg(not(target_arch = "wasm32"))] +pub const MAX_VALUES_READ: u64 = 64_000_000; + +/// A file's header, waiting for it: for an analysis that reads the values (opengeos/RustGIS#579). +#[cfg(not(target_arch = "wasm32"))] +pub fn info_blocking(url: &str) -> Result, String> { + let url = split(url).0; + let (tx, rx) = std::sync::mpsc::channel(); + with_info(url, Box::new(move |r| tx.send(r).unwrap_or_default())); + rx.recv_timeout(Duration::from_secs(120)).map_err(|_| format!("{url}: no answer in two minutes"))? +} + +/// Band `band`'s values (0-based) in a window of a level: columns `cols.0..cols.1` and rows +/// `rows.0..rows.1` of that level's grid, row by row, waiting for the tiles. No-data, and the +/// window's pixels outside the file, are NaN. The values are as stored (no scale or offset). +/// A read returns up to [`MAX_VALUES_READ`] values (256 MB): a caller reading on several threads +/// bounds its windows itself (a STAC trend's slices are a million pixels at most). It waits up to +/// two minutes for the tiles, as [`info_blocking`] does for the header. +#[cfg(not(target_arch = "wasm32"))] +pub fn read_values_blocking( + info: &Arc, + url: &str, + level: usize, + band: usize, + cols: (u32, u32), + rows: (u32, u32), +) -> Result, String> { + let url = split(url).0.to_string(); + let l = info.layout.levels.get(level).ok_or("no such overview")?; + if band >= info.layout.bands.max(1) { + return Err(format!("the file has {} band(s)", info.layout.bands)); + } + // The window's size checked before its values are allocated: a caller's window, not the file. + let (w, h) = (cols.1.saturating_sub(cols.0) as usize, rows.1.saturating_sub(rows.0) as usize); + let n = (w as u64).saturating_mul(h as u64); + if n > MAX_VALUES_READ { + return Err(format!("a window of {w} × {h} values; one read takes {MAX_VALUES_READ} at most")); + } + // The part of the window inside the file. + let (x0, x1) = (cols.0.min(l.width), cols.1.min(l.width)); + let (y0, y1) = (rows.0.min(l.height), rows.1.min(l.height)); + if x1 <= x0 || y1 <= y0 { + return Ok(vec![f32::NAN; n as usize]); + } + let (tw, th, across) = (l.tile_width.max(1), l.tile_height.max(1), l.across().max(1)); + // The tiles the window takes, counted before they are listed, in checked arithmetic: a + // hostile header can't make a long list or a wrapped index. + let (c0, c1, r0, r1) = (x0 / tw, (x1 - 1) / tw, y0 / th, (y1 - 1) / th); + let count = (u64::from(c1 - c0) + 1) * (u64::from(r1 - r0) + 1); + if count > MAX_VALUE_TILES as u64 { + return Err(format!("{count} tiles of the file for one read; at most {MAX_VALUE_TILES}: take a coarser level or a smaller area")); + } + let tiles: Vec = + (r0..=r1).flat_map(|ty| (c0..=c1).filter_map(move |tx| ty.checked_mul(across).and_then(|t| t.checked_add(tx)))).collect(); + let mut out = vec![f32::NAN; n as usize]; + let (tx, rx) = std::sync::mpsc::channel(); + fetch_bands(info.clone(), url.clone(), level, tiles.clone(), vec![band], Box::new(move |r| tx.send(r).unwrap_or_default())); + let picked = rx.recv_timeout(Duration::from_secs(120)).map_err(|_| format!("{url}: tiles not read in two minutes"))??; + let nodata = info.layout.nodata.map(|v| v as f32); + for (&t, p) in tiles.iter().zip(&picked) { + let Some(values) = p.first().and_then(|(chunk, i)| chunk.get(*i)) else { continue }; + let (tx0, ty0) = ((t % across).saturating_mul(tw), (t / across).saturating_mul(th)); + for py in ty0.max(y0)..ty0.saturating_add(th).min(y1) { + for px in tx0.max(x0)..tx0.saturating_add(tw).min(x1) { + let Some(&v) = values.get((py - ty0) as usize * tw as usize + (px - tx0) as usize) else { continue }; + if v.is_finite() + && Some(v) != nodata + && let Some(slot) = out.get_mut((py - rows.0) as usize * w + (px - cols.0) as usize) + { + *slot = v; + } + } + } + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/tiles/src/stac.rs b/crates/tiles/src/stac.rs index f3d0ce7ff..c282c821e 100644 --- a/crates/tiles/src/stac.rs +++ b/crates/tiles/src/stac.rs @@ -51,6 +51,9 @@ pub struct Asset { /// `v · scale + offset`. pub scale: Option, pub offset: Option, + /// Its first band's stored no-data value (`raster:bands` or `eo:bands` `nodata`), for files + /// whose own header doesn't name it. + pub nodata: Option, } /// An item: a scene (or tile) with its footprint, time and assets. @@ -352,6 +355,7 @@ fn item(f: &Value, from: Option<&str>) -> Option { title: text(a.get("title")), scale: band_number(a, "scale"), offset: band_number(a, "offset"), + nodata: band_number(a, "nodata"), }) }) .collect(); @@ -409,7 +413,7 @@ fn tile_of(p: &Value) -> Option { .map(|t| t.chars().take(64).collect()) } -/// An asset's first band's number `key` (`scale`, `offset`): from `raster:bands`, else from +/// An asset's first band's number `key` (`scale`, `offset`, `nodata`): from `raster:bands`, else from /// `eo:bands`, where some catalogs (Brazil Data Cube's) put it. fn band_number(asset: &Value, key: &str) -> Option { ["raster:bands", "eo:bands"].iter().find_map(|b| asset.get(*b)?.as_array()?.first()?.get(key)?.as_f64()).filter(|v| v.is_finite()) @@ -807,6 +811,7 @@ mod tests { title: String::new(), scale: None, offset: None, + nodata: None, }]; // A Planetary Computer asset is added after signing it with its container's token. let (_, href) = pc.asset_href(None).unwrap(); @@ -911,5 +916,6 @@ mod tests { assert_eq!((band_number(&a, "scale"), band_number(&a, "offset")), (Some(0.0001), Some(-0.1))); assert_eq!(band_number(&serde_json::json!({"eo:bands": [{"scale": 0.0001}]}), "scale"), Some(0.0001)); assert_eq!(band_number(&serde_json::json!({"type": "image/tiff"}), "scale"), None); + assert_eq!(band_number(&serde_json::json!({"raster:bands": [{"nodata": -3000.0}]}), "nodata"), Some(-3000.0)); } } diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 5cf68af63..f135bae32 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -342,7 +342,26 @@ and a **Mann–Kendall** test of whether the trend is significant. - a Zarr variable along its time dimension, by its layer or its store, as for Summarise over time (see [Data](data.md)): `{"path": "air.zarr", "variable": "t2m"}`; - a raster of several bands, a date each, as Earth Engine and openEO export, dated by the bands' - names or `dates`. + names or `dates`; + - a STAC collection's band (desktop), read in place from its cloud-optimised GeoTIFFs over an + area, as Add as Time Series finds them (see [Web data](web-data.md)): `{"url": + "https://data.inpe.br/bdc/stac/v1", "collection": "mod13q1-6.1", "asset": "NDVI", "bbox": [-46, + -10.95, -40.35, -2.7], "start": "2001", "end": "2025"}`. Each date's files (a grid tile each, + MODIS's h13v09 and h13v10 for Piauí) are put together on one grid, each scaled as its own + metadata says (NDVI's 0.0001). `"cellSize"` reads the level of the files' overviews nearest + it (`1000` reads MODIS's 926 m one), which reads far fewer bytes; by default the full images. + An overview is often one pixel taken from each block (the Brazil Data Cube's are), so a cell is + then one 250 m pixel, which can differ much from the place's mean; `"resampling": "average"` + makes each cell the mean of the full images' pixels in it, reading those (16 times the bytes + at 1 km from 250 m). It needs every file's corner on a cell's corner, as MODIS's tiles are; a + file on a grid shifted by part of a cell is refused: set `"resampling": "nearest"` and run it + again. A file's no-data value is skipped, from its header or from its asset's metadata + (`raster:bands` `nodata`). Where two tiles' files overlap on a date (items without a tile + name are told apart by footprint), a cell is the mean of their values, while the data cube on + the map shows one of them. This source is given as parameters (a script, the assistant, the + command line); `cube.trend` waits for the search and the reads with no Cancel, the app with + it, as for a Zarr store's address, so keep the area and period small there, or run a large + one from the command line. The rasters have to be on one grid; put them on one first with Raster Functions › Resample. - **The test:** NDVI and temperature series are autocorrelated, which makes the plain test report @@ -357,17 +376,24 @@ and a **Mann–Kendall** test of whether the trend is significant. The definitions follow `pymannkendall`'s, ties included. - **Options:** the significance level (`"alpha"`, 0.05), the slope per year or per decade (from the - real dates, so uneven spacing is handled), a period (`start`, `end`), a season across years + real dates, so uneven spacing is handled), a period (`start`, `end`; a whole number from 1000 to + 9999 is a year, and an end given as a year or a month takes all of it), a season across years (`"months": [12, 1, 2]`), and the dates a cell needs (`"minValid"`, by default half of them, at least 4). Missing values and no-data are skipped cell by cell. - **Outputs:** four 32-bit layers, kept in the project as GeoTIFFs: Sen's slope, the test's Z, the p-value, and the slope where p < alpha (no-data elsewhere), the one a map usually shows on a diverging ramp centred on 0. - **Size:** the cube is read into memory a slice at a time, up to 64 million values (cells times - dates; 16 million in the web app) and 2,000 dates. It runs in the background with progress and Cancel. + dates; 16 million in the web app) and 2,000 dates. A STAC collection's area is read in **pieces** + instead, bands of rows each within that block (a whole number of the files' tile rows where they fit), + eight dates at a time, and the outputs are put together: Piauí at about 1 km over 25 years of + 16-day composites (645 × 984 cells × 576 dates) is six pieces. Its area is up to 25 million + cells. The seasonal test compares only pairs within a season, so its time limit counts those. + It runs in the background with progress and Cancel. ```sh rustgis-cli run --cmd 'cube.trend={"layers":["ndvi_2015","ndvi_2016","ndvi_2017","ndvi_2018"],"per":"decade"}' +rustgis-cli run --cmd 'cube.trend={"url":"https://data.inpe.br/bdc/stac/v1","collection":"mod13q1-6.1","asset":"NDVI","bbox":[-46,-10.95,-40.35,-2.7],"start":"2001","end":"2025","cellSize":1000,"test":"seasonal","seasons":23,"per":"decade"}' --save piaui.rgis ``` ## Deep learning and hosted imagery diff --git a/docs/user-guide/web-data.md b/docs/user-guide/web-data.md index aa8fe65e4..f6d869838 100644 --- a/docs/user-guide/web-data.md +++ b/docs/user-guide/web-data.md @@ -92,6 +92,9 @@ and band, such as INPE's [Brazil Data Cube](https://data.inpe.br/bdc/) or Sentin - **Saved with the project:** each tile's dates and addresses. - **Web app:** a server that doesn't allow the browser to fetch its files (CORS), as the Brazil Data Cube's doesn't, works on the desktop only. +- **Its trend:** the same search, given to Trend Analysis (`cube.trend` with `collection`, + `asset`, `bbox`, `start` and `end`), reads the values over the area and works out each cell's + trend: see [Trend analysis](analysis.md#trend-analysis). ```sh rustgis-cli run --cmd 'stac.addCube={"url":"https://data.inpe.br/bdc/stac/v1","collection":"S2-16D-2","asset":"NDVI","bbox":[-55.9,-12.6,-55.5,-12.35],"start":"2024-08-01","end":"2025-06-30","range":[0,0.9]}' --cmd 'time.export={"path":"ndvi.gif"}' From d5910de728a6dcb113d140e8a74a8364c3411c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Wed, 21 Oct 2026 23:36:34 -0500 Subject: [PATCH 018/297] feat(ui): close a brace typed in the Script pane, step over it and delete the pair (#686) * feat(ui): close a brace typed in the Script pane, step over it and delete the pair * fix(ui): leave the Script pane's braces alone on undo, redo and paste --- crates/ui-egui/src/analysispane.rs | 4 + crates/ui-egui/src/scriptcomplete.rs | 198 ++++++++++++++++++++++++++- docs/user-guide/analysis.md | 4 +- 3 files changed, 204 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 348654fea..2171e87ad 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -1097,6 +1097,8 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { if let Some((offer, item)) = crate::scriptcomplete::keys(ui, box_id, &app.workflow.script) { crate::scriptcomplete::accept(ui, box_id, &mut app.workflow.script, &offer, &item, &app.session); } + // `{` closing itself (#662): what the box does with the keys, followed through after it. + let before = crate::scriptcomplete::before(ui, box_id, &app.workflow.script); let out = egui::ScrollArea::vertical().id_salt("script_box").max_height(room).auto_shrink([false, true]).show(ui, |ui| { egui::TextEdit::multiline(&mut app.workflow.script) .id(box_id) @@ -1112,6 +1114,8 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { crate::scriptcomplete::hint(ui, box_id, &out.inner, out.inner_rect, &app.workflow.script, &app.session); if let Some((offer, item)) = clicked { crate::scriptcomplete::accept(ui, box_id, &mut app.workflow.script, &offer, &item, &app.session); + } else { + crate::scriptcomplete::braces(ui, before, &out.inner, &mut app.workflow.script); } ui.horizontal(|ui| { if ui.button(tl!("Run")).clicked() { diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 51a1984b1..7684621da 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -1,7 +1,9 @@ //! Autocomplete for the Script pane (#662): a floating list of suggestions just under the cursor, //! as in a code editor. ↑/↓ move the highlight, Enter or Tab accept it, Escape closes the list, or //! a click accepts an item. What to offer at a cursor (`offer`) is worked out apart from the box -//! and tested on its own; this first step completes command ids (a line's first word). +//! and tested on its own; this first step completes command ids (a line's first word). Typing +//! aids beside it: a command's parameters added on accept (`template`), and braces that close +//! themselves (`brace`). use egui::text::{CCursor, CCursorRange}; use egui::widgets::text_edit::{TextEditOutput, TextEditState}; @@ -610,6 +612,120 @@ pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &st store(ui, box_id, list); } +/// The cursor and the character before it, and the script's length (bytes), as they were before +/// the box took this frame's keys: what `braces` tells the box's edit from. +#[derive(Clone, Copy, Debug)] +pub struct Before { + at: usize, + prev: Option, + len: usize, +} + +/// `Before` the box takes the keys, with it focused and no text selected. +pub fn before(ui: &Ui, box_id: Id, script: &str) -> Option { + if !ui.memory(|m| m.has_focus(box_id)) { + return None; + } + let at = TextEditState::load(ui.ctx(), box_id)?.cursor.char_range().filter(|r| r.is_empty())?.primary.index.0; + Some(Before { at, prev: at.checked_sub(1).and_then(|i| char_at(script, i)), len: script.len() }) +} + +/// The character at char `at` of `script`. +fn char_at(script: &str, at: usize) -> Option { + script.get(byte_at(script, at)?..)?.chars().next() +} + +/// Whether the end of `line` is inside a string (quotes after a backslash don't count). +fn in_string(line: &str) -> bool { + let (mut quoted, mut escaped) = (false, false); + for c in line.chars() { + let was_escaped = escaped; + escaped = c == '\\' && !was_escaped; + if c == '"' && !was_escaped { + quoted = !quoted; + } + } + quoted +} + +/// What this frame's input did to the box, as `brace` needs it: a brace typed, Backspace, or +/// anything else (an undo or redo, a paste, a cut), which it leaves as it is. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Keyed { + Brace, + Backspace, + Other, +} + +/// The script once the box's edit is followed through, the edit read from `script` as the box +/// left it, what was there `before`, and the cursor `now` (a char index): a `{` typed closes +/// itself (`{|}`) when nothing, a space or a closing `}`, `]` or `,` follows it; a `}` typed right +/// before a `}` steps over it rather than add one; Backspace in an empty `{|}` deletes both. Not +/// in a string or a comment line, and only for the keys that make those edits (`keyed`): an undo +/// can bring a `{` back as typing one would. The cursor stays where the box put it in each case. +/// None to leave the edit as it is. +fn brace(script: &str, before: Before, now: usize, keyed: Keyed) -> Option { + let Before { at, prev, len } = before; + // Outside a comment line and a string, up to char `i`. + let plain = |i: usize| { + let line = byte_at(script, i).and_then(|b| script.get(..b)).map(|b| b.rsplit('\n').next().unwrap_or_default()); + line.is_some_and(|l| !l.trim_start().starts_with('#') && !in_string(l)) + }; + let (typed, next) = (char_at(script, at), char_at(script, now)); + let one_typed = keyed == Keyed::Brace && script.len() == len + 1 && now == at + 1 && plain(at); + let one_deleted = keyed == Keyed::Backspace && script.len() + 1 == len && now + 1 == at && prev == Some('{') && plain(now); + let cut = byte_at(script, if one_deleted { now } else { at })?; + let (b, rest) = (script.get(..cut)?, script.get(cut..)?); + match (typed, next) { + (Some('{'), next) if one_typed && next.is_none_or(|c| c.is_whitespace() || matches!(c, '}' | ']' | ',')) => { + Some(format!("{b}{{}}{}", rest.get(1..)?)) + } + // The `}` typed goes: the cursor, after it, is after the one that was there. + (Some('}'), Some('}')) if one_typed => Some(format!("{b}{}", rest.get(1..)?)), + // The `}` that followed the deleted `{`, now at the cursor. + (_, Some('}')) if one_deleted => Some(format!("{b}{}", rest.get(1..)?)), + _ => None, + } +} + +/// After the box: its edit followed through as `brace` says, from what was there `before` it +/// took the keys. Read from what the box did rather than from the keys, so it holds whatever +/// keyboard types the brace (Shift, AltGr, Option). The cursor stays where the box put it, and the +/// box's undo takes the brace back with what was typed with it. +pub fn braces(ui: &Ui, before: Option, out: &TextEditOutput, script: &mut String) { + let (Some(before), Some(now)) = (before, out.cursor_range.filter(|r| r.is_empty()).map(|r| r.primary.index.0)) else { return }; + if !out.response.response.changed() { + return; + } + // One edit, made by one of those keys: the brace typed alone (Shift, AltGr or Option as it + // takes), or Backspace alone. + let keyed = ui.input(|i| { + let edits: Vec<&egui::Event> = i + .events + .iter() + .filter(|e| match e { + egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Cut | egui::Event::Ime(_) => true, + // Undo and redo, not every key with Ctrl: Windows reports AltGr, which types a + // brace on many keyboards, as Ctrl+Alt. + egui::Event::Key { key, pressed: true, modifiers, .. } => { + *key == Key::Backspace || (modifiers.command && matches!(key, Key::Z | Key::Y)) + } + _ => false, + }) + .collect(); + match edits.as_slice() { + [egui::Event::Text(t)] if t == "{" || t == "}" => Keyed::Brace, + [egui::Event::Key { key: Key::Backspace, modifiers, .. }] if modifiers.is_none() => Keyed::Backspace, + _ => Keyed::Other, + } + }); + if let Some(new) = brace(script, before, now, keyed) { + *script = new; + // The box drew the text as it left it: show the brace from the next frame on, now. + ui.ctx().request_repaint(); + } +} + #[cfg(test)] mod tests { use super::{Offer, offer}; @@ -1066,6 +1182,86 @@ mod tests { assert!(last.max.y <= shown.max.y, "every item in view, no scroll: {last:?} in {shown:?}"); } + /// What `brace` makes of `ins` typed at char `at` of `old`, as the box would leave it. + fn typed(old: &str, at: usize, ins: &str) -> Option { + let b = super::byte_at(old, at).unwrap(); + let script = format!("{}{ins}{}", &old[..b], &old[b..]); + let before = super::Before { at, prev: at.checked_sub(1).and_then(|i| super::char_at(old, i)), len: old.len() }; + super::brace(&script, before, at + ins.chars().count(), super::Keyed::Brace) + } + + /// What `brace` makes of Backspace at char `at` of `old`. + fn backspace(old: &str, at: usize) -> Option { + let (b, e) = (super::byte_at(old, at - 1).unwrap(), super::byte_at(old, at).unwrap()); + let script = format!("{}{}", &old[..b], &old[e..]); + let before = super::Before { at, prev: super::char_at(old, at - 1), len: old.len() }; + super::brace(&script, before, at - 1, super::Keyed::Backspace) + } + + /// `{` closes itself before nothing, a space or a closing `}`, `]` or `,`, not before other + /// text, in a string or in a comment line, nor for a paste. + #[test] + fn a_brace_typed_closes_itself() { + let end = |s: &str| typed(s, s.chars().count(), "{"); + assert_eq!(end("labels.style ").as_deref(), Some("labels.style {}")); + assert_eq!(end("").as_deref(), Some("{}")); + assert_eq!(typed(r#"x {"a": }"#, 8, "{").as_deref(), Some(r#"x {"a": {}}"#), "before a closing brace"); + assert_eq!(typed("x [, ]", 3, "{").as_deref(), Some("x [{}, ]"), "before a comma"); + assert_eq!(typed("x \r\ny", 2, "{").as_deref(), Some("x {} \r\ny"), "before a space"); + assert_eq!(typed("x \r\n", 2, "{").as_deref(), Some("x {}\r\n"), "at a CRLF line's end"); + assert_eq!(typed("x ab", 2, "{"), None, "before other text"); + assert_eq!(end(r#"x {"where": "a "#), None, "in a string"); + assert_eq!(end(r#"x {"where": "a \" "#), None, "an escaped quote doesn't end it"); + assert_eq!(end(" # x "), None, "in a comment line"); + assert_eq!(end("ñandú ").as_deref(), Some("ñandú {}"), "chars, not bytes"); + assert_eq!(typed("x ", 2, "{{"), None, "more than the brace typed"); + let before = super::Before { at: 2, prev: Some(' '), len: 2 }; + assert_eq!(super::brace("x {", before, 3, super::Keyed::Other), None, "a paste, an undo"); + let before = super::Before { at: 3, prev: Some('{'), len: 4 }; + assert_eq!(super::brace("x }", before, 2, super::Keyed::Other), None, "a cut, an undo"); + } + + /// `}` typed right before a `}` steps over it; Backspace in an empty `{}` deletes both. + #[test] + fn a_closing_brace_steps_over_and_backspace_deletes_both() { + assert_eq!(typed(r#"x {"a": 1}"#, 9, "}").as_deref(), Some(r#"x {"a": 1}"#), "stepped over"); + assert_eq!(typed(r#"x {"a": 1"#, 9, "}"), None, "no brace to step over"); + assert_eq!(typed(r#"x {"a": "1}"#, 10, "}"), None, "in a string"); + assert_eq!(backspace("x {}", 3).as_deref(), Some("x ")); + assert_eq!(backspace("ñ {}\r\n", 3).as_deref(), Some("ñ \r\n"), "chars, not bytes"); + assert_eq!(backspace("x {a}", 3), None, "not empty"); + assert_eq!(backspace("x (}", 3), None, "not a brace deleted"); + assert_eq!(backspace(r#"x "{}"#, 4), None, "in a string"); + } + + /// In the pane: `{` closes itself with the cursor inside, `}` steps over, Backspace deletes + /// both, and the box's undo takes the brace back with what was typed with it. + #[test] + fn braces_in_the_box() { + let mut h = pane(); + type_in(&mut h, "labels.style "); + type_in(&mut h, "{"); + assert_eq!(h.state().workflow.script, "labels.style {}"); + type_in(&mut h, "}"); + type_in(&mut h, "!"); + assert_eq!(h.state().workflow.script, "labels.style {}!", "the cursor stepped over the brace"); + let mut h = pane(); + type_in(&mut h, "labels.style "); + type_in(&mut h, "{"); + h.key_press(egui::Key::Backspace); + h.run(); + assert_eq!(h.state().workflow.script, "labels.style "); + type_in(&mut h, "{"); + let state = egui::widgets::text_edit::TextEditState::load(&h.ctx, egui::Id::new("script_text")).unwrap(); + let now = (state.cursor.char_range().unwrap(), h.state().workflow.script.clone()); + let back = state.undoer().undo(&now).map(|(_, text)| text.clone()); + assert!(back.is_some_and(|t| !t.contains('{')), "no lone brace left by undo"); + let mut h = pane(); + h.event(egui::Event::Paste("labels.style {".into())); + h.run(); + assert_eq!(h.state().workflow.script, "labels.style {", "a paste is left as it is"); + } + /// The cursor is in a command's parameters inside its line's `{…}`: not before the brace, not /// past the closing one, and a `}` in a string doesn't close it. #[test] diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index f135bae32..fdb6fcb5c 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -234,7 +234,9 @@ Accepting a command on a line of its own adds its braces: with its required keys values, the cursor at the first (`map.goToXY {"lon": , "lat": }`), or empty with the cursor inside when every key may be left out; a command with no parameters gets none, and a line that has parameters keeps them. While you type a command's parameters (the cursor inside its `{…}`), the same help stays by the cursor; -Escape closes it. +Escape closes it. A `{` typed closes itself, the cursor inside, unless text follows it or it's in a +string or a comment; a `}` typed before a `}` steps over it, and Backspace in an empty `{}` deletes +both. Pasting adds nothing. ## ModelBuilder From f9a214a59d379d0c72abc1e2f1e98c2539798752 Mon Sep 17 00:00:00 2001 From: liyongyin Date: Thu, 22 Oct 2026 18:52:53 +0800 Subject: [PATCH 019/297] feat(render): stream complete Shapefile layers in ordered chunks (#681) * feat(render): stream complete Shapefile layers in ordered chunks * fix(render): retain owning windows and report extrusion spill failures * test(render): construct source through its public default * fix(render): bound perspective snapshots and isolate complete source reads * fix(render): reuse required masks and skip disabled shadow passes * fix(render): preserve selection pixels and narrow perspective snapshots * fix(render): retain best-effort bands for ordinary providers * test(render): keep selected interaction sample deterministic * fix(render): use source totals for upright point eligibility * fix(render): log ordinary layer draw failures * fix(render): accept GPU scene tests and preserve ordinary layout failures * fix(render): preserve blocking reads and complete layout failures --------- Co-authored-by: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> --- Cargo.lock | 1 + crates/data/src/source/chunk_memory.rs | 35 + crates/data/src/source/mod.rs | 14 + crates/data/src/source/shp.rs | 81 ++ crates/data/src/source/shp/cache/tests.rs | 12 + crates/data/src/source/shp/index.rs | 28 +- crates/data/src/source/shp/tests.rs | 56 ++ crates/data/src/source/spill.rs | 94 ++ crates/data/src/source/tests.rs | 79 ++ crates/map/src/lib.rs | 11 + crates/map/src/source.rs | 5 + crates/render/Cargo.toml | 3 +- crates/render/src/effects.rs | 316 ++++-- crates/render/src/layout.rs | 91 +- crates/render/src/lib.rs | 941 +++++++++++++----- crates/render/src/stream_tests.rs | 1078 +++++++++++++++++++++ docs/user-guide/data.md | 38 + 17 files changed, 2552 insertions(+), 331 deletions(-) create mode 100644 crates/data/src/source/chunk_memory.rs create mode 100644 crates/data/src/source/spill.rs create mode 100644 crates/render/src/stream_tests.rs diff --git a/Cargo.lock b/Cargo.lock index ec259165d..cf27cd6fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4645,6 +4645,7 @@ dependencies = [ "rustgis-map", "rustgis-tiles", "serde_json", + "tempfile", "tiny-skia", "web-time", ] diff --git a/crates/data/src/source/chunk_memory.rs b/crates/data/src/source/chunk_memory.rs new file mode 100644 index 000000000..e9cd4a258 --- /dev/null +++ b/crates/data/src/source/chunk_memory.rs @@ -0,0 +1,35 @@ +//! Allocation accounting for complete render chunks. +use crate::{Feature, Geometry, Value}; +use std::mem::size_of; + +/// Allocated vector storage owned by a geometry, excluding the enum in its containing array. +pub fn geometry_bytes(g: &Geometry) -> usize { + let ring = |r: &Vec| r.capacity().saturating_mul(size_of::()); + let parts = |p: &Vec>| { + p.iter().fold(p.capacity().saturating_mul(size_of::>()), |n, r| n.saturating_add(ring(r))) + }; + match g { + Geometry::Point(_) => 0, + Geometry::MultiPoint(p) => ring(p), + Geometry::Line(p) => parts(p), + Geometry::Polygon(p) => { + p.iter().fold(p.capacity().saturating_mul(size_of::>>()), |n, r| n.saturating_add(parts(r))) + } + } +} + +/// Heap storage owned by a row, including unused capacity and attribute strings. +pub fn feature_bytes(f: &Feature) -> usize { + let attrs = f.attrs.iter().fold(f.attrs.capacity().saturating_mul(size_of::()), |n, v| { + n.saturating_add(match v { + Value::Text(s) => s.capacity(), + _ => 0, + }) + }); + attrs.saturating_add(f.geometry.as_ref().map_or(0, geometry_bytes)) +} + +/// Actual allocated chunk storage, including spare capacity. +pub fn features_bytes(features: &Vec) -> usize { + features.iter().fold(features.capacity().saturating_mul(size_of::()), |n, f| n.saturating_add(feature_bytes(f))) +} diff --git a/crates/data/src/source/mod.rs b/crates/data/src/source/mod.rs index a670c0774..66c1eff1c 100644 --- a/crates/data/src/source/mod.rs +++ b/crates/data/src/source/mod.rs @@ -171,6 +171,16 @@ pub struct Batch { /// A vector data source read in place. pub trait FeatureSource: Send + Sync { + /// Whether complete, ordered traversal is available without an interaction row limit. + fn streams_chunks(&self) -> bool { + false + } + /// Visits every matching feature in source painter order. Release each chunk before the + /// next callback. `bytes` targets owned feature storage; an indivisible larger row is emitted alone. + /// Callback errors stop traversal. Success returns an empty, complete summary batch. + fn visit_chunks(&self, _extent: Option, _bytes: usize, _visit: &mut dyn FnMut(Vec) -> Result<()>) -> Result { + Err(DataError::Format("this provider does not support chunk traversal".into())) + } fn info(&self) -> &SourceInfo; /// Reads the features a request asks for. A source read by its web address fails with /// [`remote::NEED`] until the bytes the read needs are fetched: run it with @@ -639,3 +649,7 @@ pub(crate) fn tls_provider() { #[cfg(test)] mod tests; + +pub mod chunk_memory; +#[cfg(not(target_arch = "wasm32"))] +pub mod spill; diff --git a/crates/data/src/source/shp.rs b/crates/data/src/source/shp.rs index 95024f0ba..d673fc638 100644 --- a/crates/data/src/source/shp.rs +++ b/crates/data/src/source/shp.rs @@ -185,6 +185,87 @@ impl FeatureSource for ShpSource { })?; Ok(Batch { features, matched, complete, skipped }) } + fn streams_chunks(&self) -> bool { + true + } + fn visit_chunks(&self, extent: Option, bytes: usize, visit: &mut dyn FnMut(Vec) -> Result<()>) -> Result { + if bytes == 0 { + return Err(bad("the render memory budget is zero")); + } + let extent = extent.map(|e| self.reproject.extent_from_lonlat(&e)); + if extent.is_some_and(|e| e.is_empty()) { + return Ok(Batch { complete: true, ..Batch::default() }); + } + let bounds = extent.map(|e| [e.min_x as f32, e.min_y as f32, e.max_x as f32, e.max_y as f32]); + let mut used = 0usize; + let mut shp = Cursor::open(&self.shp)?; + let mut dbf = Cursor::open(&self.dbf)?; + let mut buf = Vec::new(); + let mut row = vec![0u8; self.table.row_len]; + let mut features = Vec::new(); + let mut skipped = 0; + // The same directory, ordering, extent rules and read-only scan fallback as sampled + // reads, with no interaction row cap. The index visits records without materializing + // their directory or the complete feature collection in memory. + // Every record id fits u32. This format bound visits them all while keeping SQLite's + // LIMIT within its signed integer range on 64-bit hosts. + let (matched, complete) = self.index.visit_complete(bounds, |i, place| { + buf.resize(place.len as usize, 0); + if shp.read_at(u64::from(place.offset), &mut buf).is_err() { + skipped += 1; + return Ok(()); + } + let geometry = match parse_shape(&buf) { + Ok(Some(g)) => match self.reproject.geometry(g) { + Some(g) => Some(g), + None => { + skipped += 1; + return Ok(()); + } + }, + Ok(None) => None, + Err(()) => { + skipped += 1; + return Ok(()); + } + }; + let attrs = if u64::from(i) < self.table.rows { + let at = self.table.header_len + u64::from(i) * self.table.row_len as u64; + if dbf.read_at(at, &mut row).is_err() { + skipped += 1; + return Ok(()); + } + // A deleted row: its shape is left out too. + if row.first() == Some(&b'*') { + return Ok(()); + } + self.table.columns.iter().map(|c| value(c, row.get(1 + c.offset..1 + c.offset + c.len).unwrap_or(&[]))).collect() + } else { + vec![Value::Null; self.table.columns.len()] + }; + let feature = Feature { id: u64::from(i) + 1, geometry, attrs }; + let size = super::chunk_memory::feature_bytes(&feature).saturating_add(std::mem::size_of::()); + if !features.is_empty() && used.saturating_add(size) > bytes { + features.shrink_to_fit(); + visit(std::mem::take(&mut features))?; + used = 0; + } + // A geometry is indivisible. Preserve a valid detailed row instead of dropping it + // or failing the entire layer when it alone exceeds the chunk target. + if size > bytes { + visit(vec![feature])?; + return Ok(()); + } + used = used.saturating_add(size); + features.push(feature); + Ok(()) + })?; + if !features.is_empty() { + features.shrink_to_fit(); + visit(features)?; + } + Ok(Batch { features: Vec::new(), matched, complete, skipped }) + } } /// A file read at offsets, through a buffer that a nearby offset reuses. diff --git a/crates/data/src/source/shp/cache/tests.rs b/crates/data/src/source/shp/cache/tests.rs index 32cf7369e..077f01716 100644 --- a/crates/data/src/source/shp/cache/tests.rs +++ b/crates/data/src/source/shp/cache/tests.rs @@ -269,6 +269,18 @@ fn cache_child_fixture() { assert_eq!(batch.matched, 1); assert_eq!(batch.features.len(), limit.min(1)); } + let expected = source.read(&crate::source::Request::all()).unwrap(); + let mut rows = Vec::new(); + let summary = source + .visit_chunks(None, 1, &mut |chunk| { + rows.extend(chunk); + Ok(()) + }) + .unwrap(); + assert!(summary.complete); + assert!(summary.features.is_empty()); + assert_eq!(summary.matched, expected.matched); + assert_eq!(rows, expected.features, "complete chunks must also work without a disk cache"); assert_eq!(std::fs::read(root.join("blocked")).unwrap(), b"keep"); assert_eq!(std::fs::read(index_fallback_dir()).unwrap(), b"keep fallback"); return; diff --git a/crates/data/src/source/shp/index.rs b/crates/data/src/source/shp/index.rs index bc250769d..9efb03637 100644 --- a/crates/data/src/source/shp/index.rs +++ b/crates/data/src/source/shp/index.rs @@ -112,11 +112,28 @@ impl DiskIndex { /// Visit in file order without allocating a list of all matches. SQLite's page cache is /// budgeted at 8 MiB; a large ordering operation spills to disk. - pub fn visit(&self, extent: Option<[f32; 4]>, limit: usize, mut visit: impl FnMut(u32, Place) -> Result<()>) -> Result<(u64, bool)> { + pub fn visit(&self, extent: Option<[f32; 4]>, limit: usize, visit: impl FnMut(u32, Place) -> Result<()>) -> Result<(u64, bool)> { + let db = self.db.lock().map_err(|_| bad("spatial index lock is poisoned"))?; + self.visit_on(&db, extent, limit, visit) + } + + /// A complete render owns its read-only connection, so its callbacks never hold the + /// interaction connection's mutex. The index lease keeps this immutable file alive. + fn visit_complete(&self, extent: Option<[f32; 4]>, visit: impl FnMut(u32, Place) -> Result<()>) -> Result<(u64, bool)> { + let db = connect(self._lease.path(), false)?; + self.visit_on(&db, extent, u32::MAX as usize, visit) + } + + fn visit_on( + &self, + db: &Connection, + extent: Option<[f32; 4]>, + limit: usize, + mut visit: impl FnMut(u32, Place) -> Result<()>, + ) -> Result<(u64, bool)> { // The index's own envelope (not the untrusted file header) proves that every box // matches. Skip the R-tree and its global ordering operation for overview requests. let extent = extent.filter(|e| u64::from(self.covered) != self.count || !self.covers_bbox(*e)); - let db = self.db.lock().map_err(|_| bad("spatial index lock is poisoned"))?; let (from, args) = if let Some([x0, y0, x1, y1]) = extent { ("bounds CROSS JOIN records USING(id) WHERE x0<=?1 AND x1>=?2 AND y0<=?3 AND y1>=?4", vec![x1, x0, y1, y0]) } else { @@ -191,6 +208,13 @@ impl Index { } } } + /// Complete file-order traversal without holding the shared interaction connection. + pub fn visit_complete(&self, extent: Option<[f32; 4]>, visit: impl FnMut(u32, Place) -> Result<()>) -> Result<(u64, bool)> { + match &self.storage { + Storage::Disk(disk) => disk.visit_complete(extent, visit), + Storage::Scan { .. } => self.visit(extent, u32::MAX as usize, visit), + } + } pub fn indexed(&self) -> bool { matches!(self.storage, Storage::Disk(_)) } diff --git a/crates/data/src/source/shp/tests.rs b/crates/data/src/source/shp/tests.rs index 87740c03e..504ad4b50 100644 --- a/crates/data/src/source/shp/tests.rs +++ b/crates/data/src/source/shp/tests.rs @@ -236,3 +236,59 @@ fn shapefile_disk_index_benchmark() { }); std::fs::remove_dir_all(folder).unwrap(); } + +#[test] +fn shapefile_disk_index_complete_chunks_preserve_full_reads_and_file_order() { + let fixture = dir(); + cache::with_index_dir(fixture.join("cache"), || { + let path = fixture.join("complete-indexed.shp"); + points_shapefile(&path); + let source = open_path(&path, None).unwrap(); + assert!(source.info().indexed); + assert_eq!(source.read(&Request { extent: None, limit: 1 }).unwrap().features.len(), 1); + for extent in [None, Some(Extent::new(10.0, 5.0, 15.0, 10.0)), Some(Extent::EMPTY)] { + let expected = source.read(&Request { extent, ..Request::all() }).unwrap(); + for budget in [1, 256, 8192] { + let mut rows = Vec::new(); + let summary = source + .visit_chunks(extent, budget, &mut |chunk| { + assert!(chunk.len() == 1 || crate::source::chunk_memory::features_bytes(&chunk) <= budget); + rows.extend(chunk); + Ok(()) + }) + .unwrap(); + assert!(summary.complete); + assert!(summary.features.is_empty()); + assert_eq!(summary.matched, expected.matched); + assert_eq!(summary.skipped, expected.skipped); + assert_eq!(rows, expected.features); + } + } + for extent in [None, Some(Extent::EMPTY), Some(Extent::new(-180.0, -90.0, -179.0, -89.0))] { + assert!(source.visit_chunks(extent, 0, &mut |_| panic!("zero budget must not emit")).is_err()); + } + let mut reentrant = false; + source + .visit_chunks(None, 256, &mut |_| { + if !reentrant { + assert_eq!(source.read(&Request { extent: None, limit: 1 })?.features.len(), 1); + reentrant = true; + } + Ok(()) + }) + .unwrap(); + assert!(reentrant); + let mut calls = 0; + assert!( + source + .visit_chunks(None, 1, &mut |_| { + calls += 1; + Err(bad("cancelled indexed traversal")) + }) + .is_err() + ); + assert_eq!(calls, 1); + assert!(source.read(&Request::all()).unwrap().complete, "cancellation must release the index lock"); + }); + std::fs::remove_dir_all(fixture).unwrap(); +} diff --git a/crates/data/src/source/spill.rs b/crates/data/src/source/spill.rs new file mode 100644 index 000000000..e7c021276 --- /dev/null +++ b/crates/data/src/source/spill.rs @@ -0,0 +1,94 @@ +//! Disposable, bounded-memory ordering for render records. Payloads remain on disk. + +use crate::{DataError, Result}; +use rusqlite::{Connection, params}; + +pub struct Ordered { + db: Connection, + _file: tempfile::TempDir, +} + +fn bad(e: impl std::fmt::Display) -> DataError { + DataError::Format(format!("render ordering: {e}")) +} + +impl Ordered { + pub fn new() -> Result { + let mut builder = tempfile::Builder::new(); + builder.prefix("rustgis-render-"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + builder.permissions(std::fs::Permissions::from_mode(0o700)); + } + let file = builder.tempdir()?; + let db = Connection::open(file.path().join("ordering.sqlite")).map_err(bad)?; + db.execute_batch( + "PRAGMA page_size=4096; PRAGMA max_page_count=1048576; PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF; PRAGMA cache_size=-2048; PRAGMA mmap_size=0; PRAGMA temp_store=FILE; + CREATE TABLE shapes(seq INTEGER PRIMARY KEY, priority REAL NOT NULL, payload BLOB NOT NULL); + CREATE INDEX sorted ON shapes(priority, seq); + BEGIN;", + ) + .map_err(bad)?; + Ok(Self { db, _file: file }) + } + + pub fn insert(&self, priority: f64, payload: &[u8]) -> Result<()> { + self.db + .prepare_cached("INSERT INTO shapes(priority,payload) VALUES(?1,?2)") + .map_err(bad)? + .execute(params![priority, payload]) + .map_err(bad)?; + Ok(()) + } + + pub fn visit(&self, mut visit: impl FnMut(&[u8]) -> Result<()>) -> Result<()> { + let mut stmt = self.db.prepare("SELECT payload FROM shapes ORDER BY priority, seq").map_err(bad)?; + let mut rows = stmt.query([]).map_err(bad)?; + while let Some(row) = rows.next().map_err(bad)? { + let value = row.get_ref(0).map_err(bad)?; + visit(value.as_blob().map_err(bad)?)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn ordering_is_global_stable_bounded_and_disposable() { + let ordered = Ordered::new().unwrap(); + let path = ordered._file.path().to_owned(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o700); + } + for (priority, payload) in [(2.0, b"b"), (1.0, b"a"), (2.0, b"c")] { + ordered.insert(priority, payload).unwrap(); + } + let mut rows = Vec::new(); + ordered + .visit(|row| { + rows.push(row.to_vec()); + Ok(()) + }) + .unwrap(); + assert_eq!(rows, vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]); + let mut calls = 0; + assert!( + ordered + .visit(|_| { + calls += 1; + Err(bad("cancelled")) + }) + .is_err() + ); + assert_eq!(calls, 1); + ordered.db.execute_batch("PRAGMA max_page_count=12;").unwrap(); + assert!(ordered.insert(3.0, &vec![0; 64 * 1024]).is_err()); + drop(ordered); + assert!(!path.exists()); + } +} diff --git a/crates/data/src/source/tests.rs b/crates/data/src/source/tests.rs index 036f717cf..83a575470 100644 --- a/crates/data/src/source/tests.rs +++ b/crates/data/src/source/tests.rs @@ -1224,3 +1224,82 @@ fn nested_columns_and_vectors_by_address() { let _ = vectors_url_blocking(&cut, e, "embedding".into(), (100, 10_000)); } } + +#[test] +fn shapefile_complete_chunks_keep_all_rows_order_attributes_and_budget() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chunks.shp"); + let mut ds = Dataset::new("chunks", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + for i in 0..523 { + ds.push(Some(Geometry::Point(Coord::new(i as f64 * 0.01, 1.0))), vec![Value::Text(format!("point {i}"))]); + } + write_shapefile(&ds, &path); + let source = shp::ShpSource::open_path(&path, "chunks").unwrap(); + let expected = source.read(&Request::all()).unwrap(); + assert!(source.streams_chunks()); + for budget in [256, 1024, 8192] { + let mut rows = Vec::new(); + let mut calls = 0; + let summary = source + .visit_chunks(None, budget, &mut |chunk| { + calls += 1; + assert!(chunk_memory::features_bytes(&chunk) <= budget); + rows.extend(chunk); + Ok(()) + }) + .unwrap(); + assert!(calls > 1); + assert!(summary.complete); + assert!(summary.features.is_empty()); + assert_eq!(summary.matched, 523); + assert_eq!(rows, expected.features); + } + let mut oversized = Vec::new(); + source + .visit_chunks(None, 1, &mut |chunk| { + assert_eq!(chunk.len(), 1); + oversized.extend(chunk); + Ok(()) + }) + .unwrap(); + assert_eq!(oversized, expected.features, "indivisible rows must remain complete"); + let mut calls = 0; + assert!( + source + .visit_chunks(None, 256, &mut |_| { + calls += 1; + Err(DataError::Format("cancelled".into())) + }) + .is_err() + ); + assert_eq!(calls, 1); + assert!(source.visit_chunks(None, 0, &mut |_| { panic!("zero-budget traversal must not emit a row") }).is_err()); + assert!(source.visit_chunks(Some(Extent::EMPTY), 0, &mut |_| panic!("zero budget must not emit")).is_err()); + let empty_path = dir.path().join("empty.shp"); + // The production exporter deliberately refuses an empty layer. Build a valid zero-record + // fixture from its headers rather than changing that existing export contract. + for ext in ["shp", "shx"] { + let mut header = std::fs::read(path.with_extension(ext)).unwrap(); + header.truncate(100); + header[24..28].copy_from_slice(&50i32.to_be_bytes()); + std::fs::write(empty_path.with_extension(ext), header).unwrap(); + } + let mut header = std::fs::read(path.with_extension("dbf")).unwrap(); + let len = u16::from_le_bytes([header[8], header[9]]) as usize; + header.truncate(len); + header[4..8].copy_from_slice(&0u32.to_le_bytes()); + header.push(0x1a); + std::fs::write(empty_path.with_extension("dbf"), header).unwrap(); + let empty = shp::ShpSource::open_path(&empty_path, "empty").unwrap(); + assert!(empty.visit_chunks(None, 0, &mut |_| panic!("zero budget must not emit")).is_err()); + let extent = Some(Extent::new(0.02, 0.0, 0.1, 2.0)); + let expected = source.read(&Request { extent, limit: 1000 }).unwrap(); + let mut rows = Vec::new(); + source + .visit_chunks(extent, 256, &mut |chunk| { + rows.extend(chunk); + Ok(()) + }) + .unwrap(); + assert_eq!(rows, expected.features); +} diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index 15b8f7283..a2dab5d0a 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -1291,6 +1291,17 @@ impl Map { pub fn adopt_caches(&mut self, other: &Map) { for l in &mut self.layers { if let Some(o) = other.layer(l.id) { + // Complete rendering uses temporary chunk layers, so a failed worker need not + // have a projected cache to adopt. Transfer its diagnostic only for the same + // source and unchanged owning rows; stale workers must not overwrite edits. + if l.dataset.features.shares(&o.dataset.features) + && l.geometry_rev == o.geometry_rev + && let (Some(own), Some(drawn)) = (&mut l.source, &o.source) + && let (Some(a), Some(b)) = (own.chunk_source(), drawn.chunk_source()) + && std::sync::Arc::ptr_eq(&a, &b) + { + own.error.clone_from(&drawn.error); + } l.adopt_cache(o); } } diff --git a/crates/map/src/source.rs b/crates/map/src/source.rs index ffa9e54dc..6921aaacc 100644 --- a/crates/map/src/source.rs +++ b/crates/map/src/source.rs @@ -334,6 +334,11 @@ pub enum Polled { } impl SourceLayer { + /// A source capable of a complete, ordered render traversal. + pub fn chunk_source(&self) -> Option> { + self.handle.as_ref().filter(|h| h.streams_chunks()).cloned() + } + /// Retained interaction bytes assigned by the map, or the standalone default. pub fn budget(&self) -> usize { if self.byte_budget == 0 { VIEW_BYTES } else { self.byte_budget } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index ccd0f319b..db22b9358 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -21,8 +21,9 @@ log = { workspace = true } image = { workspace = true, features = ["jpeg"] } base64 = { workspace = true } web-time = { workspace = true } +serde_json = { workspace = true } miniz_oxide = { workspace = true } rustgis-tiles = { workspace = true } [dev-dependencies] -serde_json = { workspace = true } +tempfile = "3" diff --git a/crates/render/src/effects.rs b/crates/render/src/effects.rs index 59a65edaf..b596da3a5 100644 --- a/crates/render/src/effects.rs +++ b/crates/render/src/effects.rs @@ -2,7 +2,7 @@ //! polygons extruded with lit walls and cast shadows: in an oblique 2.5D view on a flat map, in //! perspective on a tilted one. -use std::collections::HashMap; +use std::collections::BTreeMap; use rustgis_data::{Dataset, Geometry}; use rustgis_map::effects::{Aggregation, Extrusion}; @@ -12,67 +12,80 @@ use tiny_skia::{FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixm use crate::{Ctx, View, paint}; /// Points binned into hexagons or squares, each bin coloured by its count. -pub(crate) fn aggregate(pm: &mut Pixmap, ctx: &Ctx, geoms: &[Option<&Geometry>], rows: &[bool], a: Aggregation, base: &Symbol) { - let s = (a.size.clamp(4.0, 400.0) * ctx.ppp as f64) as f32; - let (w, h) = (pm.width() as f32, pm.height() as f32); - let mut bins: HashMap<(i64, i64), u32> = HashMap::new(); - let sqrt3 = 3f32.sqrt(); - for (row, g) in geoms.iter().enumerate() { - let Some(g) = g else { continue }; - if rows.get(row) != Some(&true) { - continue; - } - let pts: Vec = match g { - Geometry::Point(c) => vec![*c], - Geometry::MultiPoint(v) => v.clone(), - _ => continue, - }; - for c in pts { - let (x, y) = ctx.px(c); - if !(x.is_finite() && y.is_finite()) || x < -s || y < -s || x > w + s || y > h + s { +#[derive(Default)] +pub(crate) struct AggregationBins { + bins: BTreeMap<(i64, i64), u32>, +} + +impl AggregationBins { + pub fn add(&mut self, size: (u32, u32), ctx: &Ctx, geoms: &[Option<&Geometry>], rows: &[bool], a: Aggregation) { + let s = (a.size.clamp(4.0, 400.0) * ctx.ppp as f64) as f32; + let (w, h) = (size.0 as f32, size.1 as f32); + let bins = &mut self.bins; + let sqrt3 = 3f32.sqrt(); + for (row, g) in geoms.iter().enumerate() { + let Some(g) = g else { continue }; + if rows.get(row) != Some(&true) { continue; } - let key = if a.hexagons { - // Pointy-top hexagons in axial coordinates, rounded through cube coordinates. - let q = (sqrt3 / 3.0 * x - y / 3.0) / s; - let r = (2.0 / 3.0 * y) / s; - let (cx, cz) = (q, r); - let cy = -cx - cz; - let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round()); - let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs()); - if dx > dy && dx > dz { - rx = -ry - rz; - } else if dy <= dz { - rz = -rx - ry; - } - (rx as i64, rz as i64) - } else { - ((x / s).floor() as i64, (y / s).floor() as i64) + let pts: Vec = match g { + Geometry::Point(c) => vec![*c], + Geometry::MultiPoint(v) => v.clone(), + _ => continue, }; - *bins.entry(key).or_insert(0) += 1; + for c in pts { + let (x, y) = ctx.px(c); + if !(x.is_finite() && y.is_finite()) || x < -s || y < -s || x > w + s || y > h + s { + continue; + } + let key = if a.hexagons { + // Pointy-top hexagons in axial coordinates, rounded through cube coordinates. + let q = (sqrt3 / 3.0 * x - y / 3.0) / s; + let r = (2.0 / 3.0 * y) / s; + let (cx, cz) = (q, r); + let cy = -cx - cz; + let (mut rx, ry, mut rz) = (cx.round(), cy.round(), cz.round()); + let (dx, dy, dz) = ((rx - cx).abs(), (ry - cy).abs(), (rz - cz).abs()); + if dx > dy && dx > dz { + rx = -ry - rz; + } else if dy <= dz { + rz = -rx - ry; + } + (rx as i64, rz as i64) + } else { + ((x / s).floor() as i64, (y / s).floor() as i64) + }; + let count = bins.entry(key).or_insert(0); + *count = count.saturating_add(1); + } } } - let max = bins.values().copied().max().unwrap_or(1).max(1) as f64; - let ramp = Ramp::Ember; - let stroke = Stroke { width: 0.8 * ctx.ppp, ..Stroke::default() }; - for ((q, r), n) in bins { - let t = if max > 1.0 { (n as f64).ln() / max.ln() } else { 1.0 }; - let fill = ramp.at(0.15 + 0.85 * t).with_alpha(210); - let mut pb = PathBuilder::new(); - if a.hexagons { - let (cx, cy) = (s * sqrt3 * (q as f32 + r as f32 / 2.0), s * 1.5 * r as f32); - for k in 0..6 { - let ang = (60.0 * k as f32 - 30.0).to_radians(); - let (x, y) = (cx + s * ang.cos(), cy + s * ang.sin()); - if k == 0 { pb.move_to(x, y) } else { pb.line_to(x, y) } + pub fn draw(&self, pm: &mut Pixmap, ctx: &Ctx, a: Aggregation, base: &Symbol) { + let bins = &self.bins; + let s = (a.size.clamp(4.0, 400.0) * ctx.ppp as f64) as f32; + let sqrt3 = 3f32.sqrt(); + let max = bins.values().copied().max().unwrap_or(1).max(1) as f64; + let ramp = Ramp::Ember; + let stroke = Stroke { width: 0.8 * ctx.ppp, ..Stroke::default() }; + for (&(q, r), &n) in bins { + let t = if max > 1.0 { (n as f64).ln() / max.ln() } else { 1.0 }; + let fill = ramp.at(0.15 + 0.85 * t).with_alpha(210); + let mut pb = PathBuilder::new(); + if a.hexagons { + let (cx, cy) = (s * sqrt3 * (q as f32 + r as f32 / 2.0), s * 1.5 * r as f32); + for k in 0..6 { + let ang = (60.0 * k as f32 - 30.0).to_radians(); + let (x, y) = (cx + s * ang.cos(), cy + s * ang.sin()); + if k == 0 { pb.move_to(x, y) } else { pb.line_to(x, y) } + } + pb.close(); + } else if let Some(rect) = tiny_skia::Rect::from_xywh(q as f32 * s, r as f32 * s, s, s) { + pb.push_rect(rect); + } + if let Some(p) = pb.finish() { + pm.fill_path(&p, &paint(fill, 1.0), FillRule::Winding, Transform::identity(), None); + pm.stroke_path(&p, &paint(base.stroke.with_alpha(160), 1.0), &stroke, Transform::identity(), None); } - pb.close(); - } else if let Some(rect) = tiny_skia::Rect::from_xywh(q as f32 * s, r as f32 * s, s, s) { - pb.push_rect(rect); - } - if let Some(p) = pb.finish() { - pm.fill_path(&p, &paint(fill, 1.0), FillRule::Winding, Transform::identity(), None); - pm.stroke_path(&p, &paint(base.stroke.with_alpha(160), 1.0), &stroke, Transform::identity(), None); } } } @@ -89,9 +102,24 @@ pub(crate) fn extrude( px_per_m: f64, symbol: impl Fn(usize) -> Symbol, ) { + let shapes = shapes(ctx, ds, geoms, rows, x, px_per_m, symbol); + paint_shapes(pm, ctx, x, &shapes, true, true); +} + +type Shape = (f32, Vec>, f32, Symbol); + +fn shapes( + ctx: &Ctx, + ds: &Dataset, + geoms: &[Option<&Geometry>], + rows: &[bool], + x: &Extrusion, + px_per_m: f64, + symbol: impl Fn(usize) -> Symbol, +) -> Vec { let fi = x.field.as_deref().and_then(|f| ds.field_index(f)); // (base centre y, outer rings in pixels, height in pixels, row) - let mut shapes: Vec<(f32, Vec>, f32, usize)> = Vec::new(); + let mut shapes: Vec = Vec::new(); for (row, g) in geoms.iter().enumerate() { let Some(Geometry::Polygon(polys)) = g else { continue }; if rows.get(row) != Some(&true) { @@ -108,10 +136,21 @@ pub(crate) fn extrude( if rings.is_empty() { continue; } - let cy = rings.iter().flatten().map(|p| p.1).sum::() / rings.iter().map(Vec::len).sum::().max(1) as f32; - shapes.push((cy, rings, hp, row)); + let count = rings.iter().map(Vec::len).sum::().max(1); + let sum = rings.iter().flatten().map(|p| p.1).sum::(); + // Preserve ordinary painter order exactly; only widen an overflowing sum. + let cy = if sum.is_finite() { + sum / count as f32 + } else { + (rings.iter().flatten().map(|p| f64::from(p.1)).sum::() / count as f64) as f32 + }; + shapes.push((cy, rings, hp, symbol(row))); } shapes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + shapes +} + +fn paint_shapes(pm: &mut Pixmap, ctx: &Ctx, x: &Extrusion, shapes: &[Shape], shadows: bool, solids: bool) { let poly_path = |rings: &[Vec<(f32, f32)>], dx: f32, dy: f32| { let mut pb = PathBuilder::new(); for r in rings { @@ -131,10 +170,10 @@ pub(crate) fn extrude( pb.close(); pb.finish() }; - if x.shadows { + if x.shadows && shadows { // Light from the north-west casts shadows to the south-east. let shade = paint(Rgba::new(0, 0, 0, 70), 1.0); - for (_, rings, hp, _) in &shapes { + for (_, rings, hp, _) in shapes { let (sx, sy) = (hp * 0.7, hp * 0.35); for r in rings { for i in 0..r.len() { @@ -152,8 +191,10 @@ pub(crate) fn extrude( } let light = (-std::f32::consts::FRAC_1_SQRT_2, -std::f32::consts::FRAC_1_SQRT_2); let edge_stroke = Stroke { width: 0.35 * ctx.ppp, ..Stroke::default() }; - for (_, rings, hp, row) in &shapes { - let sym = symbol(*row); + if !solids { + return; + } + for (_, rings, hp, sym) in shapes { let base = sym.fill.with_alpha(255); let up = -hp; // Walls: the back ones first (when drawn), then the ones facing the viewer. @@ -200,6 +241,110 @@ pub(crate) fn extrude( } } +// Store float bits as integers so JSON cannot turn a non-finite height or symbol into null. +// This is a disposable internal payload, not a persisted file format. +#[cfg(not(target_arch = "wasm32"))] +type StoredShape = (u32, Vec>, u32, Rgba, Rgba, u32, u32, rustgis_map::Marker); + +#[cfg(not(target_arch = "wasm32"))] +fn encode_shape(shape: &Shape) -> rustgis_data::Result> { + let (cy, rings, hp, sym) = shape; + let stored: StoredShape = ( + cy.to_bits(), + rings.iter().map(|r| r.iter().map(|(x, y)| (x.to_bits(), y.to_bits())).collect()).collect(), + hp.to_bits(), + sym.fill, + sym.stroke, + sym.stroke_width.to_bits(), + sym.size.to_bits(), + sym.marker, + ); + serde_json::to_vec(&stored).map_err(|e| rustgis_data::DataError::Format(e.to_string())) +} + +#[cfg(not(target_arch = "wasm32"))] +fn decode_shape(bytes: &[u8]) -> rustgis_data::Result { + let (cy, rings, hp, fill, stroke, width, size, marker): StoredShape = + serde_json::from_slice(bytes).map_err(|e| rustgis_data::DataError::Format(e.to_string()))?; + Ok(( + f32::from_bits(cy), + rings.into_iter().map(|r| r.into_iter().map(|(x, y)| (f32::from_bits(x), f32::from_bits(y))).collect()).collect(), + f32::from_bits(hp), + Symbol { fill, stroke, stroke_width: f32::from_bits(width), size: f32::from_bits(size), marker }, + )) +} + +/// Global painter ordering and a separate shadow pass, without retaining every feature. +pub(crate) struct ExtrusionSpool { + #[cfg(not(target_arch = "wasm32"))] + disk: rustgis_data::source::spill::Ordered, + #[cfg(target_arch = "wasm32")] + shapes: Vec, +} +#[cfg(test)] +thread_local! { + pub(crate) static SPOOL_FAILURE: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +fn check_spool_failure(stage: &str) -> rustgis_data::Result<()> { + if SPOOL_FAILURE.with(|s| s.get() == Some(stage)) { + return Err(rustgis_data::DataError::Format(format!("injected spool {stage} failure"))); + } + Ok(()) +} + +impl ExtrusionSpool { + pub fn new() -> rustgis_data::Result { + #[cfg(test)] + check_spool_failure("create")?; + Ok(Self { + #[cfg(not(target_arch = "wasm32"))] + disk: rustgis_data::source::spill::Ordered::new()?, + #[cfg(target_arch = "wasm32")] + shapes: Vec::new(), + }) + } + pub fn add( + &mut self, + ctx: &Ctx, + ds: &Dataset, + geoms: &[Option<&Geometry>], + rows: &[bool], + x: &Extrusion, + px_per_m: f64, + symbol: impl Fn(usize) -> Symbol, + ) -> rustgis_data::Result<()> { + #[cfg(test)] + check_spool_failure("insert")?; + for shape in shapes(ctx, ds, geoms, rows, x, px_per_m, symbol) { + #[cfg(not(target_arch = "wasm32"))] + self.disk.insert(f64::from(shape.0), &encode_shape(&shape)?)?; + #[cfg(target_arch = "wasm32")] + self.shapes.push(shape); + } + Ok(()) + } + pub fn draw(&mut self, pm: &mut Pixmap, ctx: &Ctx, x: &Extrusion) -> rustgis_data::Result<()> { + #[cfg(test)] + check_spool_failure("draw")?; + #[cfg(not(target_arch = "wasm32"))] + for shadows in [true, false].into_iter().filter(|shadow| !*shadow || x.shadows) { + self.disk.visit(|bytes| { + let shape = decode_shape(bytes)?; + paint_shapes(pm, ctx, x, std::slice::from_ref(&shape), shadows, !shadows); + Ok(()) + })?; + } + #[cfg(target_arch = "wasm32")] + { + self.shapes.sort_by(|a, b| a.0.total_cmp(&b.0)); + paint_shapes(pm, ctx, x, &self.shapes, true, true); + } + Ok(()) + } +} + /// How high (metres) feature `row` of `ds` is extruded. pub(crate) fn extrusion_metres(ds: &Dataset, x: &Extrusion, row: usize) -> f64 { let fi = x.field.as_deref().and_then(|f| ds.field_index(f)); @@ -493,3 +638,48 @@ pub(crate) fn extrude_tilted( } } } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod spool_tests { + use super::*; + #[test] + fn spool_payload_preserves_all_float_bits_and_large_finite_centres() { + let symbol = Symbol { stroke_width: f32::NAN, size: f32::INFINITY, ..Symbol::default_for(rustgis_data::GeometryType::Polygon, 1) }; + let shape = (1.0, vec![vec![(f32::MAX, -0.0)]], f32::NAN, symbol); + let decoded = decode_shape(&encode_shape(&shape).unwrap()).unwrap(); + assert_eq!(decoded.0.to_bits(), shape.0.to_bits()); + assert_eq!(decoded.1[0][0].0.to_bits(), f32::MAX.to_bits()); + assert_eq!(decoded.1[0][0].1.to_bits(), (-0.0f32).to_bits()); + assert_eq!(decoded.2.to_bits(), shape.2.to_bits()); + assert_eq!(decoded.3.stroke_width.to_bits(), symbol.stroke_width.to_bits()); + assert_eq!(decoded.3.size.to_bits(), symbol.size.to_bits()); + let view = View { + width: 100.0, + height: 100.0, + pixels_per_point: 1.0, + camera: rustgis_map::Camera::new(rustgis_geom::Coord::new(0.0, 0.0), 1.0), + sky: false, + }; + let ctx = + Ctx { view: &view, ppp: 1.0, wireframe: false, cull: rustgis_geom::Extent::EMPTY, globe: None, sprites: Default::default() }; + let geom = Geometry::Polygon(vec![vec![vec![ + rustgis_geom::Coord::new(0.0, -2e38), + rustgis_geom::Coord::new(1.0, -2e38), + rustgis_geom::Coord::new(1.0, -2e38), + rustgis_geom::Coord::new(0.0, -2e38), + ]]]); + let ds = Dataset::new("large", rustgis_data::GeometryType::Polygon, vec![]); + let x = Extrusion { height: 1.0, field: None, factor: 1.0, face_culling: true, lighting: true, shadows: true }; + let got = shapes(&ctx, &ds, &[Some(&geom)], &[true], &x, 1.0, |_| symbol); + assert_eq!(got.len(), 1); + assert!(got[0].0.is_finite()); + let ordered = rustgis_data::source::spill::Ordered::new().unwrap(); + ordered.insert(f64::from(got[0].0), &encode_shape(&got[0]).unwrap()).unwrap(); + ordered + .visit(|bytes| { + decode_shape(bytes)?; + Ok(()) + }) + .unwrap(); + } +} diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index d1ce6b3eb..d9c424cdb 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -28,6 +28,28 @@ pub struct PageInfo { const MAX_SIDE: f64 = 12_000.0; const MAX_PIXELS: f64 = if cfg!(target_arch = "wasm32") { 25_000_000.0 } else { 60_000_000.0 }; +/// Whether skipping a failed frame would omit required complete-source content. +pub(crate) fn frame_requires_complete(map: &Map, view: &View) -> bool { + let mut scales = vec![map.ground_res_at(&view.camera) / rustgis_geom::PIXEL_M]; + if view.camera.pitch != 0.0 && !map.projection.is_globe() { + scales.extend(view.camera.tilt_bands(view.width, view.height).iter().map(|b| map.ground_res_at(&b.camera) / rustgis_geom::PIXEL_M)); + } + let complete = |source: &rustgis_map::source::SourceLayer| { + source.chunk_source().is_some() || (!source.is_open() && crate::is_local_shapefile(source)) + }; + map.layers.iter().filter(|l| scales.iter().any(|scale| map.draws_at(l, *scale))).any(|layer| { + layer.source.as_ref().is_some_and(complete) + || layer + .mask + .as_deref() + .and_then(|name| { + map.layers.iter().find(|m| m.name.eq_ignore_ascii_case(name) && m.geometry_type() == GeometryType::Polygon) + }) + .and_then(|mask| mask.source.as_ref()) + .is_some_and(complete) + }) +} + /// Renders a layout at `dpi` (dots per inch). `maps` are the project's maps, found by name. pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> Option { let ppp = (if dpi.is_finite() { dpi } else { 96.0 } / 96.0).clamp(0.25, 8.0); @@ -67,7 +89,7 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O let lonlat = crate::view_lonlat(map, &view); let scale = map.ground_res_at(&cam) / rustgis_geom::PIXEL_M; map.sync_sources(&lonlat, scale, true); - shown.push((*f, shown_legends(map, scale))); + shown.push((*f, complete_shown_legends(map, scale)?)); } } for e in layout.elements.iter().filter(|e| e.visible) { @@ -83,7 +105,13 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O // A frame larger than the page budget is drawn at a lower resolution and scaled up. let fppp = ppp.min((MAX_PIXELS / (fw * fh).max(1.0)).sqrt()).max(0.01); let view = View { width: fw, height: fh, pixels_per_point: fppp, camera: cam, sky: false }; - let Some(mut img) = crate::render_complete(map, &view) else { continue }; + let Some(mut img) = crate::render_complete(map, &view) else { + if frame_requires_complete(map, &view) { + return None; + } + // Preserve upstream's best-effort page export for ordinary maps. + continue; + }; for l in img.unplaced.iter().chain(&img.labels) { text::draw(&mut img.pixmap, l, fppp as f32); } @@ -751,6 +779,65 @@ fn shown_legends(map: &Map, scale: f64) -> Vec<(u32, Vec)> { .collect() } +/// Complete-source legends retain one minimal value per used class, never its geometry. +fn complete_shown_legends(map: &mut Map, scale: f64) -> Option)>> { + if !map.layers.iter().any(|l| l.source.as_ref().and_then(|s| s.chunk_source()).is_some()) { + return Some(shown_legends(map, scale)); + } + let wanted: Vec = + map.layers.iter().enumerate().filter(|(_, l)| map.draws_at(l, scale) && !l.graphics && !l.is_raster()).map(|(i, _)| i).collect(); + let mut out = Vec::new(); + for index in wanted { + let layer = map.layers.get_mut(index)?; + let Some(source) = layer.source.as_ref().and_then(|s| s.chunk_source()) else { + if let Some(entries) = layer.shown_legend(scale) { + out.push((layer.id, entries)); + } + continue; + }; + let unique = match &layer.renderer { + rustgis_map::Renderer::Unique { field, classes, .. } => Some((field, classes)), + _ => None, + }; + let fi = unique.and_then(|(field, _)| layer.dataset.field_index(field)); + let fields = unique + .filter(|_| fi.is_some()) + .map_or_else(Vec::new, |(field, _)| vec![rustgis_data::Field::new(field, rustgis_data::FieldType::Text)]); + let mut sample = rustgis_data::Dataset::new("legend", layer.geometry_type(), fields); + let mut used = std::collections::HashSet::new(); + let mut any = false; + let mut chunk = layer.clone(); + chunk.source = None; + chunk.clear_cache(); + let result = crate::visit_complete(source.as_ref(), &mut |features| { + chunk.dataset.features = features.into(); + let rows = chunk.drawn_rows(scale); + for row in rows.iter().enumerate().filter_map(|(row, keep)| keep.then_some(row)) { + any = true; + if let Some((_, classes)) = unique { + let value = fi.map(|fi| chunk.dataset.value(row, fi).to_string()); + let key = value.as_ref().and_then(|v| classes.iter().position(|(k, _)| k == v)).unwrap_or(classes.len()); + if used.insert(key) { + sample.push(None, value.map_or_else(Vec::new, |v| vec![rustgis_data::Value::Text(v)])); + } + } + } + chunk.dataset.features = Vec::new().into(); + Ok(()) + }); + if let Err(e) = result { + if let Some(source) = &mut layer.source { + source.error = Some(e.to_string()); + } + return None; + } + if any { + out.push((layer.id, layer.renderer.legend_for(&sample, &[]))); + } + } + Some(out) +} + /// What a legend leaves out: ``, and layers by name. struct LegendOptions<'a> { hide_other: bool, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 940c553e8..846976716 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1127,6 +1127,111 @@ fn render_with_backend(map: &mut Map, view: &View, full_res_rasters: bool, backe let mut camera = view.camera; camera.bearing = rustgis_map::camera::normalize_bearing(camera.bearing); camera.pitch = rustgis_map::camera::normalize_pitch(camera.pitch); + // A persisted Shapefile has no chunk handle until it opens. Preflight required local + // sources in every view so an open failure cannot become a successful incomplete export. + let complete_layers: std::collections::HashSet = { + let mut scales = vec![map.ground_res_at(&camera) / rustgis_geom::PIXEL_M]; + if camera.pitch != 0.0 && !map.projection.is_globe() { + scales.extend(camera.tilt_bands(view.width, view.height).iter().map(|b| map.ground_res_at(&b.camera) / rustgis_geom::PIXEL_M)); + } + map.layers + .iter() + .filter(|l| scales.iter().any(|scale| map.draws_at(l, *scale))) + .flat_map(|l| { + let mask = l + .mask + .as_ref() + .and_then(|name| { + map.layers.iter().find(|m| m.name.eq_ignore_ascii_case(name) && m.geometry_type() == GeometryType::Polygon) + }) + .map(|m| m.id); + std::iter::once(l.id).chain(mask) + }) + .collect() + }; + if !complete_layers.is_empty() { + for layer in &mut map.layers { + if !complete_layers.contains(&layer.id) { + continue; + } + if let Some(source) = &mut layer.source + && !source.is_open() + && is_local_shapefile(source) + { + if let Err(e) = source.open() { + source.error = Some(e); + return None; + } + if layer.dataset.fields.is_empty() + && let Some(info) = source.info() + { + layer.dataset.fields = info.fields.clone(); + } + } + } + } + let depth_scale = map.ground_res_at(&camera) / rustgis_geom::PIXEL_M; + // Perspective markers and extrusions have a global depth pass. Preserve that complete + // upstream path using an isolated full-source snapshot until it has a chunk-aware sorter. + // The owning interaction window, selection and editing provider are never replaced. + if camera.pitch != 0.0 + && !map.projection.is_globe() + && map.layers.iter().any(|l| needs_tilted_snapshot(map, l, &complete_layers, depth_scale)) + { + // Keep asynchronous windows and pending work on their owner, never a disposable copy. + let bands = camera.tilt_bands(view.width, view.height); + let data_pending = sync_tilted(map, &View { camera, ..*view }, &bands, full_res_rasters); + let mut snapshot = map.clone(); + let mut snapshot_budget = SnapshotBudget::new(complete_snapshot_limit()); + for layer in &mut snapshot.layers { + if !complete_layers.contains(&layer.id) || layer.source.as_ref().and_then(|s| s.chunk_source()).is_none() { + layer.source = None; + continue; + } + if !needs_tilted_snapshot(map, layer, &complete_layers, depth_scale) { + // Plain fills/lines keep their complete per-band traversal, outside the + // global depth-order snapshot and its feature-storage budget. + continue; + } + let Some(source) = layer.source.as_ref().and_then(|s| s.chunk_source()) else { continue }; + let ids: std::collections::HashSet = + layer.selection.iter().filter_map(|r| layer.dataset.features.get(*r).map(|f| f.id)).collect(); + let mut features = Vec::new(); + let result = visit_complete(source.as_ref(), &mut |chunk| snapshot_budget.append(&mut features, chunk)); + if let Err(e) = result { + if let Some(s) = map.layer_mut(layer.id).and_then(|l| l.source.as_mut()) { + s.error = Some(e.to_string()); + } + return None; + } + if let Some(source) = map.layer_mut(layer.id).and_then(|l| l.source.as_mut()) { + source.error = None; + } + layer.selection = features.iter().enumerate().filter_map(|(r, f)| ids.contains(&f.id).then_some(r)).collect(); + layer.source = None; + layer.dataset.features = features.into(); + layer.clear_cache(); + layer.touch_geometry(); + } + let view = View { camera, ..*view }; + // Owner synchronization has already run. Repeating it on the copy could consume a + // shared pending result before its owner installs it, even for a plain chunk source. + let mut output = if backend.is_gpu() { + gpu_turned(&mut snapshot, &view, full_res_rasters, true) + .map(RenderOutput::Gpu) + .or_else(|| render_tilted(&mut snapshot, &view, full_res_rasters, true).map(RenderOutput::Cpu)) + } else { + render_tilted(&mut snapshot, &view, full_res_rasters, true).map(RenderOutput::Cpu) + }; + map.adopt_caches(&snapshot); + if let Some(output) = &mut output { + match output { + RenderOutput::Cpu(rendered) => rendered.data_pending |= data_pending, + RenderOutput::Gpu(scene) => scene.data_pending |= data_pending, + } + } + return output; + } // The globe turns and tilts itself (`Globe`): one frame. if map.projection.is_globe() { let view = View { camera, ..*view }; @@ -1136,12 +1241,12 @@ fn render_with_backend(map: &mut Map, view: &View, full_res_rasters: bool, backe let view = View { camera, ..*view }; if backend.is_gpu() && (camera.pitch != 0.0 || camera.bearing != 0.0) - && let Some(scene) = gpu_turned(map, &view) + && let Some(scene) = gpu_turned(map, &view, full_res_rasters, false) { return Some(RenderOutput::Gpu(scene)); } if camera.pitch != 0.0 { - return render_tilted(map, &view, full_res_rasters).map(RenderOutput::Cpu); + return render_tilted(map, &view, full_res_rasters, false).map(RenderOutput::Cpu); } if camera.bearing != 0.0 { return render_turned(map, &view, full_res_rasters).map(RenderOutput::Cpu); @@ -1150,9 +1255,110 @@ fn render_with_backend(map: &mut Map, view: &View, full_res_rasters: bool, backe render_frame(map, view, full_res_rasters, backend, Labels::Own) } +/// Persisted local Shapefiles can require complete traversal before a handle is opened. +/// Converted, embedded, remote and database sources retain their own provider behavior. +pub(crate) fn is_local_shapefile(source: &rustgis_map::source::SourceLayer) -> bool { + source.embedded.is_none() + && source.gdal.is_none() + && source.duckdb.is_none() + && source.postgis.is_none() + && source.mysql.is_none() + && source.sqlserver.is_none() + && source.path.as_ref().is_some_and(|p| { + !rustgis_data::source::is_remote_name(p) && std::path::Path::new(p).extension().is_some_and(|e| e.eq_ignore_ascii_case("shp")) + }) +} + +/// Layers whose perspective painter needs complete globally depth-ordered features. +/// Ordinary fills/lines and their masks stay on the complete per-band chunk path. +fn needs_tilted_snapshot(map: &Map, layer: &rustgis_map::Layer, complete_layers: &std::collections::HashSet, scale: f64) -> bool { + complete_layers.contains(&layer.id) + && map.draws_at(layer, scale) + && layer.source.as_ref().and_then(|s| s.chunk_source()).is_some() + && (stands_up(map, layer) || (layer.geometry_type() == GeometryType::Polygon && layer.extrusion.is_some())) +} + +// Separate from the interaction budget: this limits retained complete-source feature storage +// across the perspective snapshot. Raster targets, derived caches and decoding scratch are extra. +const COMPLETE_SNAPSHOT_BYTES: usize = rustgis_map::source::VIEW_BYTES * 4; +/// Owned-storage target for a complete traversal; distinct from interaction and depth budgets. +const COMPLETE_CHUNK_BYTES: usize = 8 * 1024 * 1024; + +#[cfg(test)] +thread_local! { + static SNAPSHOT_TEST_LIMIT: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +fn complete_snapshot_limit() -> usize { + #[cfg(test)] + if let Some(limit) = SNAPSHOT_TEST_LIMIT.with(|s| s.get()) { + return limit; + } + COMPLETE_SNAPSHOT_BYTES +} + +/// Account owned rows and actual vector capacity across all materialized chunk sources. +/// Exceeding the budget fails the entire frame; no source is sampled or truncated. +struct SnapshotBudget { + limit: usize, + used: usize, +} + +impl SnapshotBudget { + fn new(limit: usize) -> Self { + Self { limit, used: 0 } + } + + fn append(&mut self, features: &mut Vec, mut chunk: Vec) -> rustgis_data::Result<()> { + let bad = + || rustgis_data::DataError::Format(format!("complete perspective snapshot exceeds its {} byte feature budget", self.limit)); + let row_size = std::mem::size_of::(); + let heap = chunk.iter().fold(0usize, |n, f| n.saturating_add(rustgis_data::source::chunk_memory::feature_bytes(f))); + let needed = features.len().checked_add(chunk.len()).ok_or_else(bad)?; + let old_capacity = features.capacity(); + let extra = needed.saturating_sub(old_capacity).saturating_mul(row_size); + if self.used.saturating_add(heap).saturating_add(extra) > self.limit { + return Err(bad()); + } + features + .try_reserve_exact(chunk.len()) + .map_err(|e| rustgis_data::DataError::Format(format!("complete perspective snapshot: {e}")))?; + let extra = features.capacity().saturating_sub(old_capacity).saturating_mul(row_size); + let used = self.used.saturating_add(heap).saturating_add(extra); + if used > self.limit { + return Err(bad()); + } + self.used = used; + features.append(&mut chunk); + Ok(()) + } +} + +/// A capable source must explicitly finish the traversal; an incomplete summary must never +/// publish an apparently successful frame, even if it already delivered some chunks. +fn visit_complete( + source: &dyn rustgis_data::source::FeatureSource, + visit: &mut dyn FnMut(Vec) -> rustgis_data::Result<()>, +) -> rustgis_data::Result { + let result = source.visit_chunks(None, COMPLETE_CHUNK_BYTES, visit)?; + if !result.complete { + return Err(rustgis_data::DataError::Format("source traversal was incomplete".into())); + } + Ok(result) +} + /// A label waiting to be placed: the label, its priority and its (layer id, row). type Candidate = (PlacedLabel, f64, (u32, usize)); +/// Keep exactly the candidates the upstream placement limit can consider, with stable ties. +fn compact_label_candidates(candidates: &mut Vec) { + if candidates.len() > 40_000 { + let rank = |p: f64| if p.is_nan() { f64::NEG_INFINITY } else { p }; + candidates.sort_by(|a, b| rank(b.1).total_cmp(&rank(a.1))); + candidates.truncate(20_000); + } +} + /// Where a frame's labels go. enum Labels<'a> { /// Placed in the frame. @@ -1174,14 +1380,14 @@ const LABEL_MIN_DEPTH_SCALE: f64 = 0.3; /// ([`Camera::tilt_bands`]), sharp near and coarser toward the horizon (so far tiles come at /// lower zoom), then each screen pixel takes its colour from the band under it, fading into fog /// and the sky past the view's reach. Labels are placed in the tilted view, upright. -fn render_tilted(map: &mut Map, view: &View, full_res_rasters: bool) -> Option { +fn render_tilted(map: &mut Map, view: &View, full_res_rasters: bool, sources_ready: bool) -> Option { let bands_of = view.camera.tilt_bands(view.width, view.height); - let data_pending = sync_tilted(map, view, &bands_of, full_res_rasters); + let data_pending = !sources_ready && sync_tilted(map, view, &bands_of, full_res_rasters); // Labels are placed in the tilted view: a lock on a flat view's labels doesn't apply. - let lock = map.label_lock.take(); let mut candidates = Vec::new(); let (w, h) = view.pixel_size(); let mut pm = Pixmap::new(w, h)?; + let lock = map.label_lock.take(); let mut warp = Warp::new(view, map); // On terrain the bands are kept, to lay over the ground's relief once all are drawn: every // triangle of every band is then ordered far to near together. That holds all the bands at @@ -1193,27 +1399,47 @@ fn render_tilted(map: &mut Map, view: &View, full_res_rasters: bool) -> Option::new(), false, Vec::::new()); for (i, b) in bands_of.into_iter().enumerate() { let v = band_view(view, &b); - if let Some(RenderOutput::Cpu(r)) = render_frame(map, &v, full_res_rasters, Backend::Cpu, Labels::Tilted(view, &mut candidates)) { - for a in r.attribution { - if !attribution.contains(&a) { - attribution.push(a); - } + let Some(RenderOutput::Cpu(r)) = render_frame(map, &v, full_res_rasters, Backend::Cpu, Labels::Tilted(view, &mut candidates)) + else { + let scale = map.ground_res_at(&v.camera) / rustgis_geom::PIXEL_M; + let complete = sources_ready + || map.layers.iter().filter(|l| map.draws_at(l, scale)).any(|l| { + l.source.as_ref().and_then(|s| s.chunk_source()).is_some() + || l.mask + .as_deref() + .and_then(|name| { + map.layers.iter().find(|m| m.name.eq_ignore_ascii_case(name) && m.geometry_type() == GeometryType::Polygon) + }) + .and_then(|m| m.source.as_ref()) + .and_then(|s| s.chunk_source()) + .is_some() + }); + if complete { + map.label_lock = lock; + return None; } - for e in r.tile_errors { - if !tile_errors.contains(&e) { - tile_errors.push(e); - } + // Preserve the upstream best-effort band path for ordinary providers. + continue; + }; + for a in r.attribution { + if !attribution.contains(&a) { + attribution.push(a); } - tiles_pending |= r.tiles_pending; - if ground.is_some() { - // The nearest band reaches the bottom of the view, the farthest the sky. - let depth = (if i == 0 { 0.0 } else { b.depth.0 }, if i == last { f64::INFINITY } else { b.depth.1 }); - kept.push((v, depth, r.pixmap)); - } else { - // Into the view at once, nearest band first, so only one band is held at a time. - warp.band(&mut pm, &v, &r.pixmap, view.camera.depth_y(b.depth.1, view.height), view.camera.depth_y(b.depth.0, view.height)); + } + for e in r.tile_errors { + if !tile_errors.contains(&e) { + tile_errors.push(e); } } + tiles_pending |= r.tiles_pending; + if ground.is_some() { + // The nearest band reaches the bottom of the view, the farthest the sky. + let depth = (if i == 0 { 0.0 } else { b.depth.0 }, if i == last { f64::INFINITY } else { b.depth.1 }); + kept.push((v, depth, r.pixmap)); + } else { + // Into the view at once, nearest band first, so only one band is held at a time. + warp.band(&mut pm, &v, &r.pixmap, view.camera.depth_y(b.depth.1, view.height), view.camera.depth_y(b.depth.0, view.height)); + } } map.label_lock = lock; match &ground { @@ -1381,7 +1607,7 @@ fn lift_labels(view: &View, ground: &terrain::Ground, candidates: &mut [Candidat /// [`render_tilted`] warps them), with point markers upright over them and labels placed in /// the view. `None` when the view needs pictures drawn on the CPU (tiles, rasters, layers drawn /// as pictures, icons or extrusions), or the scene isn't plain: the caller draws it on the CPU. -fn gpu_turned(map: &mut Map, view: &View) -> Option { +fn gpu_turned(map: &mut Map, view: &View, full_res_rasters: bool, sources_ready: bool) -> Option { use gpu_scene::{CmdClip, DrawCmd, GpuScene, SceneTilt}; if !gpu_may_turn(map, view) { return None; @@ -1392,14 +1618,16 @@ fn gpu_turned(map: &mut Map, view: &View) -> Option { if view.camera.pitch == 0.0 { let (bw, bh) = view.camera.rotated_size(view.width, view.height); let north = View { width: bw.ceil() + 2.0, height: bh.ceil() + 2.0, camera: Camera { bearing: 0.0, ..view.camera }, ..*view }; - let Some(RenderOutput::Gpu(scene)) = render_frame(map, &north, false, Backend::Gpu, Labels::Turned(view)) else { return None }; + let Some(RenderOutput::Gpu(scene)) = render_frame(map, &north, full_res_rasters, Backend::Gpu, Labels::Turned(view)) else { + return None; + }; if pictures(&scene.cmds) || scene.labels.iter().any(|l| l.icon.is_some()) { return None; } return Some(GpuScene { tilt, ..scene }); } let bands = view.camera.tilt_bands(view.width, view.height); - let data_pending = sync_tilted(map, view, &bands, false); + let data_pending = !sources_ready && sync_tilted(map, view, &bands, full_res_rasters); let lock = map.label_lock.take(); let mut candidates = Vec::new(); let (mut cmds, mut clips) = (Vec::new(), Vec::new()); @@ -1408,7 +1636,8 @@ fn gpu_turned(map: &mut Map, view: &View) -> Option { let last = bands.len().saturating_sub(1); for (i, b) in bands.iter().enumerate() { let v = band_view(view, b); - let Some(RenderOutput::Gpu(scene)) = render_frame(map, &v, false, Backend::Gpu, Labels::Tilted(view, &mut candidates)) else { + let Some(RenderOutput::Gpu(scene)) = render_frame(map, &v, full_res_rasters, Backend::Gpu, Labels::Tilted(view, &mut candidates)) + else { plain = false; break; }; @@ -1763,6 +1992,12 @@ const UPRIGHT_SCALE: (f64, f64) = (0.25, 2.0); /// MapLibre's circles do), rather than drawing them flat on the ground. A masked, swiped or /// blended layer, an aggregation or graphics keep theirs on the ground. fn stands_up(map: &Map, layer: &rustgis_map::Layer) -> bool { + // Complete sources must use their total, never the pan-dependent interaction sample. + // An unknown total conservatively uses complete ground-marker traversal. + let rows = match layer.source.as_ref().and_then(|s| s.chunk_source()) { + Some(source) => source.info().count, + None => Some(layer.dataset.features.len() as u64), + }; layer.raster.is_none() && layer.geometry_type() == GeometryType::Point && layer.aggregation.is_none() @@ -1770,7 +2005,7 @@ fn stands_up(map: &Map, layer: &rustgis_map::Layer) -> bool { && layer.mask.is_none() && layer.blend == rustgis_map::effects::Blend::Normal && map.swipe.is_none_or(|(id, _)| id != layer.id) - && layer.dataset.features.len() <= MAX_UPRIGHT_POINTS + && rows.is_some_and(|n| n <= MAX_UPRIGHT_POINTS as u64) } /// A point marker standing upright in a tilted view, facing the viewer. @@ -2306,7 +2541,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let tokens = rustgis_map::graphics::TokenValues::of(map, scale); let n_layers = map.layers.len(); // Masks: layers shown only inside another layer's polygons, or left of the swipe divider. - let masks = layer_masks(map, &ctx, w, h, &flat, &key); + let masks = layer_masks(map, &ctx, w, h, &flat, &key, scale)?; // Bottom first; a higher depth priority draws later (on top) whatever the order. let mut order: Vec = (0..n_layers).rev().collect(); order.sort_by_key(|i| map.layers.get(*i).map_or(0, |l| l.priority)); @@ -2362,246 +2597,394 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } continue; } - // The definition query and the display filters that apply at this scale (a feature draws - // only if it matches all of them). - let mut rows = layer.drawn_rows(scale); - // View clipping: features entirely beyond the distance from the view's centre are left - // out; one reaching into it (its box's nearest point within the distance) still draws. - if let Some((centre, d)) = clip { - for (r, keep) in rows.iter_mut().enumerate() { - if *keep { - let e = layer.dataset.features.get(r).and_then(|f| f.geometry.as_ref()).map(|g| g.extent()).filter(|e| !e.is_empty()); - *keep = e.is_some_and(|e| { - let near = Coord::new(centre.x.clamp(e.min_x, e.max_x), centre.y.clamp(e.min_y, e.max_y)); - rustgis_geom::haversine_m(centre, near) <= d - }); + let source = layer.source.as_ref().and_then(|s| s.chunk_source()); + let complete_source = source.is_some(); + // LOD is chosen for the entire layer, not separately for each chunk. Both the + // activation threshold and the required saving must match an ordinary whole read. + let thin_tol = view.camera.res / view.pixels_per_point.max(0.01) * rustgis_map::lod::THIN_PX; + let mut thin_complete = false; + if globe.is_none() + && layer.geometry_type() != GeometryType::Point + && let (Some(source), Some(level)) = (&source, rustgis_map::lod::level(thin_tol)) + { + let (mut eligible, mut all, mut kept) = (0usize, 0usize, 0usize); + let result = visit_complete(source.as_ref(), &mut |features| { + for feature in features { + if let Some(g) = feature.geometry { + let g = flat.project(&g); + eligible = eligible.saturating_add(rustgis_map::lod::thinnable(&g)); + all = all.saturating_add(rustgis_map::lod::vertices(&g)); + kept = kept + .saturating_add(rustgis_map::lod::vertices(&rustgis_map::lod::thin(&g, rustgis_map::lod::tolerance(level)))); + } } + Ok(()) + }); + if let Err(e) = result { + if let Some(s) = &mut layer.source { + s.error = Some(e.to_string()); + } + return None; } + thin_complete = eligible > rustgis_map::lod::THIN_ABOVE && kept as f64 <= all as f64 * (1.0 - rustgis_map::lod::MIN_SAVING); } - // Drawn from a copy thinned to under a third of a pixel: a detailed layer seen whole - // otherwise walks many vertices per pixel on every frame. - let thin_tol = view.camera.res / view.pixels_per_point.max(0.01) * rustgis_map::lod::THIN_PX; - if globe.is_none() { - layer.thinned_in(&flat, &key, thin_tol); // refresh the projected cache and its thinned copy - } - // Everything below only reads the layer: borrow rather than copy its data each frame. - let layer: &rustgis_map::Layer = layer; - let (labels, selection, ds_ptr) = (&layer.labels, &layer.selection, &layer.dataset); - // The globe draws the lon/lat geometry itself; the flat map its Web Mercator copy. - let geoms: Vec> = if globe.is_some() { - ds_ptr.features.iter().map(|f| f.geometry.as_ref()).collect() - } else { - layer.projected_cache().iter().map(Option::as_ref).collect() - }; - // What the features draw from, and their extents for culling (on the flat map, cached). - // Only the fills and strokes below draw thinned: the selection, labels, aggregation and - // extrusion keep `geoms`, the full geometry, for their exact positions. - let drawn: Vec> = - if globe.is_some() { geoms.clone() } else { layer.thinned_cache(thin_tol).iter().map(Option::as_ref).collect() }; - let extents = if globe.is_some() { &[][..] } else { layer.projected_extents() }; + let mut target = if offscreen { Pixmap::new(w, h)? } else { Pixmap::new(1, 1)? }; let direct = !offscreen; - // GPU-eligible: a plain vector layer drawn at full opacity with no mask, blend, - // aggregation, extrusion or per-row graphics (an inert aggregation/extrusion on the - // "wrong" geometry type still falls back — conservative and correct). - // Stacked line strokes are offset in screen space, which the GPU meshes don't do. - let gpu_layer = gpu - && !offscreen - && layer.aggregation.is_none() - && layer.extrusion.is_none() - && !layer.graphics - && layer.line_strokes.is_empty() - && !upright.contains(&layer.id); - // Lyon refuses degenerate geometry tiny-skia draws fine: collect_layer reports it, - // and the layer runs the CPU branch below like any ineligible one. - let mut gpu_done = false; - if gpu_layer { - flush_acc(&mut pm, &mut cmds, &mut cpu_dirty)?; - gpu_done = mesh::collect_layer(&mut cmds, layer, &geoms, &rows, &mesh_visible, ctx.wireframe, ctx.ppp, map_per_px).is_ok(); - } - // False for a layer drawn elsewhere: nothing goes into the picture for it. - let mut drawn_here = true; - if !gpu_done { - let dest = if direct { &mut pm } else { &mut target }; - let aggregate = layer.aggregation.filter(|_| layer.geometry_type() == GeometryType::Point); - let extrude = layer.extrusion.as_ref().filter(|_| layer.geometry_type() == GeometryType::Polygon && globe.is_none()); - if let Some(a) = aggregate { - effects::aggregate(dest, &ctx, &geoms, &rows, a, &layer.symbol(0)); - } else if extrude.is_some() && matches!(labels_to, Labels::Tilted(..)) { - // Stood up in perspective over the tilted view (`render_tilted`); its labels still - // come from here. - drawn_here = false; - } else if upright.contains(&layer.id) { - // Stood up facing the viewer over the tilted view (`upright_markers`); its labels - // still come from here. - drawn_here = false; - } else if let Some(x) = extrude.filter(|_| !matches!(labels_to, Labels::Shared)) { - let px_per_m = view.pixels_per_point / (map_ground_res(view, &flat) * body); - effects::extrude(dest, &ctx, ds_ptr, &geoms, &rows, x, px_per_m, |r| layer.symbol(r)); - } else { - for (row, g) in drawn.iter().enumerate() { - let Some(g) = g else { continue }; - if rows.get(row) != Some(&true) || !visible(&extents.get(row).copied().unwrap_or_else(|| g.extent())) { - continue; + let mut bins = effects::AggregationBins::default(); + let mut aggregate_symbol = None; + // Only selected geometry is retained; paint directly after the whole layer to avoid + // a second full-size canvas and transparent-intermediate rounding differences. + let selected_order: std::collections::HashMap = if complete_source { + layer.selection.iter().enumerate().filter_map(|(rank, row)| layer.dataset.features.get(*row).map(|f| (f.id, rank))).collect() + } else { + Default::default() + }; + let mut highlights: Vec<(usize, Geometry, Symbol)> = Vec::new(); + let mut extrusions = if complete_source + && layer.geometry_type() == GeometryType::Polygon + && layer.extrusion.is_some() + && globe.is_none() + && !matches!(labels_to, Labels::Tilted(..) | Labels::Shared) + { + match effects::ExtrusionSpool::new() { + Ok(spool) => Some(spool), + Err(e) => { + if let Some(source) = &mut layer.source { + source.error = Some(e.to_string()); } - // A picture draws in its row's turn, so graphics keep their order. (On the globe - // the geometry and `px` are both longitude/latitude, so the corners land right.) - if layer.graphics && rustgis_map::graphics::kind(ds_ptr, row) == "picture" { - let e = g.extent(); - let (x0, y0) = ctx.px(Coord::new(e.min_x, e.max_y)); - let (x1, y1) = ctx.px(Coord::new(e.max_x, e.min_y)); - let (pw, ph) = (dest.width() as f32, dest.height() as f32); - if x0.max(x1) >= 0.0 && x0.min(x1) <= pw && y0.max(y1) >= 0.0 && y0.min(y1) <= ph { - picture::draw(dest, ds_ptr, row, (x0, y0, x1, y1), if direct { opacity } else { 1.0 }); - } - continue; + return None; + } + } + } else { + None + }; + let mut draw_chunk = |layer: &mut rustgis_map::Layer| -> rustgis_data::Result<()> { + // The definition query and the display filters that apply at this scale (a feature draws + // only if it matches all of them). + let mut rows = layer.drawn_rows(scale); + // View clipping: features entirely beyond the distance from the view's centre are left + // out; one reaching into it (its box's nearest point within the distance) still draws. + if let Some((centre, d)) = clip { + for (r, keep) in rows.iter_mut().enumerate() { + if *keep { + let e = + layer.dataset.features.get(r).and_then(|f| f.geometry.as_ref()).map(|g| g.extent()).filter(|e| !e.is_empty()); + *keep = e.is_some_and(|e| { + let near = Coord::new(centre.x.clamp(e.min_x, e.max_x), centre.y.clamp(e.min_y, e.max_y)); + rustgis_geom::haversine_m(centre, near) <= d + }); } - let sym = layer.symbol(row); - // A feature under a pixel across: blend its coverage into the pixels it touches - // (a dense layer seen whole is mostly these, and a path each is slow). - // Not stacked strokes: a splat would draw them unshifted, in one colour. - if globe.is_none() - && !layer.graphics - && layer.line_strokes.is_empty() - && let Some(e) = extents.get(row) - && ctx.splat(dest, e, g, &sym) - { - continue; + } + } + // Drawn from a copy thinned to under a third of a pixel: a detailed layer seen whole + // otherwise walks many vertices per pixel on every frame. + if globe.is_none() { + if complete_source { + layer.projected_in(&flat, &key); + } else { + layer.thinned_in(&flat, &key, thin_tol); + } + } + // Everything below only reads the layer: borrow rather than copy its data each frame. + let layer: &rustgis_map::Layer = layer; + let label_row = |row: usize| if complete_source { layer.dataset.features.get(row).map_or(row, |f| f.id as usize) } else { row }; + let (labels, selection, ds_ptr) = (&layer.labels, &layer.selection, &layer.dataset); + // The globe draws the lon/lat geometry itself; the flat map its Web Mercator copy. + let geoms: Vec> = if globe.is_some() { + ds_ptr.features.iter().map(|f| f.geometry.as_ref()).collect() + } else { + layer.projected_cache().iter().map(Option::as_ref).collect() + }; + // What the features draw from, and their extents for culling (on the flat map, cached). + // Only the fills and strokes below draw thinned: the selection, labels, aggregation and + // extrusion keep `geoms`, the full geometry, for their exact positions. + let chunk_thinned: Vec> = if thin_complete { + let tolerance = rustgis_map::lod::level(thin_tol).map_or(thin_tol, rustgis_map::lod::tolerance); + geoms.iter().map(|g| g.map(|g| rustgis_map::lod::thin(g, tolerance))).collect() + } else { + Vec::new() + }; + let drawn: Vec> = if globe.is_some() { + geoms.clone() + } else if complete_source { + if thin_complete { chunk_thinned.iter().map(Option::as_ref).collect() } else { geoms.clone() } + } else { + layer.thinned_cache(thin_tol).iter().map(Option::as_ref).collect() + }; + let extents = if globe.is_some() { &[][..] } else { layer.projected_extents() }; + // GPU-eligible: a plain vector layer drawn at full opacity with no mask, blend, + // aggregation, extrusion or per-row graphics (an inert aggregation/extrusion on the + // "wrong" geometry type still falls back — conservative and correct). + // Stacked line strokes are offset in screen space, which the GPU meshes don't do. + let gpu_layer = gpu + && !offscreen + && layer.aggregation.is_none() + && layer.extrusion.is_none() + && !layer.graphics + && layer.line_strokes.is_empty() + && !upright.contains(&layer.id); + // Lyon refuses degenerate geometry tiny-skia draws fine: collect_layer reports it, + // and the layer runs the CPU branch below like any ineligible one. + let mut gpu_done = false; + if gpu_layer { + flush_acc(&mut pm, &mut cmds, &mut cpu_dirty) + .ok_or_else(|| rustgis_data::DataError::Format("cannot allocate render target".into()))?; + gpu_done = mesh::collect_layer(&mut cmds, layer, &geoms, &rows, &mesh_visible, ctx.wireframe, ctx.ppp, map_per_px).is_ok(); + } + // False for a layer drawn elsewhere: nothing goes into the picture for it. + let mut drawn_here = true; + if !gpu_done { + let dest = if direct { &mut pm } else { &mut target }; + let aggregate = layer.aggregation.filter(|_| layer.geometry_type() == GeometryType::Point); + let extrude = layer.extrusion.as_ref().filter(|_| layer.geometry_type() == GeometryType::Polygon && globe.is_none()); + if let Some(a) = aggregate { + bins.add((w, h), &ctx, &geoms, &rows, a); + aggregate_symbol.get_or_insert_with(|| layer.symbol(0)); + } else if extrude.is_some() && matches!(labels_to, Labels::Tilted(..)) { + // Stood up in perspective over the tilted view (`render_tilted`); its labels still + // come from here. + drawn_here = false; + } else if upright.contains(&layer.id) { + // Stood up facing the viewer over the tilted view (`upright_markers`); its labels + // still come from here. + drawn_here = false; + } else if let Some(x) = extrude.filter(|_| !matches!(labels_to, Labels::Shared)) { + let px_per_m = view.pixels_per_point / (map_ground_res(view, &flat) * body); + if let Some(spool) = &mut extrusions { + spool.add(&ctx, ds_ptr, &geoms, &rows, x, px_per_m, |r| layer.symbol(r))?; + } else { + effects::extrude(dest, &ctx, ds_ptr, &geoms, &rows, x, px_per_m, |r| layer.symbol(r)); } - match g { - Geometry::Line(parts) if !layer.line_strokes.is_empty() && !layer.graphics => { - ctx.stacked_line(dest, parts, &sym, &layer.line_strokes) + } else { + for (row, g) in drawn.iter().enumerate() { + let Some(g) = g else { continue }; + if rows.get(row) != Some(&true) || !visible(&extents.get(row).copied().unwrap_or_else(|| g.extent())) { + continue; + } + // A picture draws in its row's turn, so graphics keep their order. (On the globe + // the geometry and `px` are both longitude/latitude, so the corners land right.) + if layer.graphics && rustgis_map::graphics::kind(ds_ptr, row) == "picture" { + let e = g.extent(); + let (x0, y0) = ctx.px(Coord::new(e.min_x, e.max_y)); + let (x1, y1) = ctx.px(Coord::new(e.max_x, e.min_y)); + let (pw, ph) = (dest.width() as f32, dest.height() as f32); + if x0.max(x1) >= 0.0 && x0.min(x1) <= pw && y0.max(y1) >= 0.0 && y0.min(y1) <= ph { + picture::draw(dest, ds_ptr, row, (x0, y0, x1, y1), if direct { opacity } else { 1.0 }); + } + continue; + } + let sym = layer.symbol(row); + // A feature under a pixel across: blend its coverage into the pixels it touches + // (a dense layer seen whole is mostly these, and a path each is slow). + // Not stacked strokes: a splat would draw them unshifted, in one colour. + if globe.is_none() + && !layer.graphics + && layer.line_strokes.is_empty() + && let Some(e) = extents.get(row) + && ctx.splat(dest, e, g, &sym) + { + continue; + } + match g { + Geometry::Line(parts) if !layer.line_strokes.is_empty() && !layer.graphics => { + ctx.stacked_line(dest, parts, &sym, &layer.line_strokes) + } + _ => ctx.feature(dest, g, &sym), } - _ => ctx.feature(dest, g, &sym), } } + cpu_dirty |= drawn_here; } - cpu_dirty |= drawn_here; - } - if !direct { - composite(&mut pm, &target); - } - // An upright marker shows its own selection. - for row in selection.iter().filter(|_| !upright.contains(&layer.id)) { - if let Some(Some(g)) = geoms.get(*row) - && rows.get(*row) == Some(&true) - { - // A text graphic's point is invisible: its label carries the frame (`framed`), - // drawn with the text wherever that lands (turned, tilted or on the globe). - if layer.graphics && rustgis_map::graphics::label(ds_ptr, *row).is_some() { - continue; + if !complete_source { + if let (Some(a), Some(base)) = (layer.aggregation, aggregate_symbol) { + bins.draw(if direct { &mut pm } else { &mut target }, &ctx, a, &base); } - // Follow the layer's own backend: when it fell back to the CPU pixmap, the - // highlight must too — tessellated here, the layer's later-flushed Image would - // draw over it (the CPU path draws highlights over the layer). - if gpu_done && mesh::collect_highlight(&mut cmds, g, &layer.symbol(*row), ctx.ppp, map_per_px).is_ok() { - // highlight tessellated into the scene, right after its layer - } else { - ctx.highlight(&mut pm, g, &layer.symbol(*row), if layer.graphics { &[] } else { &layer.line_strokes }); - cpu_dirty = true; + if !direct { + composite(&mut pm, &target); } } - } - if layer.graphics { - for (row, g) in geoms.iter().enumerate() { - let Some(g) = g else { continue }; - if rows.get(row) != Some(&true) { - continue; - } - if let (Some(t), Some(c)) = (rustgis_map::graphics::label(ds_ptr, row), g.label_point()) - && opacity > 0.0 - && let Some((x, y)) = ctx.screen(c) + // An upright marker shows its own selection. + for row in selection.iter().filter(|_| !upright.contains(&layer.id)) { + if let Some(Some(g)) = geoms.get(*row) + && rows.get(*row) == Some(&true) { - let base = rustgis_map::graphics::text_color(ds_ptr, row); - let color = base.with_alpha((base.a() as f32 * opacity).round() as u8); - fixed_labels.push(PlacedLabel { - text: rustgis_map::graphics::expand(t, &tokens), - x: x as f32, - y: y as f32, - size: rustgis_map::graphics::font_size(ds_ptr, row), - color, - halo: Some(halo_for(color)), - bold: rustgis_map::graphics::bold(ds_ptr, row), - icon: None, - align: 0.0, - framed: selection.contains(&row), - italic: rustgis_map::graphics::italic(ds_ptr, row), - spacing: rustgis_map::graphics::letter_spacing(ds_ptr, row), - line_align: rustgis_map::graphics::align(ds_ptr, row), - angle: None, - collide: Default::default(), - }); + // A text graphic's point is invisible: its label carries the frame (`framed`), + // drawn with the text wherever that lands (turned, tilted or on the globe). + if layer.graphics && rustgis_map::graphics::label(ds_ptr, *row).is_some() { + continue; + } + // Follow the layer's own backend: when it fell back to the CPU pixmap, the + // highlight must too — tessellated here, the layer's later-flushed Image would + // draw over it (the CPU path draws highlights over the layer). + if complete_source { + let rank = ds_ptr.features.get(*row).and_then(|f| selected_order.get(&f.id)).copied().unwrap_or(usize::MAX); + highlights.push((rank, (**g).clone(), layer.symbol(*row))); + } else if gpu_done && mesh::collect_highlight(&mut cmds, g, &layer.symbol(*row), ctx.ppp, map_per_px).is_ok() { + // highlight tessellated into the scene, right after its layer + } else { + ctx.highlight(&mut pm, g, &layer.symbol(*row), if layer.graphics { &[] } else { &layer.line_strokes }); + cpu_dirty = true; + } } } - } - // Each feature is labelled by the first label class whose clause takes it, Default last; - // that class draws it when shown, in range and on a field the layer has (#618). - let label_fields: Vec> = if labels.enabled && !display.paused { - labels.in_order().map(|c| ds_ptr.field_index(&c.field).filter(|_| c.visible && c.in_range(scale, distance))).collect() - } else { - Vec::new() - }; - if label_fields.iter().any(Option::is_some) { - let classes = rustgis_map::LabelMatcher::new(labels, ds_ptr); - let lid = layer.id; - for (row, g) in geoms.iter().enumerate() { - let Some(g) = g else { continue }; - if rows.get(row) != Some(&true) || lock.as_ref().is_some_and(|k| !k.contains(&(lid, row))) { - continue; + if layer.graphics { + for (row, g) in geoms.iter().enumerate() { + let Some(g) = g else { continue }; + if rows.get(row) != Some(&true) { + continue; + } + if let (Some(t), Some(c)) = (rustgis_map::graphics::label(ds_ptr, row), g.label_point()) + && opacity > 0.0 + && let Some((x, y)) = ctx.screen(c) + { + let base = rustgis_map::graphics::text_color(ds_ptr, row); + let color = base.with_alpha((base.a() as f32 * opacity).round() as u8); + fixed_labels.push(PlacedLabel { + text: rustgis_map::graphics::expand(t, &tokens), + x: x as f32, + y: y as f32, + size: rustgis_map::graphics::font_size(ds_ptr, row), + color, + halo: Some(halo_for(color)), + bold: rustgis_map::graphics::bold(ds_ptr, row), + icon: None, + align: 0.0, + framed: selection.contains(&row), + italic: rustgis_map::graphics::italic(ds_ptr, row), + spacing: rustgis_map::graphics::letter_spacing(ds_ptr, row), + line_align: rustgis_map::graphics::align(ds_ptr, row), + angle: None, + collide: Default::default(), + }); + } } - let Some(c) = g.label_point() else { continue }; - if globe.is_none() && !ctx.cull.contains(c) { - continue; + } + // Each feature is labelled by the first label class whose clause takes it, Default last; + // that class draws it when shown, in range and on a field the layer has (#618). + let label_fields: Vec> = if labels.enabled && !display.paused { + labels.in_order().map(|c| ds_ptr.field_index(&c.field).filter(|_| c.visible && c.in_range(scale, distance))).collect() + } else { + Vec::new() + }; + if label_fields.iter().any(Option::is_some) { + let classes = rustgis_map::LabelMatcher::new(labels, ds_ptr); + let lid = layer.id; + for (row, g) in geoms.iter().enumerate() { + let Some(g) = g else { continue }; + if rows.get(row) != Some(&true) || lock.as_ref().is_some_and(|k| !k.contains(&(lid, label_row(row)))) { + continue; + } + let Some(c) = g.label_point() else { continue }; + if globe.is_none() && !ctx.cull.contains(c) { + continue; + } + let Some((x, y)) = ctx.screen(c) else { continue }; + // The clauses are read for the features in view only. + let Some((ci, class)) = classes.class_of(ds_ptr, row) else { continue }; + let Some(fi) = label_fields.get(ci).copied().flatten() else { continue }; + let v = ds_ptr.value(row, fi); + if v.is_null() { + continue; + } + let text = v.to_string(); + let is_point = g.kind() == GeometryType::Point; + let marker = if is_point { layer.symbol(row).size } else { 0.0 }; + let (dx, dy) = class.placement.offset( + text::block_width(&text, class.size, class.bold), + text::block_height(&text, class.size), + marker, + is_point, + ); + // Bigger polygons get priority. + let pri = match g { + Geometry::Polygon(_) => { + let e = g.extent(); + // (An extent of no number counts as none.) + let a = (e.width() * e.height()).sqrt(); + if a.is_finite() { a.clamp(0.0, 1e8) } else { 0.0 } + } + _ => 0.0, + } + (n_layers - li) as f64 * 1e9; + label_candidates.push(( + PlacedLabel { + text, + x: x as f32 + dx, + y: y as f32 + dy, + size: class.size, + color: class.color, + halo: class.halo.then_some(halo_for(class.color)), + bold: class.bold, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + }, + pri, + (lid, label_row(row)), + )); } - let Some((x, y)) = ctx.screen(c) else { continue }; - // The clauses are read for the features in view only. - let Some((ci, class)) = classes.class_of(ds_ptr, row) else { continue }; - let Some(fi) = label_fields.get(ci).copied().flatten() else { continue }; - let v = ds_ptr.value(row, fi); - if v.is_null() { - continue; + } + if complete_source && matches!(labels_to, Labels::Own | Labels::Turned(_)) { + compact_label_candidates(&mut label_candidates); + } + Ok(()) + }; + if let Some(source) = source { + let mut chunk = layer.clone(); + chunk.source = None; + chunk.clear_cache(); + chunk.dataset.features = Vec::new().into(); + // All coordinates are read here: filtering only to the viewport can change labels, + // shadows and screen-space effects whose geometry lies beyond its boundary. + let result = visit_complete(source.as_ref(), &mut |features| { + chunk.clear_cache(); + chunk.selection = + features.iter().enumerate().filter_map(|(r, f)| selected_order.contains_key(&f.id).then_some(r)).collect(); + chunk.dataset.features = features.into(); + chunk.touch_geometry(); + draw_chunk(&mut chunk)?; + chunk.clear_cache(); + chunk.dataset.features = Vec::new().into(); + Ok(()) + }); + if let Err(e) = result { + if let Some(s) = &mut layer.source { + s.error = Some(e.to_string()); } - let text = v.to_string(); - let is_point = g.kind() == GeometryType::Point; - let marker = if is_point { layer.symbol(row).size } else { 0.0 }; - let (dx, dy) = class.placement.offset( - text::block_width(&text, class.size, class.bold), - text::block_height(&text, class.size), - marker, - is_point, - ); - // Bigger polygons get priority. - let pri = match g { - Geometry::Polygon(_) => { - let e = g.extent(); - // (An extent of no number counts as none.) - let a = (e.width() * e.height()).sqrt(); - if a.is_finite() { a.clamp(0.0, 1e8) } else { 0.0 } - } - _ => 0.0, - } + (n_layers - li) as f64 * 1e9; - label_candidates.push(( - PlacedLabel { - text, - x: x as f32 + dx, - y: y as f32 + dy, - size: class.size, - color: class.color, - halo: class.halo.then_some(halo_for(class.color)), - bold: class.bold, - icon: None, - align: 0.0, - framed: false, - italic: false, - spacing: 0.0, - line_align: TextAlign::Left, - angle: None, - collide: Default::default(), - }, - pri, - (lid, row), - )); + return None; + } + if let Some(source) = &mut layer.source { + source.error = None; } + } else if let Err(e) = draw_chunk(layer) { + log::warn!("cannot render layer '{}': {e}", layer.name); + return None; + } + if let (Some(spool), Some(x)) = (&mut extrusions, &layer.extrusion) + && let Err(e) = spool.draw(if direct { &mut pm } else { &mut target }, &ctx, x) + { + if let Some(source) = &mut layer.source { + source.error = Some(e.to_string()); + } + return None; + } + if complete_source { + if let (Some(a), Some(base)) = (layer.aggregation, aggregate_symbol) { + bins.draw(if direct { &mut pm } else { &mut target }, &ctx, a, &base); + } + if !direct { + composite(&mut pm, &target); + } + } + highlights.sort_by_key(|(rank, _, _)| *rank); + for (_, geometry, symbol) in highlights { + ctx.highlight(&mut pm, &geometry, &symbol, if layer.graphics { &[] } else { &layer.line_strokes }); + cpu_dirty = true; } } if let Some(g) = &globe { @@ -2753,16 +3136,26 @@ pub(crate) fn blend_mode(b: rustgis_map::effects::Blend) -> tiny_skia::BlendMode /// The mask of every masked or swiped layer: inside its mask layer's polygons and left of the /// swipe divider. -fn layer_masks(map: &mut Map, ctx: &Ctx, w: u32, h: u32, flat: &rustgis_map::projection::Flat, key: &str) -> Vec<(u32, tiny_skia::Mask)> { +fn layer_masks( + map: &mut Map, + ctx: &Ctx, + w: u32, + h: u32, + flat: &rustgis_map::projection::Flat, + key: &str, + scale: f64, +) -> Option> { let wanted: Vec<(u32, Option, Option)> = map .layers .iter() + .filter(|l| map.draws_at(l, scale)) .filter_map(|l| { let swipe = map.swipe.filter(|(id, _)| *id == l.id).map(|(_, x)| x.clamp(0.0, 1.0)); (l.mask.is_some() || swipe.is_some()).then(|| (l.id, l.mask.clone(), swipe)) }) .collect(); let mut out = Vec::new(); + let mut paths = std::collections::HashMap::new(); for (id, by, swipe) in wanted { let Some(mut m) = tiny_skia::Mask::new(w, h) else { continue }; match by @@ -2770,24 +3163,43 @@ fn layer_masks(map: &mut Map, ctx: &Ctx, w: u32, h: u32, flat: &rustgis_map::pro .and_then(|n| map.layers.iter().position(|l| l.name.eq_ignore_ascii_case(n) && l.geometry_type() == GeometryType::Polygon)) { Some(mi) => { - let mut pb = PathBuilder::new(); - // The mask's shapes go straight into the path, without copying them. - let mut add = |g: &Geometry| { - if let Geometry::Polygon(polys) = g { - for r in polys.iter().flatten() { - ctx.ring_path(&mut pb, r, true); + if let std::collections::hash_map::Entry::Vacant(entry) = paths.entry(mi) { + let mut pb = PathBuilder::new(); + // The mask's shapes go straight into the path, without copying them. + let mut add = |g: &Geometry| { + if let Geometry::Polygon(polys) = g { + for r in polys.iter().flatten() { + ctx.ring_path(&mut pb, r, true); + } + } + }; + if let Some(source) = map.layers.get(mi).and_then(|l| l.source.as_ref()).and_then(|s| s.chunk_source()) { + let result = visit_complete(source.as_ref(), &mut |features| { + for feature in features { + if let Some(g) = feature.geometry { + let g = if ctx.globe.is_some() { g } else { flat.project(&g) }; + add(&g); + } + } + Ok(()) + }); + if let Err(e) = result { + if let Some(s) = map.layers.get_mut(mi).and_then(|l| l.source.as_mut()) { + s.error = Some(e.to_string()); + } + return None; + } + } else if let Some(ml) = map.layers.get_mut(mi) { + if ctx.globe.is_some() { + ml.dataset.features.iter().filter_map(|f| f.geometry.as_ref()).for_each(&mut add); + } else { + ml.projected_in(flat, key).iter().flatten().for_each(&mut add); } } - }; - if let Some(ml) = map.layers.get_mut(mi) { - if ctx.globe.is_some() { - ml.dataset.features.iter().filter_map(|f| f.geometry.as_ref()).for_each(&mut add); - } else { - ml.projected_in(flat, key).iter().flatten().for_each(&mut add); - } + entry.insert(pb.finish()); } - if let Some(p) = pb.finish() { - m.fill_path(&p, FillRule::EvenOdd, true, Transform::identity()); + if let Some(p) = paths.get(&mi).and_then(Option::as_ref) { + m.fill_path(p, FillRule::EvenOdd, true, Transform::identity()); } } // No (or no usable) mask layer: everything shows. @@ -2804,7 +3216,7 @@ fn layer_masks(map: &mut Map, ctx: &Ctx, w: u32, h: u32, flat: &rustgis_map::pro } out.push((id, m)); } - out + Some(out) } /// The colour of the space around the globe: the dark sky, or the light space of exports. @@ -4877,5 +5289,8 @@ mod tests { } } +#[cfg(test)] +mod stream_tests; + #[cfg(test)] mod budget_tests; diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs new file mode 100644 index 000000000..7937f0b7b --- /dev/null +++ b/crates/render/src/stream_tests.rs @@ -0,0 +1,1078 @@ +use super::*; +use rustgis_data::source::{Batch, FeatureSource, Request, SourceInfo}; +use rustgis_data::{Dataset, Feature, Field, FieldType, Value}; +use rustgis_map::{Layer, Renderer, source::SourceLayer}; + +struct Chunks { + info: SourceInfo, + ds: Dataset, + size: usize, +} + +impl FeatureSource for Chunks { + fn info(&self) -> &SourceInfo { + &self.info + } + fn read(&self, _: &Request) -> rustgis_data::Result { + Ok(Batch { + features: self.ds.features.iter().take(1).cloned().collect(), + matched: self.ds.features.len() as u64, + complete: false, + skipped: 0, + }) + } + fn streams_chunks(&self) -> bool { + true + } + fn visit_chunks( + &self, + _: Option, + _: usize, + visit: &mut dyn FnMut(Vec) -> rustgis_data::Result<()>, + ) -> rustgis_data::Result { + if self.size == 0 { + visit(self.ds.features.iter().take(1).cloned().collect())?; + return Err(rustgis_data::DataError::Format("cancelled after a source failure".into())); + } + for chunk in self.ds.features.chunks(self.size) { + visit(chunk.to_vec())?; + } + Ok(Batch { complete: self.size != usize::MAX, matched: self.ds.features.len() as u64, ..Default::default() }) + } +} +fn streamed(layer: &Layer, size: usize) -> Layer { + streamed_kind(layer, size, false) +} + +fn streamed_kind(layer: &Layer, size: usize, native: bool) -> Layer { + let ds = layer.dataset.clone(); + let info = SourceInfo { + name: ds.name.clone(), + format: "test", + geometry_type: ds.geometry_type, + fields: ds.fields.clone(), + extent: ds.extent(), + count: Some(ds.features.len() as u64), + crs: if native { "EPSG:3857".into() } else { "EPSG:4326".into() }, + indexed: true, + notes: Vec::new(), + }; + let mut l = layer.clone(); + l.dataset.features = ds.features.iter().take(1).cloned().collect::>().into(); + l.source = Some(SourceLayer::from_path("test.shp", None, Box::new(Chunks { info, ds, size }))); + l +} + +#[test] +fn complete_chunks_preserve_painter_order_filters_transparency_labels_and_selection() { + // Keep selected interaction rows authoritative even if source synchronization completes + // during this test. Complete traversal still emits the original file order. + struct SelectedSample(Chunks); + impl FeatureSource for SelectedSample { + fn info(&self) -> &SourceInfo { + self.0.info() + } + fn read(&self, _: &Request) -> rustgis_data::Result { + Ok(Batch { + features: [5, 3].iter().filter_map(|row| self.0.ds.features.get(*row).cloned()).collect(), + matched: self.0.ds.features.len() as u64, + complete: false, + skipped: 0, + }) + } + fn streams_chunks(&self) -> bool { + true + } + fn visit_chunks( + &self, + extent: Option, + bytes: usize, + visit: &mut dyn FnMut(Vec) -> rustgis_data::Result<()>, + ) -> rustgis_data::Result { + self.0.visit_chunks(extent, bytes, visit) + } + } + let mut ds = + Dataset::new("overlap", GeometryType::Polygon, vec![Field::new("name", FieldType::Text), Field::new("value", FieldType::Integer)]); + for i in 0..37 { + let x = (i % 7) as f64 * 0.015 - 0.05; + let y = (i % 5) as f64 * 0.015 - 0.03; + ds.push( + Some(Geometry::Polygon(vec![vec![vec![ + Coord::new(x, y), + Coord::new(x + 0.04, y), + Coord::new(x + 0.04, y + 0.04), + Coord::new(x, y + 0.04), + Coord::new(x, y), + ]]])), + vec![Value::Text(format!("p{i}")), Value::Int(i)], + ); + } + let mut m = Map::new("test"); + m.add_dataset(ds); + m.camera = Camera::new(rustgis_geom::lonlat_to_merc(Coord::new(0.02, 0.02)), 80.0); + let l = &mut m.layers[0]; + l.transparency = 0.35; + l.selection = vec![5, 3]; + l.definition_query = Some("value >= 3".into()); + l.labels.enabled = true; + l.labels.default.field = "name".into(); + l.renderer = Renderer::Single(Symbol { fill: Rgba::new(210, 60, 25, 120), ..Symbol::default_for(GeometryType::Polygon, l.id) }); + let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: m.camera, sky: false }; + for extrusion in [None, Some(true), Some(false)] { + m.layers[0].extrusion = extrusion.map(|shadows| rustgis_map::effects::Extrusion { + height: 1500.0, + field: None, + factor: 1.0, + face_culling: true, + lighting: true, + shadows, + }); + let expected = render(&mut m.clone(), &view).unwrap(); + for size in [1, 3, 11, 100] { + let mut actual = m.clone(); + actual.layers[0] = streamed(&m.layers[0], size); + // Selected source rows are deliberately ordered differently from file order. + actual.layers[0].dataset.features = + [5, 3].iter().map(|row| m.layers[0].dataset.features[*row].clone()).collect::>().into(); + actual.layers[0].selection = vec![0, 1]; + let info = actual.layers[0].source.as_ref().unwrap().info().unwrap().clone(); + actual.layers[0].source = Some(SourceLayer::from_path( + "selected.shp", + None, + Box::new(SelectedSample(Chunks { info, ds: m.layers[0].dataset.clone(), size })), + )); + + let Some(RenderOutput::Cpu(got)) = render_with_backend(&mut actual, &view, true, Backend::Cpu) else { + panic!("complete selected frame"); + }; + assert!(got.pixmap.data() == expected.pixmap.data(), "chunk size {size} changed pixels"); + assert_eq!(got.labels.iter().map(|l| &l.text).collect::>(), expected.labels.iter().map(|l| &l.text).collect::>()); + } + } +} + +#[test] +fn complete_chunks_accumulate_point_bins_across_boundaries() { + let mut ds = Dataset::new("points", GeometryType::Point, vec![]); + for i in 0..211 { + ds.push(Some(Geometry::Point(Coord::new((i % 13) as f64 * 0.002, (i % 7) as f64 * 0.002))), vec![]); + } + let mut m = Map::new("test"); + m.add_dataset(ds); + m.camera = Camera::new(Coord::new(0.0, 0.0), 30.0); + m.layers[0].aggregation = Some(rustgis_map::effects::Aggregation { size: 18.0, hexagons: false }); + m.layers[0].selection = vec![0]; + let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: m.camera, sky: false }; + let expected = render(&mut m.clone(), &view).unwrap(); + for size in [1, 17, 300] { + let mut actual = m.clone(); + actual.layers[0] = streamed(&m.layers[0], size); + + let got = render(&mut actual, &view).unwrap(); + assert!( + got.pixmap.data() == expected.pixmap.data(), + "aggregation changed across chunks: {:?}", + got.pixmap.data().iter().zip(expected.pixmap.data()).position(|(a, b)| a != b) + ); + } +} + +#[test] +fn complete_chunks_use_the_entire_mask_source() { + let rectangle = |x: f64, y: f64, w: f64, h: f64| { + Geometry::Polygon(vec![vec![vec![ + Coord::new(x, y), + Coord::new(x + w, y), + Coord::new(x + w, y + h), + Coord::new(x, y + h), + Coord::new(x, y), + ]]]) + }; + let mut m = Map::new("mask"); + let mut ds = Dataset::new("ink", GeometryType::Polygon, vec![]); + ds.push(Some(rectangle(-0.2, -0.15, 0.4, 0.3)), vec![]); + let ink = m.add_dataset(ds); + let mut ds = Dataset::new("mask", GeometryType::Polygon, vec![]); + for i in 0..5 { + ds.push(Some(rectangle(i as f64 * 0.05 - 0.12, -0.08, 0.025, 0.16)), vec![]); + } + let mask = m.add_dataset(ds); + m.layer_mut(mask).unwrap().visible = false; + m.layer_mut(ink).unwrap().mask = Some("mask".into()); + m.camera = Camera::new(Coord::new(0.0, 0.0), 100.0); + let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: m.camera, sky: false }; + let expected = render(&mut m.clone(), &view).unwrap(); + for size in [1, 3, 10] { + let mut actual = m.clone(); + *actual.layer_mut(mask).unwrap() = streamed(m.layer(mask).unwrap(), size); + + let got = render(&mut actual, &view).unwrap(); + assert!(got.pixmap.data() == expected.pixmap.data(), "a mask lost features across chunks"); + } +} + +#[test] +fn complete_chunks_preserve_label_classes_projection_rotation_and_png_export() { + let mut ds = + Dataset::new("lines", GeometryType::Polyline, vec![Field::new("name", FieldType::Text), Field::new("value", FieldType::Integer)]); + for i in 0..23 { + let y = i as f64 * 0.004 - 0.04; + ds.push( + Some(Geometry::Line(vec![vec![Coord::new(-0.06, y), Coord::new(0.0, y + 0.006), Coord::new(0.06, y)]])), + vec![Value::Text(format!("line {i}")), Value::Int(i)], + ); + } + let mut base = Map::new("classes"); + base.add_dataset(ds); + let l = &mut base.layers[0]; + l.labels.enabled = true; + l.labels.default.field = "name".into(); + let mut class = l.labels.default.clone(); + class.name = "large".into(); + class.where_ = Some("value >= 10".into()); + class.size = 18.0; + class.color = Rgba::rgb(180, 20, 30); + class.bold = true; + l.labels.classes.push(class); + l.display_filters.push(rustgis_map::effects::DisplayFilter { + name: "subset".into(), + min_scale: None, + max_scale: None, + where_: "value < 20".into(), + }); + l.selection = vec![0]; + l.line_strokes = vec![ + rustgis_map::LineStroke { color: Some(Rgba::rgb(20, 20, 20)), width: Some(5.0), offset: 3.0, ..Default::default() }, + rustgis_map::LineStroke { color: Some(Rgba::rgb(240, 180, 40)), width: Some(2.0), offset: 3.0, ..Default::default() }, + ]; + let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: Camera::new(Coord::new(0.0, 0.0), 80.0), sky: false }; + for projection in [rustgis_map::Projection::default(), rustgis_map::Projection::Custom("robinson".into())] { + base.projection = projection; + for bearing in [0.0, 25.0] { + let view = View { camera: Camera { bearing, ..view.camera }, ..view }; + let expected = render(&mut base.clone(), &view).unwrap(); + assert!(!expected.labels.is_empty()); + assert!(expected.labels.iter().any(|l| l.bold)); + for size in [1, 3, 11, 100] { + let mut actual = base.clone(); + actual.layers[0] = streamed(&base.layers[0], size); + let got = render(&mut actual, &view).unwrap(); + assert_eq!(got.labels, expected.labels, "label class or placement changed"); + assert!(got.pixmap.data() == expected.pixmap.data(), "line rendering changed"); + assert_eq!(render_png(&mut actual, &view).unwrap(), render_png(&mut base.clone(), &view).unwrap(), "PNG export changed"); + } + } + } +} + +#[test] +fn complete_chunks_fail_without_publishing_a_partial_frame() { + let mut ds = Dataset::new("failure", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + let mut map = Map::new("failure"); + map.add_dataset(ds); + map.layers[0] = streamed(&map.layers[0], 0); + let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: map.camera, sky: false }; + assert!(render(&mut map, &view).is_none()); + assert!(map.layers[0].source.as_ref().unwrap().error.as_ref().unwrap().contains("cancelled")); +} + +#[test] +fn complete_chunks_use_whole_layer_lod_decisions() { + let mut ds = Dataset::new("detailed", GeometryType::Polyline, vec![]); + for row in 0..11 { + let points = (0..20_001).map(|i| Coord::new(-0.1 + i as f64 * 0.00001, row as f64 * 0.004 + (i % 2) as f64 * 0.000001)).collect(); + ds.push(Some(Geometry::Line(vec![points])), vec![]); + } + let mut base = Map::new("LOD"); + base.add_dataset(ds); + let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: Camera::new(Coord::new(0.0, 0.0), 80.0), sky: false }; + let expected = render(&mut base.clone(), &view).unwrap(); + for size in [1, 3, 20] { + let mut actual = base.clone(); + actual.layers[0] = streamed(&base.layers[0], size); + let got = render(&mut actual, &view).unwrap(); + assert!(got.pixmap.data() == expected.pixmap.data(), "whole-layer LOD changed at chunk size {size}"); + } +} + +#[test] +fn perspective_uses_complete_isolated_fallback() { + let mut ds = Dataset::new("upright", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + for i in 0..13 { + ds.push(Some(Geometry::Point(Coord::new((i % 5) as f64 * 0.01, (i / 5) as f64 * 0.01))), vec![Value::Text(format!("point {i}"))]); + } + let mut base = Map::new("perspective"); + base.add_dataset(ds); + base.layers[0].labels.enabled = true; + base.layers[0].labels.default.field = "name".into(); + base.layers[0].selection = vec![0]; + let view = View { + width: 320.0, + height: 240.0, + pixels_per_point: 1.0, + camera: Camera { pitch: 35.0, ..Camera::new(Coord::new(0.0, 0.0), 80.0) }, + sky: false, + }; + let expected = render(&mut base.clone(), &view).unwrap(); + for size in [1, 3, 20] { + let mut actual = base.clone(); + actual.layers[0] = streamed(&base.layers[0], size); + let before = actual.layers[0].dataset.features.clone(); + let got = render(&mut actual, &view).unwrap(); + assert_eq!(got.labels, expected.labels); + assert!(got.pixmap.data() == expected.pixmap.data()); + assert!(actual.layers[0].dataset.features.shares(&before)); + assert_eq!(actual.layers[0].selection, vec![0]); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn actual_shapefile_export_is_complete_despite_one_row_interaction_limit() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("complete.shp"); + let mut ds = + Dataset::new("complete", GeometryType::Point, vec![Field::new("name", FieldType::Text), Field::new("value", FieldType::Integer)]); + for i in 0..29 { + ds.push( + Some(Geometry::Point(Coord::new((i % 7) as f64 * 0.02 - 0.06, (i / 7) as f64 * 0.02 - 0.04))), + vec![Value::Text(format!("p{i}")), Value::Int(i)], + ); + } + for (ext, bytes) in rustgis_data::io::shapefile_parts(&ds).unwrap().0 { + std::fs::write(path.with_extension(ext), bytes).unwrap(); + } + let source = rustgis_data::source::shp::ShpSource::open_path(&path, "complete").unwrap(); + ds.fields = source.info().fields.clone(); + ds.features = source.read(&Request::all()).unwrap().features.into(); + let mut base = Map::new("complete"); + base.add_dataset(ds); + base.layers[0].labels.enabled = true; + base.layers[0].labels.default.field = "name".into(); + base.layers[0].selection = vec![0]; + let symbol = base.layers[0].symbol(0); + let renderers = vec![ + Renderer::Single(symbol), + Renderer::unique(&base.layers[0].dataset, &[], "value", rustgis_map::Ramp::Ember, symbol).unwrap(), + Renderer::graduated( + &base.layers[0].dataset, + &[], + "value", + 4, + rustgis_map::ClassMethod::EqualInterval, + rustgis_map::Ramp::Ember, + symbol, + ) + .unwrap(), + Renderer::proportional(&base.layers[0].dataset, &[], "value", symbol).unwrap(), + ]; + let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: Camera::new(Coord::new(0.0, 0.0), 100.0), sky: false }; + for renderer in renderers { + base.layers[0].renderer = renderer; + let expected = render(&mut base.clone(), &view).unwrap(); + assert!(expected.labels.len() > 1); + let mut actual = base.clone(); + actual.layers[0].dataset.features = base.layers[0].dataset.features.iter().take(1).cloned().collect::>().into(); + let mut source = SourceLayer::from_path( + path.to_string_lossy(), + None, + Box::new(rustgis_data::source::shp::ShpSource::open_path(&path, "complete").unwrap()), + ); + source.limit = 1; + actual.layers[0].source = Some(source); + let bytes = render_png(&mut actual, &view).unwrap(); + assert_eq!(bytes, render_png(&mut base.clone(), &view).unwrap()); + assert_eq!(actual.layers[0].dataset.features.len(), 1, "export must not replace the interaction window"); + if let Ok(path) = std::env::var("RUSTGIS_CHUNK_TEST_PNG") { + std::fs::write(path, bytes).unwrap(); + } + } +} + +#[test] +fn partial_source_failure_and_incomplete_summary_reach_unchanged_owner() { + let mut ds = Dataset::new("failure", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + let mut base = Map::new("failure"); + base.add_dataset(ds); + for (size, message) in [(0, "cancelled"), (usize::MAX, "incomplete")] { + let mut map = base.clone(); + map.layers[0] = streamed(&base.layers[0], size); + map.sync_sources(&Extent::WORLD_LONLAT, 1.0, true); + let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: map.camera, sky: false }; + let mut worker = map.clone(); + assert!(render(&mut worker, &view).is_none()); + map.adopt_caches(&worker); + assert!(map.layers[0].source.as_ref().unwrap().error.as_ref().unwrap().contains(message)); + map.layers[0].source.as_mut().unwrap().error = None; + map.layers[0].touch_geometry(); + map.adopt_caches(&worker); + assert!(map.layers[0].source.as_ref().unwrap().error.is_none()); + } +} + +#[test] +fn layout_export_keeps_all_drawn_legend_classes() { + use rustgis_map::layout::{ElementKind, FrameShape, INDICATOR_COLOR, INDICATOR_WIDTH, Layout}; + let mut ds = Dataset::new("classes", GeometryType::Point, vec![Field::new("value", FieldType::Integer)]); + for i in 0..12 { + ds.push(Some(Geometry::Point(Coord::new((i % 4) as f64 * 0.02, (i / 4) as f64 * 0.02))), vec![Value::Int(i)]); + } + let mut base = Map::new("legend"); + base.add_dataset(ds); + let layer = &mut base.layers[0]; + layer.renderer = Renderer::unique(&layer.dataset, &[], "value", rustgis_map::Ramp::Ember, layer.symbol(0)).unwrap(); + layer.definition_query = Some("value >= 2".into()); + let mut page = Layout::new("page", 140.0, 100.0); + let frame = page.add( + "map", + (0.0, 0.0, 85.0, 95.0), + ElementKind::MapFrame { + map: base.name.clone(), + center: Coord::new(0.02, 0.02), + scale: 300000.0, + shape: FrameShape::Rectangle, + grid: None, + extent_of: None, + extent_color: INDICATOR_COLOR, + extent_width: INDICATOR_WIDTH, + bearing: 0.0, + pitch: 0.0, + }, + ); + page.add( + "legend", + (90.0, 0.0, 45.0, 95.0), + ElementKind::Legend { frame, title: "classes".into(), all_classes: false, hide_other: false, exclude: vec![] }, + ); + let expected = layout::render(&page, &mut [base.clone()], 96.0, &layout::PageInfo::default()).unwrap(); + for size in [1, 3, 20] { + let mut actual = base.clone(); + actual.layers[0] = streamed(&base.layers[0], size); + let got = layout::render(&page, &mut [actual], 96.0, &layout::PageInfo::default()).unwrap(); + assert!(got.data() == expected.data(), "layout/legend changed at chunk size {size}"); + } + let mut failed = base.clone(); + failed.layers[0] = streamed(&base.layers[0], 0); + assert!(layout::render(&page, &mut [failed], 96.0, &layout::PageInfo::default()).is_none()); +} + +#[test] +fn chunked_label_compaction_keeps_late_priorities_stable_ties_and_nan_order() { + let candidates: Vec = (0..50_003) + .map(|i| { + ( + PlacedLabel { + text: format!("label {i}"), + x: 100.0, + y: 100.0, + size: 10.0, + color: Rgba::rgb(0, 0, 0), + halo: None, + bold: false, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + }, + if i % 17 == 0 { f64::NAN } else { (i % 7) as f64 }, + (1, i), + ) + }) + .collect(); + let expected = place_labels(candidates.clone(), vec![], 320.0, 240.0, false); + let mut compacted = Vec::new(); + for batch in candidates.chunks(1001) { + compacted.extend_from_slice(batch); + compact_label_candidates(&mut compacted); + } + let got = place_labels(compacted, vec![], 320.0, 240.0, false); + assert_eq!(got.labels, expected.labels); + assert_eq!(got.unplaced, expected.unplaced); + assert_eq!(got.keys, expected.keys); +} + +#[test] +fn perspective_does_not_read_unused_hidden_sources() { + let mut ds = Dataset::new("visible", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + let mut base = Map::new("hidden"); + base.add_dataset(ds.clone()); + let hidden = base.add_dataset(ds); + base.layer_mut(hidden).unwrap().visible = false; + let view = View { + width: 320.0, + height: 240.0, + pixels_per_point: 1.0, + camera: Camera { pitch: 35.0, ..Camera::new(Coord::new(0.0, 0.0), 80.0) }, + sky: false, + }; + let expected = render(&mut base.clone(), &view).unwrap(); + let mut actual = base.clone(); + for layer in &mut actual.layers { + *layer = streamed(layer, if layer.id == hidden { 0 } else { 1 }); + } + let got = render(&mut actual, &view).unwrap(); + assert!(got.pixmap.data() == expected.pixmap.data()); + assert!(actual.layer(hidden).unwrap().source.as_ref().unwrap().error.is_none()); +} + +#[test] +fn extrusion_spool_failures_retain_the_actual_source_diagnostic() { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + effects::SPOOL_FAILURE.with(|s| s.set(None)); + } + } + let _reset = Reset; + let mut ds = Dataset::new("extrusion failure", GeometryType::Polygon, vec![]); + ds.push( + Some(Geometry::Polygon(vec![vec![vec![ + Coord::new(0.0, 0.0), + Coord::new(0.01, 0.0), + Coord::new(0.01, 0.01), + Coord::new(0.0, 0.0), + ]]])), + vec![], + ); + let mut base = Map::new("spool failure"); + base.add_dataset(ds); + base.layers[0].extrusion = Some(rustgis_map::effects::Extrusion { + height: 100.0, + field: None, + factor: 1.0, + face_culling: true, + lighting: true, + shadows: true, + }); + let view = View { width: 120.0, height: 90.0, pixels_per_point: 1.0, camera: Camera::new(Coord::new(0.0, 0.0), 80.0), sky: false }; + for stage in ["create", "insert", "draw"] { + let mut map = base.clone(); + map.layers[0] = streamed(&base.layers[0], 1); + effects::SPOOL_FAILURE.with(|s| s.set(Some(stage))); + assert!(render(&mut map, &view).is_none()); + assert!(map.layers[0].source.as_ref().unwrap().error.as_ref().unwrap().contains(&format!("spool {stage} failure"))); + effects::SPOOL_FAILURE.with(|s| s.set(None)); + assert!(render(&mut map, &view).is_some()); + assert!(map.layers[0].source.as_ref().unwrap().error.is_none()); + } +} + +#[test] +fn perspective_reports_shapefile_open_failure() { + let mut ds = Dataset::new("visible", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + let mut map = Map::new("open failure"); + map.add_dataset(ds); + let view = View { + width: 120.0, + height: 90.0, + pixels_per_point: 1.0, + camera: Camera { pitch: 35.0, ..Camera::new(Coord::new(0.0, 0.0), 80.0) }, + sky: false, + }; + let dir = tempfile::tempdir().unwrap(); + let mut source = SourceLayer::default(); + source.path = Some(dir.path().join("missing.shp").display().to_string()); + map.layers[0].source = Some(source); + assert!(render(&mut map, &view).is_none()); + assert!(map.layers[0].source.as_ref().unwrap().error.is_some()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn pitched_mixed_sources_keep_windows_and_pending_on_the_owner() { + use std::sync::{Arc, Condvar, Mutex}; + struct WindowSource { + chunks: Chunks, + gate: Arc<(Mutex, Condvar)>, + } + impl FeatureSource for WindowSource { + fn info(&self) -> &SourceInfo { + &self.chunks.info + } + fn read(&self, _: &Request) -> rustgis_data::Result { + let (lock, wake) = &*self.gate; + let mut ready = lock.lock().unwrap(); + while !*ready { + ready = wake.wait(ready).unwrap(); + } + Ok(Batch { + features: self.chunks.ds.features.to_vec(), + matched: self.chunks.ds.features.len() as u64, + complete: true, + skipped: 0, + }) + } + } + struct Release(Arc<(Mutex, Condvar)>); + impl Drop for Release { + fn drop(&mut self) { + *self.0.0.lock().unwrap() = true; + self.0.1.notify_all(); + } + } + let mut ds = Dataset::new("mixed", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.01, 0.01))), vec![]); + let mut base = Map::new("mixed"); + base.add_dataset(ds.clone()); + base.add_dataset(ds); + let view = View { + width: 120.0, + height: 90.0, + pixels_per_point: 1.0, + camera: Camera { pitch: 35.0, ..Camera::new(Coord::new(0.0, 0.0), 80.0) }, + sky: false, + }; + for (backend, total) in [(Backend::Cpu, Some(2)), (Backend::Gpu, Some(2)), (Backend::Cpu, None), (Backend::Gpu, None)] { + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let release = Release(gate.clone()); + let mut map = base.clone(); + map.layers[0] = streamed(&base.layers[0], 1); + let mut info = map.layers[0].source.as_ref().unwrap().info().unwrap().clone(); + info.count = total; + map.layers[0].source = Some(SourceLayer::from_path( + "test.shp", + None, + Box::new(Chunks { info: info.clone(), ds: base.layers[0].dataset.clone(), size: 1 }), + )); + map.layers[1].dataset.features = Vec::new().into(); + map.layers[1].source = Some(SourceLayer::from_path( + "mixed.gpkg", + None, + Box::new(WindowSource { chunks: Chunks { info, ds: base.layers[1].dataset.clone(), size: 1 }, gate }), + )); + let output = render_with_backend(&mut map, &view, false, backend).unwrap(); + let pending = match output { + RenderOutput::Cpu(r) => r.data_pending, + RenderOutput::Gpu(s) => s.data_pending, + }; + assert!(pending); + assert!(map.layers[1].source.as_ref().unwrap().is_pending()); + drop(release); + // Wait for the same owning request, then verify the next frame retains its window. + let bands = view.camera.tilt_bands(view.width, view.height); + sync_tilted(&mut map, &view, &bands, true); + assert_eq!(map.layers[1].dataset.features.len(), 2); + assert!(map.layers[1].source.as_ref().unwrap().reads > 0); + let output = render_with_backend(&mut map, &view, true, backend).unwrap(); + let pending = match output { + RenderOutput::Cpu(r) => r.data_pending, + RenderOutput::Gpu(s) => s.data_pending, + }; + assert!(!pending); + assert_eq!(map.layers[1].dataset.features.len(), 2); + } +} + +#[test] +fn perspective_snapshot_budget_counts_all_layers_capacity_and_nested_values() { + let row = Feature { + id: 1, + geometry: Some(Geometry::MultiPoint(vec![Coord::new(0.0, 0.0); 8])), + attrs: vec![Value::Text("large attribute".repeat(16))], + }; + let row_bytes = rustgis_data::source::chunk_memory::feature_bytes(&row) + std::mem::size_of::(); + let mut budget = SnapshotBudget::new(row_bytes); + let mut first = Vec::new(); + budget.append(&mut first, vec![row.clone()]).unwrap(); + assert_eq!(first.len(), 1); + assert_eq!(budget.used, row_bytes); + let mut second = Vec::new(); + assert!(budget.append(&mut second, vec![row.clone()]).is_err()); + assert!(second.is_empty()); + assert!(budget.append(&mut first, vec![row]).is_err()); + assert_eq!(first.len(), 1); + assert_eq!(budget.used, row_bytes); +} + +#[test] +fn perspective_snapshot_budget_failure_reaches_source_without_partial_output() { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + SNAPSHOT_TEST_LIMIT.with(|s| s.set(None)); + } + } + let _reset = Reset; + let mut ds = Dataset::new("snapshot", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + let mut base = Map::new("snapshot"); + base.add_dataset(ds); + let view = View { + width: 120.0, + height: 90.0, + pixels_per_point: 1.0, + camera: Camera { pitch: 35.0, ..Camera::new(Coord::new(0.0, 0.0), 80.0) }, + sky: false, + }; + for backend in [Backend::Cpu, Backend::Gpu] { + let mut map = base.clone(); + map.layers[0] = streamed(&base.layers[0], 1); + SNAPSHOT_TEST_LIMIT.with(|s| s.set(Some(0))); + assert!(render_with_backend(&mut map, &view, true, backend).is_none()); + assert!(map.layers[0].source.as_ref().unwrap().error.as_ref().unwrap().contains("snapshot exceeds")); + assert!(map.layers[0].source.as_ref().unwrap().chunk_source().is_some()); + SNAPSHOT_TEST_LIMIT.with(|s| s.set(None)); + assert!(render_with_backend(&mut map, &view, true, backend).is_some()); + assert!(map.layers[0].source.as_ref().unwrap().error.is_none()); + } +} + +#[test] +fn masks_skip_undrawn_targets_and_reuse_one_complete_source_with_distinct_swipes() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + struct Counted { + chunks: Chunks, + calls: Arc, + } + impl FeatureSource for Counted { + fn info(&self) -> &SourceInfo { + self.chunks.info() + } + fn read(&self, request: &Request) -> rustgis_data::Result { + self.chunks.read(request) + } + fn streams_chunks(&self) -> bool { + true + } + fn visit_chunks( + &self, + extent: Option, + bytes: usize, + visit: &mut dyn FnMut(Vec) -> rustgis_data::Result<()>, + ) -> rustgis_data::Result { + self.calls.fetch_add(1, Ordering::Relaxed); + self.chunks.visit_chunks(extent, bytes, visit) + } + } + let mut ds = Dataset::new("target", GeometryType::Polygon, vec![]); + ds.push( + Some(Geometry::Polygon(vec![vec![vec![ + Coord::new(-0.02, -0.02), + Coord::new(0.02, -0.02), + Coord::new(0.02, 0.02), + Coord::new(-0.02, 0.02), + Coord::new(-0.02, -0.02), + ]]])), + vec![], + ); + let mut base = Map::new("shared masks"); + let first = base.add_dataset(ds.clone()); + let second = base.add_dataset(ds.clone()); + ds.name = "mask".into(); + let mask = base.add_dataset(ds.clone()); + base.layer_mut(mask).unwrap().visible = false; + for id in [first, second] { + base.layer_mut(id).unwrap().mask = Some("mask".into()); + } + base.swipe = Some((first, 0.35)); + let view = View { width: 120.0, height: 90.0, pixels_per_point: 1.0, camera: Camera::new(Coord::new(0.0, 0.0), 80.0), sky: false }; + let ctx = Ctx { view: &view, ppp: 1.0, wireframe: false, cull: Extent::EMPTY, globe: None, sprites: Default::default() }; + let flat = base.flat(); + let scale = base.ground_res_at(&view.camera) / rustgis_geom::PIXEL_M; + let expected = layer_masks(&mut base.clone(), &ctx, 120, 90, &flat, "test", scale).unwrap(); + let info = streamed(base.layer(mask).unwrap(), 1).source.unwrap().info().unwrap().clone(); + let calls = Arc::new(AtomicUsize::new(0)); + let mut actual = base.clone(); + actual.layer_mut(mask).unwrap().source = + Some(SourceLayer::from_path("mask.shp", None, Box::new(Counted { chunks: Chunks { info, ds, size: 1 }, calls: calls.clone() }))); + let got = layer_masks(&mut actual, &ctx, 120, 90, &flat, "test", scale).unwrap(); + assert_eq!(calls.load(Ordering::Relaxed), 1); + assert_eq!(got.len(), 2); + for ((id, got), (expected_id, expected)) in got.iter().zip(&expected) { + assert_eq!(id, expected_id); + assert_eq!(got.data(), expected.data()); + } + assert_ne!(got[0].1.data(), got[1].1.data(), "swipe remains target-specific"); + for mode in 0..5 { + let mut map = base.clone(); + *map.layer_mut(mask).unwrap() = streamed(base.layer(mask).unwrap(), 0); + for id in [first, second] { + match mode { + 0 => map.layer_mut(id).unwrap().visible = false, + 1 => map.layer_mut(id).unwrap().max_scale = Some(scale * 2.0), + 2 | 3 => { + let mut group = rustgis_map::LayerGroup::new(format!("group {id}")); + group.id = id; + group.children = vec![id]; + if mode == 2 { + group.visible = false; + } else { + group.max_scale = Some(scale * 2.0); + } + map.groups.push(group); + } + _ => {} + } + } + let got = layer_masks(&mut map, &ctx, 120, 90, &flat, "test", scale); + if mode == 4 { + assert!(got.is_none(), "a failed required mask must still fail the frame"); + } else { + assert!(got.unwrap().is_empty()); + assert!(map.layer(mask).unwrap().source.as_ref().unwrap().error.is_none()); + } + } +} + +#[test] +fn pitched_plain_chunks_bypass_depth_budget_alone_and_with_upright_markers() { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + SNAPSHOT_TEST_LIMIT.with(|s| s.set(None)); + } + } + let _reset = Reset; + let view = View { + width: 120.0, + height: 90.0, + pixels_per_point: 1.0, + camera: Camera { pitch: 35.0, ..Camera::new(Coord::new(0.0, 0.0), 80.0) }, + sky: false, + }; + for kind in [GeometryType::Polyline, GeometryType::Polygon] { + let mut ds = Dataset::new("ground", kind, vec![Field::new("text", FieldType::Text)]); + for i in 0..5 { + let x = i as f64 * 0.005 - 0.01; + let ring = vec![ + Coord::new(x, -0.01), + Coord::new(x + 0.004, -0.01), + Coord::new(x + 0.004, 0.01), + Coord::new(x, 0.01), + Coord::new(x, -0.01), + ]; + let g = if kind == GeometryType::Polyline { Geometry::Line(vec![ring]) } else { Geometry::Polygon(vec![vec![ring]]) }; + ds.push(Some(g), vec![Value::Text("attribute".repeat(256))]); + } + let mut base = Map::new("depth eligibility"); + let ground = base.add_dataset(ds); + base.layer_mut(ground).unwrap().selection = vec![0]; + base.layer_mut(ground).unwrap().transparency = 0.35; + for mixed in [false, true] { + let mut full = base.clone(); + if mixed { + let mut ds = Dataset::new("upright", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + full.add_dataset(ds); + } + for backend in [Backend::Cpu, Backend::Gpu] { + let pixels = |output: RenderOutput| match output { + RenderOutput::Cpu(r) => (r.pixmap.data().to_vec(), r.labels), + RenderOutput::Gpu(_) => panic!("complete chunk painter uses the CPU fallback"), + }; + // Always use a CPU reference; a plain in-memory ground layer may be GPU eligible. + let expected_cpu = render_with_backend(&mut full.clone(), &view, true, Backend::Cpu).unwrap(); + let (expected_pixels, expected_labels) = pixels(expected_cpu); + let mut map = full.clone(); + for layer in &mut map.layers { + *layer = streamed(layer, 1); + } + SNAPSHOT_TEST_LIMIT.with(|s| s.set(Some(std::mem::size_of::() * 2))); + let got = render_with_backend(&mut map, &view, true, backend).unwrap(); + let (got_pixels, got_labels) = pixels(got); + assert_eq!(got_pixels, expected_pixels, "kind {kind:?}, mixed {mixed}, backend {backend:?}"); + assert_eq!(got_labels, expected_labels); + + let mut failed = full.clone(); + for layer in &mut failed.layers { + *layer = streamed(layer, if layer.id == ground { 0 } else { 1 }); + } + let lock = Some(vec![(ground, 0)]); + failed.label_lock = lock.clone(); + assert!(render_with_backend(&mut failed, &view, true, backend).is_none()); + assert_eq!(failed.label_lock, lock); + assert!(failed.layer(ground).unwrap().source.as_ref().unwrap().error.as_ref().unwrap().contains("cancelled")); + SNAPSHOT_TEST_LIMIT.with(|s| s.set(None)); + } + } + } +} + +#[test] +fn pitched_large_or_unknown_point_sources_use_total_rows_not_the_interaction_window() { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + SNAPSHOT_TEST_LIMIT.with(|s| s.set(None)); + } + } + let _reset = Reset; + let mut ds = Dataset::new("many points", GeometryType::Point, vec![]); + for i in 0..=MAX_UPRIGHT_POINTS { + let c = if i < 3 { Coord::new(i as f64 * 0.005, 0.0) } else { Coord::new(179.0, 80.0) }; + ds.push(Some(Geometry::Point(c)), vec![]); + } + let mut full = Map::new("source total"); + full.add_dataset(ds.clone()); + let view = View { + width: 96.0, + height: 64.0, + pixels_per_point: 1.0, + camera: Camera { pitch: 35.0, ..Camera::new(Coord::new(0.0, 0.0), 80.0) }, + sky: false, + }; + // Exercise the direct GPU path as well as the selected CPU fallback. + for selected in [false, true] { + full.layers[0].selection = if selected { vec![0] } else { Vec::new() }; + let expected = render(&mut full.clone(), &view).unwrap(); + assert!(!stands_up(&full, &full.layers[0])); + for total in [Some(MAX_UPRIGHT_POINTS as u64 + 1), None] { + for window in [1, 3] { + for backend in [Backend::Cpu, Backend::Gpu] { + let mut map = full.clone(); + map.layers[0] = streamed(&full.layers[0], 2048); + map.layers[0].dataset.features = ds.features.iter().take(window).cloned().collect::>().into(); + let mut info = map.layers[0].source.as_ref().unwrap().info().unwrap().clone(); + info.count = total; + map.layers[0].source = + Some(SourceLayer::from_path("points.shp", None, Box::new(Chunks { info, ds: ds.clone(), size: 2048 }))); + assert!(!stands_up(&map, &map.layers[0])); + SNAPSHOT_TEST_LIMIT.with(|s| s.set(Some(0))); + match render_with_backend(&mut map, &view, true, backend).expect("complete large-source render") { + RenderOutput::Cpu(got) => { + assert!(got.pixmap.data() == expected.pixmap.data()); + assert_eq!(got.labels, expected.labels); + assert!(!got.data_pending); + } + RenderOutput::Gpu(scene) => { + assert_eq!(backend, Backend::Gpu); + let Some(RenderOutput::Gpu(reference)) = render_with_backend(&mut full.clone(), &view, true, backend) else { + panic!("the resident GPU reference should use the same ground-marker path"); + }; + let count = |scene: &gpu_scene::GpuScene| { + scene + .cmds + .iter() + .map(|cmd| match cmd { + gpu_scene::DrawCmd::Points { instances, .. } => instances.len(), + _ => 0, + }) + .sum::() + }; + assert!(scene.tilt.is_some()); + assert!(scene.clips.iter().all(|clip| !clip.upright)); + assert!(count(&scene) > 0); + assert_eq!(count(&scene), count(&reference), "the GPU scene must include all visible source markers"); + assert_eq!(scene.labels, reference.labels); + assert_eq!(scene.clear, reference.clear); + assert!(!scene.data_pending); + } + } + assert!(!stands_up(&map, &map.layers[0])); + assert!(map.layers[0].source.as_ref().unwrap().error.is_none()); + } + } + } + } +} + +#[test] +fn layout_failure_policy_only_requires_active_complete_sources_and_masks() { + let mut ds = Dataset::new("plain", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + let mut map = Map::new("layout policy"); + let plain = map.add_dataset(ds); + let mut ds = Dataset::new("mask", GeometryType::Polygon, vec![]); + ds.push( + Some(Geometry::Polygon(vec![vec![vec![ + Coord::new(-0.01, -0.01), + Coord::new(0.01, -0.01), + Coord::new(0.01, 0.01), + Coord::new(-0.01, -0.01), + ]]])), + vec![], + ); + let mask = map.add_dataset(ds); + *map.layer_mut(mask).unwrap() = streamed(map.layer(mask).unwrap(), 1); + map.layer_mut(mask).unwrap().visible = false; + let view = View { width: 120.0, height: 90.0, pixels_per_point: 1.0, camera: Camera::new(Coord::new(0.0, 0.0), 80.0), sky: false }; + assert!(!layout::frame_requires_complete(&map, &view), "an unused hidden source cannot change ordinary frame failure behavior"); + map.layer_mut(plain).unwrap().mask = Some("mask".into()); + assert!(layout::frame_requires_complete(&map, &view), "a needed hidden complete mask is authoritative"); + map.layer_mut(plain).unwrap().visible = false; + assert!(!layout::frame_requires_complete(&map, &view)); + map.layer_mut(mask).unwrap().visible = true; + assert!(layout::frame_requires_complete(&map, &view)); + let mut group = rustgis_map::LayerGroup::new("hidden group"); + group.visible = false; + group.children = vec![mask]; + map.groups.push(group); + assert!(!layout::frame_requires_complete(&map, &view)); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn layout_export_rejects_unopened_local_shapefiles_and_required_masks() { + use rustgis_map::layout::{ElementKind, FrameShape, INDICATOR_COLOR, INDICATOR_WIDTH, Layout}; + let dir = tempfile::tempdir().unwrap(); + let mut base = Map::new("persisted sources"); + let mut points = Dataset::new("points", GeometryType::Point, vec![]); + points.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + let point = base.add_dataset(points); + let mask = base.add_dataset(Dataset::new("mask", GeometryType::Polygon, vec![])); + base.layer_mut(mask).unwrap().visible = false; + for pitch in [0.0, 35.0] { + let mut page = Layout::new("page", 30.0, 20.0); + page.add( + "map", + (0.0, 0.0, 30.0, 20.0), + ElementKind::MapFrame { + map: base.name.clone(), + center: Coord::new(0.0, 0.0), + scale: 300000.0, + shape: FrameShape::Rectangle, + grid: None, + extent_of: None, + extent_color: INDICATOR_COLOR, + extent_width: INDICATOR_WIDTH, + bearing: 0.0, + pitch, + }, + ); + for masked in [false, true] { + let mut map = base.clone(); + let owner = if masked { mask } else { point }; + let mut source = SourceLayer::default(); + source.path = Some(dir.path().join("missing.SHP").display().to_string()); + map.layer_mut(owner).unwrap().source = Some(source); + if masked { + map.layer_mut(point).unwrap().mask = Some("MASK".into()); + } + assert!( + layout::render(&page, std::slice::from_mut(&mut map), 96.0, &layout::PageInfo::default()).is_none(), + "missing complete source must fail the page: pitch {pitch}, masked {masked}" + ); + assert!(map.layer(owner).unwrap().source.as_ref().unwrap().error.is_some()); + } + let mut unused = base.clone(); + let mut source = SourceLayer::default(); + source.path = Some(dir.path().join("unused.shp").display().to_string()); + unused.layer_mut(mask).unwrap().source = Some(source); + assert!( + layout::render(&page, &mut [unused], 96.0, &layout::PageInfo::default()).is_some(), + "an unused hidden Shapefile must not fail export" + ); + + let mut ordinary = base.clone(); + let mut source = SourceLayer::default(); + source.path = Some(dir.path().join("missing.gpkg").display().to_string()); + ordinary.layer_mut(point).unwrap().source = Some(source); + assert!( + layout::render(&page, &mut [ordinary], 96.0, &layout::PageInfo::default()).is_some(), + "ordinary sources retain upstream best-effort behavior" + ); + } +} diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index a784abf95..264e19e53 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -599,3 +599,41 @@ map file or project. Data › Layer Templates adds empty layers with ready-made routes, areas, observations…) to draw into. Add Folder connects a folder to the Browser, which also lists the project's layouts, styles and tasks. A task's steps each run a command; New Task From History turns the geoprocessing history into a task you can replay. + +### Complete streamed Shapefile rendering + +The CPU renderer traverses a streamed Shapefile in source order instead of painting only +its interaction sample. Shapes and attributes arrive in chunks targeting 8 MiB of owned +feature storage; projection and display geometry are temporary copies of one chunk. +Whole-file imports, loading for editing and other providers retain their existing read APIs. +An indivisible row exceeding the chunk target is emitted alone, preserving its full geometry. +A zero budget reports an error. + +Styles, definition queries, display filters and label classes use the same painter as an +in-memory layer. The renderer chooses geometry simplification from statistics over the whole +layer, accumulates point bins across chunks, applies transparency and blend once per layer, +and draws selection highlights after the whole layer. Labels share the existing global +priority, stable tie ordering, collision rules and 20,000-candidate placement limit. +Screen rendering and PNG/layout exports share this traversal. Live redraws of a streamed +Shapefile therefore also read the complete source; line/polygon layers currently make an +additional statistics pass. Interaction windows remain byte-budgeted, but they are not a +shortcut for this complete painter, so redraws can be I/O intensive on large files. + +Native flat extrusions use a private disposable SQLite ordering workspace (2 MiB page-cache +target, memory mapping disabled, 4 GiB database ceiling), with a global shadow pass followed +by globally sorted solids. It is removed when rendering finishes. Failure or incomplete +traversal does not publish a partial successful frame. This workspace is not a reusable index. + +This is not a fixed bound on total renderer memory: raster targets, label candidates and the +existing combined polygon-mask path have separate costs. Upright perspective point markers +and polygon extrusions retain the complete upstream depth renderer through an isolated +full-source snapshot. Upright points require a known source total of at most 50,000 rows; +larger or unknown totals use complete ground-marker traversal, independent of the current +interaction-window size. Plain fills, lines and mask sources continue complete chunk traversal +per band, including terrain bands; they do not consume that snapshot's feature budget. +Retained complete-source feature storage across the snapshot has a separate budget (512 MiB natively, 192 MiB in the browser). +Exceeding it fails the whole frame with a source diagnostic, without sampling or truncating +features; raster targets, derived geometry caches and decoding scratch are outside that budget. Flat line/polygon rendering +makes a statistics pass before painting and currently reads the entire source, so it trades +additional I/O for complete rendering and predictable chunk storage. Geometry preprocessing +and GPU preparation are separate optimizations. From f9b84aba97e31ef0f0f432619aa893994dacdcaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ninh=20H=E1=BA=A3i=20=C4=90=C4=83ng?= Date: Thu, 22 Oct 2026 17:53:38 +0700 Subject: [PATCH 020/297] Raster: a minimum and maximum for each band of an RGB stretch (#566) (#693) --- crates/engine/src/cmd/raster.rs | 237 +++++++++++++++++++++++--------- crates/engine/src/cmd/zarr.rs | 2 +- crates/engine/src/tests.rs | 98 ++++++++++++- crates/map/src/raster.rs | 94 ++++++++++++- crates/ui-egui/src/panes.rs | 52 ++++++- docs/user-guide/data.md | 2 +- 6 files changed, 405 insertions(+), 80 deletions(-) diff --git a/crates/engine/src/cmd/raster.rs b/crates/engine/src/cmd/raster.rs index 7cfa14466..ca37047eb 100644 --- a/crates/engine/src/cmd/raster.rs +++ b/crates/engine/src/cmd/raster.rs @@ -6,7 +6,9 @@ use std::sync::Arc; use rustgis_data::raster::{self, Raster}; use rustgis_geom::Extent; -use rustgis_map::raster::{RasterLayer, RasterMask, RasterRenderer, RasterStyle, Resampling, Stretch, classify_masked, unique_values}; +use rustgis_map::raster::{ + Normalizer, RasterLayer, RasterMask, RasterRenderer, RasterStyle, Resampling, Stretch, classify_masked, unique_values, +}; use rustgis_map::{ClassMethod, Ramp, RasterDefaults}; use serde_json::{Value, json}; @@ -36,55 +38,9 @@ pub fn commands() -> Vec { C::new("raster.symbology", "Symbology", "Style › Rendering", symbology).when(need_raster).params( r#"{"layer"?, "type": "stretch"|"rgb"|"classify"|"unique"|"colormap"|"shadedRelief", "band"?: 1.., "ramp"?, "invert"?: bool, "classes"?: 1..32, "method"?, "azimuth"?, "altitude"?, "zFactor"?, "grey"?: bool (shadedRelief without its colour ramp)}"#, ), - C::new("raster.stretch", "Stretch Type", "Style › Rendering", |s, p| { - if let Some(like) = p.get("like").filter(|v| !v.is_null()) { - let like = like.as_str().ok_or("like is a raster layer's name")?; - let (min, max) = stretch_range(s, like)?; - return with(s, p, |rl, _| { - rl.style.stretch = Stretch::Custom { min, max }; - Ok(()) - }); - } - let code = ps(p, "type").ok_or("type is required")?; - // A bound that was given but isn't a finite number is refused, not replaced by the layer's own. - let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); - for k in ["min", "max"] { - if given(k) && f(p, k).is_none() { - return Err(format!("{k} must be a finite number")); - } - } - let (min, max) = (f(p, "min"), f(p, "max")); - with(s, p, |rl, r| { - // Only a custom stretch reads the statistics: those of the band being drawn. - let band = rl.style.renderer.bands().first().copied().unwrap_or(0); - let stats = r.bands.get(band).map(|b| &b.stats); - if code.trim().eq_ignore_ascii_case("custom") && stats.is_none() { - return Err("the raster has no bands".into()); - } - let mut st = Stretch::from_code(code, f(p, "low"), f(p, "high"), f(p, "n"), min, max, stats) - .ok_or_else(|| format!("unknown stretch '{code}'"))?; - // A band with a single value has no range of its own: open one around it to edit from. - if let Stretch::Custom { min: lo, max: hi } = st - && min.is_none() - && max.is_none() - && lo >= hi - { - // Padded by the value's magnitude too, or a large value would absorb ±0.5. - let pad = (lo.abs() * 1e-6).max(0.5); - st = Stretch::Custom { min: lo - pad, max: hi + pad }; - } - // Statistics can be NaN or infinite (a band of nodata), which `min >= max` alone misses. - if let Stretch::Custom { min, max } = st - && !(min.is_finite() && max.is_finite() && min < max) - { - return Err(format!("min {min} must be a finite number less than max {max}")); - } - rl.style.stretch = st; - Ok(()) - }) - }) + C::new("raster.stretch", "Stretch Type", "Style › Rendering", stretch) .when(need_raster) - .params(r#"{"layer"?, "type": "none"|"minMax"|"custom"|"percentClip"|"standardDeviation"|"histogramEqualize", "low"?: %, "high"?: %, "n"?: deviations, "min"?, "max"?: custom bounds in the data's units (a missing one is the layer's own), "like"?: raster layer (instead of type: that layer's stretch as fixed bounds, so tiles match at their joins)}"#), + .params(r#"{"layer"?, "type": "none"|"minMax"|"custom"|"percentClip"|"standardDeviation"|"histogramEqualize", "low"?: %, "high"?: %, "n"?: deviations, "min"?, "max"?: custom bounds in the data's units, a number for every band or [red, green, blue] on an RGB layer, null for a band's own (a missing one is the range now drawn; the layer's own on one band), "like"?: raster layer (instead of type: that layer's stretch as fixed bounds, so tiles match at their joins)}"#), C::new("raster.dra", "DRA", "Style › Rendering", |s, p| { let on = b(p, "on"); with(s, p, |rl, _| { @@ -146,23 +102,149 @@ fn get(s: &Session, p: &Value) -> Result<(u32, Arc), String> { Ok((id, data)) } -/// The value range a raster layer's stretch spans on the band it draws, for another layer to share. -fn stretch_range(s: &Session, name: &str) -> Result<(f64, f64), String> { +/// A `min`/`max` parameter: one number for every band, or three (red, green, blue) on an RGB +/// layer, where `null` leaves a band's own. +#[derive(Clone, Copy)] +enum Bound { + Unset, + All(f64), + Each([Option; 3]), +} + +impl Bound { + fn at(self, channel: usize) -> Option { + match self { + Bound::Unset => None, + Bound::All(v) => Some(v), + Bound::Each(v) => v.get(channel).copied().flatten(), + } + } +} + +fn bound(p: &Value, k: &str) -> Result { + let Some(v) = p.get(k).filter(|v| !v.is_null()) else { return Ok(Bound::Unset) }; + let num = |v: &Value| v.as_f64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).filter(|x: &f64| x.is_finite()); + let Some(a) = v.as_array() else { return num(v).map(Bound::All).ok_or_else(|| format!("{k} must be a finite number")) }; + if a.len() != 3 { + return Err(format!("{k} for each band is [red, green, blue], not {} values", a.len())); + } + let mut each = [None; 3]; + for (slot, x) in each.iter_mut().zip(a) { + if !x.is_null() { + *slot = Some(num(x).ok_or_else(|| format!("{k} must be finite numbers or null"))?); + } + } + Ok(Bound::Each(each)) +} + +/// The value range channel `i` (the only one on a single band) is drawn over now, without the +/// pixels the mask hides. +fn channel_range(rl: &RasterLayer, r: &Raster, i: usize) -> Option<(f64, f64)> { + let band = *rl.style.renderer.bands().get(i)?; + let masked = rl.masked_stats(); + let stats = masked.as_ref().and_then(|m| m.get(i)).or_else(|| r.bands.get(band).map(|b| &b.stats))?; + let n = Normalizer::for_channel(rl.style.stretch, i, stats, &r.data_type); + Some((n.lo, n.hi)) +} + +/// The lowest and highest values of `band`, without the pixels the mask hides. +fn band_extent(rl: &RasterLayer, r: &Raster, band: usize) -> Option<(f64, f64)> { + if rl.style.mask.is_empty() { + let s = &r.bands.get(band)?.stats; + return Some((s.min, s.max)); + } + let s = rl.style.mask.stats(r, band); + Some((s.min, s.max)) +} + +fn valid_range(lo: f64, hi: f64) -> bool { + lo.is_finite() && hi.is_finite() && lo < hi +} + +/// A raster layer's stretch as fixed bounds (one pair per channel on an RGB layer), for another +/// layer to share. +fn like_stretch(s: &Session, name: &str) -> Result { let rl = s.map().layer_by_name(name).and_then(|l| l.raster.as_ref()).ok_or_else(|| format!("no raster layer '{name}'"))?; let r = rl.data.as_ref().ok_or_else(|| format!("{name}: the raster's data couldn't be read"))?; - let band = rl.style.renderer.bands().first().copied().unwrap_or(0); - // Without the values its mask hides, as its own stretch is drawn. - let masked = rl.masked_stats(); - let stats = masked - .as_ref() - .and_then(|m| m.first()) - .or_else(|| r.bands.get(band).map(|b| &b.stats)) - .ok_or_else(|| format!("{name} has no bands"))?; - let n = rustgis_map::raster::Normalizer::new(rl.style.stretch, stats, &r.data_type); - if !(n.lo.is_finite() && n.hi.is_finite() && n.lo < n.hi) { - return Err(format!("{name} has no range of values to share")); + let channels = rl.style.renderer.bands().len().min(3); + let mut pairs = [[0.0; 2]; 3]; + for (i, pair) in pairs.iter_mut().enumerate().take(channels) { + let (lo, hi) = channel_range(rl, r, i).ok_or_else(|| format!("{name} has no bands"))?; + if !valid_range(lo, hi) { + return Err(format!("{name} has no range of values to share")); + } + *pair = [lo, hi]; } - Ok((n.lo, n.hi)) + Ok(if channels == 3 { Stretch::custom_bands(pairs) } else { Stretch::custom(pairs[0][0], pairs[0][1]) }) +} + +fn stretch(s: &mut Session, p: &Value) -> CmdResult { + if let Some(like) = p.get("like").filter(|v| !v.is_null()) { + let like = like.as_str().ok_or("like is a raster layer's name")?; + let st = like_stretch(s, like)?; + return with(s, p, |rl, _| { + rl.style.stretch = st; + Ok(()) + }); + } + let code = ps(p, "type").ok_or("type is required")?; + // A bound that was given but isn't a finite number is refused, not replaced by the layer's own. + let (min_b, max_b) = (bound(p, "min")?, bound(p, "max")?); + let custom = code.trim().eq_ignore_ascii_case("custom"); + with(s, p, |rl, r| { + let rgb = matches!(rl.style.renderer, RasterRenderer::Rgb { .. }); + let each = matches!(min_b, Bound::Each(_)) || matches!(max_b, Bound::Each(_)); + if custom && each && !rgb { + return Err("a bound for each band needs an RGB symbology".into()); + } + // On an RGB layer a bound that isn't a number for every band starts from the range each + // band is drawn over now. + if custom && rgb && !matches!((min_b, max_b), (Bound::All(_), Bound::All(_))) { + let mut pairs = [[0.0; 2]; 3]; + for (i, pair) in pairs.iter_mut().enumerate() { + let (own_lo, own_hi) = channel_range(rl, r, i).ok_or("the raster has no bands")?; + let (given_lo, given_hi) = (min_b.at(i), max_b.at(i)); + let (mut lo, mut hi) = (given_lo.unwrap_or(own_lo), given_hi.unwrap_or(own_hi)); + if given_lo.is_none() && given_hi.is_none() && lo >= hi { + let pad = (lo.abs() * 1e-6).max(0.5); + (lo, hi) = (lo - pad, hi + pad); + } + if !valid_range(lo, hi) { + return Err(format!("{} min {lo} must be a finite number less than max {hi}", ["red", "green", "blue"][i])); + } + *pair = [lo, hi]; + } + rl.style.stretch = Stretch::custom_bands(pairs); + return Ok(()); + } + let (min, max) = (min_b.at(0).filter(|_| !each), max_b.at(0).filter(|_| !each)); + // Only a custom stretch reads the statistics: those of the band being drawn. + let band = rl.style.renderer.bands().first().copied().unwrap_or(0); + let stats = r.bands.get(band).map(|b| &b.stats); + if custom && stats.is_none() { + return Err("the raster has no bands".into()); + } + let mut st = Stretch::from_code(code, f(p, "low"), f(p, "high"), f(p, "n"), min, max, stats) + .ok_or_else(|| format!("unknown stretch '{code}'"))?; + // A band with a single value has no range of its own: open one around it to edit from. + if let Stretch::Custom { min: lo, max: hi, .. } = st + && min.is_none() + && max.is_none() + && lo >= hi + { + // Padded by the value's magnitude too, or a large value would absorb ±0.5. + let pad = (lo.abs() * 1e-6).max(0.5); + st = Stretch::custom(lo - pad, hi + pad); + } + // Statistics can be NaN or infinite (a band of nodata), which `min >= max` alone misses. + if let Stretch::Custom { min, max, .. } = st + && !valid_range(min, max) + { + return Err(format!("min {min} must be a finite number less than max {max}")); + } + rl.style.stretch = st; + Ok(()) + }) } /// Applies `f` to the raster layer named by `p`. @@ -622,8 +704,35 @@ fn bands(s: &mut Session, p: &Value) -> CmdResult { (band(p, "red", &r)?.unwrap_or(cur.0), band(p, "green", &r)?.unwrap_or(cur.1), band(p, "blue", &r)?.unwrap_or(cur.2)) } }; - with(s, p, |rl, _| { + with(s, p, |rl, r| { + let before = match rl.style.renderer { + RasterRenderer::Rgb { red, green, blue } => Some([red, green, blue]), + _ => None, + }; + // The range each channel is drawn over, read before the bands change. + let drawn = before.filter(|_| matches!(rl.style.stretch, Stretch::Custom { .. })).and_then(|b| { + let ranges: Vec<_> = (0..3).filter_map(|i| channel_range(rl, r, i)).collect(); + (ranges.len() == 3).then_some((b, ranges)) + }); rl.style.renderer = RasterRenderer::Rgb { red, green, blue }; + // A channel moved to another band is redrawn over that band's range; the others keep theirs. + if let Some((old, ranges)) = drawn { + let new = [red, green, blue]; + if old != new { + let mut pairs = [[0.0; 2]; 3]; + for (i, pair) in pairs.iter_mut().enumerate() { + let kept = ranges.get(i).copied().unwrap_or((0.0, 1.0)); + let (lo, hi) = new + .get(i) + .filter(|b| old.get(i) != Some(*b)) + .and_then(|b| band_extent(rl, r, *b)) + .filter(|(lo, hi)| valid_range(*lo, *hi)) + .unwrap_or(kept); + *pair = [lo, hi]; + } + rl.style.stretch = Stretch::custom_bands(pairs); + } + } Ok(()) }) } diff --git a/crates/engine/src/cmd/zarr.rs b/crates/engine/src/cmd/zarr.rs index a7d4eba0a..4612fe08b 100644 --- a/crates/engine/src/cmd/zarr.rs +++ b/crates/engine/src/cmd/zarr.rs @@ -657,7 +657,7 @@ fn add_following(s: &mut Session, p: &Value) -> CmdResult { { let n = rustgis_map::raster::Normalizer::new(layer.style.stretch, stats, &r.data_type); if n.lo.is_finite() && n.hi.is_finite() && n.lo < n.hi { - layer.style.stretch = rustgis_map::raster::Stretch::Custom { min: n.lo, max: n.hi }; + layer.style.stretch = rustgis_map::raster::Stretch::custom(n.lo, n.hi); } } zt.shown = want; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index aa868f808..08bb4d245 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1076,6 +1076,8 @@ fn hostile_params_never_panic() { json!({"band": -1, "red": 1e12, "green": "x", "blue": f64::MAX, "preset": "", "ramp": "nope", "classes": -5, "n": 1e308, "low": -50, "high": 900, "gamma": 0, "zFactor": -1, "fileName": "a.tif", "data": "!!!"}), json!({"fileName": "a.tif", "data": "AAAA", "type": "unique", "on": "maybe"}), json!({"type": "custom", "min": 1e308, "max": -1e308}), + json!({"type": "custom", "min": [1e308, "x", null, 4], "max": [null, -1e308, 5]}), + json!({"type": "custom", "min": [1e308, null, 0], "max": [-1e308, f64::NAN, null], "red": 3}), json!({"kind": "colorbar", "ticks": -1e308, "tickSide": 7, "orientation": "x", "layer": "", "reverse": "maybe"}), json!({"type": "albers", "lon_0": f64::MAX, "lat_1": "x", "lat_2": -1e308}), json!({"kind": "legend", "element": 1, "exclude": [null, 7, "", {"x": 1}]}), @@ -11135,17 +11137,98 @@ fn buffer_point_adds_only_the_buffer() { assert!(s.run("gp.bufferPoint", &json!({"lon": 0, "lat": 0})).is_err()); } +#[test] +fn raster_stretch_custom_bounds_per_band() { + use rustgis_map::Stretch; + let mut s = with_raster(); + let layer = |s: &Session, i: usize| s.map().layers.get(i).and_then(|l| l.raster.as_ref()).unwrap().clone(); + let stretch = |s: &Session| layer(s, s.map().layers.len() - 1).style.stretch; + // Three bands with ranges of their own. + let ranges = [[0.0, 100.0], [0.0, 200.0], [10.0, 50.0]]; + { + let rl = s.map_mut().layers.last_mut().and_then(|l| l.raster.as_mut()).unwrap(); + let mut d = (**rl.data.as_ref().unwrap()).clone(); + assert!(d.bands.len() >= 3); + for (b, [lo, hi]) in d.bands.iter_mut().zip(ranges) { + b.stats.min = lo; + b.stats.max = hi; + } + rl.data = Some(std::sync::Arc::new(d)); + } + s.run("raster.symbology", &json!({"type": "rgb"})).unwrap(); + s.run("raster.stretch", &json!({"type": "minMax"})).unwrap(); + // Custom starts from the range each band is drawn over now. + s.run("raster.stretch", &json!({"type": "custom"})).unwrap(); + assert_eq!(stretch(&s), Stretch::custom_bands(ranges)); + // A bound for each band; null keeps that band's current one. + s.run("raster.stretch", &json!({"type": "custom", "min": [null, 5, null], "max": [90, null, null]})).unwrap(); + assert_eq!(stretch(&s), Stretch::custom_bands([[0.0, 90.0], [5.0, 200.0], [10.0, 50.0]])); + // A number is for every band. + s.run("raster.stretch", &json!({"type": "custom", "min": 1, "max": 99})).unwrap(); + assert_eq!(stretch(&s), Stretch::custom(1.0, 99.0)); + s.run("raster.stretch", &json!({"type": "custom", "min": 1})).unwrap(); + assert_eq!(stretch(&s), Stretch::custom_bands([[1.0, 99.0]; 3]), "the other bound is the range drawn now"); + // Bad bounds are refused, naming the band, and leave the stretch alone. + let before = stretch(&s); + for p in [ + json!({"type": "custom", "min": [300, null, null]}), + json!({"type": "custom", "max": [null, null, 1]}), + json!({"type": "custom", "min": [1, 2]}), + json!({"type": "custom", "min": [1, "x", null]}), + json!({"type": "custom", "min": [1, 2, 3, 4]}), + ] { + assert!(s.run("raster.stretch", &p).is_err(), "{p}"); + assert_eq!(stretch(&s), before, "{p}"); + } + let e = s.run("raster.stretch", &json!({"type": "custom", "max": [null, null, 1]})).unwrap_err(); + assert!(e.to_string().contains("blue"), "{e}"); + // A project round trip keeps them. + let json = serde_json::to_string(&stretch(&s)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), stretch(&s)); + // A channel moved to another band is redrawn over that band's range, the others keep theirs. + s.run("raster.stretch", &json!({"type": "custom", "min": [2, 3, 12], "max": [80, 150, 40]})).unwrap(); + s.run("raster.bands", &json!({"red": 2})).unwrap(); + assert_eq!(stretch(&s), Stretch::custom_bands([[0.0, 200.0], [3.0, 150.0], [12.0, 40.0]])); + s.run("raster.bands", &json!({"red": 2})).unwrap(); + assert_eq!(stretch(&s), Stretch::custom_bands([[0.0, 200.0], [3.0, 150.0], [12.0, 40.0]]), "same bands, same bounds"); + // Another stretch type leaves bands alone. + s.run("raster.stretch", &json!({"type": "minMax"})).unwrap(); + s.run("raster.bands", &json!({"red": 3})).unwrap(); + assert_eq!(stretch(&s), Stretch::MinMax); + // One band takes one pair only. + s.run("raster.symbology", &json!({"type": "stretch", "band": 1})).unwrap(); + assert!(s.run("raster.stretch", &json!({"type": "custom", "min": [1, 2, 3]})).is_err()); + s.run("raster.stretch", &json!({"type": "custom", "min": 3, "max": 7})).unwrap(); + assert_eq!(stretch(&s), Stretch::custom(3.0, 7.0)); +} + +#[test] +fn raster_stretch_like_copies_each_band() { + use rustgis_map::Stretch; + let mut s = with_raster(); + s.run("raster.symbology", &json!({"type": "rgb"})).unwrap(); + s.run("raster.stretch", &json!({"type": "custom", "min": [1, 2, 3], "max": [10, 20, 30]})).unwrap(); + s.run("layer.addDataBytes", &json!({"fileName": "scene.tif", "data": utm_tiff_b64()})).unwrap(); + let (from, to) = ("scene", "scene (2)"); + s.run("raster.symbology", &json!({"layer": to, "type": "rgb"})).unwrap(); + s.run("raster.stretch", &json!({"layer": to, "like": from})).unwrap(); + let st = s.map().layer_by_name(to).and_then(|l| l.raster.as_ref()).unwrap().style.stretch; + assert_eq!(st, Stretch::custom_bands([[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]])); +} + #[test] fn raster_stretch_custom_bounds() { use rustgis_map::Stretch; let mut s = with_raster(); + // One band: a missing bound is that band's own statistic (on RGB it is the range drawn now). + s.run("raster.symbology", &json!({"type": "stretch", "band": 1})).unwrap(); let stretch = |s: &Session| s.map().layers.last().unwrap().raster.as_ref().unwrap().style.stretch; let stats = s.map().layers.last().unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].stats.clone(); s.run("raster.stretch", &json!({"type": "custom", "min": 10, "max": 200})).unwrap(); - assert_eq!(stretch(&s), Stretch::Custom { min: 10.0, max: 200.0 }); + assert_eq!(stretch(&s), Stretch::custom(10.0, 200.0)); // A missing bound is the layer's own statistic. s.run("raster.stretch", &json!({"type": "custom", "min": 10})).unwrap(); - assert_eq!(stretch(&s), Stretch::Custom { min: 10.0, max: stats.max }); + assert_eq!(stretch(&s), Stretch::custom(10.0, stats.max)); // The bounds survive DRA and a project round trip. s.run("raster.dra", &json!({"on": true})).unwrap(); let kept = serde_json::to_string(&stretch(&s)).unwrap(); @@ -11161,6 +11244,7 @@ fn raster_stretch_custom_bounds() { assert_eq!(stretch(&s), before); // A band with one value still gets a range to edit from. let mut flat = with_raster(); + flat.run("raster.symbology", &json!({"type": "stretch", "band": 1})).unwrap(); { let rl = flat.map_mut().layers.last_mut().and_then(|l| l.raster.as_mut()).unwrap(); let mut d = (**rl.data.as_ref().unwrap()).clone(); @@ -11169,7 +11253,7 @@ fn raster_stretch_custom_bounds() { rl.data = Some(std::sync::Arc::new(d)); } flat.run("raster.stretch", &json!({"type": "custom"})).unwrap(); - assert_eq!(stretch(&flat), Stretch::Custom { min: 6.5, max: 7.5 }); + assert_eq!(stretch(&flat), Stretch::custom(6.5, 7.5)); // The padding follows the value's size, and statistics that aren't numbers are refused. let set_stats = |s: &mut Session, v: f64| { let rl = s.map_mut().layers.last_mut().and_then(|l| l.raster.as_mut()).unwrap(); @@ -11180,7 +11264,7 @@ fn raster_stretch_custom_bounds() { }; set_stats(&mut flat, 9007199254740992.0); flat.run("raster.stretch", &json!({"type": "custom"})).unwrap(); - assert!(matches!(stretch(&flat), Stretch::Custom { min, max } if min < 9007199254740992.0 && max > 9007199254740992.0)); + assert!(matches!(stretch(&flat), Stretch::Custom { min, max, .. } if min < 9007199254740992.0 && max > 9007199254740992.0)); let before = stretch(&flat); for v in [f64::NAN, f64::INFINITY] { set_stats(&mut flat, v); @@ -11606,14 +11690,14 @@ fn dem_tiles_mosaic_clip_by_polygon_share_a_stretch_and_report_overviews() { s.run("raster.stretch", &json!({"layer": "west", "type": "custom", "min": 100, "max": 300})).unwrap(); s.run("raster.stretch", &json!({"layer": "east", "like": "west"})).unwrap(); let st = |s: &Session, n: &str| s.map().layer_by_name(n).unwrap().raster.as_ref().unwrap().style.stretch; - assert_eq!(st(&s, "east"), rustgis_map::Stretch::Custom { min: 100.0, max: 300.0 }); + assert_eq!(st(&s, "east"), rustgis_map::Stretch::custom(100.0, 300.0)); s.run("raster.stretch", &json!({"layer": "west", "type": "minMax"})).unwrap(); s.run("raster.stretch", &json!({"layer": "east", "like": "west"})).unwrap(); - assert_eq!(st(&s, "east"), rustgis_map::Stretch::Custom { min: 100.0, max: 109.0 }); + assert_eq!(st(&s, "east"), rustgis_map::Stretch::custom(100.0, 109.0)); // A mask on the source narrows its range, and the shared one with it. s.run("raster.mask", &json!({"layer": "west", "values": [100]})).unwrap(); s.run("raster.stretch", &json!({"layer": "east", "like": "west"})).unwrap(); - assert_eq!(st(&s, "east"), rustgis_map::Stretch::Custom { min: 101.0, max: 109.0 }); + assert_eq!(st(&s, "east"), rustgis_map::Stretch::custom(101.0, 109.0)); assert!(s.run("raster.stretch", &json!({"layer": "east", "like": "Countries"})).is_err()); // A raster opened from an overview says so in its results, not only in a notice. diff --git a/crates/map/src/raster.rs b/crates/map/src/raster.rs index 2f9cb9b16..70ed665e2 100644 --- a/crates/map/src/raster.rs +++ b/crates/map/src/raster.rs @@ -256,10 +256,13 @@ pub enum Stretch { /// Values as they are (0–255 for 8-bit data, otherwise minimum to maximum). None, MinMax, - /// Fixed bounds in the data's own units, whatever the layer's statistics. + /// Fixed bounds in the data's own units, whatever the layer's statistics. `bands` holds one + /// `[min, max]` pair per RGB channel when they differ; `min`/`max` are then the red channel's. Custom { min: f64, max: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + bands: Option<[[f64; 2]; 3]>, }, /// Clip the lowest and highest percent of pixels. PercentClip { @@ -281,6 +284,16 @@ impl Default for Stretch { } impl Stretch { + /// One pair of bounds for every band. + pub fn custom(min: f64, max: f64) -> Stretch { + Stretch::Custom { min, max, bands: None } + } + + /// A custom stretch with a pair of bounds per RGB channel. + pub fn custom_bands(bands: [[f64; 2]; 3]) -> Stretch { + Stretch::Custom { min: bands[0][0], max: bands[0][1], bands: Some(bands) } + } + pub const KINDS: [(&'static str, &'static str); 6] = [ ("none", "None"), ("minMax", "Minimum Maximum"), @@ -320,7 +333,7 @@ impl Stretch { Some(match code.trim().to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { "none" => Stretch::None, "minmax" | "minimummaximum" => Stretch::MinMax, - "custom" | "custommin/max" => Stretch::Custom { min: min.or(stats.map(|s| s.min))?, max: max.or(stats.map(|s| s.max))? }, + "custom" | "custommin/max" => Stretch::custom(min.or(stats.map(|s| s.min))?, max.or(stats.map(|s| s.max))?), "percentclip" => Stretch::PercentClip { low: pct(low), high: pct(high) }, "standarddeviation" | "stddev" => Stretch::StandardDeviation { n: n.unwrap_or(2.0).clamp(0.1, 10.0) }, "histogramequalize" | "histogramequalization" => Stretch::HistogramEqualize, @@ -700,11 +713,20 @@ pub struct Normalizer { impl Normalizer { pub fn new(stretch: Stretch, s: &BandStats, data_type: &str) -> Normalizer { + Normalizer::for_channel(stretch, 0, s, data_type) + } + + /// The normaliser of renderer channel `channel` (red 0, green 1, blue 2): the same as + /// [`Normalizer::new`], but a custom stretch with bounds per channel uses that channel's. + pub fn for_channel(stretch: Stretch, channel: usize, s: &BandStats, data_type: &str) -> Normalizer { let (lo, hi) = match stretch { Stretch::None if data_type == "UInt8" => (0.0, 255.0), Stretch::None | Stretch::MinMax | Stretch::HistogramEqualize => (s.min, s.max), // Fixed bounds ignore the statistics, so DRA's view statistics don't move them. - Stretch::Custom { min, max } => (min, max), + Stretch::Custom { min, max, bands } => match bands.as_ref().and_then(|b| b.get(channel)) { + Some([lo, hi]) => (*lo, *hi), + None => (min, max), + }, Stretch::PercentClip { low, high } => (s.percentile(low), s.percentile(100.0 - high)), Stretch::StandardDeviation { n } => ((s.mean - n * s.std_dev).max(s.min), (s.mean + n * s.std_dev).min(s.max)), }; @@ -773,7 +795,7 @@ impl<'a> Colorizer<'a> { .enumerate() .map(|(i, b)| { let s = view_stats.and_then(|v| v.get(i)).or_else(|| r.bands.get(*b).map(|b| &b.stats)); - s.map(|s| Normalizer::new(style.stretch, s, &r.data_type)).unwrap_or(Normalizer { lo: 0.0, hi: 1.0, cdf: None }) + s.map(|s| Normalizer::for_channel(style.stretch, i, s, &r.data_type)).unwrap_or(Normalizer { lo: 0.0, hi: 1.0, cdf: None }) }) .collect(); let ramp_lut = match &style.renderer { @@ -1073,7 +1095,7 @@ mod tests { } // Bounds read from a hand-edited project can be reversed, equal or not numbers. for (min, max) in [(5.0, 1.0), (2.0, 2.0), (f64::NAN, 1.0), (0.0, f64::INFINITY)] { - let n = Normalizer::new(Stretch::Custom { min, max }, st, "Float32"); + let n = Normalizer::new(Stretch::custom(min, max), st, "Float32"); assert!((0.0..=1.0).contains(&n.t(3.0)) && (0.0..=1.0).contains(&n.t(f64::NAN).max(0.0)), "{min} {max}"); } assert_eq!(Stretch::from_code("custom", None, None, None, None, None, None), None); @@ -1085,6 +1107,68 @@ mod tests { let _ = enhancement_lut(f64::NAN, f64::INFINITY, f64::NAN); } + fn rgb() -> Raster { + let gt = GeoTransform { origin_x: 0.0, origin_y: 1.0, pixel_width: 1.0, pixel_height: -1.0 }; + let bands = vec![vec![0.0, 100.0], vec![0.0, 200.0], vec![0.0, 50.0]]; + Raster::new("rgb", 2, 1, bands, gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap() + } + + #[test] + fn custom_stretch_bounds_per_channel() { + let r = rgb(); + let mut style = RasterStyle::default_for(&r); + style.renderer = RasterRenderer::Rgb { red: 0, green: 1, blue: 2 }; + // One pair for every channel: the same value draws differently only by band, never by bounds. + style.stretch = Stretch::custom(0.0, 100.0); + let shared = Colorizer::new(&style, &r, None).color(&[50.0, 50.0, 50.0], None).unwrap(); + assert_eq!(shared[..3], [128, 128, 128]); + // A pair per channel: each channel scales by its own bounds. + style.stretch = Stretch::custom_bands([[0.0, 100.0], [0.0, 200.0], [0.0, 50.0]]); + let c = Colorizer::new(&style, &r, None).color(&[50.0, 50.0, 50.0], None).unwrap(); + assert_eq!(c[..3], [128, 64, 255]); + // The red channel's bounds are the plain pair, so a single band reads them. + assert_eq!(style.stretch, Stretch::Custom { min: 0.0, max: 100.0, bands: style_bands(&style) }); + let n = Normalizer::for_channel(style.stretch, 1, &r.bands[1].stats, "Float32"); + assert_eq!((n.lo, n.hi), (0.0, 200.0)); + let n = Normalizer::new(style.stretch, &r.bands[1].stats, "Float32"); + assert_eq!((n.lo, n.hi), (0.0, 100.0)); + } + + fn style_bands(style: &RasterStyle) -> Option<[[f64; 2]; 3]> { + match style.stretch { + Stretch::Custom { bands, .. } => bands, + _ => None, + } + } + + #[test] + fn custom_stretch_files_stay_compatible() { + // Saved by a version that had one pair of bounds. + let old: Stretch = serde_json::from_str(r#"{"Custom":{"min":1.0,"max":2.0}}"#).unwrap(); + assert_eq!(old, Stretch::custom(1.0, 2.0)); + assert_eq!(serde_json::to_string(&old).unwrap(), r#"{"Custom":{"min":1.0,"max":2.0}}"#); + let per = Stretch::custom_bands([[0.0, 1.0], [2.0, 3.0], [4.0, 5.0]]); + let back: Stretch = serde_json::from_str(&serde_json::to_string(&per).unwrap()).unwrap(); + assert_eq!(back, per); + // Read as one pair (an older version ignoring `bands`) it is the red channel's. + let json: serde_json::Value = serde_json::to_value(per).unwrap(); + assert_eq!(json["Custom"]["min"], 0.0); + assert_eq!(json["Custom"]["max"], 1.0); + } + + #[test] + fn hostile_channel_bounds_never_panic() { + let r = rgb(); + let st = &r.bands[0].stats; + let bad = [5.0, 1.0]; + for pair in [bad, [2.0, 2.0], [f64::NAN, 1.0], [0.0, f64::INFINITY]] { + for ch in 0..5 { + let n = Normalizer::for_channel(Stretch::custom_bands([pair, pair, pair]), ch, st, "Float32"); + assert!((0.0..=1.0).contains(&n.t(3.0)) && (0.0..=1.0).contains(&n.t(f64::NAN).max(0.0)), "{pair:?} {ch}"); + } + } + } + #[test] fn hillshade_faces_the_light() { // Ground rising to the east faces west: light from the west brightens it. Flat ground is diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index b56caf1f3..ee61147a7 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -7,7 +7,7 @@ use egui::{Align2, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use rustgis_engine::Session; use rustgis_engine::analysis::{ParamKind, tools}; use rustgis_engine::data::GeometryType; -use rustgis_engine::map::raster::{RasterLegend, RasterStyle}; +use rustgis_engine::map::raster::{Normalizer, RasterLegend, RasterStyle}; use rustgis_engine::map::symbology::{ ClassRef, ClassStyle, LineCap, LineJoin, LineStroke, MAX_LABEL, MAX_STROKE_OFFSET, MAX_STROKE_WIDTH, MAX_STROKES, MAX_UNIQUE, normalize_label, @@ -4039,6 +4039,42 @@ fn raster_details(ui: &mut Ui, rl: &rustgis_engine::map::RasterLayer, t: &Tokens }); } +/// A Min and Max for each colour channel of an RGB layer: a stretch to the bands' own minimum and +/// maximum or to bounds of your own, which an edit turns it into. Edits are kept here until every +/// pair is valid, so the layer's values don't snap back. +fn rgb_stretch_bounds(app: &mut RustGisApp, ui: &mut Ui, layer: impl std::hash::Hash + std::fmt::Debug, t: &Tokens, shown: [[f64; 2]; 3]) { + type Bounds = [[f64; 2]; 3]; + let key = ui.id().with(("custom_stretch_rgb", layer)); + let pending = ui.data(|d| d.get_temp::<(Bounds, Bounds)>(key)).filter(|(base, _)| *base == shown); + let mut edit = pending.map_or(shown, |(_, e)| e); + let (mut done, mut changed) = (false, false); + for (name, pair) in [tl!("Red"), tl!("Green"), tl!("Blue")].into_iter().zip(edit.iter_mut()) { + let [mut lo, mut hi] = *pair; + let speed = ((hi - lo).abs() / 200.0).clamp(1e-6, 1e6); + ui.horizontal(|ui| { + ui.add_sized([44.0, 18.0], egui::Label::new(name)); + ui.label(tl!("Min")); + let a = ui.add(egui::DragValue::new(&mut lo).speed(speed)); + ui.label(tl!("Max")); + let b = ui.add(egui::DragValue::new(&mut hi).speed(speed)); + done |= a.drag_stopped() || b.drag_stopped() || ((a.changed() || b.changed()) && !a.dragged() && !b.dragged()); + changed |= a.changed() || b.changed(); + }); + if !(lo.is_finite() && hi.is_finite() && lo < hi) { + ui.label(egui::RichText::new(tl!("Min must be less than Max")).size(11.5).color(t.red)); + } + *pair = [lo, hi]; + } + let valid = edit.iter().all(|[lo, hi]| lo.is_finite() && hi.is_finite() && lo < hi); + if valid && done && edit != shown { + ui.data_mut(|d| d.remove::<(Bounds, Bounds)>(key)); + let col = |i: usize| edit.map(|p| p[i]); + let _ = app.run("raster.stretch", json!({"type": "custom", "min": col(0), "max": col(1)})); + } else if changed { + ui.data_mut(|d| d.insert_temp(key, (shown, edit))); + } +} + fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { ui.label(egui::RichText::new(&l.name).font(semibold(14.0))); let Some(rl) = l.raster.clone() else { return }; @@ -4245,7 +4281,19 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { } }); } - Stretch::Custom { min, max } => { + Stretch::Custom { .. } | Stretch::MinMax if matches!(style.renderer, RasterRenderer::Rgb { .. }) => { + let masked = rl.masked_stats(); + let shown: [[f64; 2]; 3] = std::array::from_fn(|i| { + let band = style.renderer.bands().get(i).copied().unwrap_or(0); + let stats = masked.as_ref().and_then(|m| m.get(i)).or_else(|| d.bands.get(band).map(|b| &b.stats)); + stats.map_or([0.0, 1.0], |st| { + let n = Normalizer::for_channel(style.stretch, i, st, &d.data_type); + [n.lo, n.hi] + }) + }); + rgb_stretch_bounds(app, ui, l.id, t, shown); + } + Stretch::Custom { min, max, .. } => { // Edits are kept here until both bounds are valid, so the layer's values don't snap back. // They remember the layer's bounds they started from, and go stale when those change. let key = ui.id().with(("custom_stretch", l.id)); diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 264e19e53..400b308f0 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -468,7 +468,7 @@ With a raster layer selected in Layers, the **Style** and **Data** tabs show its | Tab › Group | Controls | | --- | --- | | Style › Visibility Range, Effects | Scale range, transparency | -| Style › Rendering | **Symbology**: Stretch, RGB, Classify, Unique Values, Colormap, Shaded Relief; **Stretch Type**: None, Minimum Maximum, Custom Min/Max (your own bounds), Percent Clip, Standard Deviation, Histogram Equalize; **DRA** (stretch the pixels in view); **Resampling** (nearest or bilinear); **Band Combination** (natural colour, colour infrared, or any bands) | +| Style › Rendering | **Symbology**: Stretch, RGB, Classify, Unique Values, Colormap, Shaded Relief; **Stretch Type**: None, Minimum Maximum, Custom Min/Max (your own bounds, one pair per colour channel on an RGB layer), Percent Clip, Standard Deviation, Histogram Equalize; **DRA** (stretch the pixels in view); **Resampling** (nearest or bilinear); **Band Combination** (natural colour, colour infrared, or any bands) | | Style › Enhancement | Brightness, contrast, gamma | | Data | Raster information (size, bands, statistics, coordinate system) and Export Raster (GeoTIFF) | From 32e06020c3059620a72486b19f4d56af7e9615f5 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 12:54:32 +0200 Subject: [PATCH 021/297] fix(data): keep 3D surfaces and curves of layers read through GDAL (#691) * fix(data): keep 3D surfaces and curves of layers read through GDAL A layer mixing geometry types was split with filters that knew only points, lines and polygons: TINs and polyhedral surfaces (how GDAL reads an Esri multipatch) and curves were silently left out. swissBUILDINGS3D's Building_solid kept 98 of 5,270 buildings. The filters now take curves and surfaces, and each layer converts to its family's plain type, so TINs become multipolygons and curves become lines. The conversion type is part of the cache key, so earlier conversions are made again. Co-Authored-By: Claude Opus 5.5 * docs(data): group convert_one_in's parameter notes; drop an unneeded clippy allow Co-Authored-By: Claude Opus 5.5 * fix(data): a CircularString layer is lines, not mixed Co-Authored-By: Claude Opus 5.5 * fix(data): convert_one matches the layer name as convert does, ignoring case Co-Authored-By: Claude Opus 5.5 * fix(data): keep multipatches GDAL reads as geometry collections GDAL's Shapefile driver, and possibly other drivers, read a multipatch as a geometry collection of TINs and polygons; the polygon filter left them out. They convert to multipolygons like the TINs. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- crates/data/src/source/gdal.rs | 107 ++++++++++++++++++++++++++++----- crates/data/src/source/mod.rs | 36 ++++++++++- docs/user-guide/data.md | 6 +- 3 files changed, 128 insertions(+), 21 deletions(-) diff --git a/crates/data/src/source/gdal.rs b/crates/data/src/source/gdal.rs index b47d118df..3a9ccac4f 100644 --- a/crates/data/src/source/gdal.rs +++ b/crates/data/src/source/gdal.rs @@ -69,12 +69,12 @@ pub fn cache_dir() -> Option { external::cache_dir("RUSTGIS_GDAL_CACHE", "gdal") } -/// A layer: its name, whether it holds one kind of geometry (`Some`) or mixes them (`None`), and -/// its feature count when GDAL knows it. +/// A layer: its name, the one kind of geometry it holds (`None`: it mixes them), and its +/// feature count when GDAL knows it. #[derive(Clone, Debug, PartialEq)] struct LayerInfo { name: String, - single: bool, + family: Option, count: Option, /// Whether the layer says its coordinate system. has_srs: bool, @@ -98,10 +98,9 @@ fn list(src: &Path) -> Result> { .filter_map(|l| { let name = l.get("name")?.as_str()?.to_string(); let geom = l.get("geometryFields").and_then(|g| g.as_array()).and_then(|g| g.first())?; - let ty = geom.get("type").and_then(|t| t.as_str()).unwrap_or("Geometry").to_ascii_lowercase(); - let single = ["point", "linestring", "polygon", "curve", "surface"].iter().any(|k| ty.contains(k)); + let family = Family::of_ogr_type(geom.get("type").and_then(|t| t.as_str()).unwrap_or("Geometry")); let has_srs = geom.get("coordinateSystem").is_some_and(|c| !c.is_null()); - Some(LayerInfo { name, single, count: l.get("featureCount").and_then(|c| c.as_i64()), has_srs }) + Some(LayerInfo { name, family, count: l.get("featureCount").and_then(|c| c.as_i64()), has_srs }) }) .take(MAX_LAYERS) .collect()) @@ -148,9 +147,9 @@ fn convert_in(dir: &Path, src: &Path, layer: Option<&str>, crs: Option<&str>) -> for l in chosen { let before = out.len(); let mut errors = Vec::new(); - let families: Vec> = if l.single { vec![None] } else { Family::ALL.iter().copied().map(Some).collect() }; + let families: Vec> = if l.family.is_some() { vec![None] } else { Family::ALL.iter().copied().map(Some).collect() }; for family in families { - match convert_one_in(dir, src, &l.name, family, crs, l.has_srs) { + match convert_one_in(dir, src, &l.name, family, l.family, crs, l.has_srs) { Ok(Some((path, unprojected))) => out.push(Converted { path, unprojected, @@ -186,19 +185,30 @@ fn convert_in(dir: &Path, src: &Path, layer: Option<&str>, crs: Option<&str>) -> /// features. The cache key covers the file's path, size and modification time. pub fn convert_one(src: &Path, layer: &str, family: Option, crs: Option<&str>) -> Result> { let dir = cache_dir().ok_or_else(|| DataError::Format("no cache folder for GDAL conversions".into()))?; - // Whether the layer says its coordinate system, as a fresh listing tells. - let has_srs = list(src)?.into_iter().find(|l| l.name == layer).is_none_or(|l| l.has_srs); - Ok(convert_one_in(&dir, src, layer, family, crs, has_srs)?.map(|(p, _)| p)) + // The layer's own kind of geometry, and whether it says its coordinate system, as a fresh + // listing tells. Its name is matched as `convert` matches it, ignoring case (the exact + // spelling first), and converted under the listed spelling, so the two share a conversion. + let all = list(src)?; + let info = all.iter().find(|l| l.name == layer).or_else(|| all.iter().find(|l| l.name.eq_ignore_ascii_case(layer))); + let has_srs = info.is_none_or(|l| l.has_srs); + let kind = info.and_then(|l| l.family); + let layer = info.map_or(layer, |l| l.name.as_str()); + Ok(convert_one_in(&dir, src, layer, family, kind, crs, has_srs)?.map(|(p, _)| p)) } /// The conversion and whether it is unprojected (see [`Converted::unprojected`]). -/// `has_srs` says whether the layer gives its coordinate system: one that doesn't (and has no -/// `crs` given) is taken as it is, unprojected. +/// +/// - `family` picks one kind of geometry out of a layer that mixes them; `kind` is the layer's +/// own, when it holds one. Either converts curves and surfaces (an Esri multipatch is a TIN or +/// a polyhedral surface) to plain lines and polygons, which the FlatGeobuf reader takes. +/// - `has_srs` says whether the layer gives its coordinate system: one that doesn't (and has no +/// `crs` given) is taken as it is, unprojected. fn convert_one_in( dir: &Path, src: &Path, layer: &str, family: Option, + kind: Option, crs: Option<&str>, has_srs: bool, ) -> Result> { @@ -213,7 +223,9 @@ fn convert_one_in( let (len, modified) = stamp(&full)?; // The cache key, spelled out: kept beside the conversion and checked, so a hash collision // can't hand back another file's layer. - let key = format!("{}\n{layer}\n{:?}\n{:?}\n{len}\n{modified}", full.display(), family.map(Family::label), crs); + let nlt = family.or(kind).map_or("PROMOTE_TO_MULTI", Family::ogr_type); + // The type is part of the key: conversions made before surfaces were kept are made again. + let key = format!("{}\n{layer}\n{:?}\n{:?}\n{nlt}\n{len}\n{modified}", full.display(), family.map(Family::label), crs); let tag = format!("{:016x}", fnv1a(key.as_bytes())); let out = dir.join(format!("{tag}.fgb")); let empty = dir.join(format!("{tag}.empty")); @@ -243,7 +255,7 @@ fn convert_one_in( // neither is taken as it is. let unprojected = crs.is_none() && !has_srs; let mut c = Command::new(&ogr2ogr); - c.args(["-f", "FlatGeobuf", "-skipfailures", "-nlt", "PROMOTE_TO_MULTI", "-dim", "XY", "-lco", "SPATIAL_INDEX=YES"]); + c.args(["-f", "FlatGeobuf", "-skipfailures", "-nlt", nlt, "-dim", "XY", "-lco", "SPATIAL_INDEX=YES"]); if let Some(crs) = crs { c.args(["-s_srs", crs]); } @@ -311,6 +323,7 @@ fn ours(name: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::GeometryType; #[test] fn names() { @@ -323,7 +336,7 @@ mod tests { ); assert!(!is_gdal_name("a.geojson") && !is_gdal_name("a.fgb") && !is_gdal_name("noext")); // A layer named like an option never reaches GDAL. - let e = convert_one_in(&std::env::temp_dir(), Path::new("x.kml"), "-overwrite", None, None, true).unwrap_err(); + let e = convert_one_in(&std::env::temp_dir(), Path::new("x.kml"), "-overwrite", None, None, None, true).unwrap_err(); assert!(e.to_string().contains("starting with '-'"), "{e}"); // Pruning touches only the cache's own files. assert!(ours("0123456789abcdef.fgb") && ours("0123456789abcdef.key") && ours("0123456789abcdef.empty")); @@ -334,6 +347,68 @@ mod tests { assert_ne!(fnv1a(b"a"), fnv1a(b"b")); } + #[test] + fn layer_types_name_their_family() { + for (ty, family) in [ + ("MultiPolygonZ", Some(Family::Polygons)), + ("TIN", Some(Family::Polygons)), + ("PolyhedralSurfaceZ", Some(Family::Polygons)), + ("MultiSurface", Some(Family::Polygons)), + ("CurvePolygon", Some(Family::Polygons)), + ("CompoundCurve", Some(Family::Lines)), + ("CircularStringZ", Some(Family::Lines)), + ("MultiLineString", Some(Family::Lines)), + ("PointZ", Some(Family::Points)), + ("GeometryZ", None), + ("Geometry", None), + ("GeometryCollection", None), + ] { + assert_eq!(Family::of_ogr_type(ty), family, "{ty}"); + } + } + + /// A layer mixing a point, a TIN (how GDAL reads an Esri multipatch), a curved polygon, a + /// compound curve and a collection of a TIN and a polygon (a multipatch from a Shapefile) + /// keeps all five: the surfaces, the curves and the collection were left out before. + #[test] + fn keeps_surfaces_curves_and_collections_when_gdal_is_installed() { + if !available() { + return; + } + let dir = std::env::temp_dir().join(format!("rustgis-gdal-surfaces-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("mixed.csv"); + std::fs::write( + &csv, + "WKT,name\n\ + \"POINT (10 20)\",p\n\ + \"TIN Z (((0 0 1,1 0 1,0 1 2,0 0 1)),((1 0 1,1 1 2,0 1 2,1 0 1)))\",tin\n\ + \"CURVEPOLYGON (CIRCULARSTRING (0 0,1 1,2 0,1 -1,0 0))\",curved\n\ + \"COMPOUNDCURVE ((0 0,1 0),CIRCULARSTRING (1 0,2 1,3 0))\",curve\n\ + \"GEOMETRYCOLLECTION Z (TIN Z (((5 5 1,6 5 1,5 6 2,5 5 1))),POLYGON Z ((7 7 0,8 7 0,8 8 0,7 7 0)))\",patch\n", + ) + .unwrap(); + let (out, failed) = convert_in(&dir.join("cache"), &csv, None, Some("EPSG:4326")).unwrap(); + assert!(failed.is_empty(), "{failed:?}"); + let kinds: Vec<(String, GeometryType, u64)> = out + .iter() + .map(|c| { + let s = crate::source::open_path(&c.path, None).unwrap(); + (c.name.clone(), s.info().geometry_type, s.info().count.unwrap_or(0)) + }) + .collect(); + assert_eq!( + kinds, + vec![ + ("mixed (points)".to_string(), GeometryType::Point, 1), + ("mixed (lines)".to_string(), GeometryType::Polyline, 1), + ("mixed (polygons)".to_string(), GeometryType::Polygon, 3), + ], + "{kinds:?}" + ); + let _ = std::fs::remove_dir_all(dir); + } + /// A KML mixing points and polygons, through GDAL when it is installed (skipped otherwise). #[test] fn converts_a_mixed_kml_when_gdal_is_installed() { diff --git a/crates/data/src/source/mod.rs b/crates/data/src/source/mod.rs index 66c1eff1c..63a1a22c9 100644 --- a/crates/data/src/source/mod.rs +++ b/crates/data/src/source/mod.rs @@ -61,12 +61,42 @@ impl Family { } } - /// An OGR SQL filter on the geometry type. + /// An OGR SQL filter on the geometry type. Curves and surfaces count as lines and polygons: + /// an Esri multipatch, read as a TIN, a polyhedral surface or (from a Shapefile) a geometry + /// collection of them, is polygons. A collection of anything else fails the conversion to + /// polygons and is left out, as before. pub fn filter(self) -> &'static str { match self { Family::Points => "OGR_GEOMETRY IN ('POINT', 'MULTIPOINT')", - Family::Lines => "OGR_GEOMETRY IN ('LINESTRING', 'MULTILINESTRING')", - Family::Polygons => "OGR_GEOMETRY IN ('POLYGON', 'MULTIPOLYGON')", + Family::Lines => "OGR_GEOMETRY IN ('LINESTRING', 'MULTILINESTRING', 'CIRCULARSTRING', 'COMPOUNDCURVE', 'MULTICURVE')", + Family::Polygons => { + "OGR_GEOMETRY IN ('POLYGON', 'MULTIPOLYGON', 'CURVEPOLYGON', 'MULTISURFACE', 'TRIANGLE', 'TIN', 'POLYHEDRALSURFACE', 'GEOMETRYCOLLECTION')" + } + } + } + + /// The OGR geometry type its features are converted to: curves become straight segments, and + /// a surface's faces the parts of a multipolygon. + pub fn ogr_type(self) -> &'static str { + match self { + Family::Points => "MULTIPOINT", + Family::Lines => "MULTILINESTRING", + Family::Polygons => "MULTIPOLYGON", + } + } + + /// The family of an OGR layer geometry type as `ogrinfo -json` spells it (`MultiPolygonZ`, + /// `TIN`, `CompoundCurve`); `None` for one that mixes them (`GeometryZ`). + pub fn of_ogr_type(ty: &str) -> Option { + let ty = ty.to_ascii_lowercase(); + if ty.contains("point") { + Some(Family::Points) + } else if ["polygon", "surface", "tin", "triangle"].iter().any(|k| ty.contains(k)) { + Some(Family::Polygons) + } else if ["linestring", "circularstring", "curve"].iter().any(|k| ty.contains(k)) { + Some(Family::Lines) + } else { + None } } } diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 400b308f0..bf1af26a3 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -66,8 +66,10 @@ FlatGeobuf with a spatial index in WGS 84 (GDAL reprojects it), kept in the cach `%LOCALAPPDATA%\RustGIS\cache\gdal` on Windows, or `RUSTGIS_GDAL_CACHE`). Adding the file again reuses the conversion until the file changes, and large layers stream like any large FlatGeobuf (below). Every layer of the file is added; a layer mixing geometry types is -added as one layer per type. Projects remember the original file and convert it again if the -cached copy is gone. +added as one layer per type. Curves become straight segments, and 3D surfaces (an Esri +multipatch, such as the buildings of swissBUILDINGS3D, is read as a TIN) become flat +polygons, one part per face, without their heights. Projects remember the original file and +convert it again if the cached copy is gone. ## SQL queries and DuckDB databases From 8b4f78ada6a548485dd5602616da0069e5d6d64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Thu, 22 Oct 2026 05:55:05 -0500 Subject: [PATCH 022/297] fix(ui): let Ctrl/Cmd+Z undo a text field's typing, not the project (#689) * fix(ui): let Ctrl/Cmd+Z undo a text field's typing, not the project * fix(ui): leave undo to a focused text field only, not to any focused widget --- crates/ui-egui/src/chrome.rs | 41 ++++++++++++++++++++++++++++++++++-- crates/ui-egui/src/keymap.rs | 14 ++++++++++++ docs/user-guide/index.md | 3 ++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2fb83a425..0e4b0b7ec 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -476,14 +476,18 @@ pub fn shortcuts(app: &mut RustGisApp, ctx: &egui::Context) { // The search box was typed in until egui took its focus away on Escape: that Escape clears // the box, not the tool. let typing = ctx.egui_wants_keyboard_input() || crate::searchbox::had_focus(&app.ui.search, ctx.cumulative_pass_nr()); + // A text field focused, not any widget (a button reached with Tab): it has an undo of its own. + let text_field = ctx.text_edit_focused(); // An element selected in the open layout, for the layout's own keys. let element = app.session.active_layout.is_some() && app.layout.selected.is_some(); let mut run: Vec<(String, Value)> = Vec::new(); let keymap = &app.ui.keymap; ctx.input_mut(|i| { for b in keymap.match_order() { - // Keys without Ctrl (⌘) type text while a text field has focus. - if typing && !b.chord.command { + // While a text field has focus, it keeps the keys it acts on: those without Ctrl (⌘), + // which type text, and undo and redo, whatever they're bound to (#685), as ModelBuilder + // leaves them. + if (typing && !b.chord.command) || (text_field && b.chord.is_text_undo()) { continue; } // Duplicating needs a selected layout element, and not a text field being typed in; @@ -1179,6 +1183,39 @@ mod tests { } } + /// With a text field focused, Ctrl/⌘+Z undoes its typing, not the project; with none, the + /// project (#685). + #[test] + fn undo_in_a_text_field_undoes_its_text() { + use egui_kittest::kittest::Queryable; + let mut app = RustGisApp::with_sample(); + app.run("labels.style", serde_json::json!({"layer": "Cities", "size": 20})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(500.0, 700.0)).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + shortcuts(app, &ctx); + crate::analysispane::script_pane(app, ui, &Tokens::light()); + }, + app, + ); + h.run(); + h.get_by_role(egui::accesskit::Role::MultilineTextInput).focus(); + h.run(); + h.event(egui::Event::Text("map.zoomFull".into())); + h.run(); + let depth = h.state().session.undo_depth(); + let undo = egui::Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }; + h.event(undo.clone()); + h.run(); + assert_eq!(h.state().session.undo_depth(), depth, "the project untouched"); + assert_eq!(h.state().workflow.script, "", "the typing undone"); + h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new("script_text"))); + h.run(); + h.event(undo); + h.run(); + assert_eq!(h.state().session.undo_depth(), depth - 1, "no field focused: the project"); + } + #[test] fn header_without_a_unified_title_bar_keeps_its_usual_size() { let l = header_layout(None); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 68ead143d..f487928e0 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -70,6 +70,12 @@ pub struct Chord { } impl Chord { + /// Whether a focused text field takes this chord for itself: egui's undo (Mod+Z) and redo + /// (Mod+Y, Mod+Shift+Z), matched as egui matches them, an extra Alt or Shift aside. + pub fn is_text_undo(self) -> bool { + self.command && matches!(self.key, Key::Z | Key::Y) + } + /// Reads `Mod+Shift+Z`, `Ctrl+T`, `Home`, `F5`, `Mod++`. `Ctrl`, `Cmd` and `⌘` all mean /// `Mod`; letters may be lower case. `None` for anything else. pub fn parse(s: &str) -> Option { @@ -275,6 +281,14 @@ pub fn label(app: &RustGisApp, command: &str, params: &Value) -> String { mod tests { use super::*; + /// Undo and redo are a text field's own, an extra Alt or Shift aside; nothing else is. + #[test] + fn text_undo_chords_are_undo_and_redo() { + let is = |s: &str| Chord::parse(s).unwrap().is_text_undo(); + assert!(is("Mod+Z") && is("Mod+Shift+Z") && is("Mod+Y") && is("Mod+Alt+Z")); + assert!(!is("Mod+S") && !is("Z") && !is("Mod+K")); + } + #[test] fn chords_read_and_write() { let c = Chord::parse("Mod+Shift+Z"); diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 9a6c3ac08..c7e1146e7 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -137,4 +137,5 @@ Every change is a command, and every command can be undone (++ctrl+z++) and redo | ++ctrl+c++ / ++ctrl+x++ / ++ctrl+v++ | Copy / Cut / Paste features | On macOS, ++cmd++ takes the place of ++ctrl++. **Help › Shortcuts** lists them all in the app, -from the same table the app runs them from. +from the same table the app runs them from. While you type in a text field, undo and redo take +back its typing, not the project's last change. From 58e2fdaefd073b0436e875bc5fb9c9b13b513586 Mon Sep 17 00:00:00 2001 From: Juhans Date: Thu, 22 Oct 2026 21:07:03 +0700 Subject: [PATCH 023/297] Add attribute table geometry calculator with field and unit selection (#633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add attribute table geometry calculator with field and unit selection * Fix new field naming when switching geometry calculator modes * Validate longitude bounds in geometry calculator * Calculate Geometry: measure shapes across the antimeridian, review fixes - Allow longitudes up to ±360° for area, perimeter and length, which use longitude differences, so a buffer drawn across the antimeridian is measured; X/Y coordinates still need a real longitude. - Trim the field name the dialog sends and say when the EPSG code is not a number instead of "EPSG code is required". - Test that the dialog calculates the table's layer, not the active one. - Drop the duplicate acknowledgements page from the assistant's docs and the "MARAGA" names; document the dialog in the editing guide. --------- Co-authored-by: giswqs --- crates/engine/src/cmd/edit.rs | 3 + crates/engine/src/geometry_calc.rs | 176 +++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/tests/geometry_calculator.rs | 164 +++++++++++++++ crates/ui-egui/src/dialogs.rs | 5 + crates/ui-egui/src/geometrycalc.rs | 219 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 7 + crates/ui-egui/src/table.rs | 11 ++ crates/ui-egui/tests/smoke.rs | 46 +++++ docs/user-guide/editing.md | 5 +- 10 files changed, 635 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/geometry_calc.rs create mode 100644 crates/engine/tests/geometry_calculator.rs create mode 100644 crates/ui-egui/src/geometrycalc.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 016848e2d..7188f6e35 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,6 +8,9 @@ use crate::{CmdResult, CommandSpec as C, Session}; pub fn commands() -> Vec { vec![ + C::new("edit.calculateGeometry", "Calculate Geometry", "Attribute Table", crate::geometry_calc::run) + .when(super::design::need_editable) + .params(r#"{"layer"?, "field": string, "create": bool, "overwrite": bool, "property": string, "unit": string, "epsg"?: number, "scope": "all"|"selected"}"#), C::new("edit.addFeature", "Create Feature", "Edit › Features", add_feature) .when(super::design::need_editable) .params(r#"{"layer"?, "geometry": GeoJSON geometry (lon/lat), "attributes"?: {field: value}, "template"?: feature template}"#), diff --git a/crates/engine/src/geometry_calc.rs b/crates/engine/src/geometry_calc.rs new file mode 100644 index 000000000..6a4b1245b --- /dev/null +++ b/crates/engine/src/geometry_calc.rs @@ -0,0 +1,176 @@ +//! The attribute table's geometry calculator: stores each feature's area, perimeter, length or +//! X/Y coordinate, calculated from its WGS84 geometry, in a field. +use crate::{CmdResult, Session}; +use rustgis_data::{FieldType, Geometry, GeometryType, Value, raster::Crs}; +use rustgis_geom::{geodesic_length_m, geodesic_ring_area_m2, spherical_centre}; +use serde_json::{Value as Json, json}; + +/// The properties a layer of `kind` can calculate, as `(key, label)` pairs. +pub fn properties(kind: GeometryType) -> &'static [(&'static str, &'static str)] { + match kind { + GeometryType::Polygon => &[("area", "Area"), ("perimeter", "Perimeter")], + GeometryType::Polyline => &[("length", "Length")], + GeometryType::Point => &[("x", "X coordinate"), ("y", "Y coordinate")], + GeometryType::None => &[], + } +} + +/// The units `property` can be calculated in, as `(key, label)` pairs. +pub fn units(property: &str) -> &'static [(&'static str, &'static str)] { + match property { + "area" => &[("m2", "Square metres"), ("km2", "Square kilometres"), ("ha", "Hectares"), ("ac", "Acres")], + "length" | "perimeter" => &[("m", "Metres"), ("km", "Kilometres"), ("ft", "Feet"), ("mi", "Miles")], + "x" | "y" => &[("deg", "Degrees (WGS84)"), ("projected", "Projected CRS units")], + _ => &[], + } +} + +fn measure(g: &Geometry, property: &str) -> Option { + match (g, property) { + (Geometry::Polygon(polys), "area") => Some( + polys + .iter() + .map(|rings| rings.iter().enumerate().map(|(i, r)| geodesic_ring_area_m2(r) * if i == 0 { 1.0 } else { -1.0 }).sum::()) + .sum(), + ), + (Geometry::Polygon(polys), "perimeter") => Some( + polys + .iter() + .flatten() + .map(|r| { + let closing = r.first().zip(r.last()).filter(|(a, b)| a != b).map(|(a, b)| geodesic_length_m(&[*b, *a])).unwrap_or(0.0); + geodesic_length_m(r) + closing + }) + .sum(), + ), + (Geometry::Line(parts), "length") => Some(parts.iter().map(|r| geodesic_length_m(r)).sum()), + _ => None, + } +} + +/// Runs `edit.calculateGeometry`: fills `field` (new or existing numeric) of the chosen features +/// with `property` in `unit`, as one undo step; on any error nothing changes. +pub fn run(s: &mut Session, p: &Json) -> CmdResult { + let id = s.layer_id(p)?; + let l = s.map().layer(id).ok_or("no layer")?; + let property = p.get("property").and_then(Json::as_str).ok_or("property is required")?; + let unit = p.get("unit").and_then(Json::as_str).ok_or("unit is required")?; + if !properties(l.geometry_type()).iter().any(|(k, _)| *k == property) { + return Err("property does not match the layer geometry".into()); + } + if !units(property).iter().any(|(k, _)| *k == unit) { + return Err("unit does not match the property".into()); + } + let transformer = if unit == "projected" { + let code = p.get("epsg").and_then(Json::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or("EPSG code is required")?; + let crs = Crs::from_epsg(code).filter(|c| !c.is_geographic()).ok_or("choose a supported projected EPSG code")?; + Some(crs.transformer()?) + } else { + None + }; + let field = p.get("field").and_then(Json::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or("field is required")?; + let create = p.get("create").and_then(Json::as_bool).unwrap_or(false); + let existing = l.dataset.field_index(field); + let ty = if create { + if existing.is_some() { + return Err("field already exists".into()); + } + if field.chars().count() > 64 || !field.chars().all(|c| c.is_alphanumeric() || c == '_') { + return Err("field names use letters, digits and _ (at most 64)".into()); + } + FieldType::Double + } else { + let ty = existing.and_then(|i| l.dataset.fields.get(i)).map(|f| f.ty).filter(|t| t.is_numeric()).ok_or("choose a numeric field")?; + if p.get("overwrite").and_then(Json::as_bool) != Some(true) { + return Err("confirm overwriting the existing field".into()); + } + ty + }; + let mut rows: Vec = match p.get("scope").and_then(Json::as_str) { + Some("all") => (0..l.dataset.features.len()).collect(), + Some("selected") if !l.selection.is_empty() => l.selection.clone(), + Some("selected") => return Err("no features selected".into()), + _ => return Err("scope must be all or selected".into()), + }; + rows.sort_unstable(); + rows.dedup(); + if rows.is_empty() { + return Err("no features to calculate".into()); + } + let divisor = match unit { + "km" => 1e3, + "ft" => 0.3048, + "mi" => 1609.344, + "km2" => 1e6, + "ha" => 1e4, + "ac" => 4046.8564224, + _ => 1.0, + }; + let mut values = Vec::with_capacity(rows.len()); + let mut nulls = 0; + for row in &rows { + let f = l.dataset.features.get(*row).ok_or("selection contains a missing feature")?; + let value = if let Some(g) = &f.geometry { + if g.kind() != l.geometry_type() { + return Err("feature geometry does not match the layer".into()); + } + // Lengths and areas use longitude differences, so a shape drawn or buffered across the + // antimeridian may run past ±180°; a coordinate has to be a real longitude. + let max_x = if matches!(property, "x" | "y") { 180.0 } else { 360.0 }; + let mut valid = true; + g.for_each_coord(|c| { + valid &= c.is_finite() && (-max_x..=max_x).contains(&c.x) && (-90.0..=90.0).contains(&c.y); + }); + if !valid { + return Err(format!("feature {} has invalid coordinates", f.id)); + } + if g.coord_count() == 0 { + None + } else if matches!(property, "x" | "y") { + let c = match g { + Geometry::Point(c) => Some(*c), + Geometry::MultiPoint(v) => spherical_centre(v), + _ => None, + }; + let c = match (c, &transformer) { + (Some(c), Some(t)) => Some(t.from_lonlat(c).ok_or_else(|| format!("feature {} cannot be projected", f.id))?), + (c, _) => c, + }; + c.map(|c| if property == "x" { c.x } else { c.y }) + } else { + measure(g, property).map(|v| v / divisor) + } + } else { + None + }; + let value = match value { + Some(v) if !v.is_finite() || (matches!(property, "area" | "length" | "perimeter") && v < 0.0) => { + return Err("invalid geometry measurement".into()); + } + Some(v) if ty == FieldType::Integer => { + let n = v.round(); + if !(-9223372036854775808.0..9223372036854775808.0).contains(&n) { + return Err("result exceeds the integer field range".into()); + } + Value::Int(n as i64) + } + Some(v) => Value::Float(v), + None => { + nulls += 1; + Value::Null + } + }; + values.push((*row, value)); + } + let l = s.map_mut().layer_mut(id).ok_or("no layer")?; + let fi = if create { l.dataset.ensure_field(field, FieldType::Double) } else { existing.ok_or("no field")? }; + let mut changes = Vec::with_capacity(values.len()); + for (row, value) in values { + let f = l.dataset.features.get_mut(row).ok_or("no feature")?; + let before = f.clone(); + *f.attrs.get_mut(fi).ok_or("missing attribute cell")? = value; + changes.push(crate::cmd::design::Change { before: Some(before), row: Some(row) }); + } + crate::cmd::design::apply(s, id, changes)?; + Ok(json!({"rows": rows.len(), "nulls": nulls, "field": field})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6f423ce27..57fa6e77c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod cmd; +pub mod geometry_calc; pub mod model; pub mod project; pub mod sample; diff --git a/crates/engine/tests/geometry_calculator.rs b/crates/engine/tests/geometry_calculator.rs new file mode 100644 index 000000000..fb2c5cb24 --- /dev/null +++ b/crates/engine/tests/geometry_calculator.rs @@ -0,0 +1,164 @@ +use rustgis_engine::{ + Project, Session, + data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}, + geom::Coord, +}; +use serde_json::json; + +fn session(kind: GeometryType, geometries: Vec>) -> (Session, u32) { + rustgis_engine::plugins::store::set_dir(Some(std::env::temp_dir().join("rustgis-geometry-calc-test-plugins"))); + let mut s = Session::new(Project::default()); + let mut ds = Dataset::new("test", kind, vec![Field::new("result", FieldType::Double), Field::new("integer", FieldType::Integer)]); + for g in geometries { + ds.push(g, vec![Value::Float(99.0), Value::Int(99)]); + } + let id = s.map_mut().add_dataset(ds); + s.active_layer = Some(id); + (s, id) +} + +#[test] +fn selected_units_overwrite_and_undo() { + let line = |x| Some(Geometry::Line(vec![vec![Coord::new(0.0, 0.0), Coord::new(x, 0.0)]])); + let (mut s, id) = session(GeometryType::Polyline, vec![line(1.0), line(2.0), None]); + s.map_mut().layer_mut(id).unwrap().selection = vec![1]; + let mut p = json!({"layer":id,"field":"result","property":"length","unit":"km","scope":"selected"}); + assert!(s.run("edit.calculateGeometry", &p).is_err()); + assert_eq!(s.map().layer(id).unwrap().dataset.value(1, 0).as_f64(), Some(99.0)); + p["overwrite"] = json!(true); + assert_eq!(s.run("edit.calculateGeometry", &p).unwrap()["rows"], 1); + let value = s.map().layer(id).unwrap().dataset.value(1, 0).as_f64().unwrap(); + assert!((222.0..223.0).contains(&value)); + assert_eq!(s.map().layer(id).unwrap().dataset.value(0, 0).as_f64(), Some(99.0)); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.map().layer(id).unwrap().dataset.value(1, 0).as_f64(), Some(99.0)); + p["create"] = json!(true); + p["field"] = json!("metres"); + p["unit"] = json!("m"); + p["scope"] = json!("all"); + assert_eq!(s.run("edit.calculateGeometry", &p).unwrap()["nulls"], 1); + let ds = &s.map().layer(id).unwrap().dataset; + let fi = ds.field_index("metres").unwrap(); + assert!((ds.value(1, fi).as_f64().unwrap() / value - 1000.0).abs() < 1e-9); + assert_eq!(ds.value(2, fi), &Value::Null); + s.run("edit.undo", &json!({})).unwrap(); + assert!(s.map().layer(id).unwrap().dataset.field_index("metres").is_none()); + s.map_mut().layer_mut(id).unwrap().selection.clear(); + p["scope"] = json!("selected"); + assert!(s.run("edit.calculateGeometry", &p).is_err()); + assert!(s.map().layer(id).unwrap().dataset.field_index("metres").is_none()); +} + +#[test] +fn polygon_holes_multipart_and_area_units() { + let ring = |x: f64, size: f64| { + vec![Coord::new(x, 0.0), Coord::new(x + size, 0.0), Coord::new(x + size, size), Coord::new(x, size), Coord::new(x, 0.0)] + }; + let outer = ring(0.0, 0.01); + let hole = ring(0.002, 0.002); + let part = ring(0.02, 0.005); + let expected = rustgis_engine::geom::geodesic_ring_area_m2(&outer) - rustgis_engine::geom::geodesic_ring_area_m2(&hole) + + rustgis_engine::geom::geodesic_ring_area_m2(&part); + let (mut s, id) = session(GeometryType::Polygon, vec![Some(Geometry::Polygon(vec![vec![outer, hole], vec![part]]))]); + for (unit, divisor) in [("m2", 1.0), ("km2", 1e6), ("ha", 1e4), ("ac", 4046.8564224)] { + s.run("edit.calculateGeometry", &json!({"layer":id,"field":"result","overwrite":true,"property":"area","unit":unit,"scope":"all"})) + .unwrap(); + let actual = s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap(); + assert!((actual - expected / divisor).abs() < 1e-6); + } + assert!( + s.run("edit.calculateGeometry", &json!({"layer":id,"field":"bad","create":true,"property":"length","unit":"m","scope":"all"})) + .is_err() + ); + assert!(s.map().layer(id).unwrap().dataset.field_index("bad").is_none()); +} + +#[test] +fn coordinates_projection_rounding_and_atomic_validation() { + let (mut s, id) = session(GeometryType::Point, vec![Some(Geometry::Point(Coord::new(117.0, 3.0)))]); + let mut p = json!({"layer":id,"field":"result","overwrite":true,"property":"x","unit":"deg","scope":"all"}); + s.run("edit.calculateGeometry", &p).unwrap(); + assert_eq!(s.map().layer(id).unwrap().dataset.value(0, 0).as_f64(), Some(117.0)); + p["unit"] = json!("projected"); + p["epsg"] = json!(32650); + s.run("edit.calculateGeometry", &p).unwrap(); + assert!((s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap() - 500000.0).abs() < 1.0); + p["epsg"] = json!(4326); + assert!(s.run("edit.calculateGeometry", &p).is_err()); + assert!((s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap() - 500000.0).abs() < 1.0); + p["unit"] = json!("deg"); + p["property"] = json!("y"); + p["field"] = json!("integer"); + s.run("edit.calculateGeometry", &p).unwrap(); + assert_eq!(s.map().layer(id).unwrap().dataset.value(0, 1), &Value::Int(3)); + s.map_mut().editing.settings.enabled = false; + assert!(s.run("edit.calculateGeometry", &p).is_err()); +} + +#[test] +fn longitude_bounds_and_failed_calculation_preserve_data() { + for x in [-180.0, 180.0] { + let (mut s, id) = session(GeometryType::Point, vec![Some(Geometry::Point(Coord::new(x, 0.0)))]); + s.run( + "edit.calculateGeometry", + &json!({ + "layer": id, "field": "result", "overwrite": true, + "property": "x", "unit": "deg", "scope": "all" + }), + ) + .unwrap(); + assert_eq!(s.map().layer(id).unwrap().dataset.value(0, 0).as_f64(), Some(x)); + } + + for x in [-180.001, 180.001, -1e300, 1e300] { + let (mut s, id) = + session(GeometryType::Point, vec![Some(Geometry::Point(Coord::new(117.0, 3.0))), Some(Geometry::Point(Coord::new(x, 0.0)))]); + let mut p = json!({ + "layer": id, "field": "result", "overwrite": true, + "property": "x", "unit": "deg", "scope": "all" + }); + + let error = s.run("edit.calculateGeometry", &p).unwrap_err(); + assert!(error.contains("invalid coordinates"), "{error}"); + for row in 0..2 { + assert_eq!(s.map().layer(id).unwrap().dataset.value(row, 0).as_f64(), Some(99.0),); + } + + p["create"] = json!(true); + p["field"] = json!("new_x"); + assert!(s.run("edit.calculateGeometry", &p).is_err()); + assert!(s.map().layer(id).unwrap().dataset.field_index("new_x").is_none()); + } +} + +/// A buffer near the antimeridian runs past 180°: its area is still measured, while a point's +/// coordinate must stay a real longitude. +#[test] +fn polygons_across_the_antimeridian_are_measured() { + let (mut s, id) = session(GeometryType::Point, vec![Some(Geometry::Point(Coord::new(179.99, 0.0)))]); + let name = s.map().layer(id).unwrap().name.clone(); + s.run("gp.run", &json!({"tool": "buffer", "params": {"input": name, "distance": 10, "units": "Kilometers"}})).unwrap(); + let buffer = s.map().layers.iter().find(|l| l.geometry_type() == GeometryType::Polygon).map(|l| l.id).unwrap(); + let mut max_x = f64::MIN; + for f in &s.map().layer(buffer).unwrap().dataset.features { + f.geometry.as_ref().unwrap().for_each_coord(|c| max_x = max_x.max(c.x)); + } + assert!(max_x > 180.0, "the buffer crosses the antimeridian: {max_x}"); + s.run( + "edit.calculateGeometry", + &json!({"layer": buffer, "field": "area", "create": true, "property": "area", "unit": "km2", "scope": "all"}), + ) + .unwrap(); + let ds = &s.map().layer(buffer).unwrap().dataset; + let area = ds.value(0, ds.field_index("area").unwrap()).as_f64().unwrap(); + assert!((300.0..330.0).contains(&area), "about π·10² km²: {area}"); + + let ring = vec![Coord::new(179.0, 0.0), Coord::new(181.0, 0.0), Coord::new(181.0, 1.0), Coord::new(179.0, 1.0), Coord::new(179.0, 0.0)]; + let (mut s, id) = session(GeometryType::Polygon, vec![Some(Geometry::Polygon(vec![vec![ring]]))]); + for property in ["area", "perimeter"] { + let unit = if property == "area" { "km2" } else { "km" }; + let p = json!({"layer": id, "field": "result", "overwrite": true, "property": property, "unit": unit, "scope": "all"}); + s.run("edit.calculateGeometry", &p).unwrap(); + assert!(s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap() > 0.0); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 80bbdb7a5..9f1c80e8d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,6 +10,7 @@ use crate::theme::{Tokens, semibold}; #[derive(Default)] pub struct Dialogs { + pub geometry: Option, pub open: Option<&'static str>, pub layer: String, pub text: String, @@ -126,6 +127,10 @@ impl Dialogs { pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { let Some(which) = app.dialogs.open else { return }; + if which == "calculateGeometry" { + crate::geometrycalc::show(app, ctx); + return; + } let title = match which { "selectByAttributes" => "Select By Attributes", "goToXY" => "Go To XY", diff --git a/crates/ui-egui/src/geometrycalc.rs b/crates/ui-egui/src/geometrycalc.rs new file mode 100644 index 000000000..c3556f020 --- /dev/null +++ b/crates/ui-egui/src/geometrycalc.rs @@ -0,0 +1,219 @@ +//! Compact attribute-table geometry calculator, bound to the table's layer. +use crate::{RustGisApp, theme::Tokens}; +use rustgis_engine::{ + Session, + data::FieldType, + geometry_calc::{properties, units}, +}; +use serde_json::json; + +/// The state of the Calculate Geometry dialog. +pub struct Form { + pub layer: u32, + property: String, + unit: String, + field: String, + field_edited: bool, + create: bool, + selected: bool, + overwrite: bool, + epsg: String, + error: Option, +} + +impl Form { + /// A form for `layer`, writing its first property to a new, uniquely named field. + pub fn new(s: &Session, layer: u32) -> Result { + let l = s.map().layer(layer).ok_or_else(|| tl!("Layer not found").to_string())?; + let property = properties(l.geometry_type()).first().map(|v| v.0).ok_or_else(|| tl!("No geometry to calculate").to_string())?; + let unit = units(property).first().map(|v| v.0).unwrap_or("m"); + let base = format!("{property}_{unit}"); + let mut field = base.clone(); + let mut n = 2; + while l.dataset.field_index(&field).is_some() { + field = format!("{base}_{n}"); + n += 1; + } + Ok(Self { + layer, + property: property.into(), + unit: unit.into(), + field, + field_edited: false, + create: true, + selected: !l.selection.is_empty(), + overwrite: false, + epsg: "32650".into(), + error: None, + }) + } +} + +/// A combo box over `choices` (`(key, label)` pairs) editing the key `value`; true when changed. +fn choice(ui: &mut egui::Ui, id: &str, value: &mut String, choices: &[(&str, &str)], width: f32) -> bool { + let mut changed = false; + let label = choices.iter().find(|(k, _)| *k == value).map(|v| v.1).unwrap_or(""); + egui::ComboBox::from_id_salt(id).selected_text(tl!(label)).width(width).show_ui(ui, |ui| { + for (key, label) in choices { + changed |= ui.selectable_value(value, (*key).into(), tl!(*label)).changed(); + } + }); + changed +} + +/// Shows the dialog while it is open and runs `edit.calculateGeometry` on Calculate. +pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { + let Some(mut f) = app.dialogs.geometry.take() else { return }; + let Some(l) = app.session.map().layer(f.layer) else { + app.dialogs.open = None; + return; + }; + let name = l.name.clone(); + let props = properties(l.geometry_type()); + let fields: Vec<_> = l.dataset.fields.iter().filter(|f| f.ty.is_numeric()).map(|f| (f.name.clone(), f.ty)).collect(); + let selected_count = l.selection.len(); + let total = l.dataset.features.len(); + let mut open = true; + let mut close = false; + let mut calculate = false; + let t = Tokens::get(ctx); + egui::Window::new(tl!("Calculate Geometry")) + .id(egui::Id::new("geometry_calculator_compact")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .auto_sized() + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center() + egui::vec2(0.0, -40.0)) + .show(ctx, |ui| { + let width = (ctx.content_rect().width() - 48.0).clamp(240.0, 320.0); + let control_width = width - 80.0; + ui.set_width(width); + ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + { + egui::Grid::new("geometry_form").num_columns(2).min_col_width(72.0).spacing(egui::vec2(8.0, 8.0)).show(ui, |ui| { + ui.label(tl!("Layer")); + ui.label(&name); + ui.end_row(); + let previous = (f.property.clone(), f.unit.clone()); + ui.label(tl!("Property")); + if choice(ui, "geom_property", &mut f.property, props, control_width) { + f.unit = units(&f.property).first().map(|v| v.0).unwrap_or("m").into(); + f.overwrite = false; + } + ui.end_row(); + ui.label(tl!("Unit")); + if choice(ui, "geom_unit", &mut f.unit, units(&f.property), control_width) { + f.overwrite = false; + } + ui.end_row(); + if f.create && !f.field_edited && previous != (f.property.clone(), f.unit.clone()) { + let base = format!("{}_{}", f.property, f.unit); + f.field = base.clone(); + let mut n = 2; + while l.dataset.field_index(&f.field).is_some() { + f.field = format!("{base}_{n}"); + n += 1; + } + } + if f.unit == "projected" { + ui.label(tl!("EPSG code")); + if ui.add(egui::TextEdit::singleline(&mut f.epsg).desired_width(control_width)).changed() { + f.overwrite = false; + } + ui.end_row(); + } + ui.label(tl!("Output")); + ui.horizontal(|ui| { + if ui.radio_value(&mut f.create, true, tl!("New field")).changed() { + f.overwrite = false; + f.field_edited = false; + let base = format!("{}_{}", f.property, f.unit); + f.field = base.clone(); + let mut n = 2; + while l.dataset.field_index(&f.field).is_some() { + f.field = format!("{base}_{n}"); + n += 1; + } + f.error = None; + } + if ui.add_enabled(!fields.is_empty(), egui::RadioButton::new(!f.create, tl!("Existing field"))).clicked() { + f.create = false; + f.overwrite = false; + if !fields.iter().any(|(n, _)| *n == f.field) { + f.field = fields.first().map(|v| v.0.clone()).unwrap_or_default(); + } + } + }); + ui.end_row(); + ui.label(tl!("Field")); + if f.create { + f.field_edited |= ui + .add(egui::TextEdit::singleline(&mut f.field).hint_text(tl!("Field name")).desired_width(control_width)) + .changed(); + } else { + egui::ComboBox::from_id_salt("geom_field").selected_text(&f.field).width(control_width).show_ui(ui, |ui| { + for (name, _) in &fields { + if ui.selectable_value(&mut f.field, name.clone(), name).changed() { + f.overwrite = false; + } + } + }); + } + ui.end_row(); + ui.label(tl!("Features")); + ui.horizontal(|ui| { + if ui.radio_value(&mut f.selected, false, format!("{} ({total})", tl!("All"))).changed() { + f.overwrite = false; + } + let label = format!("{} ({selected_count})", tl!("Selected")); + if ui.add_enabled(selected_count > 0, egui::RadioButton::new(f.selected, label)).clicked() { + f.selected = true; + f.overwrite = false; + } + }); + ui.end_row(); + }); + if !f.create && fields.iter().any(|(name, ty)| *name == f.field && *ty == FieldType::Integer) { + ui.label(egui::RichText::new(tl!("Integer field: values are rounded.")).small().weak()); + } + if !f.create { + ui.checkbox(&mut f.overwrite, tl!("Overwrite existing values")); + } + if let Some(error) = &f.error { + ui.colored_label(t.red, error); + } + ui.separator(); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + calculate = ui + .add_enabled_ui( + !f.field.trim().is_empty() && (f.create || f.overwrite) && (!f.selected || selected_count > 0), + |ui| crate::widgets::dialog_button(ui, tl!("Calculate"), Some(t.accent), egui::vec2(84.0, 28.0)).clicked(), + ) + .inner; + close = crate::widgets::dialog_button(ui, tl!("Cancel"), None, egui::vec2(84.0, 28.0)).clicked(); + }); + }); + } + }); + let epsg = f.epsg.trim().parse::().ok(); + if calculate && f.unit == "projected" && epsg.is_none() { + f.error = Some(tl!("Enter an EPSG code as a number, such as 32650").to_string()); + } else if calculate { + let p = json!({"layer": f.layer, "field": f.field.trim(), "create": f.create, "overwrite": f.overwrite, + "property": f.property, "unit": f.unit, "epsg": epsg, "scope": if f.selected { "selected" } else { "all" }}); + match app.run("edit.calculateGeometry", p) { + Ok(result) => { + app.session.notify(crate::i18n::fmt("Calculated {rows} features", &[("rows", &result["rows"].to_string())]), false); + close = true; + } + Err(error) => f.error = Some(error), + } + } + if !open || close { + app.dialogs.open = None; + } else { + app.dialogs.geometry = Some(f); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2c40a059d..4884bfbce 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ mod editpanes; mod embeddingsui; mod failureui; mod geocodeui; +mod geometrycalc; mod globe_drape; mod graphicformat; pub mod icons; @@ -2734,6 +2735,12 @@ impl RustGisApp { } "ui.dialog" => { let which = p.get("name").and_then(Value::as_str).unwrap_or(""); + if which == "calculateGeometry" { + let id = self.session.layer_id(p)?; + let form = geometrycalc::Form::new(&self.session, id)?; + self.dialogs = dialogs::Dialogs { open: Some("calculateGeometry"), geometry: Some(form), ..Default::default() }; + return Ok(Value::Null); + } // A Web Data service opens in the right pane, beside the map it searches. if let Some(name) = panes::web_data_name(which) { // A Web Data panel opened on a search. diff --git a/crates/ui-egui/src/table.rs b/crates/ui-egui/src/table.rs index 03733be7f..fcacb0d27 100644 --- a/crates/ui-egui/src/table.rs +++ b/crates/ui-egui/src/table.rs @@ -81,6 +81,17 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { small(ui, app, "delete", "", "Delete selected rows", "edit.delete", Value::Null, false); small(ui, app, "field", "", "Add field", "ui.dialog", json!({"name": "addField"}), false); small(ui, app, "calculate", "", "Calculate field", "ui.dialog", json!({"name": "calculateField"}), false); + // The table's layer, even when another layer is active in Layers. + small( + ui, + app, + "calc_geom", + "", + "Calculate Geometry", + "ui.dialog", + json!({"name": "calculateGeometry", "layer": layer_id}), + false, + ); ui.add_space(6.0); ui.checkbox(&mut app.table.selected_only, egui::RichText::new(tl!("Selected only")).size(11.5)); } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 4f84555c1..18e806a5d 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -2281,6 +2281,52 @@ fn help_shortcuts_lists_the_keymap() { assert!(h.query_by_label("Map Tool: Explore").is_some(), "and what Esc does"); } +#[test] +fn geometry_calculator_switching_back_creates_a_unique_field() { + use rustgis_ui_egui::rustgis_engine::{ + data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}, + geom::Coord, + }; + + let mut h = harness(); + h.state_mut().run("ui.language", json!({"code": "en"})).unwrap(); + + let mut ds = Dataset::new("Geometry mode test", GeometryType::Point, vec![Field::new("x_deg", FieldType::Double)]); + ds.push(Some(Geometry::Point(Coord::new(117.0, 3.0))), vec![Value::Float(99.0)]); + + let id = h.state_mut().session.map_mut().add_dataset(ds); + // Another layer is active: the dialog still calculates the table's layer. + let mut other = Dataset::new("Active layer", GeometryType::Point, vec![Field::new("x_deg", FieldType::Double)]); + other.push(Some(Geometry::Point(Coord::new(10.0, 3.0))), vec![Value::Float(5.0)]); + let other = h.state_mut().session.map_mut().add_dataset(other); + h.state_mut().session.active_layer = Some(other); + h.state_mut().run("ui.dialog", json!({"name": "calculateGeometry", "layer": id})).unwrap(); + h.step(); + h.step(); + + h.get_by_label("Existing field").click(); + h.step(); + h.step(); + + h.get_by_label("New field").click(); + h.step(); + h.step(); + + h.get_by_role_and_label(egui::accesskit::Role::Button, "Calculate").click(); + h.step(); + h.step(); + + assert!(h.state().dialogs.open.is_none(), "calculation should close the dialog"); + let ds = &h.state().session.map().layer(id).unwrap().dataset; + let old = ds.field_index("x_deg").unwrap(); + let new = ds.field_index("x_deg_2").expect("a unique field should be created"); + assert_eq!(ds.value(0, old).as_f64(), Some(99.0)); + assert_eq!(ds.value(0, new).as_f64(), Some(117.0)); + let active = &h.state().session.map().layer(other).unwrap().dataset; + assert!(active.field_index("x_deg_2").is_none(), "the active layer is left alone"); + assert_eq!(active.value(0, 0).as_f64(), Some(5.0)); +} + #[test] fn map_and_layout_tabs_rename_from_their_menus() { let mut h = harness(); diff --git a/docs/user-guide/editing.md b/docs/user-guide/editing.md index 61ec15645..9338a40ff 100644 --- a/docs/user-guide/editing.md +++ b/docs/user-guide/editing.md @@ -6,8 +6,9 @@ click each vertex and double-click to finish (++esc++ cancels). **Delete** (++delete++) removes the selected features. Edit attributes in the attribute table (++ctrl+t++): double-click a cell to change it. **Data › Data Design** (and **Data › Fields**) adds and deletes fields and calculates a -field from an expression; **Calculate Geometry** fills a field with each feature's area, length or -coordinates. Every edit is one undo step. +field from an expression; **Calculate Geometry** (in the attribute table's toolbar) fills a new or +existing numeric field with each feature's area, perimeter, length or X/Y coordinate, in the units +you choose, for all features or the selected ones. Every edit is one undo step. ## Modifying features From 8da26061547b204a27531f01632c16b3719739fe Mon Sep 17 00:00:00 2001 From: Huzaifa Muhammad Mukhtiar <57326102+huzaifffa@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:12:34 +0300 Subject: [PATCH 024/297] fix(layout): show the map, not the page, sliding into a frame as it's panned (#656) * fix(layout): show the map, not the page, sliding into a frame as it's panned Panning a layout map frame previewed by sliding the page already drawn inside the frame, so what came into view while dragging was the page beside the frame (paper, title, legend) until the button was released. As a pan starts, the frame's map is now drawn once with a frame's width and height of map past each side (at screen resolution, within six million pixels), and slid under the pointer, so map comes into view. It's let go when the pan ends, which applies it as before. The page renderer and the preview draw a frame's map through one new function, render::layout::frame_map. Co-Authored-By: Claude Opus 5.5 * fix(layout): draw a pan's preview without waiting, within the GPU's side The pan preview no longer waits for tiles still on their way (a new render::layout::frame_map_preview draws through the non-blocking renderer; frame_map still waits, for pages), keeps its longest side within the GPU's max texture side (and 8192 px), and pitched frames keep the page-texture preview, as a taller drawing would change the tilt's perspective. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: giswqs --- crates/render/src/layout.rs | 32 ++++++-- crates/ui-egui/src/layoutview.rs | 121 ++++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 7 deletions(-) diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index d9c424cdb..38035b350 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -105,18 +105,13 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O // A frame larger than the page budget is drawn at a lower resolution and scaled up. let fppp = ppp.min((MAX_PIXELS / (fw * fh).max(1.0)).sqrt()).max(0.01); let view = View { width: fw, height: fh, pixels_per_point: fppp, camera: cam, sky: false }; - let Some(mut img) = crate::render_complete(map, &view) else { + let Some(mut frame_pm) = frame_map(map, cam, fw, fh, fppp) else { if frame_requires_complete(map, &view) { return None; } // Preserve upstream's best-effort page export for ordinary maps. continue; }; - for l in img.unplaced.iter().chain(&img.labels) { - text::draw(&mut img.pixmap, l, fppp as f32); - } - crate::draw_attribution(&mut img.pixmap, &img.attribution, fppp as f32); - let mut frame_pm = img.pixmap; if let Some(g) = grid { draw_grid(&mut frame_pm, map, &view, g); } @@ -305,6 +300,31 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O Some(pm) } +/// A map frame's map, `w` × `h` points at `ppp` pixels a point, labels and attribution included: +/// what a frame shows before its grid, indicator and outline. It waits for the map's tiles, as a +/// page needs them all. +pub fn frame_map(map: &mut Map, camera: rustgis_map::Camera, w: f64, h: f64, ppp: f64) -> Option { + let view = View { width: w, height: h, pixels_per_point: ppp, camera, sky: false }; + crate::render_complete(map, &view).map(|img| finish_frame_map(img, ppp)) +} + +/// [`frame_map`] without waiting for tiles still on their way, for a preview drawn while the +/// interface waits: a pan's preview, whose size is more than the frame's for the overscan slid +/// under the pointer. +pub fn frame_map_preview(map: &mut Map, camera: rustgis_map::Camera, w: f64, h: f64, ppp: f64) -> Option { + let view = View { width: w, height: h, pixels_per_point: ppp, camera, sky: false }; + crate::render(map, &view).map(|img| finish_frame_map(img, ppp)) +} + +/// A rendered frame map with its labels and attribution drawn on it. +fn finish_frame_map(mut img: crate::Rendered, ppp: f64) -> Pixmap { + for l in img.unplaced.iter().chain(&img.labels) { + text::draw(&mut img.pixmap, l, ppp as f32); + } + crate::draw_attribution(&mut img.pixmap, &img.attribution, ppp as f32); + img.pixmap +} + /// Expands layout text tokens: the page ones here, the map ones from the first frame. pub fn expand(t: &str, layout: &Layout, info: &PageInfo, frame: Option<(&Map, rustgis_map::Camera)>) -> String { let t = t diff --git a/crates/ui-egui/src/layoutview.rs b/crates/ui-egui/src/layoutview.rs index 26284fc01..a13f72ef1 100644 --- a/crates/ui-egui/src/layoutview.rs +++ b/crates/ui-egui/src/layoutview.rs @@ -93,6 +93,10 @@ pub struct LayoutView { /// Where the primary button last went down: egui forgets it on release, and a click is /// judged by where it started. pressed: Option, + /// The map of a frame being panned, with [`PAN_OVERSCAN`] of map past each side: drawn once + /// as the pan starts and slid under the pointer, so the map comes into view as it moves; None + /// when it couldn't be drawn (it isn't tried again for that pan). + pan_preview: Option<(u32, Option)>, /// The arrow-key nudges so far: how many runs of them there have been, and the last one's /// layout, element and time (seconds), for [`LayoutView::nudge_key`]. nudges: u64, @@ -131,6 +135,38 @@ pub(crate) fn follow_project(app: &mut RustGisApp) { app.layout.project = Some(opened); } +/// How much map a pan's preview draws past each side of the frame, in frame widths and heights. +const PAN_OVERSCAN: f64 = 1.0; +/// Most pixels a pan's preview is drawn with: it's drawn on the spot as the pan starts. +const PAN_PREVIEW_PIXELS: f64 = 6_000_000.0; +/// Longest side, in pixels, a pan's preview is drawn with, within what the GPU takes. +const PAN_PREVIEW_SIDE: f64 = 8192.0; + +/// A map frame's map with [`PAN_OVERSCAN`] past each side, at the screen's resolution (or less, +/// within [`PAN_PREVIEW_PIXELS`] and [`PAN_PREVIEW_SIDE`]), for a pan's preview. It doesn't wait +/// for tiles still on their way, as it's drawn while the pointer moves. +fn pan_preview(app: &mut RustGisApp, ctx: &egui::Context, layout: &Layout, id: u32, s: f32) -> Option { + let e = layout.element(id)?; + let ElementKind::MapFrame { map, center, scale, bearing, pitch, .. } = &e.kind else { return None }; + // A tilted view's perspective follows the drawing's height, so a taller drawing wouldn't match + // the frame: a pitched frame slides the page as before. + if *pitch != 0.0 { + return None; + } + let m = app.session.project.maps.iter_mut().find(|m| m.name.eq_ignore_ascii_case(map))?; + let cam = frame_camera(m, *center, *scale, *bearing, *pitch); + let k = 1.0 + 2.0 * PAN_OVERSCAN; + let (w, h) = (e.w * PT_PER_MM * k, e.h * PT_PER_MM * k); + let screen = f64::from(s) / PT_PER_MM * f64::from(ctx.pixels_per_point()); + // Within the pixel budget, and within the longest side the GPU takes for a texture (a long, + // thin frame could pass the first and not the second). + let side = (ctx.input(|i| i.max_texture_side) as f64).min(PAN_PREVIEW_SIDE); + let ppp = screen.min((PAN_PREVIEW_PIXELS / (w * h).max(1.0)).sqrt()).min(side / w.max(h).max(1.0)).max(0.01); + let pm = rustgis_engine::render::layout::frame_map_preview(m, cam, w, h, ppp)?; + let img = egui::ColorImage::from_rgba_premultiplied([pm.width() as usize, pm.height() as usize], pm.data()); + Some(ctx.load_texture("layout_pan_preview", img, egui::TextureOptions::LINEAR)) +} + /// Seconds after the wheel stops before a map frame's zoom is applied. const FRAME_ZOOM_SETTLE: f64 = 0.35; @@ -549,6 +585,16 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { app.layout.key = Some(key); } } + // A pan's preview is drawn as the pan starts, and let go when it ends. + match app.layout.drag { + Some(Drag::PanFrame(id, _)) if app.layout.pan_preview.as_ref().is_none_or(|(p, _)| *p != id) => { + let ctx = ui.ctx().clone(); + let map = pan_preview(app, &ctx, &layout, id, s); + app.layout.pan_preview = Some((id, map)); + } + Some(Drag::PanFrame(..)) => {} + _ => app.layout.pan_preview = None, + } // Shadow, page, elements. painter.rect_filled(page.translate(vec2(3.0, 4.0)), 2.0, Color32::from_black_alpha(50)); painter.rect_filled(page, 0.0, Color32::WHITE); @@ -576,7 +622,19 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { let uv = Rect::from_min_max(src((e.x, e.y)), src((e.x + e.w, e.y + e.h))); let clip = painter.with_clip_rect(frame); clip.rect_filled(frame, 0.0, Color32::WHITE); - clip.image(tex.id(), frame, uv, Color32::WHITE); + match &app.layout.pan_preview { + // A pan: the frame's map with the overscan around it, moved with the pointer, so + // what comes into view is map, not the page beside the frame. + Some((pid, Some(map))) if *pid == id && k == 1.0 => { + let o = PAN_OVERSCAN as f32; + let around = Rect::from_min_max(frame.min - frame.size() * o, frame.max + frame.size() * o); + let full = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + clip.image(map.id(), around.translate(vec2(dx as f32, dy as f32) * s), full, Color32::WHITE); + } + _ => { + clip.image(tex.id(), frame, uv, Color32::WHITE); + } + } } } let accent = t.accent; @@ -1774,6 +1832,67 @@ mod tests { assert_eq!(app.layout.selected, Some(id), "still selected"); } + /// While a map frame is panned, its map is drawn past the frame's sides and slid with the + /// pointer, so what comes into view is map rather than the page beside the frame; the drawing + /// is let go when the pan ends, which moves the frame's map as before. + #[test] + fn a_panned_frame_shows_map_beyond_its_edges() { + use rustgis_engine::map::layout::ElementKind; + let mut app = RustGisApp::with_sample(); + app.run("layout.new", json!({"name": "Pan"})).unwrap(); + app.layout.navigate = true; + let li = app.session.active_layout.unwrap(); + let area = Rc::new(Cell::new(Rect::NOTHING)); + let shown = area.clone(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + move |ui, app: &mut RustGisApp| { + if !fonts { + crate::theme::install_fonts(ui.ctx(), &[], None); + fonts = true; + return; + } + shown.set(ui.available_rect_before_wrap()); + super::show(app, ui); + }, + app, + ); + h.run_steps(3); + let layout = h.state().session.project.layouts[li].clone(); + let id = layout.first_frame().unwrap(); + let center = |l: &rustgis_engine::map::layout::Layout| match &l.element(id).unwrap().kind { + ElementKind::MapFrame { center, .. } => *center, + k => panic!("{k:?}"), + }; + let before = center(&layout); + let e = layout.element(id).unwrap(); + let (page, s) = page_rect(&h.state().layout, area.get(), &layout); + let from = to_screen(page, s, (e.x + e.w / 2.0, e.y + e.h / 2.0)); + let to = from + vec2(120.0, 0.0); + let button = |pos: Pos2, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.event(Event::PointerMoved(from)); + h.step(); + h.event(button(from, true)); + h.step(); + for i in 1..=4 { + h.event(Event::PointerMoved(from + (to - from) * i as f32 / 4.0)); + h.step(); + } + // Mid-pan: the frame's map with its overscan is drawn, three frames wide and high. + let (pid, map) = h.state().layout.pan_preview.clone().expect("a preview while panning"); + assert_eq!(pid, id); + let size = map.expect("the frame's map was drawn").size(); + let frame_px = (e.w as f32 * s * h.ctx.pixels_per_point(), e.h as f32 * s * h.ctx.pixels_per_point()); + assert!( + (size[0] as f32 - 3.0 * frame_px.0).abs() < 3.0 && (size[1] as f32 - 3.0 * frame_px.1).abs() < 3.0, + "{size:?} for a frame of {frame_px:?} px" + ); + h.event(button(to, false)); + h.step(); + assert!(h.state().layout.pan_preview.is_none(), "let go after the pan"); + assert_ne!(center(&h.state().session.project.layouts[li]), before, "the pan moved the frame's map"); + } + /// Pressing a corner handle and dragging away fast resizes the element, though by the time /// egui starts the drag the pointer is already off the handle; it used to move the element. #[test] From 9a705245957f1ef4142e7fe0ea37bcc2e34688ff Mon Sep 17 00:00:00 2001 From: Huzaifa Muhammad Mukhtiar <57326102+huzaifffa@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:20:26 +0300 Subject: [PATCH 025/297] =?UTF-8?q?fix(web):=20keep=20the=20browser=20from?= =?UTF-8?q?=20acting=20on=20the=20app's=20Ctrl=20(=E2=8C=98)=20shortcuts?= =?UTF-8?q?=20(#658)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): keep the browser from acting on the app's Ctrl (⌘) shortcuts In the browser, ⌘D duplicated a layout element and also bookmarked the page: eframe prevents the browser's default only for ⌘, ⌘O ⌘P and ⌘S, and a key event's default isn't up to WebOptions::should_prevent_default. Likewise ⌘Y (redo) opened the history on a Mac, Ctrl+K (command search) went to the address bar on Windows, and ⌘= / ⌘- zoomed the page with the map. The web app now listens for keydown in the capture phase and prevents the default for the Ctrl (⌘) shortcuts the keymap binds, the clipboard's left out so copy, cut and paste stay events; the key still reaches egui. The list follows the keymap as it's changed (RustGisApp::command_keys), and keys match as egui matches them, an extra Alt or Shift allowed (RustGisApp::takes_browser_key, keymap::browser_key). Browsers keep ⌘T, ⌘N and ⌘W whatever a page does. Co-Authored-By: Claude Opus 5.5 * fix(web): skip AltGr presses, rebuild the taken keys only when the keymap changes AltGr reports as Ctrl+Alt on Windows and types a character, so the key listener leaves it to the browser. The web shell now rebuilds the list of the app's Ctrl (Cmd) shortcuts only when a cheap fingerprint of the keymap moves, instead of every frame. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: giswqs --- apps/rustgis-web/Cargo.toml | 2 +- apps/rustgis-web/src/web.rs | 44 +++++++++++++++++++++++- crates/ui-egui/src/keymap.rs | 65 ++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 39 ++++++++++++++++++++++ 4 files changed, 148 insertions(+), 2 deletions(-) diff --git a/apps/rustgis-web/Cargo.toml b/apps/rustgis-web/Cargo.toml index 8124b00d9..37c018158 100644 --- a/apps/rustgis-web/Cargo.toml +++ b/apps/rustgis-web/Cargo.toml @@ -25,4 +25,4 @@ rfd = { workspace = true, features = ["file-handle-inner"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "Node", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "File", "Url", "Location", "Event", "EventTarget", "MessageEvent", "BeforeUnloadEvent", "Storage", "Response", "Headers", "AbortController", "AbortSignal", "RequestInit", "RequestRedirect", "ReadableStream", "ReadableStreamDefaultReader", "ImageBitmap", "ImageBitmapOptions", "PremultiplyAlpha", "ColorSpaceConversion", "OffscreenCanvas", "OffscreenCanvasRenderingContext2d", "ImageData"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "Node", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "File", "Url", "Location", "Event", "EventTarget", "KeyboardEvent", "MessageEvent", "BeforeUnloadEvent", "Storage", "Response", "Headers", "AbortController", "AbortSignal", "RequestInit", "RequestRedirect", "ReadableStream", "ReadableStreamDefaultReader", "ImageBitmap", "ImageBitmapOptions", "PremultiplyAlpha", "ColorSpaceConversion", "OffscreenCanvas", "OffscreenCanvasRenderingContext2d", "ImageData"] } diff --git a/apps/rustgis-web/src/web.rs b/apps/rustgis-web/src/web.rs index d2cc36d3c..63b260987 100644 --- a/apps/rustgis-web/src/web.rs +++ b/apps/rustgis-web/src/web.rs @@ -105,6 +105,10 @@ pub fn start() { if !embedded && let Err(e) = guard_unload(dirty.clone()) { log::error!("no unsaved-changes guard: {e}"); } + let taken: TakenKeys = TakenKeys::default(); + if let Err(e) = keep_app_shortcuts(taken.clone()) { + log::error!("the browser keeps its own shortcuts: {e}"); + } let mut options = eframe::WebOptions::default(); if query().contains("webgl") && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup @@ -246,7 +250,7 @@ pub fn start() { } else { None }; - Ok(Box::new(WebShell { app, inbox, notes, dirty, embed })) + Ok(Box::new(WebShell { app, inbox, notes, dirty, embed, taken, taken_print: None })) }), ) .await; @@ -304,8 +308,16 @@ struct WebShell { notes: Notes, dirty: Rc>, embed: Option, + /// The app's Ctrl (⌘) shortcuts, kept current for [`keep_app_shortcuts`]. + taken: TakenKeys, + /// [`RustGisApp::shortcuts_fingerprint`] when `taken` was last filled. + taken_print: Option, } +/// The app's Ctrl (⌘) shortcuts as (Alt, Shift, key): [`RustGisApp::command_keys`], shared with +/// the page's key listener. +type TakenKeys = Rc>>; + /// The link to the page that embeds the app (`?embed`; see `embed.rs`). struct Embed { parent: web_sys::Window, @@ -433,6 +445,13 @@ impl eframe::App for WebShell { } self.app.logic(ctx); self.dirty.set(self.app.session.dirty); + // The shortcuts can be changed in the app: the key listener follows, rebuilt only when + // the keymap changed. + let print = self.app.shortcuts_fingerprint(); + if self.taken_print != Some(print) { + self.taken_print = Some(print); + *self.taken.borrow_mut() = self.app.command_keys(); + } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { @@ -926,6 +945,29 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> { Ok(()) } +/// Keeps the browser from acting on the app's Ctrl (⌘) shortcuts too: ⌘D duplicating a layout +/// element mustn't bookmark the page, ⌘Y (redo) open its history, or ⌘= (zoom in) zoom it. eframe +/// stops only ⌘, ⌘O, ⌘P and ⌘S. Seen first (the capture phase), the default is prevented but the +/// key still reaches egui. Browsers keep a few for themselves whatever a page does (⌘T, ⌘N, ⌘W). +fn keep_app_shortcuts(taken: TakenKeys) -> Result<(), String> { + let window = web_sys::window().ok_or("no window")?; + let on_key = wasm_bindgen::closure::Closure::::new(move |e: web_sys::KeyboardEvent| { + // AltGr reports as Ctrl+Alt on Windows: it types a character, it isn't a shortcut. + if e.get_modifier_state("AltGraph") { + return; + } + // As eframe reads it, Ctrl or ⌘ is egui's command on every platform, so the app's + // shortcut runs on either and the browser's is kept from both. + let command = e.ctrl_key() || e.meta_key(); + if RustGisApp::takes_browser_key(&taken.borrow(), &e.key(), &e.code(), command, e.alt_key(), e.shift_key()) { + e.prevent_default(); + } + }); + window.add_event_listener_with_callback_and_bool("keydown", on_key.as_ref().unchecked_ref(), true).map_err(|e| format!("{e:?}"))?; + on_key.forget(); + Ok(()) +} + /// Closing, reloading or leaving the tab with unsaved changes asks first. fn guard_unload(dirty: Rc>) -> Result<(), String> { let window = web_sys::window().ok_or("no window")?; diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index f487928e0..cb0746b62 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -242,6 +242,18 @@ impl Keymap { } } +/// A key as a browser's keyboard event names it: by its `key` (`d`, `=`, `+`), else by its +/// `code` (`KeyD`, `Digit1`, `Equal`, `Minus`), which stays put when Alt (⌥) changes the +/// character a Mac types. +pub fn browser_key(key: &str, code: &str) -> Option { + let by_name = |s: &str| Key::from_name(s).or_else(|| Key::from_name(&s.to_ascii_uppercase())); + by_name(key).or_else(|| match code { + "Equal" => Some(Key::Equals), + "Minus" => Some(Key::Minus), + c => by_name(c.strip_prefix("Key").or_else(|| c.strip_prefix("Digit")).unwrap_or(c)), + }) +} + /// Whether a key combination is one of the [`CLIPBOARD`] keys. pub fn is_clipboard(chord: Chord) -> bool { CLIPBOARD.iter().any(|(keys, _)| Chord::parse(keys) == Some(chord)) @@ -289,6 +301,59 @@ mod tests { assert!(!is("Mod+S") && !is("Z") && !is("Mod+K")); } + #[test] + fn browser_keys_read_by_key_or_code() { + assert_eq!(browser_key("d", "KeyD"), Some(Key::D)); + assert_eq!(browser_key("D", "KeyD"), Some(Key::D)); + assert_eq!(browser_key("=", "Equal"), Some(Key::Equals)); + assert_eq!(browser_key("+", "Equal"), Some(Key::Plus)); + assert_eq!(browser_key("-", "Minus"), Some(Key::Minus)); + // ⌥D types ∂ on a Mac: the key's code still says D. + assert_eq!(browser_key("∂", "KeyD"), Some(Key::D)); + assert_eq!(browser_key("¡", "Digit1"), Some(Key::Num1)); + assert_eq!(browser_key("Dead", "Unidentified"), None); + } + + /// In a browser, the app's Ctrl (⌘) shortcuts are kept from the browser (⌘D would bookmark + /// the page), the clipboard's and keys the app doesn't use are left to it. + #[test] + fn the_app_takes_its_shortcuts_from_the_browser() { + let app = crate::RustGisApp::with_sample(); + let keys = app.command_keys(); + let takes = |key: &str, code: &str, cmd: bool, alt: bool, shift: bool| { + crate::RustGisApp::takes_browser_key(&keys, key, code, cmd, alt, shift) + }; + assert!(takes("d", "KeyD", true, false, false), "⌘D: duplicate, not bookmark"); + assert!(takes("y", "KeyY", true, false, false), "⌘Y: redo, not history"); + assert!(takes("k", "KeyK", true, false, false), "Ctrl+K: command search, not the address bar"); + assert!(takes("=", "Equal", true, false, false) && takes("+", "Equal", true, false, true), "⌘= and ⌘+ zoom the map, not the page"); + assert!(takes("z", "KeyZ", true, false, true), "⌘⇧Z: redo"); + // Without Ctrl (⌘), and keys the app leaves alone, go to the browser. + assert!(!takes("d", "KeyD", false, false, false)); + assert!(!takes("r", "KeyR", true, false, false), "⌘R reloads"); + assert!(!takes("l", "KeyL", true, false, false), "⌘L: the address bar"); + // Copy, cut and paste must reach the page as the browser's clipboard events. + for (k, c) in [("c", "KeyC"), ("x", "KeyX"), ("v", "KeyV")] { + assert!(!takes(k, c, true, false, false), "⌘{k}"); + } + } + + /// The web app rebuilds its list of taken keys only when the fingerprint moves: it must move + /// when a shortcut is rebound. + #[test] + fn the_shortcuts_fingerprint_follows_the_keymap() { + let mut app = crate::RustGisApp::with_sample(); + let before = app.shortcuts_fingerprint(); + assert_eq!(before, app.shortcuts_fingerprint(), "stable while nothing changes"); + let mod_d = Chord::parse("Mod+D"); + let Some(b) = app.ui.keymap.bindings.iter_mut().find(|b| Some(b.chord) == mod_d) else { + panic!("Mod+D is bound"); + }; + b.chord = Chord { command: true, alt: false, shift: false, key: Key::J }; + assert_ne!(before, app.shortcuts_fingerprint()); + assert!(crate::RustGisApp::takes_browser_key(&app.command_keys(), "j", "KeyJ", true, false, false)); + } + #[test] fn chords_read_and_write() { let c = Chord::parse("Mod+Shift+Z"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4884bfbce..ad3072aa6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -754,6 +754,45 @@ pub struct RustGisApp { } impl RustGisApp { + /// The Ctrl (⌘) shortcuts the keymap takes, as (Alt, Shift, key), the clipboard's left out: + /// in a browser, what the web app keeps from the browser's own shortcuts (⌘D bookmarking + /// the page, ⌘Y opening its history, ⌘= zooming it), as design tools in the browser do. + pub fn command_keys(&self) -> Vec<(bool, bool, egui::Key)> { + let mut keys: Vec<(bool, bool, egui::Key)> = self + .ui + .keymap + .bindings + .iter() + .map(|b| b.chord) + .filter(|c| c.command && !keymap::is_clipboard(*c)) + .map(|c| (c.alt, c.shift, c.key)) + .collect(); + keys.sort_by_key(|k| (k.0, k.1, k.2 as u32)); + keys.dedup(); + keys + } + + /// A fingerprint of the keymap's key combinations: cheap to take every frame (no + /// allocation), it changes when a shortcut is added, removed or rebound, so a caller can + /// rebuild [`RustGisApp::command_keys`] only then. + pub fn shortcuts_fingerprint(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + for b in &self.ui.keymap.bindings { + b.chord.hash(&mut h); + } + self.ui.keymap.bindings.len().hash(&mut h); + h.finish() + } + + /// Whether a browser key press (its `key` and `code`, Ctrl or ⌘ held, Alt, Shift) is one of + /// [`RustGisApp::command_keys`]: as egui matches a shortcut, an extra Alt or Shift doesn't + /// stop it (⌘+ is ⌘⇧= on most keyboards). + pub fn takes_browser_key(keys: &[(bool, bool, egui::Key)], key: &str, code: &str, command: bool, alt: bool, shift: bool) -> bool { + command + && keymap::browser_key(key, code).is_some_and(|k| keys.iter().any(|&(a, s, bound)| bound == k && (!a || alt) && (!s || shift))) + } + /// egui's wgpu render state from the host (`frame.wgpu_render_state()`), feeding the GPU /// map backend. Cheap to call every frame: the state is Arc-backed. pub fn set_render_state(&mut self, rs: Option) { From e8fe19ca745c6b015391900eff316e5a1f8df09c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ninh=20H=E1=BA=A3i=20=C4=90=C4=83ng?= Date: Thu, 22 Oct 2026 21:25:35 +0700 Subject: [PATCH 026/297] Raster: show JPEG-compressed YCbCr GeoTIFFs as RGB (#694) * Raster: show JPEG-compressed YCbCr GeoTIFFs as RGB (#692) * Raster: convert YCbCr before an unreferenced image returns, test the threaded read * Raster: test a JPEG/YCbCr overview, give the fixture its ReferenceBlackWhite * Raster: the YCbCr test fixture's subsampling matches its JPEGs * Raster: convert only the YCbCr the colour match reads as RGB Set the YCbCr flag from the same condition (YCbCr and JPEG compression) the colour match uses, so the conversion and the match can't disagree. --------- Co-authored-by: giswqs --- crates/data/src/raster/geotiff.rs | 155 +++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 5 deletions(-) diff --git a/crates/data/src/raster/geotiff.rs b/crates/data/src/raster/geotiff.rs index d44b8ac58..61e37a313 100644 --- a/crates/data/src/raster/geotiff.rs +++ b/crates/data/src/raster/geotiff.rs @@ -459,6 +459,18 @@ pub(crate) fn read_chunk(dec: &mut Decoder, index: u32) -> Re } } +/// Turns a JPEG-compressed image's three YCbCr bands into red, green and blue in place, with the +/// JFIF (full-range) coefficients JPEG-in-TIFF files use. +fn ycbcr_to_rgb(bands: &mut [Vec]) { + let [y, cb, cr] = bands else { return }; + for ((y, cb), cr) in y.iter_mut().zip(cb.iter_mut()).zip(cr.iter_mut()) { + let (luma, blue, red) = (*y, *cb - 128.0, *cr - 128.0); + *y = (luma + 1.402 * red).round().clamp(0.0, 255.0); + *cb = (luma - 0.344_136 * blue - 0.714_136 * red).round().clamp(0.0, 255.0); + *cr = (luma + 1.772 * blue).round().clamp(0.0, 255.0); + } +} + /// Bits per sample of the current image (the most of any sample). fn bits(dec: &mut Decoder) -> u16 { dec.find_tag_unsigned_vec::(Tag::BitsPerSample).ok().flatten().and_then(|v| v.into_iter().max()).unwrap_or(8) @@ -932,6 +944,8 @@ pub(super) fn read_limited(r: Reader<'_>, name: &str, max: usize, open: Option(Tag::Compression).ok().flatten() == Some(7); // A palette image (shown to the decoder as grey) is coloured by its table. let ct = match ct { ColorType::Gray(bits) if palette && dec.find_tag(Tag::ColorMap).ok().flatten().is_some() => ColorType::Palette(bits), @@ -941,10 +955,9 @@ pub(super) fn read_limited(r: Reader<'_>, name: &str, max: usize, open: Option (1, ColorHint::Gray), ColorType::GrayA(_) => (2, ColorHint::GrayAlpha), ColorType::RGB(_) => (3, ColorHint::Rgb { alpha: false }), - // The decoder turns JPEG-compressed YCbCr into RGB; other YCbCr data would stay YCbCr. - ColorType::YCbCr(_) if dec.find_tag_unsigned::(Tag::Compression).ok().flatten() == Some(7) => { - (3, ColorHint::Rgb { alpha: false }) - } + // The decoder leaves JPEG-compressed YCbCr as it is (it turns off the JPEG library's colour + // conversion), so it is turned into RGB below (issue #692); other YCbCr data isn't read. + ColorType::YCbCr(_) if ycbcr => (3, ColorHint::Rgb { alpha: false }), ColorType::RGBA(_) => (4, ColorHint::Rgb { alpha: true }), ColorType::Palette(bits) => { let map: Vec = dec.find_tag(Tag::ColorMap).ok().flatten().and_then(|v| v.into_u16_vec().ok()).unwrap_or_default(); @@ -990,7 +1003,7 @@ pub(super) fn read_limited(r: Reader<'_>, name: &str, max: usize, open: Option return Err(err(e)), } }; - let (bands, data_type) = if let Some(buf) = whole { + let (mut bands, data_type) = if let Some(buf) = whole { let (values, dt) = to_f32(buf); if values.len() < n.saturating_mul(nbands) { return Err(err("couldn't read every band")); @@ -1020,6 +1033,9 @@ pub(super) fn read_limited(r: Reader<'_>, name: &str, max: usize, open: Option (Vec, usize) { + let strips: Vec> = (0..h / rows) + .map(|s| { + let mut rgb = image::RgbImage::new(w, rows); + for (x, y, px) in rgb.enumerate_pixels_mut() { + *px = image::Rgb(colors[(x / (w / 2) + 2 * ((s * rows + y) / (h / 2))) as usize]); + } + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100).encode_image(&rgb).unwrap(); + jpeg + }) + .collect(); + let n = strips.len() as u32; + let le16 = |v: &[u16]| v.iter().flat_map(|v| v.to_le_bytes()).collect::>(); + let le32 = |v: &[u32]| v.iter().flat_map(|v| v.to_le_bytes()).collect::>(); + let le64 = |v: &[f64]| v.iter().flat_map(|v| v.to_le_bytes()).collect::>(); + // (tag, type, count, bytes): values of up to four bytes go in the entry, longer ones after + // the entries (the strip offsets are filled in once the entries' size is known). + let mut entries: Vec<(u16, u16, u32, Vec)> = Vec::new(); + if overview { + entries.push((254, 4, 1, le32(&[1]))); + } + entries.extend([ + (256, 3, 1, le16(&[w as u16])), + (257, 3, 1, le16(&[h as u16])), + (258, 3, 3, le16(&[8, 8, 8])), + (259, 3, 1, le16(&[7])), + (262, 3, 1, le16(&[6])), + (273, 4, n, vec![0; 4 * n as usize]), + (277, 3, 1, le16(&[3])), + (278, 3, 1, le16(&[rows as u16])), + (279, 4, n, le32(&strips.iter().map(|s| s.len() as u32).collect::>())), + (284, 3, 1, le16(&[1])), + (530, 3, 2, le16(&[2, 1])), + // ReferenceBlackWhite, the JFIF values, as rationals. + (532, 5, 6, le32(&[0, 1, 255, 1, 128, 1, 255, 1, 128, 1, 255, 1])), + ]); + if geo { + entries.push((33550, 12, 3, le64(&[1.0, 1.0, 0.0]))); + entries.push((33922, 12, 6, le64(&[0.0, 0.0, 0.0, 500_000.0, 5_000_000.0, 0.0]))); + entries.push((34735, 3, 16, le16(&[1, 1, 0, 3, 1024, 0, 1, 1, 1025, 0, 1, 1, 3072, 0, 1, 25832]))); + } + let table_end = base + 2 + 12 * entries.len() + 4; + let long: usize = entries.iter().filter(|e| e.3.len() > 4).map(|e| e.3.len()).sum(); + let mut at = table_end; + let mut strip_at = (table_end + long) as u32; + let (mut table, mut data) = (Vec::new(), Vec::new()); + for (tag, kind, count, bytes) in &entries { + let mut bytes = bytes.clone(); + if *tag == 273 { + bytes = le32(&strips.iter().map(|s| (strip_at, strip_at += s.len() as u32).0).collect::>()); + } + table.extend(tag.to_le_bytes()); + table.extend(kind.to_le_bytes()); + table.extend(count.to_le_bytes()); + if bytes.len() > 4 { + table.extend((at as u32).to_le_bytes()); + at += bytes.len(); + data.extend(bytes); + } else { + bytes.resize(4, 0); + table.extend(bytes); + } + } + let mut out = (entries.len() as u16).to_le_bytes().to_vec(); + out.extend(table); + let next = 2 + 12 * entries.len(); + out.extend(0u32.to_le_bytes()); + out.extend(data); + out.extend(strips.concat()); + (out, next) + } + + /// A TIFF of [`ycbcr_jpeg_image`]s: the full image with its GeoTIFF tags (unless `!geo`), then + /// any overviews. + fn ycbcr_jpeg_tiff(sizes: &[(u32, u32, u32)], colors: [[u8; 3]; 4], geo: bool) -> Vec { + let mut out = vec![b'I', b'I', 42, 0, 8, 0, 0, 0]; + let mut next_at = None; + for (i, size) in sizes.iter().enumerate() { + let base = out.len(); + if let Some(at) = next_at { + out[at..at + 4].copy_from_slice(&(base as u32).to_le_bytes()); + } + let (bytes, next) = ycbcr_jpeg_image(base, *size, colors, geo && i == 0, i > 0); + next_at = Some(base + next); + out.extend(bytes); + } + out + } + + #[test] + fn ycbcr_jpeg_is_shown_as_rgb() { + // Issue #692: the tiff crate leaves JPEG-compressed YCbCr as it is, which drew a COG with + // a purple and green cast. Flat colours survive the JPEG within a few levels. + let colors = [[200, 30, 30], [30, 160, 40], [20, 40, 220], [230, 230, 60]]; + let check = |r: &Raster, w: usize, h: usize| { + assert_eq!((r.width as usize, r.height as usize, r.bands.len(), r.color.clone()), (w, h, 3, ColorHint::Rgb { alpha: false })); + for (i, rgb) in colors.iter().enumerate() { + let (x, y) = (w / 4 + w / 2 * (i % 2), h / 4 + h / 2 * (i / 2)); + for (band, want) in r.bands.iter().zip(rgb) { + let got = band.data[y * w + x]; + assert!((got - f32::from(*want)).abs() <= 4.0, "quarter {i}: {got} for {want}"); + } + } + }; + let bytes = ycbcr_jpeg_tiff(&[(32, 32, 32)], colors, true); + let mut dec = Decoder::new(std::io::Cursor::new(&bytes)).unwrap(); + assert!(matches!(dec.colortype().unwrap(), ColorType::YCbCr(8))); + check(&read(std::io::Cursor::new(&bytes), "ycc").unwrap(), 32, 32); + // Without geo tags (a scan), which returns before the georeferencing. + let scan = read(std::io::Cursor::new(ycbcr_jpeg_tiff(&[(32, 32, 32)], colors, false)), "scan").unwrap(); + assert!(!scan.georeferenced); + check(&scan, 32, 32); + // A million pixels in 16 strips, read a chunk at a time on several threads. + let big = ycbcr_jpeg_tiff(&[(1024, 1024, 64)], colors, true); + check(&read_threaded(&big, MAX_SAMPLES).unwrap(), 1024, 1024); + // An image too large for the limit is shown by its overview, a JPEG of its own. + let cog = ycbcr_jpeg_tiff(&[(64, 64, 64), (32, 32, 32)], colors, true); + let r = read_limited(reader(std::io::Cursor::new(&cog)).unwrap(), "cog", 4_000, None).unwrap(); + assert_eq!(r.overview, Some((64, 64))); + check(&r, 32, 32); + } + #[test] fn lzw_chunks_decoded_here_match_the_tiff_crate() { // Every predictor, padded edge tiles on the right and at the bottom. From 57036b6ac815b4201fbc018cc45b2ebb8241e1eb Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 16:28:26 +0200 Subject: [PATCH 027/297] perf(render): draw a layer too dense to mesh on the CPU in GPU mode (#695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(render): draw a layer too dense to mesh on the CPU in GPU mode A layer of ~1.5 million tiny polygons tessellated to 30-50 million vertices every GPU frame (1.8 s), over the device's max_buffer_size, so the whole scene was declined and drawn again on the CPU: ~5 s per dragged frame and ~1 min per zoom step. collect_layer now estimates the layer's mesh from point counts in the pass that already folds the extents and gives up past MAX_LAYER_VERTICES (2M, 80 MB), before tessellating anything; a hard cap while tessellating bounds what the estimate misses. That layer alone takes the CPU branch (splatted sub-pixel features) and the rest of the scene stays on the GPU. The mesh now comes from the thinned copy the CPU path draws. Co-Authored-By: Claude Opus 5.5 * fix(render): stop tessellating a layer once its mesh passes the cap Review follow-up. The cap was checked only after a whole feature was tessellated, so one feature whose round joins outran the estimate could build far past MAX_LAYER_VERTICES first. fill_polygon and the strokes now take a vertex limit checked after each ring or part, and collect_layer does not start a feature whose estimate no longer fits. Also notes that a turned or tilted view takes no CPU pictures, so there a layer too dense to mesh sends the whole view to the CPU renderer. Co-Authored-By: Claude Opus 5.5 * perf(render): look up each feature's symbol once when meshing a layer Review follow-up. collect_layer's extent pass now lists the visible features with their symbols and vertex estimates, and the tessellation pass reuses them instead of filtering, looking up the symbol and estimating again. Co-Authored-By: Claude Opus 5.5 * perf(render): count round-join arcs in the GPU mesh estimate Review follow-up. The estimate gave each stroked ring a flat 32 vertices for its joins, so a line with hairpin turns could tessellate several times over it, then pass the cap mid-way and be thrown away every frame. It now counts each join's arc from its turn (16·sin(θ/2) segments, never below lyon's ~16θ/π, without an atan2 per point) plus the caps of open lines, so it errs high and such a layer is declined before tessellating. Co-Authored-By: Claude Opus 5.5 * fix(render): cull GPU meshes on the extents the CPU path culls on Review follow-up. collect_layer culled each feature on its thinned geometry's extent while the CPU path uses the cached extents of the full geometry, so a feature at the edge of the view could draw on one backend and not the other. It now takes those cached extents (falling back to the geometry's own on the globe, as the CPU path does). The unlimited vertex limit of highlights, the basemap, the graticule and extrusion roofs is now the named NO_LIMIT. Co-Authored-By: Claude Opus 5.5 * fix(render): share the GPU mesh limit across a streamed layer's batches Review follow-up. A streamed layer is drawn one batch at a time, and each batch's collect_layer started from a fresh MAX_LAYER_VERTICES, so batches each under the limit could together mesh far past it. The layer now keeps one budget: collect_layer takes what is left and returns what it used, and once a batch falls back, the layer's later batches get no room and draw on the CPU too. Preflighting the whole layer instead would read the complete source again every frame. Co-Authored-By: Claude Opus 5.5 * fix(render): stop tessellating at the vertex limit; keep room after a refusal A capped lyon geometry builder refuses the vertex past a layer's mesh limit, so one dense part stops there instead of being finished and thrown away. TessError now says why a mesh failed: a streamed layer's later batches lose their room only when one is too dense, not when lyon refuses a batch's geometry. The perspective streaming test compared the interaction rows by allocation; the layer's background read can reinstall the same sample mid-render, so compare them by value. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: giswqs --- crates/render/src/lib.rs | 61 +++++- crates/render/src/mesh.rs | 312 ++++++++++++++++++++++++------ crates/render/src/stream_tests.rs | 36 +++- 3 files changed, 347 insertions(+), 62 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 846976716..13390a8a5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1874,7 +1874,7 @@ pub fn extruded_shapes(map: &mut Map, view: &View) -> Vec { if extents.get(row).is_some_and(|e| !e.padded(0.0, reach).intersects(&cull)) { continue; } - let roof = mesh::fill_polygon(polys, [origin.x, origin.y], [0.0; 4], view.camera.res * 0.5); + let roof = mesh::fill_polygon(polys, [origin.x, origin.y], [0.0; 4], view.camera.res * 0.5, mesh::NO_LIMIT); let Ok(Some((verts, indices))) = roof else { continue }; let sym = layer.symbol(row); let pts: Vec = polys.iter().filter_map(|p| p.first()).flatten().copied().filter(|c| c.is_finite()).collect(); @@ -2641,6 +2641,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac Default::default() }; let mut highlights: Vec<(usize, Geometry, Symbol)> = Vec::new(); + // The GPU mesh vertices the layer has left: a streamed layer's batches share them, so + // one too dense to mesh goes to the CPU however it is split. + let mut mesh_room = mesh::MAX_LAYER_VERTICES; let mut extrusions = if complete_source && layer.geometry_type() == GeometryType::Polygon && layer.extrusion.is_some() @@ -2724,13 +2727,35 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac && !layer.graphics && layer.line_strokes.is_empty() && !upright.contains(&layer.id); - // Lyon refuses degenerate geometry tiny-skia draws fine: collect_layer reports it, - // and the layer runs the CPU branch below like any ineligible one. + // Lyon refuses degenerate geometry tiny-skia draws fine, and a layer too dense to mesh + // (`MAX_LAYER_VERTICES`) is quicker splatted: collect_layer reports both, and the layer + // runs the CPU branch below like any ineligible one. It meshes the thinned copy the + // CPU draws. A turned or tilted view takes no CPU pictures (`gpu_turned`), so there + // such a layer sends the whole view to the CPU renderer, not just itself. let mut gpu_done = false; if gpu_layer { flush_acc(&mut pm, &mut cmds, &mut cpu_dirty) .ok_or_else(|| rustgis_data::DataError::Format("cannot allocate render target".into()))?; - gpu_done = mesh::collect_layer(&mut cmds, layer, &geoms, &rows, &mesh_visible, ctx.wireframe, ctx.ppp, map_per_px).is_ok(); + let collected = mesh::collect_layer( + &mut cmds, + layer, + &drawn, + extents, + &rows, + &mesh_visible, + ctx.wireframe, + ctx.ppp, + map_per_px, + mesh_room, + ); + // Once a batch is too dense, the layer's later ones fall back too (they get no + // room); one lyon refused leaves the room to the batches after it. + mesh_room = match collected { + Ok(used) => mesh_room.saturating_sub(used), + Err(mesh::TessError::TooDense) => 0, + Err(mesh::TessError::Refused) => mesh_room, + }; + gpu_done = collected.is_ok(); } // False for a layer drawn elsewhere: nothing goes into the picture for it. let mut drawn_here = true; @@ -4525,6 +4550,34 @@ mod tests { assert_eq!(scene.labels.first().map(|l| l.text.as_str()), cpu.labels.first().map(|l| l.text.as_str())); } + #[test] + fn gpu_backend_draws_a_layer_too_dense_to_mesh_on_the_cpu() { + // 72,900 triangles about a pixel across: tessellated with outlines they'd pass + // MAX_LAYER_VERTICES, so that layer alone is drawn on the CPU (splatted), never meshed. + let mut m = Map::new("Map"); + square_layer(&mut m, "sparse", 0.0, -2.0, 1.0); + let mut d = Dataset::new("dense", GeometryType::Polygon, vec![]); + for i in 0..270 { + for j in 0..270 { + let (x, y) = (i as f64 * 0.01, j as f64 * 0.01); + let ring = vec![Coord::new(x, y), Coord::new(x + 0.008, y), Coord::new(x, y + 0.008)]; + d.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![]); + } + } + m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(1.35, 0.5)), res: 1000.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 400.0, height: 600.0, pixels_per_point: 1.0, camera, sky: false }; + let Some(RenderOutput::Gpu(scene)) = render_backend(&mut m, &view, Backend::Gpu) else { panic!("gpu scene") }; + let kinds = cmd_kinds(&scene); + assert!(kinds.contains(&"image"), "the dense layer as a CPU picture: {kinds:?}"); + assert!(kinds.contains(&"mesh"), "the sparse layer stays a mesh: {kinds:?}"); + for c in &scene.cmds { + if let gpu_scene::DrawCmd::Mesh { vertices, .. } = c { + assert!(vertices.len() <= mesh::MAX_LAYER_VERTICES, "{} vertices", vertices.len()); + } + } + } + #[test] fn gpu_backend_interleaves_cpu_image_for_blended_layer() { let mut m = Map::new("Map"); diff --git a/crates/render/src/mesh.rs b/crates/render/src/mesh.rs index 890360a14..ebbc86819 100644 --- a/crates/render/src/mesh.rs +++ b/crates/render/src/mesh.rs @@ -6,7 +6,8 @@ use lyon::path::Path; use lyon::tessellation::{ - BuffersBuilder, FillOptions, FillRule, FillTessellator, LineCap, LineJoin, StrokeOptions, StrokeTessellator, VertexBuffers, + BuffersBuilder, FillGeometryBuilder, FillOptions, FillRule, FillTessellator, FillVertex, GeometryBuilder, LineCap, LineJoin, + StrokeGeometryBuilder, StrokeOptions, StrokeTessellator, StrokeVertex, VertexBuffers, VertexId, }; use rustgis_data::Geometry; use rustgis_geom::{Coord, Extent}; @@ -15,11 +16,76 @@ use rustgis_map::{Layer, Rgba, Symbol}; use crate::SELECTION; use crate::gpu_scene::{DrawCmd, MeshVertex, PointInstance}; -/// Lyon refused to tessellate something tiny-skia would draw; the caller falls the layer (or -/// the outline) back to the CPU path. Empty marker for `Result<_, TessError>` so clippy's -/// `result_unit_err` stays happy. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(crate) struct TessError; +/// Why a mesh wasn't made; the caller falls the layer (or the outline) back to the CPU path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum TessError { + /// Lyon refused something tiny-skia would draw. + Refused, + /// The mesh would pass its vertex limit ([`MAX_LAYER_VERTICES`]). + TooDense, +} + +/// A lyon geometry builder that refuses vertices past `room`, so a tessellation stops at the +/// limit instead of finishing a mesh that would be thrown away. +struct Capped { + inner: B, + room: usize, + /// Set once a vertex was refused for want of room (lyon then reports a builder error). + full: bool, +} + +impl Capped { + fn new(inner: B, room: usize) -> Self { + Capped { inner, room, full: false } + } + + fn take(&mut self) -> Result<(), lyon::tessellation::GeometryBuilderError> { + match self.room.checked_sub(1) { + Some(left) => { + self.room = left; + Ok(()) + } + None => { + self.full = true; + Err(lyon::tessellation::GeometryBuilderError::TooManyVertices) + } + } + } + + /// The error for a failed tessellation: the limit when this builder ran out of room. + fn error(&self) -> TessError { + if self.full { TessError::TooDense } else { TessError::Refused } + } +} + +impl GeometryBuilder for Capped { + fn begin_geometry(&mut self) { + self.inner.begin_geometry(); + } + fn end_geometry(&mut self) { + self.inner.end_geometry(); + } + fn add_triangle(&mut self, a: VertexId, b: VertexId, c: VertexId) { + self.inner.add_triangle(a, b, c); + } + fn abort_geometry(&mut self) { + self.inner.abort_geometry(); + } +} + +impl FillGeometryBuilder for Capped { + fn add_fill_vertex(&mut self, vertex: FillVertex) -> Result { + self.take()?; + self.inner.add_fill_vertex(vertex) + } +} + +impl StrokeGeometryBuilder for Capped { + fn add_stroke_vertex(&mut self, vertex: StrokeVertex) -> Result { + self.take()?; + self.inner.add_stroke_vertex(vertex) + } +} /// Premultiplied 0..1 RGBA for the GPU (tiny-skia stores premultiplied; wgpu blends the same way). pub(crate) fn premul(c: Rgba) -> [f32; 4] { @@ -91,33 +157,39 @@ fn path_of(rings: &[Vec], origin: [f64; 2], map_per_px: f64, min_points: /// Tessellates polygon fills (all rings in one EvenOdd path, like the CPU's `Ctx::polygon`). /// `Ok(None)` when nothing would draw; `Err` when lyon fails (the caller falls the layer back /// to the CPU pixmap — tiny-skia still draws geometry lyon refuses). Non-finite coordinates -/// never reach lyon: `decimate` drops them. +/// never reach lyon: `decimate` drops them. `Err(TooDense)` once the vertices would pass `limit`: +/// tessellation stops at the vertex that doesn't fit ([`MAX_LAYER_VERTICES`]). pub(crate) fn fill_polygon( polys: &[Vec>], origin: [f64; 2], color: [f32; 4], map_per_px: f64, + limit: usize, ) -> Result, Vec)>, TessError> { let mut out: VertexBuffers = VertexBuffers::new(); let mut tess = FillTessellator::new(); for rings in polys { let Some(path) = path_of(rings, origin, map_per_px, 3) else { continue }; - tess.tessellate_path( - &path, - &FillOptions::default().with_fill_rule(FillRule::EvenOdd), - &mut BuffersBuilder::new(&mut out, |v: lyon::tessellation::FillVertex| { + let room = limit.saturating_sub(out.vertices.len()); + let mut builder = Capped::new( + BuffersBuilder::new(&mut out, |v: FillVertex| { let p = v.position(); MeshVertex { position: [p.x, p.y], normal: [0.0, 0.0], color, stroke_px: 0.0, _pad: 0.0 } }), - ) - .map_err(|_| TessError)?; + room, + ); + if tess.tessellate_path(&path, &FillOptions::default().with_fill_rule(FillRule::EvenOdd), &mut builder).is_err() { + return Err(builder.error()); + } } Ok((!out.indices.is_empty()).then_some((out.vertices, out.indices))) } /// Strokes rings (closed, polygon outlines) or line parts (open) at width 1.0; the vertex /// shader applies the real width. Ring outlines take no width floor (`Ctx::polygon`), -/// line parts floor at half a physical pixel (`Ctx::line`). +/// line parts floor at half a physical pixel (`Ctx::line`). `Err(TooDense)` once the vertices +/// would pass `limit`, as soon as one doesn't fit. +#[allow(clippy::too_many_arguments)] fn stroke( rings: &[Vec], origin: [f64; 2], @@ -126,6 +198,7 @@ fn stroke( map_per_px: f64, closed: bool, min_points: usize, + limit: usize, ) -> Result, Vec)>, TessError> { let mut out: VertexBuffers = VertexBuffers::new(); let mut tess = StrokeTessellator::new(); @@ -149,15 +222,17 @@ fn stroke( } b.end(closed); // position_on_path = position - normal * half_width, half_width = 0.5 at width 1.0. - tess.tessellate_path( - &b.build(), - &options, - &mut BuffersBuilder::new(&mut out, |v: lyon::tessellation::StrokeVertex| { + let room = limit.saturating_sub(out.vertices.len()); + let mut builder = Capped::new( + BuffersBuilder::new(&mut out, |v: StrokeVertex| { let (p, n) = (v.position(), v.normal()); MeshVertex { position: [p.x - n.x * 0.5, p.y - n.y * 0.5], normal: [n.x, n.y], color, stroke_px: width_px, _pad: 0.0 } }), - ) - .map_err(|_| TessError)?; + room, + ); + if tess.tessellate_path(&b.build(), &options, &mut builder).is_err() { + return Err(builder.error()); + } } } Ok((!out.indices.is_empty()).then_some((out.vertices, out.indices))) @@ -170,9 +245,10 @@ pub(crate) fn stroke_rings( color: [f32; 4], width_px: f32, map_per_px: f64, + limit: usize, ) -> Result, Vec)>, TessError> { let rings: Vec> = polys.iter().flatten().cloned().collect(); - stroke(&rings, origin, color, width_px, map_per_px, true, 3) + stroke(&rings, origin, color, width_px, map_per_px, true, 3, limit) } /// Line features (round caps, round joins, 0.5 px minimum width). @@ -182,8 +258,9 @@ pub(crate) fn stroke_lines( color: [f32; 4], width_px: f32, map_per_px: f64, + limit: usize, ) -> Result, Vec)>, TessError> { - stroke(parts, origin, color, width_px.max(0.5), map_per_px, false, 2) + stroke(parts, origin, color, width_px.max(0.5), map_per_px, false, 2, limit) } /// One marker as instances of a unit-space shape template: the selection halo first (a @@ -216,7 +293,7 @@ fn extend_mesh( ) -> Result<(), TessError> { if let Some((pv, pi)) = part? { if v.len() + pv.len() > u32::MAX as usize { - return Err(TessError); // a mesh this big can't index itself (u32): the layer falls back to the CPU + return Err(TessError::TooDense); // a mesh this big can't index itself (u32): the layer falls back to the CPU } let base = v.len() as u32; v.extend(pv); @@ -228,7 +305,9 @@ fn extend_mesh( /// Appends one feature's GPU geometry, in the CPU path's per-feature order (fill, then /// outline — `Ctx::feature`). Transparent fills/strokes are skipped: tiny-skia draws them as /// no-ops, and not emitting them is identical output for less work. `Err` when lyon refuses -/// the geometry — the layer then falls back to the CPU path entirely. +/// the geometry, or once the mesh passes `limit` vertices — the layer then falls back to the +/// CPU path entirely. +#[allow(clippy::too_many_arguments)] fn append_geometry( v: &mut Vec, i: &mut Vec, @@ -239,75 +318,148 @@ fn append_geometry( wireframe: bool, ppp: f32, map_per_px: f64, + limit: usize, ) -> Result<(), TessError> { + // What the layer's mesh has left: tessellation stops once a part passes it. + let room = |v: &Vec| limit.saturating_sub(v.len()); match g { Geometry::Point(c) => pts.extend(marker_instances(*c, origin, s, ppp, None)), Geometry::MultiPoint(cs) => cs.iter().for_each(|c| pts.extend(marker_instances(*c, origin, s, ppp, None))), Geometry::Line(parts) => { // A non-finite width (hostile symbol state) would put NaN vertices on the GPU. if s.stroke.a() > 0 && (s.stroke_width * ppp).is_finite() { - extend_mesh(v, i, stroke_lines(parts, origin, premul(s.stroke), s.stroke_width * ppp, map_per_px))?; + extend_mesh(v, i, stroke_lines(parts, origin, premul(s.stroke), s.stroke_width * ppp, map_per_px, room(v)))?; } } Geometry::Polygon(polys) if wireframe => { - extend_mesh(v, i, stroke_rings(polys, origin, premul(s.stroke.with_alpha(255)), s.stroke_width.max(1.0) * ppp, map_per_px))?; + let width = s.stroke_width.max(1.0) * ppp; + extend_mesh(v, i, stroke_rings(polys, origin, premul(s.stroke.with_alpha(255)), width, map_per_px, room(v)))?; } Geometry::Polygon(polys) => { if s.fill.a() > 0 { - extend_mesh(v, i, fill_polygon(polys, origin, premul(s.fill), map_per_px))?; + extend_mesh(v, i, fill_polygon(polys, origin, premul(s.fill), map_per_px, room(v)))?; } if s.stroke_width > 0.0 && s.stroke.a() > 0 { - extend_mesh(v, i, stroke_rings(polys, origin, premul(s.stroke), s.stroke_width * ppp, map_per_px))?; + extend_mesh(v, i, stroke_rings(polys, origin, premul(s.stroke), s.stroke_width * ppp, map_per_px, room(v)))?; } } } Ok(()) } -/// Collects one GPU-eligible layer into Mesh/Points commands. `geoms`, `rows` and `visible` -/// are the render loop's own (projected geometries, row filter, extent cull), so the GPU -/// draws exactly the features the CPU would. `Ok(None)` when nothing visible was collected; -/// `Err(TessError)` when lyon refused a feature — the caller then draws the whole layer with -/// tiny-skia instead (nothing is pushed part-way). +/// The most vertices one layer's mesh may take (40 bytes each: 80 MB, well inside the 256 MiB +/// `max_buffer_size` devices and WebGL offer). A layer of a million tiny polygons (3D buildings +/// as faces) would tessellate to tens of millions — seconds per frame, for a mesh the device +/// then refuses — while the CPU path splats its sub-pixel features in milliseconds. +pub(crate) const MAX_LAYER_VERTICES: usize = 2_000_000; + +/// The vertex limit of meshes that aren't a layer's (highlights, the basemap, the graticule, +/// extrusion roofs): small or bounded by their own data. +pub(crate) const NO_LIMIT: usize = usize::MAX; + +/// How many mesh vertices `append_geometry` makes of a geometry, roughly: a fill one per +/// point; a round-joined stroke two per point, a join's arc for each turn (up to 16, a half +/// circle at the stroke tolerance, for a hairpin) and two round caps on an open line. Points +/// draw as instances and count nothing. It is an early out, not a bound: lyon adds vertices +/// where an EvenOdd ring crosses itself, and decimation drops some, so the tessellation itself +/// stops at the limit ([`Capped`]). +fn estimate_vertices(g: &Geometry, s: &Symbol, wireframe: bool) -> usize { + fn stroked<'a>(rings: impl Iterator>, closed: bool) -> usize { + rings.map(|r| 2 * r.len() + joins(r, closed) + if closed { 0 } else { 32 }).sum() + } + // sin(θ/2) ≥ θ/π for a turn θ in 0..π, so 16·sin(θ/2) arc segments covers lyon's ~16θ/π — + // without an atan2 per point. + fn joins(r: &[Coord], closed: bool) -> usize { + let n = r.len(); + let at = |k: usize| if closed { r.get(k % n) } else { r.get(k) }; + let ends = if closed { 0..n } else { 1..n.saturating_sub(1) }; + ends.filter_map(|k| { + let (a, b, c) = (at(if closed { k + n - 1 } else { k.checked_sub(1)? })?, at(k)?, at(k + 1)?); + let (u, v) = ((b.x - a.x, b.y - a.y), (c.x - b.x, c.y - b.y)); + let lens = ((u.0 * u.0 + u.1 * u.1) * (v.0 * v.0 + v.1 * v.1)).sqrt(); + let cos = ((u.0 * v.0 + u.1 * v.1) / lens).clamp(-1.0, 1.0); + // A repeated or non-finite point turns nowhere. + (lens > 0.0 && cos.is_finite()).then(|| (16.0 * ((1.0 - cos) / 2.0).sqrt()).ceil() as usize + 1) + }) + .sum() + } + match g { + Geometry::Point(_) | Geometry::MultiPoint(_) => 0, + Geometry::Line(parts) if s.stroke.a() > 0 => stroked(parts.iter(), false), + Geometry::Line(_) => 0, + Geometry::Polygon(polys) if wireframe => stroked(polys.iter().flatten(), true), + Geometry::Polygon(polys) => { + let fill = if s.fill.a() > 0 { polys.iter().flatten().map(Vec::len).sum() } else { 0 }; + let outline = if s.stroke_width > 0.0 && s.stroke.a() > 0 { stroked(polys.iter().flatten(), true) } else { 0 }; + fill + outline + } + } +} + +/// Collects one GPU-eligible layer, or one streamed batch of it, into Mesh/Points commands. +/// `geoms`, `extents`, `rows` and `visible` are the render loop's own (geometries as the CPU +/// draws them, their cached extents, row filter, extent cull), so the GPU draws exactly the +/// features the CPU would. `limit` is what is left of the layer's [`MAX_LAYER_VERTICES`] (its +/// earlier batches took the rest). `Ok` with the mesh vertices pushed; `Err(Refused)` when +/// lyon refused a feature, `Err(TooDense)` when the mesh would pass `limit` — the caller then draws the layer +/// (or batch) with tiny-skia instead (nothing is pushed part-way). +#[allow(clippy::too_many_arguments)] pub(crate) fn collect_layer( cmds: &mut Vec, layer: &Layer, geoms: &[Option<&Geometry>], + extents: &[Extent], rows: &[bool], visible: &dyn Fn(&Extent) -> bool, wireframe: bool, ppp: f32, map_per_px: f64, -) -> Result, TessError> { - // Stable origin: the centre of the drawn features' extent. Two passes cost one extra - // extent fold — the same order of work as the culling pass the CPU render already does. + limit: usize, +) -> Result { + // Stable origin: the centre of the drawn features' extent, so the features are listed + // first — with their symbols and estimates, each worked out once. The same pass gives up on + // a layer too dense to mesh before any of it is tessellated. let mut ext = Extent::EMPTY; + let mut estimate = 0usize; + let mut features = Vec::new(); for (row, g) in geoms.iter().enumerate() { let Some(g) = g else { continue }; - if rows.get(row) != Some(&true) || !visible(&g.extent()) { + // Culled on the extent the CPU path culls on (the full geometry's, cached), so both + // backends keep the same features; the globe has none cached. + let e = extents.get(row).copied().unwrap_or_else(|| g.extent()); + if rows.get(row) != Some(&true) || !visible(&e) { continue; } - ext = ext.union(&g.extent()); + ext = ext.union(&e); + let s = layer.symbol(row); + let n = estimate_vertices(g, &s, wireframe); + estimate = estimate.saturating_add(n); + if estimate > limit { + return Err(TessError::TooDense); + } + features.push((*g, s, n)); } if ext.is_empty() { - return Ok(None); + return Ok(0); } let origin = [ext.center().x, ext.center().y]; let (mut v, mut i, mut pts) = (Vec::new(), Vec::new(), Vec::new()); - for (row, g) in geoms.iter().enumerate() { - let Some(g) = g else { continue }; - if rows.get(row) != Some(&true) || !visible(&g.extent()) { - continue; + for (g, s, n) in &features { + // The estimate is rough: a feature estimated not to fit isn't started, and + // `append_geometry` stops one that overflows at the first vertex past the limit. + if v.len().saturating_add(*n) > limit { + return Err(TessError::TooDense); } - append_geometry(&mut v, &mut i, &mut pts, g, &layer.symbol(row), origin, wireframe, ppp, map_per_px)?; + append_geometry(&mut v, &mut i, &mut pts, g, s, origin, wireframe, ppp, map_per_px, limit)?; } + let used = v.len(); if !i.is_empty() { cmds.push(DrawCmd::Mesh { origin, vertices: v, indices: i }); } if !pts.is_empty() { cmds.push(DrawCmd::Points { origin, instances: pts }); } - Ok(Some(origin)) + Ok(used) } /// Selection highlight of one feature, after its layer (`Ctx::highlight`): haloed markers, @@ -323,16 +475,20 @@ pub(crate) fn collect_highlight(cmds: &mut Vec, g: &Geometry, s: &Symbo } Geometry::Line(parts) => { let (mut v, mut i) = (Vec::new(), Vec::new()); - extend_mesh(&mut v, &mut i, stroke_lines(parts, origin, premul(SELECTION), (s.stroke_width + 3.0) * ppp, map_per_px))?; - extend_mesh(&mut v, &mut i, stroke_lines(parts, origin, premul(s.stroke), s.stroke_width * ppp, map_per_px))?; + extend_mesh( + &mut v, + &mut i, + stroke_lines(parts, origin, premul(SELECTION), (s.stroke_width + 3.0) * ppp, map_per_px, NO_LIMIT), + )?; + extend_mesh(&mut v, &mut i, stroke_lines(parts, origin, premul(s.stroke), s.stroke_width * ppp, map_per_px, NO_LIMIT))?; if !i.is_empty() { cmds.push(DrawCmd::Mesh { origin, vertices: v, indices: i }); } } Geometry::Polygon(polys) => { let (mut v, mut i) = (Vec::new(), Vec::new()); - extend_mesh(&mut v, &mut i, fill_polygon(polys, origin, premul(SELECTION.with_alpha(40)), map_per_px))?; - extend_mesh(&mut v, &mut i, stroke_rings(polys, origin, premul(SELECTION), 2.0 * ppp, map_per_px))?; + extend_mesh(&mut v, &mut i, fill_polygon(polys, origin, premul(SELECTION.with_alpha(40)), map_per_px, NO_LIMIT))?; + extend_mesh(&mut v, &mut i, stroke_rings(polys, origin, premul(SELECTION), 2.0 * ppp, map_per_px, NO_LIMIT))?; if !i.is_empty() { cmds.push(DrawCmd::Mesh { origin, vertices: v, indices: i }); } @@ -352,9 +508,9 @@ pub(crate) fn collect_basemap( map_per_px: f64, ) -> Result<(), TessError> { let (mut v, mut i) = (Vec::new(), Vec::new()); - extend_mesh(&mut v, &mut i, fill_polygon(polys, origin, premul(land), map_per_px))?; + extend_mesh(&mut v, &mut i, fill_polygon(polys, origin, premul(land), map_per_px, NO_LIMIT))?; if outline.a() > 0 { - extend_mesh(&mut v, &mut i, stroke_rings(polys, origin, premul(outline), 0.6 * ppp, map_per_px))?; + extend_mesh(&mut v, &mut i, stroke_rings(polys, origin, premul(outline), 0.6 * ppp, map_per_px, NO_LIMIT))?; } if !i.is_empty() { cmds.push(DrawCmd::Mesh { origin, vertices: v, indices: i }); @@ -372,7 +528,7 @@ pub(crate) fn collect_graticule( map_per_px: f64, ) -> Result<(), TessError> { let (mut v, mut i) = (Vec::new(), Vec::new()); - extend_mesh(&mut v, &mut i, stroke_lines(lines, origin, premul(grat), 0.6 * ppp, map_per_px))?; + extend_mesh(&mut v, &mut i, stroke_lines(lines, origin, premul(grat), 0.6 * ppp, map_per_px, NO_LIMIT))?; if !i.is_empty() { cmds.push(DrawCmd::Mesh { origin, vertices: v, indices: i }); } @@ -382,6 +538,7 @@ pub(crate) fn collect_graticule( #[cfg(test)] mod tests { use super::*; + use rustgis_data::GeometryType; use rustgis_map::Marker; fn square(x: f64, y: f64, s: f64) -> Vec { @@ -400,7 +557,7 @@ mod tests { #[test] fn polygon_with_hole_tessellates_evenodd() { let polys = vec![vec![square(0.0, 0.0, 100.0), square(30.0, 30.0, 40.0)]]; - let (v, i) = fill_polygon(&polys, [0.0, 0.0], [1.0, 0.0, 0.0, 1.0], 0.0).expect("tessellate").expect("non-empty"); + let (v, i) = fill_polygon(&polys, [0.0, 0.0], [1.0, 0.0, 0.0, 1.0], 0.0, NO_LIMIT).expect("tessellate").expect("non-empty"); assert!(i.len() >= 24, "a hole needs interior triangles: {}", i.len()); assert!(!point_covered(&v, &i, 50.0, 50.0), "hole center must stay uncovered"); // (10, 5): inside, and not on a tessellation diagonal (edge points read as uncovered). @@ -445,7 +602,7 @@ mod tests { #[test] fn stroke_vertices_carry_centerline_and_normal() { let parts = vec![vec![Coord::new(0.0, 0.0), Coord::new(100.0, 0.0)]]; - let (v, i) = stroke_lines(&parts, [0.0, 0.0], [0.0, 0.0, 1.0, 1.0], 2.0, 0.0).expect("stroke").expect("non-empty"); + let (v, i) = stroke_lines(&parts, [0.0, 0.0], [0.0, 0.0, 1.0, 1.0], 2.0, 0.0, NO_LIMIT).expect("stroke").expect("non-empty"); assert!(!i.is_empty()); for vtx in &v { assert!(vtx.position[1].abs() < 1e-4, "centerline stays on the path: y={}", vtx.position[1]); @@ -458,7 +615,7 @@ mod tests { #[test] fn ring_stroke_has_no_width_floor_and_closes() { let polys = vec![vec![square(0.0, 0.0, 10.0)]]; - let (v, i) = stroke_rings(&polys, [0.0, 0.0], [0.0; 4], 0.2, 0.0).expect("stroke").expect("non-empty"); + let (v, i) = stroke_rings(&polys, [0.0, 0.0], [0.0; 4], 0.2, 0.0, NO_LIMIT).expect("stroke").expect("non-empty"); assert!(!i.is_empty()); assert!(v.iter().all(|x| x.stroke_px == 0.2), "polygon outlines keep their exact width"); } @@ -473,14 +630,14 @@ mod tests { Coord::new(a.cos() * 100.0, a.sin() * 100.0) }) .collect(); - let (v, _) = stroke_lines(&[arc], [0.0, 0.0], [0.0; 4], 2.0, 0.0).expect("stroke").expect("non-empty"); + let (v, _) = stroke_lines(&[arc], [0.0, 0.0], [0.0; 4], 2.0, 0.0, NO_LIMIT).expect("stroke").expect("non-empty"); assert!(v.len() >= 40, "round join subdivided finely: {} vertices", v.len()); } #[test] fn line_width_floors_at_half_pixel() { let parts = vec![vec![Coord::new(0.0, 0.0), Coord::new(10.0, 0.0)]]; - let (v, _) = stroke_lines(&parts, [0.0, 0.0], [0.0; 4], 0.2, 0.0).expect("stroke").expect("non-empty"); + let (v, _) = stroke_lines(&parts, [0.0, 0.0], [0.0; 4], 0.2, 0.0, NO_LIMIT).expect("stroke").expect("non-empty"); assert!(v.iter().all(|x| x.stroke_px == 0.5)); } @@ -501,6 +658,47 @@ mod tests { assert_eq!(one.len(), 1, "fill only"); } + #[test] + fn vertex_estimate_tracks_tessellation() { + let s = Symbol { fill: Rgba::rgb(255, 0, 0), stroke: Rgba::rgb(0, 0, 0), stroke_width: 1.0, size: 4.0, marker: Marker::Circle }; + let polys = vec![vec![square(0.0, 0.0, 100.0), square(30.0, 30.0, 40.0)]]; + let shapes = [ + Geometry::Polygon(polys), + Geometry::Polygon(vec![vec![vec![Coord::new(0.0, 0.0), Coord::new(1.0, 0.0), Coord::new(0.0, 1.0)]]]), + Geometry::Line(vec![vec![Coord::new(0.0, 0.0), Coord::new(50.0, 0.0), Coord::new(50.0, 50.0), Coord::new(0.0, 60.0)]]), + ]; + for g in &shapes { + for wireframe in [false, true] { + let (mut v, mut i, mut pts) = (Vec::new(), Vec::new(), Vec::new()); + append_geometry(&mut v, &mut i, &mut pts, g, &s, [0.0, 0.0], wireframe, 1.0, 0.0, NO_LIMIT).expect("tessellate"); + let est = estimate_vertices(g, &s, wireframe); + assert!(est >= v.len() / 2 && est <= v.len() * 2, "{g:?}: estimated {est}, tessellated {}", v.len()); + } + } + let point = Geometry::Point(Coord::new(0.0, 0.0)); + assert_eq!(estimate_vertices(&point, &s, false), 0, "markers are instances, not mesh"); + let hollow = Symbol { fill: Rgba::rgb(0, 0, 0).with_alpha(0), ..s }; + let (est_hollow, est_full) = (estimate_vertices(&shapes[1], &hollow, false), estimate_vertices(&shapes[1], &s, false)); + assert_eq!(est_full - est_hollow, 3, "a transparent fill tessellates nothing"); + } + + #[test] + fn tessellation_stops_past_its_vertex_limit() { + // Hairpin turns: each round join adds a half circle, which the estimate counts. + let zigzag: Vec = (0..200).map(|k| Coord::new((k % 2) as f64 * 100.0, k as f64)).collect(); + let parts = vec![zigzag.clone(), zigzag]; + let (v, _) = stroke_lines(&parts, [0.0, 0.0], [0.0; 4], 2.0, 0.0, NO_LIMIT).expect("stroke").expect("non-empty"); + let est = estimate_vertices(&Geometry::Line(parts.clone()), &Symbol::default_for(GeometryType::Polyline, 0), false); + assert!(est >= v.len() && est <= v.len() * 2, "hairpins: estimated {est}, tessellated {}", v.len()); + // The first part already passes the limit: the second is never tessellated. + assert_eq!(stroke_lines(&parts, [0.0, 0.0], [0.0; 4], 2.0, 0.0, v.len() / 4).err(), Some(TessError::TooDense)); + // One part stops at the vertex past the limit, not after it is finished. + assert_eq!(stroke_lines(&parts[..1], [0.0, 0.0], [0.0; 4], 2.0, 0.0, 10).err(), Some(TessError::TooDense)); + let polys = vec![vec![square(0.0, 0.0, 10.0)], vec![square(20.0, 0.0, 10.0)]]; + assert!(fill_polygon(&polys, [0.0, 0.0], [0.0; 4], 0.0, 3).is_err(), "past the limit after the first square"); + assert!(fill_polygon(&polys, [0.0, 0.0], [0.0; 4], 0.0, 8).is_ok_and(|m| m.is_some()), "two squares fit in 8"); + } + #[test] fn premultiplies_alpha() { assert_eq!(premul(Rgba::rgb(255, 0, 0).with_alpha(128)), [128.0 / 255.0, 0.0, 0.0, 128.0 / 255.0]); diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index 7937f0b7b..0fb349006 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -323,7 +323,10 @@ fn perspective_uses_complete_isolated_fallback() { let got = render(&mut actual, &view).unwrap(); assert_eq!(got.labels, expected.labels); assert!(got.pixmap.data() == expected.pixmap.data()); - assert!(actual.layers[0].dataset.features.shares(&before)); + // The complete chunks never reach the interaction rows. Compared by value, not by + // allocation: the layer's background read can finish mid-render and install the same + // one-row sample again as a new allocation. + assert_eq!(actual.layers[0].dataset.features, before); assert_eq!(actual.layers[0].selection, vec![0]); } } @@ -1076,3 +1079,34 @@ fn layout_export_rejects_unopened_local_shapefiles_and_required_masks() { ); } } + +#[test] +fn gpu_mesh_limit_spans_a_streamed_layers_batches() { + // 72,900 triangles about a pixel across, streamed in batches that each mesh well under + // MAX_LAYER_VERTICES but together pass it: the batches past the limit draw on the CPU. + let mut ds = Dataset::new("dense", GeometryType::Polygon, vec![]); + for i in 0..270 { + for j in 0..270 { + let (x, y) = (i as f64 * 0.01, j as f64 * 0.01); + let ring = vec![Coord::new(x, y), Coord::new(x + 0.008, y), Coord::new(x, y + 0.008)]; + ds.push(Some(Geometry::Polygon(vec![vec![ring]])), vec![]); + } + } + let mut map = Map::new("dense"); + map.add_dataset(ds); + map.layers[0] = streamed(&map.layers[0], 72_900 / 8 + 1); + let camera = Camera { center: lonlat_to_merc(Coord::new(1.35, 1.35)), res: 1000.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 400.0, height: 400.0, pixels_per_point: 1.0, camera, sky: false }; + let Some(RenderOutput::Gpu(scene)) = render_backend(&mut map, &view, Backend::Gpu) else { panic!("gpu scene") }; + let meshed: Vec = scene + .cmds + .iter() + .filter_map(|c| match c { + gpu_scene::DrawCmd::Mesh { vertices, .. } => Some(vertices.len()), + _ => None, + }) + .collect(); + assert!(!meshed.is_empty(), "the first batches still mesh"); + assert!(meshed.iter().sum::() <= mesh::MAX_LAYER_VERTICES, "the layer's meshes: {meshed:?}"); + assert!(scene.cmds.iter().any(|c| matches!(c, gpu_scene::DrawCmd::Image { .. })), "the rest drawn on the CPU"); +} From 8d55849f74bdfd10dc9460d92fa5cdee163ff5fa Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Thu, 22 Oct 2026 11:33:29 -0300 Subject: [PATCH 028/297] Analysis: a STAC trend checks its bbox, cellSize and resampling before searching (#579) (#696) * cube.trend: check a STAC source's bbox, cellSize and resampling before searching; reserve the outputs once a piece is worked out Co-Authored-By: Claude Opus 5.5 * STAC: the trend searches the area it read; "view" in any case or spacing in a search Co-Authored-By: Claude Opus 5.5 * Tests: the STAC view test above the trend test's doc comment, not inside it Co-Authored-By: Claude Opus 5.5 * STAC search: a bbox other than an array or "view" is refused, not read as no area Co-Authored-By: Claude Opus 5.5 * Tests: a STAC trend given " VIEW " sends the view's box in its search request Co-Authored-By: Claude Opus 5.5 * STAC search: a bbox array is four numbers, every one; the reserved total named apart from a piece's cells Co-Authored-By: Claude Opus 5.5 * Tests: the STAC view search test reads a percent-encoded comma in either case Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 Co-authored-by: giswqs --- crates/engine/src/cmd/cube.rs | 13 +++--- crates/engine/src/cmd/stac.rs | 87 ++++++++++++++++++++--------------- crates/engine/src/tests.rs | 65 ++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 42 deletions(-) diff --git a/crates/engine/src/cmd/cube.rs b/crates/engine/src/cmd/cube.rs index a64faa92f..03eeb036e 100644 --- a/crates/engine/src/cmd/cube.rs +++ b/crates/engine/src/cmd/cube.rs @@ -536,12 +536,6 @@ impl TrendRun { let mut rows = 0usize; // The four outputs, a band each, filled piece by piece (never held interleaved whole). let mut bands: [Vec; OUTPUTS] = Default::default(); - // Their full length up front when the cube knows it, so they don't grow by doubling. - if let Some(cells) = whole { - for band in &mut bands { - band.reserve_exact(cells); - } - } let mut cells_done = 0usize; for piece in 0..self.cube.pieces().max(1) { let (grid, block, cells) = self.read_piece(piece, cancel, done, whole.is_none().then_some(&too_long))?; @@ -566,6 +560,13 @@ impl TrendRun { let part = self.work_out(&block, cells, cells_done, tick, need, ×, &seasons, cancel, done)?; // The values are read: their block goes before the next piece's is read. drop(block); + // Their full length when the cube knows it, so they don't grow by doubling: once the + // first piece is worked out, so a run cancelled or refused before then hasn't taken it. + if let (0, Some(total)) = (piece, whole) { + for band in &mut bands { + band.reserve_exact(total); + } + } for (k, band) in bands.iter_mut().enumerate() { band.extend(part.chunks(OUTPUTS).map(|o| o.get(k).copied().unwrap_or(f32::NAN))); } diff --git a/crates/engine/src/cmd/stac.rs b/crates/engine/src/cmd/stac.rs index 244a8e2e5..e9413c500 100644 --- a/crates/engine/src/cmd/stac.rs +++ b/crates/engine/src/cmd/stac.rs @@ -34,16 +34,19 @@ pub fn search_params(s: &Session, p: &Value) -> Result { _ => Vec::new(), }; let bbox = match p.get("bbox") { - Some(Value::String(v)) if v == "view" => Some(view(s)?), + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("view") => Some(view(s)?), Some(v @ Value::Array(_)) => { - let n: Vec = v.as_array().into_iter().flatten().filter_map(Value::as_f64).collect(); - match n.as_slice() { - [w, south, e, n] => Some([*w, *south, *e, *n]), + // Four numbers, every one: `[10, 45, 11, 46, "extra"]` is refused, not read without its last. + let n: Option> = v.as_array().map(|a| a.iter().map(Value::as_f64).collect()).unwrap_or_default(); + match n.as_deref() { + Some([w, south, e, n]) => Some([*w, *south, *e, *n]), _ => return Err("bbox is [west, south, east, north], or \"view\"".into()), } } - _ if b(p, "view").unwrap_or(false) => Some(view(s)?), - _ => None, + None | Some(Value::Null) if b(p, "view").unwrap_or(false) => Some(view(s)?), + None | Some(Value::Null) => None, + // Anything else ("nowhere", a number) is refused rather than read as no area at all. + Some(_) => return Err("bbox is [west, south, east, north], or \"view\"".into()), }; Ok(Search { collections, @@ -538,7 +541,46 @@ pub fn piece_rows(width: u32, height: u32, dates: usize, tile_height: u32, max: #[cfg(not(target_arch = "wasm32"))] pub fn stac_cube(s: &Session, p: &Value) -> Result { use crate::render::rustgis_tiles::cog; - let CubeFiles { tiles, scales, nodata, collection, asset, .. } = cube_files(s, p)?; + // The parameters read here, before the search and the first header are waited for. + let size = match p.get("cellSize").filter(|v| !v.is_null()) { + None => None, + Some(v) => { + Some(v.as_f64().filter(|c| c.is_finite() && *c > 0.0).ok_or("cellSize is a size in the files' units (metres for most)")?) + } + }; + // An overview is often made by taking one pixel in each block (the Brazil Data Cube's are, + // GDAL's NEAREST), so a cell of it is one of the full images' pixels, which can differ much + // from the place's mean: "average" reads the full images and takes the mean of each cell's. + let average = match p.get("resampling").filter(|v| !v.is_null()) { + None => false, + Some(v) => match v.as_str().map(str::trim) { + Some("") | Some("nearest") => false, + Some("average") => true, + Some(x) => return Err(format!("resampling is nearest or average, not {x}")), + // A number or anything else isn't read as the default: said. + None => return Err("resampling is nearest or average".into()), + }, + }; + // The area, in degrees. + let bbox = match p.get("bbox").filter(|v| !v.is_null()) { + Some(v) if v.as_str().is_some_and(|t| t.trim().eq_ignore_ascii_case("view")) => view(s)?, + None => view(s)?, + Some(v) => { + let b: Option> = + v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite())).collect()); + match b.as_deref() { + Some([w, so, e, n]) if w < e && so < n => [*w, *so, *e, *n], + _ => return Err("bbox is [west, south, east, north] in degrees, or \"view\"".into()), + } + } + }; + // The search covers the area as read here: "view" (in any case or spacing) or none given is + // the map's view, resolved, so the search and the grid agree. + let mut searched = p.clone(); + if let Some(o) = searched.as_object_mut() { + o.insert("bbox".into(), json!(bbox)); + } + let CubeFiles { tiles, scales, nodata, collection, asset, .. } = cube_files(s, &searched)?; // Every tile's file for a date, by day. let mut by_day: std::collections::BTreeMap)>> = Default::default(); for days in tiles.values() { @@ -559,30 +601,15 @@ pub fn stac_cube(s: &Session, p: &Value) -> Result { } // The level whose cells are nearest the size asked, by ratio (`1000` takes MODIS's 926.6 m // overview, not its 463 m one); the full images by default. - let level = match p.get("cellSize").filter(|v| !v.is_null()) { + let level = match size { None => 0, - Some(v) => { - let size = - v.as_f64().filter(|c| c.is_finite() && *c > 0.0).ok_or("cellSize is a size in the files' units (metres for most)")?; + Some(size) => { let off = |l: &rustgis_data::raster::cog::Level| (layout.transform_of(l).pixel_width.abs() / size).ln().abs(); (0..layout.levels.len()).min_by(|a, b| off(&layout.levels[*a]).total_cmp(&off(&layout.levels[*b]))).unwrap_or(0) } }; let lv = layout.levels.get(level).ok_or("the file has no image")?; let lt = layout.transform_of(lv); - // An overview is often made by taking one pixel in each block (the Brazil Data Cube's are, - // GDAL's NEAREST), so a cell of it is one of the full images' pixels, which can differ much - // from the place's mean: "average" reads the full images and takes the mean of each cell's. - let average = match p.get("resampling").filter(|v| !v.is_null()) { - None => false, - Some(v) => match v.as_str().map(str::trim) { - Some("") | Some("nearest") => false, - Some("average") => true, - Some(x) => return Err(format!("resampling is nearest or average, not {x}")), - // A number or anything else isn't read as the default: said. - None => return Err("resampling is nearest or average".into()), - }, - }; let full = layout.levels.first().ok_or("the file has no image")?; let (read, factor) = if average && level > 0 { let f = (f64::from(full.width) / f64::from(lv.width.max(1))).round(); @@ -602,18 +629,6 @@ pub fn stac_cube(s: &Session, p: &Value) -> Result { let read_tile_rows = layout.levels.get(read).map_or(1, |l| l.tile_height).div_ceil(factor).max(1); // The area, in the files' coordinates: the box's edges, each at 32 points, as a curved grid // (MODIS's sinusoidal) bends them. - let bbox = match p.get("bbox").filter(|v| !v.is_null()) { - Some(v) if v.as_str().is_some_and(|t| t.trim().eq_ignore_ascii_case("view")) => view(s)?, - None => view(s)?, - Some(v) => { - let b: Option> = - v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite())).collect()); - match b.as_deref() { - Some([w, so, e, n]) if w < e && so < n => [*w, *so, *e, *n], - _ => return Err("bbox is [west, south, east, north] in degrees, or \"view\"".into()), - } - } - }; let tr = layout.crs.transformer()?; let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); let [w, so, e, n] = bbox; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 08bb4d245..ded5b0f69 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7252,6 +7252,57 @@ fn a_stac_data_cube_on_the_time_slider() { assert_eq!((s.map().tiles.len(), s.map().layers.len()), (tiles, layers)); } +/// A search's `"bbox": "view"` is the map's view in any case or spacing, as the trend and the data +/// cube read it; any other text is refused, never read as no area at all. +#[test] +fn a_stac_search_takes_view_in_any_case() { + let s = world(); + let search = |bbox: &str| crate::cmd::stac::search_params(&s, &json!({"bbox": bbox})).unwrap().bbox; + assert!(search("view").is_some()); + assert_eq!(search(" VIEW "), search("view")); + for bad in [json!("nowhere"), json!(7), json!({"w": 1}), json!([10, 45, 11, 46, "extra"]), json!([10, 45, 11, "46"])] { + let e = crate::cmd::stac::search_params(&s, &json!({"bbox": bad})).unwrap_err(); + assert!(e.contains("bbox is"), "{bad}: {e}"); + } + assert!(crate::cmd::stac::search_params(&s, &json!({})).unwrap().bbox.is_none(), "no bbox, no area"); +} + +/// A trend given `"bbox": " VIEW "` searches the map's view: its search request carries the box +/// the trend reads, not no area at all. +#[test] +fn a_stac_trend_searches_the_view_it_reads() { + use std::io::{Read, Write}; + let mut s = world(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let api = format!("http://{}/v1", listener.local_addr().unwrap()); + let asked = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = asked.clone(); + std::thread::spawn(move || { + for mut c in listener.incoming().take(4).flatten() { + let mut buf = [0u8; 8192]; + let n = c.read(&mut buf).unwrap_or(0); + let line = String::from_utf8_lossy(&buf[..n]).lines().next().unwrap_or("").to_string(); + seen.lock().unwrap().push(line); + let body = r#"{"type": "FeatureCollection", "features": []}"#; + let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + } + }); + let p = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": " VIEW ", "start": "2024", "end": "2024"}); + let e = s.run("cube.trend", &p).unwrap_err(); + assert!(e.contains("no item"), "the search was made and found nothing: {e}"); + let lines = asked.lock().unwrap().clone(); + let search = lines.iter().find(|l| l.contains("/search")).unwrap_or_else(|| panic!("{lines:?}")); + let query = search.split(['?', ' ']).nth(2).unwrap_or_default(); + let bbox = query.split('&').find_map(|kv| kv.strip_prefix("bbox=")).unwrap_or_else(|| panic!("no bbox in {search}")); + // The commas as sent, or percent-encoded in either case. + let asked: Vec = bbox.replace("%2C", ",").replace("%2c", ",").split(',').map(|v| v.parse().unwrap()).collect(); + let view = crate::cmd::stac::search_params(&s, &json!({"bbox": "view"})).unwrap().bbox.unwrap(); + assert_eq!(asked.len(), 4, "{search}"); + for (a, v) in asked.iter().zip(view) { + assert!((a - v).abs() < 1e-6, "{search} against the view {view:?}"); + } +} + /// A trend over a STAC collection's band (#579), read in place from its files over an area: each /// date's values scaled as its own metadata says (here date k's scale is k + 1 ten-thousandths, /// so every cell grows in a straight line), Sen's slope as worked out by hand; the same outputs @@ -7311,6 +7362,20 @@ fn a_trend_over_a_stac_collection() { let got = f64::from(ws.bands[0].data[y * 768 + 256 + x]); assert!((got - per_year(x, y)).abs() < 1e-4, "({x}, {y}): {got} vs {}", per_year(x, y)); } + // The parameters read without the network are refused before the search: an API on a + // closed port, never asked. + for (bad, said) in [ + (json!({"bbox": [11, 45, 10, 46]}), "bbox is"), + (json!({"cellSize": 0}), "cellSize is"), + (json!({"resampling": "bilinear"}), "resampling is"), + ] { + let mut q = json!({"url": "http://127.0.0.1:9/v1", "collection": "mod", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024", "end": "2024"}); + for (k, v) in bad.as_object().unwrap() { + q[k] = v.clone(); + } + let e = s.run("cube.trend", &q).unwrap_err(); + assert!(e.contains(said), "{e}"); + } // An area no file covers (the items only near it) is refused, not four empty rasters. let away = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [20, 45, 21, 46], "start": "2024-01-01", "end": "2024-03"}); let e = s.run("cube.trend", &away).unwrap_err(); From 866d9ec4ccc5a786e5e4b7378216c6b26763f1a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Thu, 22 Oct 2026 09:40:50 -0500 Subject: [PATCH 029/297] feat(ui): offer a command's parameter keys in the Script pane (#690) * feat(ui): offer a command's parameter keys in the Script pane * refactor(ui): simplify how the Script pane reads a key and its alternatives * fix(ui): let a key already closed in the Script pane still be changed * fix(ui): count a key repeated on a Script line as given * fix(ui): leave the key at the cursor out of the Script line's given keys by position --------- Co-authored-by: giswqs --- crates/ui-egui/src/scriptcomplete.rs | 234 +++++++++++++++++++++++++-- docs/user-guide/analysis.md | 4 +- 2 files changed, 223 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 7684621da..156199912 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -1,9 +1,9 @@ //! Autocomplete for the Script pane (#662): a floating list of suggestions just under the cursor, //! as in a code editor. ↑/↓ move the highlight, Enter or Tab accept it, Escape closes the list, or -//! a click accepts an item. What to offer at a cursor (`offer`) is worked out apart from the box -//! and tested on its own; this first step completes command ids (a line's first word). Typing -//! aids beside it: a command's parameters added on accept (`template`), and braces that close -//! themselves (`brace`). +//! a click accepts an item. What to offer at a cursor (`offer_at`) is worked out apart from the +//! box and tested on its own: command ids (a line's first word), and a command's parameter keys in +//! its `{…}` (`key_at`). Typing aids beside it: a command's parameters added on accept +//! (`template`), and braces that close themselves (`brace`). use egui::text::{CCursor, CCursorRange}; use egui::widgets::text_edit::{TextEditOutput, TextEditState}; @@ -18,12 +18,13 @@ const MIN_TYPED: usize = 2; const MIN_WIDTH: f32 = 220.0; /// What to offer at a cursor: the word being typed (char indexes, `end` at the cursor) and what -/// can replace it. +/// can replace it: command ids, or the keys of command `keys_of`'s parameters. #[derive(Clone, Debug, PartialEq)] pub struct Offer { pub start: usize, pub end: usize, pub items: Vec, + pub keys_of: Option, } /// The ids that complete the command id being typed at char `at` of `script`: the cursor at the @@ -133,7 +134,99 @@ fn complete(start: usize, end: usize, word: &str, ids: &[String]) -> Option Option<(Hint, usize, String)> { + let h = hint_at(script, at)?; + let text = script.get(byte_at(script, h.brace)?..byte_at(script, at)?)?; + // What follows the cursor ends the word. + let next = script.get(byte_at(script, at)?..)?.chars().next(); + if next.is_some_and(|c| !c.is_whitespace() && !matches!(c, '"' | ',' | '}')) { + return None; + } + let (mut depth, mut quoted, mut escaped, mut entry, mut start) = (0usize, false, false, false, None); + for (i, c) in text.chars().enumerate() { + let was_escaped = escaped; + escaped = c == '\\' && !was_escaped; + if quoted { + if c == '"' && !was_escaped { + (quoted, start) = (false, None); + } + continue; + } + match c { + '"' => (quoted, start, entry) = (true, (depth == 1 && entry).then_some(i + 1), false), + '{' | '[' | '(' => { + depth += 1; + entry = c == '{' && depth == 1; + } + '}' | ']' | ')' => (depth, entry) = (depth.saturating_sub(1), false), + ',' => entry = depth == 1, + c if c.is_whitespace() => {} + _ => entry = false, + } + } + let start = start?; + let word: String = text.chars().skip(start).collect(); + if word.chars().any(|c| c.is_whitespace() || c == '\\') { + return None; + } + let start = h.brace + start; + Some((h, start, word)) +} + +/// The keys of the parameters `desc` describes, entry by entry, each with its alternatives +/// (`"lon" | "x"`), and the entry's text as described. +fn entries(desc: &str) -> Vec<(Vec, String)> { + let params = rustgis_engine::param_list(desc).unwrap_or_default(); + params + .iter() + .map(|p| { + let entry = format!("{{{}}}", p.text); + let keys = rustgis_engine::param_keys(&entry).filter(|k| !k.is_empty()).unwrap_or(vec![p.key]); + (keys.into_iter().map(str::to_string).collect(), p.text.to_string()) + }) + .collect() +} + +/// The keys of command `id`'s parameters (`desc`) that complete `word`, typed from char `start` +/// to `end`, case aside, in the order described; an entry already given on the line (`line`, from +/// its `{`: a script's command is one line) isn't offered, nor its alternatives. `line` comes +/// without the word being typed, so that key, closed or not, doesn't count as given (a key already +/// closed, `"lat|": 5`, can still be changed), while the same key given elsewhere does. +fn complete_keys(id: &str, desc: &str, line: &str, start: usize, end: usize, word: &str) -> Option { + let given = rustgis_engine::param_keys(line).unwrap_or_default(); + let items: Vec = entries(desc) + .into_iter() + .filter(|(keys, _)| !keys.iter().any(|k| given.contains(&k.as_str()))) + .flat_map(|(keys, _)| keys) + .filter(|k| k.get(..word.len()).is_some_and(|p| p.eq_ignore_ascii_case(word))) + .collect(); + (!items.is_empty()).then_some(Offer { start, end, items, keys_of: Some(id.to_string()) }) +} + +/// What to offer at char `at` of `script`: the command ids for a line's first word, or the keys +/// of the line's command for a key being typed in its `{…}`. +fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::Session) -> Option { + if let Some((start, word)) = word_at(script, at) { + return complete(start, at, &word, ids); + } + let (h, start, word) = key_at(script, at)?; + // The line from its `{`, the word being typed taken out. + let before = script.get(byte_at(script, h.brace)?..byte_at(script, start)?)?; + let after = script.get(byte_at(script, at)?..)?.split('\n').next()?; + complete_keys(&h.id, ¶ms_of(session, &h.id)?, &format!("{before}{after}"), start, at, &word) +} + +/// The text describing key `key` of command `id`'s parameters (`"size"?: number`), for the help +/// beside a list of keys. +fn key_help(session: &rustgis_engine::Session, id: &str, key: &str) -> Option { + entries(¶ms_of(session, id)?).into_iter().find(|(keys, _)| keys.iter().any(|k| k == key)).map(|(_, text)| text) } /// `script_ids`, kept until the loaded plugins or the session's commands change (the built-in @@ -229,6 +322,14 @@ const HELP_MIN_WIDTH: f32 = 200.0; const GAP: f32 = 12.0; const FRAME: f32 = 24.0; +/// Draws a key's parameter `text` beside the list, coloured as the script is. +fn draw_key_help(ui: &mut Ui, text: &str, width: f32) { + let palette = crate::scriptcolor::Palette::of(&crate::theme::Tokens::get(ui.ctx())); + let mut job = crate::scriptcolor::help_job(None, text, palette, &egui::TextStyle::Monospace.resolve(ui.style())); + job.wrap.max_width = width; + ui.label(job); +} + /// Draws `help` beside the list: the signature and parameters coloured as the script is, in its /// monospace font (smaller for the parameters). fn draw_help(ui: &mut Ui, help: &Help, width: f32) { @@ -430,10 +531,7 @@ pub fn show( match kept { Some(offer) => offer, None => { - // The ids only once there's a word to complete. - let offer = at - .and_then(|at| word_at(script, at).map(|w| (at, w))) - .and_then(|(at, (start, word))| complete(start, at, &word, &script_ids_cached(ui, session))); + let offer = at.and_then(|at| offer_at(script, at, &script_ids_cached(ui, session), session)); list.worked = Some(Worked { at, text, with: plugins, value: offer.clone() }); offer } @@ -514,7 +612,13 @@ pub fn show( // short description split. Kept, it would need the id and the language as its // key (the help is translated), for next to nothing saved. let shown = hovered.filter(|_| pointer_leads).unwrap_or(selected); - if let Some(help) = offer.items.get(shown).and_then(|id| help(session, id)) { + if let Some(id) = &offer.keys_of { + // A key: the line describing its parameter. + if let Some(text) = offer.items.get(shown).and_then(|key| key_help(session, id, key)) { + ui.add_space(GAP); + draw_key_help(ui, &text, help_width); + } + } else if let Some(help) = offer.items.get(shown).and_then(|id| help(session, id)) { // A gap, not a separator: in a row, egui's separator takes all the height it // can, and would stretch the list down to the bottom of the screen. ui.add_space(GAP); @@ -590,9 +694,22 @@ pub fn hint(ui: &Ui, box_id: Id, out: &TextEditOutput, visible: egui::Rect, scri /// takes the pick back), and gives the box the focus again. pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &str, session: &rustgis_engine::Session) { let chars: Vec = script.chars().collect(); - let (Some(before), Some(after)) = (chars.get(..offer.start), chars.get(offer.end..)) else { return }; - let bare = after.iter().take_while(|c| **c != '\n').all(|c| c.is_whitespace()); - let (tail, at) = if bare { params_of(session, item).map(|d| template(&d)).unwrap_or_default() } else { Default::default() }; + let (Some(before), Some(mut after)) = (chars.get(..offer.start), chars.get(offer.end..)) else { return }; + let (tail, at) = if offer.keys_of.is_some() { + // A key: closed, its colon and a space after it, ready for the value; a closing quote + // already there is taken in, and a key that has its colon keeps it. + let quoted = after.first() == Some(&'"'); + let colon = quoted && after.get(1..).is_some_and(|a| a.iter().find(|c| !c.is_whitespace()) == Some(&':')); + if quoted && !colon { + after = after.get(1..).unwrap_or_default(); + } + // With its colon, the cursor goes after the quote already there. + if colon { (String::new(), 1) } else { ("\": ".to_string(), 3) } + } else if after.iter().take_while(|c| **c != '\n').all(|c| c.is_whitespace()) { + params_of(session, item).map(|d| template(&d)).unwrap_or_default() + } else { + Default::default() + }; let mut new: String = before.iter().collect(); new.push_str(item); new.push_str(&tail); @@ -1262,6 +1379,95 @@ mod tests { assert_eq!(h.state().workflow.script, "labels.style {", "a paste is left as it is"); } + /// A key is being typed in a quoted word that starts an entry of the line's object: right + /// after its `{` or a `,`, at the top level; not in a value, a nested object or a comment. + #[test] + fn key_at_finds_a_key_being_typed() { + let at = |s: &str| super::key_at(s, s.chars().count()).map(|(h, start, word)| (h.id, start, word)); + assert_eq!(at(r#"labels.style {""#), Some(("labels.style".into(), 15, String::new()))); + assert_eq!(at(r#"labels.style {"layer": "Cities", "si"#).map(|k| k.2), Some("si".into()), "after a comma"); + assert_eq!(at(r#"x.y {"w": "a\"b", ""#).map(|k| k.2), Some(String::new()), "an escaped quote in a value"); + assert_eq!(at("# ñ\r\nlabels.style {\"la").map(|k| (k.1, k.2)), Some((20, "la".into())), "chars, CRLF"); + assert_eq!(at(r#"labels.style {"where": "po"#), None, "a value"); + assert_eq!(at(r#"x.y {"a": {""#), None, "a nested object"); + assert_eq!(at(r#"x.y {"a": "#), None, "no quote"); + assert_eq!(at(r#"x.y {"a" "#), None, "past the key"); + assert_eq!(at(r#"# x.y {""#), None, "a comment"); + let s = r#"labels.style {"layer"}"#; + assert_eq!(super::key_at(s, 20).map(|k| k.2), Some("layer".into()), "a closing quote after the cursor"); + assert!(super::key_at(s, 18).is_none(), "a letter after the cursor"); + } + + /// The keys offered: the command's, in the order described, those typed so far, case aside; + /// not an entry already given, nor its alternatives. + #[test] + fn keys_offered_leave_out_those_given() { + let desc = r#"{"lon" | "x": number, "lat" | "y": number, "scale"?: number}"#; + let keys = |line: &str, word: &str| super::complete_keys("map.goToXY", desc, line, 0, 0, word).map(|o| o.items).unwrap_or_default(); + assert_eq!(keys(r#"{""#, ""), ["lon", "x", "lat", "y", "scale"]); + assert_eq!(keys(r#"{"lon": 2, ""#, ""), ["lat", "y", "scale"]); + assert_eq!(keys(r#"{"x": 2, ""#, ""), ["lat", "y", "scale"], "an alternative given"); + assert_eq!(keys(r#"{""#, "S"), ["scale"], "case aside"); + assert!(keys(r#"{""#, "zz").is_empty()); + assert_eq!(keys(r#"{"lat": 5}"#, ""), ["lon", "x", "scale"], "a key given"); + let o = super::complete_keys("map.goToXY", desc, r#"{""#, 0, 0, "").unwrap(); + assert_eq!(o.keys_of.as_deref(), Some("map.goToXY")); + } + + /// The key under the cursor, closed or not, doesn't count as given; the same key given + /// elsewhere on the line does. + #[test] + fn the_key_being_typed_is_not_given() { + let app = crate::RustGisApp::with_sample(); + let keys = |s: &str, at: usize| super::offer_at(s, at, &[], &app.session).map(|o| o.items).unwrap_or_default(); + let s = r#"map.goToXY {"lat": 5}"#; + assert_eq!(keys(s, 16), ["lat"], "a closed key can be changed"); + let s = r#"map.goToXY {"lat": 5, "lat": 6}"#; + assert!(keys(s, 26).is_empty(), "the same key closed elsewhere"); + let s = r#"map.goToXY {"lat": 5, "lat"#; + assert!(keys(s, s.chars().count()).is_empty(), "the same key, the cursor's not closed"); + } + + /// In the pane: a quote in a command's braces offers its keys, the help beside shows the + /// highlighted key's parameter, and accepting closes the key with its colon. + #[test] + fn a_key_is_offered_and_accepted() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + type_in(&mut h, r#"labels.style {""#); + h.get_by_role_and_label(egui::accesskit::Role::Button, "layer"); + type_in(&mut h, "si"); + h.get_by_label(r#""size"?: number"#); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, r#"labels.style {"size": "#); + } + + /// A closing quote already there is taken in; a key that has its colon keeps it. + #[test] + fn accepting_a_key_takes_in_what_follows() { + let mut h = pane(); + type_in(&mut h, r#"map.goToXY {"la"}"#); + for _ in 0..2 { + h.key_press(egui::Key::ArrowLeft); + h.run(); + } + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, r#"map.goToXY {"lat": }"#); + let mut h = pane(); + type_in(&mut h, r#"map.goToXY {"la": 5}"#); + for _ in 0..5 { + h.key_press(egui::Key::ArrowLeft); + h.run(); + } + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, r#"map.goToXY {"lat": 5}"#); + type_in(&mut h, "!"); + assert_eq!(h.state().workflow.script, r#"map.goToXY {"lat"!: 5}"#, "the cursor after the key's quote"); + } + /// The cursor is in a command's parameters inside its line's `{…}`: not before the brace, not /// past the closing one, and a `}` in a string doesn't close it. #[test] diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index fdb6fcb5c..1fa2ec253 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -234,7 +234,9 @@ Accepting a command on a line of its own adds its braces: with its required keys values, the cursor at the first (`map.goToXY {"lon": , "lat": }`), or empty with the cursor inside when every key may be left out; a command with no parameters gets none, and a line that has parameters keeps them. While you type a command's parameters (the cursor inside its `{…}`), the same help stays by the cursor; -Escape closes it. A `{` typed closes itself, the cursor inside, unless text follows it or it's in a +Escape closes it. A quote that starts a key in a command's braces opens the list of its keys not +given yet, beside the highlighted key's description; accepting one closes it with its colon +(`"size": `). A `{` typed closes itself, the cursor inside, unless text follows it or it's in a string or a comment; a `}` typed before a `}` steps over it, and Backspace in an empty `{}` deletes both. Pasting adds nothing. From a5d0662479d7f0aa3b4d7667ca55182a6821957f Mon Sep 17 00:00:00 2001 From: liyongyin Date: Thu, 22 Oct 2026 22:46:46 +0800 Subject: [PATCH 030/297] fix(engine): restore project name when Save As fails (#703) * fix(engine): restore project name when Save As fails * docs(engine): clarify Save As rollback contract --------- Co-authored-by: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> --- crates/engine/src/cmd/project.rs | 12 ++++-- crates/engine/tests/project_save.rs | 67 +++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/project_save.rs diff --git a/crates/engine/src/cmd/project.rs b/crates/engine/src/cmd/project.rs index 236bae69e..2ce5de18e 100644 --- a/crates/engine/src/cmd/project.rs +++ b/crates/engine/src/cmd/project.rs @@ -137,12 +137,18 @@ fn save(s: &mut Session, p: &Value) -> CmdResult { write(s, path) } +/// Saves with the destination's name, restoring the current name if writing fails. fn save_as(s: &mut Session, p: &Value) -> CmdResult { let path = PathBuf::from(super::ps(p, "path").ok_or("path is required")?); - if let Some(stem) = path.file_stem().and_then(|x| x.to_str()) { - s.project.name = stem.to_string(); + let previous_name = path.file_stem().and_then(|x| x.to_str()).map(|stem| std::mem::replace(&mut s.project.name, stem.to_string())); + let result = write(s, path); + // Only undoable commands get a Session snapshot; restore this view command's name by hand. + if result.is_err() + && let Some(name) = previous_name + { + s.project.name = name; } - write(s, path) + result } fn rename(s: &mut Session, p: &Value) -> CmdResult { diff --git a/crates/engine/tests/project_save.rs b/crates/engine/tests/project_save.rs new file mode 100644 index 000000000..68799f9f4 --- /dev/null +++ b/crates/engine/tests/project_save.rs @@ -0,0 +1,67 @@ +use rustgis_engine::{Project, Session, cmd::design::EditCounts}; +use serde_json::{Value, json}; + +/// Failed Save As preserves session state and history, and a later save can still succeed. +#[test] +fn failed_save_as_preserves_project_state_and_a_later_save_succeeds() -> Result<(), String> { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|e| e.to_string())?.as_nanos(); + let root = std::env::temp_dir().join(format!("rustgis-save-failure-{}-{nonce}", std::process::id())); + std::fs::create_dir(&root).map_err(|e| e.to_string())?; + let original = root.join("original.rgis"); + let occupied = root.join("occupied.rgis"); + std::fs::create_dir(&occupied).map_err(|e| e.to_string())?; + let marker = occupied.join("keep.txt"); + std::fs::write(&marker, b"keep destination contents").map_err(|e| e.to_string())?; + + for saved_before in [false, true] { + for dirty in [false, true] { + let mut s = Session::new(Project::new("original")); + if saved_before { + s.run("project.saveAs", &json!({"path": original}))?; + } + // Keep a real redo step, as well as undo history when the project is dirty. + s.run("project.rename", &json!({"name": "pending"}))?; + s.run("project.rename", &json!({"name": "redo name"}))?; + s.run("edit.undo", &Value::Null)?; + s.dirty = dirty; + s.edit_counts.insert(1, EditCounts { added: 2, modified: 3, deleted: 1 }); + let before = serde_json::to_value(&s.project).map_err(|e| e.to_string())?; + let path = s.path.clone(); + let depth = s.undo_depth(); + let counts = s.edit_counts.clone(); + let history = (s.undo_label(), s.redo_label()); + assert!(s.can_undo() && s.can_redo()); + for target in [root.join("missing/changed.rgis"), occupied.clone()] { + assert!(s.run("project.saveAs", &json!({"path": target})).is_err()); + assert_eq!(serde_json::to_value(&s.project).map_err(|e| e.to_string())?, before); + assert_eq!(s.path, path); + assert_eq!(s.dirty, dirty); + assert_eq!(s.undo_depth(), depth); + assert_eq!(s.edit_counts, counts); + assert_eq!((s.undo_label(), s.redo_label()), history); + assert!(s.can_undo() && s.can_redo()); + assert_eq!(std::fs::read(&marker).map_err(|e| e.to_string())?, b"keep destination contents"); + if saved_before { + assert_eq!(Project::load(&original)?.name, "original"); + } + } + s.run("edit.redo", &Value::Null)?; + assert_eq!(s.project.name, "redo name"); + s.run("edit.undo", &Value::Null)?; + assert_eq!(serde_json::to_value(&s.project).map_err(|e| e.to_string())?, before); + + s.edit_counts = counts; + let saved = root.join("renamed"); + let expected = saved.with_extension("rgis"); + s.run("project.saveAs", &json!({"path": saved}))?; + assert_eq!(s.project.name, "renamed"); + assert_eq!(Project::load(&expected)?.name, "renamed"); + assert_eq!(s.path.as_ref(), Some(&expected)); + assert!(!s.dirty); + assert!(s.edit_counts.is_empty()); + assert_eq!(s.undo_depth(), depth); + assert!(s.can_redo()); + } + } + std::fs::remove_dir_all(root).map_err(|e| e.to_string()) +} From 94bfaebdb1385ace48511818897402de24ceb57b Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Thu, 22 Oct 2026 10:48:18 -0400 Subject: [PATCH 031/297] test(render): let streamed reads land before the stream tests render (#700) * test(render): let streamed reads land before the stream tests render Two stream tests from #681 failed on main's CI, intermittently. Rendering a streamed layer for the screen starts a background read of the view. When that read finished before the frame polled it, which depends on scheduling, it installed a fresh window into the layer. The test then saw the owner's rows change (perspective_uses_complete_isolated_fallback) and the worker's diagnostic was not adopted (partial_source_failure_and_incomplete_summary_reach_unchanged_owner). A settle() helper now waits for the view's reads to land first, as the screen does between frames, so the render under test starts none. Pinned to one CPU, the tests failed 10 times in 40 runs before this change and 0 times in 80 after it. * test(render): check the owner has no error before adopting the worker's The failure test now asserts the owner's source error is unset before adopt_caches, so the error it finds after can only be the worker's. --- crates/render/src/stream_tests.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index 0fb349006..f737b2026 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -44,6 +44,25 @@ fn streamed(layer: &Layer, size: usize) -> Layer { streamed_kind(layer, size, false) } +/// Lets the streamed layers' background reads of `view` land first, as the screen does between +/// frames. A render then finds the view read and starts no read of its own, which could land +/// mid-test (on a busy machine) and replace the owner's rows. +fn settle(map: &mut Map, view: &View) { + for _ in 0..2000 { + let pending = if view.camera.pitch > 0.0 { + sync_tilted_view(map, view) + } else { + let scale = map.ground_res_at(&view.camera) / rustgis_geom::PIXEL_M; + map.sync_sources(&view_lonlat(map, view), scale, false) + }; + if !pending { + return; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + panic!("the background reads did not land"); +} + fn streamed_kind(layer: &Layer, size: usize, native: bool) -> Layer { let ds = layer.dataset.clone(); let info = SourceInfo { @@ -319,6 +338,7 @@ fn perspective_uses_complete_isolated_fallback() { for size in [1, 3, 20] { let mut actual = base.clone(); actual.layers[0] = streamed(&base.layers[0], size); + settle(&mut actual, &view); let before = actual.layers[0].dataset.features.clone(); let got = render(&mut actual, &view).unwrap(); assert_eq!(got.labels, expected.labels); @@ -403,10 +423,12 @@ fn partial_source_failure_and_incomplete_summary_reach_unchanged_owner() { for (size, message) in [(0, "cancelled"), (usize::MAX, "incomplete")] { let mut map = base.clone(); map.layers[0] = streamed(&base.layers[0], size); - map.sync_sources(&Extent::WORLD_LONLAT, 1.0, true); let view = View { width: 320.0, height: 240.0, pixels_per_point: 1.0, camera: map.camera, sky: false }; + settle(&mut map, &view); let mut worker = map.clone(); assert!(render(&mut worker, &view).is_none()); + // The error below can only come from the worker. + assert!(map.layers[0].source.as_ref().unwrap().error.is_none()); map.adopt_caches(&worker); assert!(map.layers[0].source.as_ref().unwrap().error.as_ref().unwrap().contains(message)); map.layers[0].source.as_mut().unwrap().error = None; From 02633625236126fc748bcd7429eeb40e9ce83516 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 17:14:19 +0200 Subject: [PATCH 032/297] feat(data): read Esri File Geodatabases without GDAL (#697) * feat(data): read Esri File Geodatabases without GDAL A pure-Rust reader for .gdb folders: every feature class and table, all field types, nulls, and the coordinate system by its EPSG or Esri code (GDB_Items), else its WKT. 3D multipatches (types 54, 31 and 32) become their footprints, the union of the faces seen from above (faces tilted past about 87 degrees, walls, are left out), with z_min, z_max and height fields that an extrusion stands them up by. Curves are drawn as chords. Compressed tables, raster fields and sparse version 4 row indexes aren't read; layer.addData and info.fileLayers then fall back to GDAL when it is installed. swissBUILDINGS3D's 2 km tile loads in 0.8 s instead of a 22 s GDAL conversion, with 5,419 footprints instead of ~720,000 triangles. Co-Authored-By: Claude Opus 5.5 * fix(data): bound the row index walk and a multipatch's faces A version 3 row index's row count comes from its header; with a bitmap it wasn't capped, so a tiny index claiming billions of rows in absent blocks walked them one by one. Blocks are now walked whole, no more than are stored or the bitmap has bits for. The face cap is now kept within a part too: one triangle strip could add millions of faces. Co-Authored-By: Claude Opus 5.5 * fix(data): review fixes for the File Geodatabase reader - Numbers whose bits run past 64 are refused: a varint decoding to i64::MIN panicked when negated. - A table that can't be opened (too large, not readable) is said by name and the others are still read; the reader returns the tables it couldn't read, and layer.addData retries each through GDAL when it is installed (else says which and why). - A multipatch past the face cap is drawn as its points' hull, as documented, not as part of its faces. - Whether a face shows from above is computed from its first point: projected coordinates lost millimetres in the products. - Features whose coordinates can't be converted are counted in a notice. - info.fileLayers and layer.addData list tables by one filter. Co-Authored-By: Claude Opus 5.5 * fix(data): bound a table's multipatch unions, smaller in the browser A table's multipatches merge at most 4 million faces in all (500,000 in the browser): past the budget they are drawn as their points' hull, so a crafted table can't make loading run for hours. In the browser, where a panic in the union library can't be caught, only patches with up to 5,000 faces seen from above are merged. Hulls from walls only and from unmerged faces are said apart. Co-Authored-By: Claude Opus 5.5 * fix(data): say a listed table whose files are missing A table the catalog lists whose .gdbtable or .gdbtablx is missing was passed over silently while info.fileLayers listed it; it is now among the tables not read, with the files missing named. Co-Authored-By: Claude Opus 5.5 * fix(data): no unions in the browser; add_geodatabase never panics In the browser, where a panic in the union library can't be caught, a multipatch is always the hull of its points: hostile input can't end the page. add_geodatabase builds its result without indexing into JSON, and retries or reports the tables not read even when every layer read natively fails to add. Co-Authored-By: Claude Opus 5.5 * fix(data): name only the table files that are missing Co-Authored-By: Claude Opus 5.5 * fix(data): leave out rings with no area; cap a multipatch union at 50k faces A zero-area polygon ring became an outer ring (a sliver); it is now left out, and a polygon of only such rings has no geometry. The desktop per-row union cap drops from 200k to 50k faces so one crafted multipatch can't stall loading, and the CRS choice reads as a match on (code, WKT) instead of a guarded unwrap_or. * fix(data): share the geodatabase file read; take row values, not clones read_dir and layers now open files through one size-checked helper. Row values move into the feature instead of being cloned, the module doc is rewrapped, and a comment says the union budget is spent even when a union panics. * fix(data): build no faces for a union where none runs In the browser MAX_UNION_FACES is 0, so the faces seen from above were built and thrown away for every multipatch. They are now skipped, and a multipatch with faces is reported as unmerged rather than as having none seen from above. * fix(data): say where unions run by target, not by a constant compare Comparing MAX_UNION_FACES with 0 is always false on wasm32, which clippy flags there. * fix(data): measure a ring's area from its first point; no faces on wasm signed_area now works from the ring's first point, so a small ring far from the origin keeps its area instead of rounding to zero and being dropped as degenerate. In the browser a multipatch's faces aren't built at all (it is always the hull), and MAX_UNION_FACES is a plain constant with the browser gate in footprint. * fix(engine): say natively read layers that failed when GDAL added others When adding the geodatabase's natively read layers failed but GDAL then added an unread table, the failure was dropped. It is now shown as a notice. * docs: mark native File Geodatabase reading as desktop-only in the roadmap * fix(data): no CRS code from items that disagree; say where unions run A table whose name several GDB_Items rows share with different codes now takes its coordinate system from its WKT. The union's guard checks the browser flag itself instead of relying on an empty face list. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: giswqs --- Cargo.lock | 1 + ROADMAP.md | 4 +- crates/data/Cargo.toml | 2 + crates/data/src/filegdb.rs | 1168 ++++++++++++++++++++++++++++++ crates/data/src/filegdb/tests.rs | 625 ++++++++++++++++ crates/data/src/lib.rs | 1 + crates/engine/src/cmd/layer.rs | 78 ++ docs/user-guide/data-formats.md | 2 +- docs/user-guide/data.md | 6 +- 9 files changed, 1882 insertions(+), 5 deletions(-) create mode 100644 crates/data/src/filegdb.rs create mode 100644 crates/data/src/filegdb/tests.rs diff --git a/Cargo.lock b/Cargo.lock index cf27cd6fc..54776df3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4509,6 +4509,7 @@ dependencies = [ "crs-definitions", "csv", "flatgeobuf", + "geo", "geozero", "image", "laz", diff --git a/ROADMAP.md b/ROADMAP.md index 8ec5a6132..732506709 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,8 +10,8 @@ Many features are first versions: there are no full 3D scenes or enterprise port - Data: files by their web address; GeoJSON, CSV/TSV (x/y in any EPSG system, or WKT), Excel and OpenDocument spreadsheets, GeoRSS, LandXML, encoded polylines, Shapefile (zipped too, with the coordinate system in its `.prj`), GeoPackage, GeoParquet, FlatGeobuf, KML/KMZ (styles included), GPX, GML, MapInfo MIF, OSM PBF, TopoJSON, DXF and LiDAR (LAS/LAZ/COPC) in, all of them - in the web app too (large files read in place, a view at a time); the vector formats GDAL - reads when it is installed, PostGIS, MySQL and MariaDB tables read live (desktop), and SQL queries + in the web app too (large files read in place, a view at a time); Esri File Geodatabases without GDAL (desktop; 3D multipatches + as footprints with their heights); the vector formats GDAL reads when it is installed, PostGIS, MySQL and MariaDB tables read live (desktop), and SQL queries and DuckDB databases (DuckDB Spatial) when DuckDB is; GeoJSON, CSV, GeoPackage, GeoParquet, Shapefile, FlatGeobuf, KML/KMZ, GPX and WKT out; standalone tables; geotagged photos and KML ground overlays; geocoding (Find Place, address tables); a Data Sources pane (favourites, recent, saved services, a library) and an SQL pane diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index cad9f3d08..ddb1ec459 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -28,6 +28,8 @@ parquet = { workspace = true, features = ["zstd"] } url = { workspace = true } bytes = { workspace = true } flatgeobuf = { workspace = true } +# Polygon union: a multipatch's footprint (File Geodatabase). +geo = { workspace = true } geozero = { workspace = true } calamine = { workspace = true } # LAZ (compressed LiDAR) point clouds. diff --git a/crates/data/src/filegdb.rs b/crates/data/src/filegdb.rs new file mode 100644 index 000000000..2f2102b08 --- /dev/null +++ b/crates/data/src/filegdb.rs @@ -0,0 +1,1168 @@ +//! Esri File Geodatabases (a `.gdb` folder), read without GDAL: every feature class and table, with +//! their fields and coordinate system. +//! +//! The format is ESRI's own and undocumented; this follows the description published by the GDAL +//! project (`dump_gdbtable`'s FGDB-Spec). A geodatabase is a folder of tables, `aXXXXXXXX.gdbtable` +//! (fields and rows) with `aXXXXXXXX.gdbtablx` (where each row starts), numbered by their row in +//! the system catalog, `a00000001.gdbtable`. +//! +//! Geometry is read in 2D. A multipatch (3D surfaces, such as the buildings of swissBUILDINGS3D) +//! becomes its footprint: the union of its faces seen from above, with its lowest and highest Z in +//! the fields `z_min` and `z_max`, and the difference in `height` (an extrusion stands it up by it). +//! In the browser, where a panic in the union can't be caught, a multipatch is the hull of its +//! points. Curves are drawn as straight segments between their ends. +//! Compressed tables, raster fields and the newer sparse row layouts aren't read: they give +//! [`DataError::Unsupported`], and the caller can read the file through GDAL instead. +//! +//! Files are hostile input: every count and offset is checked, rows that can't be read are skipped +//! and counted. + +use crate::raster::{Crs, Transformer}; +use crate::{DataError, Dataset, Field, FieldType, Geometry, GeometryType, ReadReport, Result, Value}; +use rustgis_geom::Coord; +use std::path::Path; + +/// The largest table read (its `.gdbtable` is read whole). +pub const MAX_TABLE_BYTES: usize = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; +/// Most tables read from one geodatabase. +const MAX_TABLES: usize = 512; +/// Most fields a table may have. +const MAX_FIELDS: usize = 4096; +/// Most parts and points one geometry may have (its blob's length bounds them first). +const MAX_PARTS: u64 = 1 << 24; +/// Most faces of one multipatch merged into its footprint; beyond, its outline is its points' hull. +const MAX_FACES: usize = 200_000; +/// Most faces of one multipatch merged by a union; past it, the hull too (a union's time grows +/// faster than its faces, so one crafted row mustn't stall loading). The browser merges none: a +/// panic in the union library can't be caught there, and hostile input mustn't end the page, so +/// multipatches are always the hulls of their points (see [`footprint`]). On the desktop the catch +/// relies on the workspace's default `panic = "unwind"`. +const MAX_UNION_FACES: usize = 50_000; +/// Most faces merged for one table, whatever its rows ask: past it, multipatches are hulls, so a +/// crafted table can't make loading take hours. +const MAX_TABLE_FACES: usize = if cfg!(target_arch = "wasm32") { 500_000 } else { 4_000_000 }; + +/// Whether a folder is a File Geodatabase: named `.gdb`, holding a system catalog. +pub fn is_geodatabase(dir: &Path) -> bool { + crate::source::is_gdb_name(&dir.to_string_lossy()) && dir.join("a00000001.gdbtable").is_file() +} + +/// What a geodatabase gave: its layers, and the tables that couldn't be read (by name, with why), +/// which GDAL may read instead. +#[derive(Debug, Default)] +pub struct Read { + pub layers: Vec<(Dataset, ReadReport)>, + pub unread: Vec<(String, String)>, +} + +/// Reads the feature classes and tables of a geodatabase folder; `layer` picks one by name +/// (ignoring case). +pub fn read_dir(dir: &Path, layer: Option<&str>) -> Result { + let name = dir.file_stem().and_then(|s| s.to_str()).unwrap_or("geodatabase").to_string(); + let mut out = read(&name, &|file: &str| read_file(dir, file), layer)?; + for (ds, _) in &mut out.layers { + ds.source = Some(dir.display().to_string()); + } + Ok(out) +} + +/// The names of a geodatabase folder's feature classes and tables, from its catalog alone. +pub fn layers(dir: &Path) -> Result> { + let name = dir.file_stem().and_then(|s| s.to_str()).unwrap_or("geodatabase"); + let read = |file: &str| -> Result> { + read_file(dir, file)?.ok_or_else(|| DataError::Format(format!("{name}: not a File Geodatabase (no {file})"))) + }; + Ok(own_tables(catalog(name, &read("a00000001.gdbtable")?, &read("a00000001.gdbtablx")?)?).map(|(_, n, _)| n).collect()) +} + +/// One file of a geodatabase folder, `None` when it isn't there; one past [`MAX_TABLE_BYTES`] is +/// refused before it is read. +fn read_file(dir: &Path, file: &str) -> Result>> { + let path = dir.join(file); + match std::fs::metadata(&path) { + Ok(m) if m.len() > MAX_TABLE_BYTES as u64 => Err(DataError::Unsupported(format!("{file} is too large to read here"))), + Ok(_) => Ok(Some(std::fs::read(&path)?)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +/// The tables the catalog lists that hold the geodatabase's data, not its own bookkeeping +/// (`GDB_…`) nor nothing at all: their number, name and whether they are compressed. What +/// [`layers`] lists and [`read`] reads (or says it can't). +fn own_tables(listed: Vec<(u64, String, Option)>) -> impl Iterator { + listed + .into_iter() + .filter(|(_, n, _)| !n.starts_with("GDB_")) + .filter_map(|(row, n, format)| match format { + None | Some(0.0) => Some((row, n, false)), + Some(1.0) => Some((row, n, true)), + _ => None, + }) + .take(MAX_TABLES) +} + +/// Each table the system catalog lists: its number, name and storage (0: a table of its own; +/// 1: compressed; 2: none). +fn catalog(name: &str, t: &[u8], x: &[u8]) -> Result)>> { + let table = Table::parse(t)?; + let rows = row_offsets(x)?; + let format_at = table.field("FileFormat"); + let Some(name_at) = table.field("Name") else { + return Err(DataError::Format(format!("{name}: the system catalog has no Name field"))); + }; + Ok(rows + .into_iter() + .filter_map(|(row, offset)| { + let values = table.row(t, offset)?; + let Some(Value::Text(name)) = values.get(name_at).map(|v| v.value.clone()) else { return None }; + Some((row, name, format_at.and_then(|i| values.get(i)).and_then(|v| v.value.as_f64()))) + }) + .collect()) +} + +/// Reads the feature classes and tables of a geodatabase whose files `open` gives by name +/// (`a00000001.gdbtable`; `None` for a file it doesn't have). +pub fn read(name: &str, open: &dyn Fn(&str) -> Result>>, layer: Option<&str>) -> Result { + let t = open("a00000001.gdbtable")?.ok_or_else(|| DataError::Format(format!("{name}: not a File Geodatabase (no system catalog)")))?; + let x = open("a00000001.gdbtablx")?.ok_or_else(|| DataError::Format(format!("{name}: the system catalog has no row index")))?; + let listed = catalog(name, &t, &x)?; + // The coordinate system codes the item definitions give, by table name. + let wkids = listed + .iter() + .find(|(_, n, _)| n == "GDB_Items") + .and_then(|(row, _, _)| { + let file = format!("a{row:08x}"); + Some(wkids(&open(&format!("{file}.gdbtable")).ok()??, &open(&format!("{file}.gdbtablx")).ok()??)) + }) + .unwrap_or_default(); + let mut out = Read::default(); + for (row, table_name, compressed) in own_tables(listed) { + if layer.is_some_and(|l| !l.eq_ignore_ascii_case(&table_name)) { + continue; + } + if compressed { + out.unread.push((table_name, "it is compressed".into())); + continue; + } + // A table that can't be opened (too large, not readable) is said; the others are read. + let file = format!("a{row:08x}"); + let files = open(&format!("{file}.gdbtable")).and_then(|t| Ok((t, open(&format!("{file}.gdbtablx"))?))); + let (t, x) = match files { + Ok((Some(t), Some(x))) => (t, x), + // Listed, but its files aren't there: said by name, as `layers` lists it. + Ok((t, x)) => { + let missing: Vec = [(t.is_none(), "gdbtable"), (x.is_none(), "gdbtablx")] + .iter() + .filter(|(gone, _)| *gone) + .map(|(_, e)| format!("{file}.{e}")) + .collect(); + let why = match missing.as_slice() { + [one] => format!("its file {one} is missing"), + _ => format!("its files {} are missing", missing.join(" and ")), + }; + out.unread.push((table_name, why)); + continue; + } + Err(e) => { + out.unread.push((table_name, e.to_string())); + continue; + } + }; + // Items that share the table's name but disagree on the code give none: the WKT decides. + let mut codes = wkids.iter().filter(|(n, _)| n.eq_ignore_ascii_case(&table_name)).map(|&(_, c)| c); + let wkid = codes.next().filter(|&c| codes.all(|d| d == c)); + match read_table(&table_name, &t, &x, wkid) { + Ok(Some(read)) => out.layers.push(read), + Ok(None) => {} + Err(e) => out.unread.push((table_name, e.to_string())), + } + } + if out.layers.is_empty() { + return Err(match (out.unread.first(), layer) { + (Some((table, why)), _) => DataError::Unsupported(format!("{name}: {table}: {why}")), + (None, Some(l)) => DataError::Format(format!("{name}: no layer '{l}'")), + (None, None) => DataError::Format(format!("{name}: no feature classes or tables")), + }); + } + // A note several layers share is said once. + let mut said: Vec = Vec::new(); + for (_, report) in &mut out.layers { + report.messages.retain(|m| !said.contains(m)); + said.extend(report.messages.iter().cloned()); + } + Ok(out) +} + +/// The coordinate system code (`LatestWKID`, else `WKID`) of each item `GDB_Items` defines, by +/// name: the WKT a table holds is Esri's, which names some projections differently. +fn wkids(t: &[u8], x: &[u8]) -> Vec<(String, u32)> { + let (Ok(table), Ok(rows)) = (Table::parse(t), row_offsets(x)) else { return Vec::new() }; + let (Some(name_at), Some(def_at)) = (table.field("Name"), table.field("Definition")) else { return Vec::new() }; + let tag = |xml: &str, tag: &str| -> Option { + let open = format!("<{tag}>"); + let start = xml.find(&open)? + open.len(); + xml.get(start..)?.split('<').next()?.trim().parse().ok() + }; + rows.into_iter() + .filter_map(|(_, offset)| { + let values = table.row(t, offset)?; + let Some(Value::Text(name)) = values.get(name_at).map(|v| v.value.clone()) else { return None }; + let Some(Value::Text(def)) = values.get(def_at).map(|v| &v.value) else { return None }; + Some((name, tag(def, "LatestWKID").or_else(|| tag(def, "WKID"))?)) + }) + .collect() +} + +/// A coordinate system by its EPSG or Esri code. +fn crs_of_code(code: u32) -> Option { + Crs::from_epsg(code).or_else(|| { + let proj4 = crate::raster::crs::esri_proj4(code)?; + Crs::custom(crate::raster::crs::CustomCrs { + proj4: proj4.to_string(), + label: format!("ESRI:{code}"), + geokeys: Vec::new(), + doubles: Vec::new(), + ascii: String::new(), + }) + .ok() + }) +} + +/// One table as a dataset: `None` for a table with no rows and no fields worth showing. `wkid` is +/// its coordinate system's code, when the item definitions give one. +fn read_table(name: &str, t: &[u8], x: &[u8], wkid: Option) -> Result> { + let table = Table::parse(t)?; + let rows = row_offsets(x)?; + let mut report = ReadReport::default(); + let layer_type = match table.shape { + _ if table.geometry.is_none() => GeometryType::None, + Shape::Point | Shape::Multipoint => GeometryType::Point, + Shape::Polyline => GeometryType::Polyline, + Shape::Polygon | Shape::Multipatch => GeometryType::Polygon, + Shape::None => GeometryType::None, + Shape::Other(code) => return Err(DataError::Unsupported(format!("geometry type {code}"))), + }; + // Its coordinate system: by its code, else from the geometry field's WKT. + let by_code = wkid.filter(|_| table.geometry.is_some()).and_then(crs_of_code).and_then(|c| match c.transformer() { + Ok(t) if c != Crs::LonLat => Some((Some(c), Some(t))), + Ok(_) => Some((None, None)), + Err(_) => None, + }); + let wkt = table.geometry.as_ref().map(|g| g.wkt.as_str()).filter(|w| !w.is_empty() && !w.starts_with('{')); + let (crs, to_lonlat) = match (by_code, wkt) { + (Some(by_code), _) => by_code, + (None, Some(wkt)) => match crate::raster::wkt::read_wkt(wkt) { + Ok((c, note)) => { + if let Some(n) = note { + report.messages.push(format!("coordinate system: {n}")); + } + match c.transformer() { + Ok(t) if c != Crs::LonLat => (Some(c), Some(t)), + Ok(_) => (None, None), + Err(e) => { + report.messages.push(format!("coordinate system: {e}; coordinates are read as longitude/latitude")); + (None, None) + } + } + } + Err(e) => { + report.messages.push(format!("coordinate system: {e}; coordinates are read as longitude/latitude")); + (None, None) + } + }, + (None, None) => (None, None), + }; + // The attribute fields: every field but the geometry, and the object ID first. + let mut fields: Vec = Vec::new(); + let mut columns: Vec = Vec::new(); + for (i, f) in table.fields.iter().enumerate() { + let ty = match f.kind { + Kind::Geometry | Kind::Binary | Kind::Raster => continue, + Kind::Int16 | Kind::Int32 | Kind::Int64 | Kind::ObjectId => FieldType::Integer, + Kind::Float32 | Kind::Float64 => FieldType::Double, + Kind::Text | Kind::Xml | Kind::Guid | Kind::GlobalId | Kind::Date | Kind::DateOnly | Kind::TimeOnly | Kind::DateTimeOffset => { + FieldType::Text + } + }; + let alias = (!f.alias.is_empty() && f.alias != f.name).then(|| f.alias.clone()); + fields.push(Field { name: f.name.clone(), ty, alias }); + columns.push(i); + } + let patches = table.shape == Shape::Multipatch; + // A multipatch's lowest and highest Z, and the difference (what an extrusion stands it up by), + // in three fields after the table's own (named apart from them). + let z_at = patches.then(|| { + let free = |base: &str| -> String { + let taken = |n: &str| fields.iter().any(|f| f.name.eq_ignore_ascii_case(n)); + (0..100) + .map(|k| if k == 0 { base.to_string() } else { format!("{base}_{k}") }) + .find(|n| !taken(n)) + .unwrap_or_else(|| base.to_string()) + }; + let names = [free("z_min"), free("z_max"), free("height")]; + let at = fields.len(); + fields.extend(names.into_iter().map(|name| Field { name, ty: FieldType::Double, alias: None })); + at + }); + if rows.is_empty() && fields.is_empty() { + return Ok(None); + } + let mut ds = Dataset::new(name, layer_type, fields); + ds.crs = crs; + let geometry_at = table.fields.iter().position(|f| f.kind == Kind::Geometry); + let (mut curves, mut wall_hulls, mut unmerged, mut unprojected) = (0usize, 0usize, 0usize, 0usize); + let mut budget = MAX_TABLE_FACES; + for (row, offset) in rows { + let Some(mut values) = table.row(t, offset) else { + report.skipped += 1; + continue; + }; + let mut attrs: Vec = Vec::with_capacity(ds.fields.len()); + for &c in &columns { + attrs.push(match table.fields.get(c).map(|f| f.kind) { + Some(Kind::ObjectId) => Value::Int(i64::try_from(row).unwrap_or(i64::MAX)), + // Taken, not cloned: each column is read once, and the geometry's blob stays. + _ => values.get_mut(c).map(|v| std::mem::replace(&mut v.value, Value::Null)).unwrap_or(Value::Null), + }); + } + let mut geometry = None; + if let (Some(g), Some(def)) = (geometry_at.and_then(|i| values.get(i)).and_then(|v| v.blob.as_ref()), table.geometry.as_ref()) { + match decode_geometry(g, def, &mut budget) { + Ok(Decoded::Geometry(geom, curved)) => { + curves += curved; + geometry = Some(geom); + } + Ok(Decoded::Patch(patch)) => { + if let (Some((lo, hi)), Some(at)) = (patch.z, z_at) { + attrs.resize(at, Value::Null); + attrs.extend([Value::Float(lo), Value::Float(hi), Value::Float(hi - lo)]); + } + wall_hulls += usize::from(patch.hull == Some(Hull::NoFaces)); + unmerged += usize::from(patch.hull == Some(Hull::Unmerged)); + geometry = patch.footprint; + } + Ok(Decoded::Empty) => {} + Err(()) => report.skipped += 1, + } + } + let geometry = match (geometry, &to_lonlat) { + (Some(g), Some(t)) => { + let g = to_lonlat_geometry(g, t); + unprojected += usize::from(g.is_none()); + g + } + (g, _) => g, + }; + ds.push_next(geometry, attrs); + } + if curves > 0 { + report.messages.push(format!("{curves} curved segments are drawn as straight lines")); + } + if unprojected > 0 { + report + .messages + .push(format!("{unprojected} features have coordinates outside their coordinate system: they are kept without geometry")); + } + if patches { + report.messages.push("3D multipatches are drawn as their footprints, with their lowest and highest Z in z_min and z_max, and the difference in height".into()); + } + if wall_hulls > 0 { + report.messages.push(format!("{wall_hulls} multipatches with no faces seen from above are drawn as the outline of their points")); + } + if unmerged > 0 { + report.messages.push(if cfg!(target_arch = "wasm32") { + format!("{unmerged} multipatches are drawn as the outline of their points (the desktop app draws their footprints)") + } else { + format!("{unmerged} multipatches with too many faces to merge are drawn as the outline of their points") + }); + } + Ok(Some((ds, report))) +} + +/// Projects a geometry to longitude/latitude; `None` when a point can't be. +fn to_lonlat_geometry(g: Geometry, t: &Transformer) -> Option { + let mut ok = true; + let out = g.map_coords(&|c| t.to_lonlat(c).unwrap_or(Coord::new(f64::NAN, f64::NAN))); + out.for_each_coord(|c: Coord| ok &= c.is_finite()); + ok.then_some(out) +} + +// --------------------------------------------------------------------------------------------- +// Bytes + +/// A reader over a byte slice that never reads past its end. +struct Cur<'a> { + b: &'a [u8], + i: usize, +} + +impl<'a> Cur<'a> { + fn new(b: &'a [u8], i: usize) -> Self { + Cur { b, i } + } + fn bytes(&mut self, n: usize) -> Option<&'a [u8]> { + let out = self.b.get(self.i..self.i.checked_add(n)?)?; + self.i += n; + Some(out) + } + fn skip(&mut self, n: usize) -> Option<()> { + self.bytes(n).map(|_| ()) + } + fn u8(&mut self) -> Option { + self.bytes(1).and_then(|b| b.first().copied()) + } + fn arr(&mut self) -> Option<[u8; N]> { + self.bytes(N).and_then(|b| b.try_into().ok()) + } + fn i16(&mut self) -> Option { + self.arr().map(i16::from_le_bytes) + } + fn i32(&mut self) -> Option { + self.arr().map(i32::from_le_bytes) + } + fn u32(&mut self) -> Option { + self.arr().map(u32::from_le_bytes) + } + fn i64(&mut self) -> Option { + self.arr().map(i64::from_le_bytes) + } + fn f32(&mut self) -> Option { + self.arr().map(f32::from_le_bytes) + } + fn f64(&mut self) -> Option { + self.arr().map(f64::from_le_bytes) + } + /// An unsigned little-endian base-128 number (at most 64 bits). + fn varuint(&mut self) -> Option { + let mut v: u64 = 0; + for shift in (0..64).step_by(7) { + let part = u64::from(self.u8()?); + // Bits past the 64th are refused, not dropped. + if shift > 57 && (part & 0x7F) >> (64 - shift) != 0 { + return None; + } + v |= (part & 0x7F) << shift; + if part & 0x80 == 0 { + return Some(v); + } + } + None + } + /// A signed base-128 number: the first byte's 0x40 bit is the sign. + fn varint(&mut self) -> Option { + let c = self.u8()?; + let negative = c & 0x40 != 0; + let mut v: u64 = u64::from(c & 0x3F); + let mut more = c & 0x80 != 0; + let mut shift = 6u32; + while more { + let part = u64::from(self.u8()?); + // Bits past the 64th are refused, not dropped. + if shift >= 64 || (shift > 57 && (part & 0x7F) >> (64 - shift) != 0) { + return None; + } + v |= (part & 0x7F) << shift; + shift += 7; + more = part & 0x80 != 0; + } + // A magnitude past `i64::MAX` (which `-i64::MIN` would overflow) is refused. + let v = i64::try_from(v).ok()?; + Some(if negative { -v } else { v }) + } + /// A count, bounded by `max` and by the bytes left (each item takes at least `min_bytes`). + fn count(&mut self, max: u64, min_bytes: usize) -> Option { + let n = self.varuint()?; + let left = (self.b.len().saturating_sub(self.i) / min_bytes.max(1)) as u64; + (n <= max && n <= left.max(1)).then(|| usize::try_from(n).ok()).flatten() + } +} + +fn utf16(b: &[u8]) -> String { + let units: Vec = b.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + String::from_utf16_lossy(&units) +} + +// --------------------------------------------------------------------------------------------- +// Tables + +/// A field's type. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + Int16, + Int32, + Float32, + Float64, + Text, + Date, + ObjectId, + Geometry, + Binary, + Raster, + Guid, + GlobalId, + Xml, + Int64, + DateOnly, + TimeOnly, + DateTimeOffset, +} + +impl Kind { + fn of(code: u8) -> Option { + Some(match code { + 0 => Kind::Int16, + 1 => Kind::Int32, + 2 => Kind::Float32, + 3 => Kind::Float64, + 4 => Kind::Text, + 5 => Kind::Date, + 6 => Kind::ObjectId, + 7 => Kind::Geometry, + 8 => Kind::Binary, + 9 => Kind::Raster, + 10 => Kind::Guid, + 11 => Kind::GlobalId, + 12 => Kind::Xml, + 13 => Kind::Int64, + 14 => Kind::DateOnly, + 15 => Kind::TimeOnly, + 16 => Kind::DateTimeOffset, + _ => return None, + }) + } +} + +#[derive(Clone, Debug)] +struct FieldDef { + name: String, + alias: String, + kind: Kind, + /// Its bit in each row's null flags, for a field that can be null. + null_bit: Option, +} + +/// How a geometry field's coordinates are stored. +#[derive(Clone, Debug)] +struct GeomDef { + wkt: String, + x0: f64, + y0: f64, + xy_scale: f64, + z0: f64, + z_scale: f64, +} + +/// A table's kind of geometry. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Shape { + None, + Point, + Multipoint, + Polyline, + Polygon, + Multipatch, + Other(u8), +} + +struct Table { + fields: Vec, + geometry: Option, + shape: Shape, + /// Strings are UTF-8 (else UTF-16). + utf8: bool, + /// How many fields have a bit in each row's null flags. + nullable: usize, +} + +/// One field's value in a row: a value, or a geometry's bytes. +struct Cell<'a> { + value: Value, + blob: Option<&'a [u8]>, +} + +impl Table { + fn parse(t: &[u8]) -> Result { + let bad = |what: &str| DataError::Format(format!("unreadable table ({what})")); + let mut c = Cur::new(t, 32); + let at = c.i64().and_then(|o| usize::try_from(o).ok()).ok_or_else(|| bad("header"))?; + let mut c = Cur::new(t, at); + c.skip(8).ok_or_else(|| bad("field header"))?; + let flags = c.u32().ok_or_else(|| bad("field header"))?; + let count = c.i16().and_then(|n| usize::try_from(n).ok()).filter(|&n| n <= MAX_FIELDS).ok_or_else(|| bad("field count"))?; + let has_z = flags & 0x8000_0000 != 0; + let has_m = flags & 0x4000_0000 != 0; + let shape = match flags & 0xFF { + 0 => Shape::None, + 1 => Shape::Point, + 2 => Shape::Multipoint, + 3 => Shape::Polyline, + 4 => Shape::Polygon, + 9 => Shape::Multipatch, + other => Shape::Other(other as u8), + }; + let mut fields = Vec::with_capacity(count); + let mut geometry = None; + for _ in 0..count { + let field = (|| -> Option<(FieldDef, Option)> { + let n = usize::from(c.u8()?); + let name = utf16(c.bytes(n * 2)?); + let n = usize::from(c.u8()?); + let alias = utf16(c.bytes(n * 2)?); + let code = c.u8()?; + let kind = Kind::of(code)?; + let mut geom = None; + let flag = match kind { + Kind::Text => { + c.skip(4)?; + let flag = c.u8()?; + let default = usize::try_from(c.varuint()?).ok()?; + if flag & 4 != 0 { + c.skip(default)?; + } + flag + } + Kind::ObjectId => { + c.skip(1)?; + c.u8()? + } + Kind::Geometry => { + c.skip(1)?; + let flag = c.u8()?; + let n = usize::try_from(c.i16()?).ok()?; + let wkt = utf16(c.bytes(n)?); + let g = c.u8()?; + let (z, m) = (g & 2 != 0, g & 4 != 0); + let (x0, y0, xy_scale) = (c.f64()?, c.f64()?, c.f64()?); + if m { + c.skip(16)?; + } + let (z0, z_scale) = if z { (c.f64()?, c.f64()?) } else { (0.0, 1.0) }; + // Tolerances, then the extent (and its Z and M ranges). + c.skip(8 + if m { 8 } else { 0 } + if z { 8 } else { 0 } + 32)?; + if has_z { + c.skip(16)?; + } + if has_m { + c.skip(16)?; + } + // The spatial index's grid sizes. + c.skip(1)?; + let grids = usize::try_from(c.u32()?).ok().filter(|&n| n <= 3)?; + c.skip(grids * 8)?; + let usable = |s: f64| s.is_finite() && s > 0.0; + if !usable(xy_scale) || !x0.is_finite() || !y0.is_finite() || (z && (!usable(z_scale) || !z0.is_finite())) { + return None; + } + geom = Some(GeomDef { wkt, x0, y0, xy_scale, z0, z_scale }); + flag + } + Kind::Binary | Kind::Guid | Kind::GlobalId | Kind::Xml => { + c.skip(1)?; + c.u8()? + } + // Raster fields have a layout of their own; such tables are left to GDAL. + Kind::Raster => return None, + _ => { + c.skip(1)?; + let flag = c.u8()?; + let default = usize::from(c.u8()?); + if flag & 4 != 0 { + c.skip(default)?; + } + flag + } + }; + let nullable = kind != Kind::ObjectId && flag & 1 != 0; + Some((FieldDef { name, alias, kind, null_bit: nullable.then_some(0) }, geom)) + })(); + let Some((f, g)) = field else { + return Err(DataError::Unsupported("a field this reader doesn't know (a raster field?)".into())); + }; + if g.is_some() && geometry.is_none() { + geometry = g; + } + fields.push(f); + } + // The null flags have a bit for each field that can be null, in order. + let mut nullable = 0usize; + for f in &mut fields { + if f.null_bit.is_some() { + f.null_bit = Some(nullable); + nullable += 1; + } + } + Ok(Table { fields, geometry, shape, utf8: flags & 0x100 != 0, nullable }) + } + + fn field(&self, name: &str) -> Option { + self.fields.iter().position(|f| f.name.eq_ignore_ascii_case(name)) + } + + /// A row's values, by field (the object ID's is `Null`: it isn't stored). `None` when the row + /// can't be read. + fn row<'a>(&self, t: &'a [u8], offset: usize) -> Option>> { + let mut c = Cur::new(t, offset); + let size = usize::try_from(c.i32()?).ok()?; + let body = c.bytes(size)?; + let mut c = Cur::new(body, 0); + let nulls = c.bytes(self.nullable.div_ceil(8))?; + let mut out = Vec::with_capacity(self.fields.len()); + for f in &self.fields { + let null = f.null_bit.is_some_and(|bit| nulls.get(bit / 8).is_some_and(|b| b >> (bit % 8) & 1 == 1)); + if null || f.kind == Kind::ObjectId { + out.push(Cell { value: Value::Null, blob: None }); + continue; + } + let text = |b: &[u8]| if self.utf8 { String::from_utf8_lossy(b).into_owned() } else { utf16(b) }; + let cell = |value: Value| Cell { value, blob: None }; + out.push(match f.kind { + Kind::Int16 => cell(Value::Int(i64::from(c.i16()?))), + Kind::Int32 => cell(Value::Int(i64::from(c.i32()?))), + Kind::Int64 => cell(Value::Int(c.i64()?)), + Kind::Float32 => cell(float(f64::from(c.f32()?))), + Kind::Float64 => cell(float(c.f64()?)), + Kind::Text | Kind::Xml => { + let n = usize::try_from(c.varuint()?).ok()?; + cell(Value::Text(text(c.bytes(n)?))) + } + Kind::Date => cell(date(c.f64()?, "YYYY-MM-DDTHH:mm:ss")), + Kind::DateOnly => cell(date(c.f64()?, "YYYY-MM-DD")), + Kind::TimeOnly => cell(date(c.f64()?, "HH:mm:ss")), + Kind::DateTimeOffset => { + let days = c.f64()?; + let minutes = c.i16()?; + cell(match date(days, "YYYY-MM-DDTHH:mm:ss") { + Value::Text(s) => Value::Text(format!( + "{s}{}{:02}:{:02}", + if minutes < 0 { '-' } else { '+' }, + minutes.unsigned_abs() / 60, + minutes.unsigned_abs() % 60 + )), + v => v, + }) + } + Kind::Guid | Kind::GlobalId => cell(Value::Text(guid(c.bytes(16)?))), + Kind::Geometry => { + let n = usize::try_from(c.varuint()?).ok()?; + Cell { value: Value::Null, blob: Some(c.bytes(n)?) } + } + Kind::Binary => { + let n = usize::try_from(c.varuint()?).ok()?; + c.skip(n)?; + cell(Value::Null) + } + Kind::ObjectId | Kind::Raster => cell(Value::Null), + }); + } + Some(out) + } +} + +fn float(v: f64) -> Value { + if v.is_finite() { Value::Float(v) } else { Value::Null } +} + +/// Days since 1899-12-30 as text in `fmt`. +fn date(days: f64, fmt: &str) -> Value { + // 1970-01-01 is day 25,569. + let ms = (days - 25_569.0) * 86_400_000.0; + if ms.is_finite() && ms.abs() < 1e16 { Value::Text(crate::time::format(ms, fmt)) } else { Value::Null } +} + +/// A GUID as `{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}` (its first three groups little-endian). +fn guid(b: &[u8]) -> String { + let h = |r: &[usize]| r.iter().filter_map(|&i| b.get(i)).map(|x| format!("{x:02X}")).collect::(); + format!("{{{}-{}-{}-{}-{}}}", h(&[3, 2, 1, 0]), h(&[5, 4]), h(&[7, 6]), h(&[8, 9]), h(&[10, 11, 12, 13, 14, 15])) +} + +/// Where each row starts in its table, from the `.gdbtablx`: `(object ID, offset)` for every row +/// not deleted. +fn row_offsets(x: &[u8]) -> Result> { + let bad = |what: &str| DataError::Format(format!("unreadable row index ({what})")); + let mut c = Cur::new(x, 0); + let version = c.i32().ok_or_else(|| bad("header"))?; + let blocks = c.i32().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("header"))?; + let rows_v3 = c.i32().ok_or_else(|| bad("header"))?; + let width = c.i32().and_then(|n| usize::try_from(n).ok()).filter(|w| (4..=6).contains(w)).ok_or_else(|| bad("offset size"))?; + let stored = blocks.checked_mul(1024).ok_or_else(|| bad("block count"))?; + let trailer = stored.checked_mul(width).and_then(|n| n.checked_add(16)).filter(|&n| n <= x.len()).ok_or_else(|| bad("block count"))?; + let mut c = Cur::new(x, trailer); + // Which 1024-row blocks are stored, when some are left out; and how many rows there are. + let (bitmap, total): (Option<&[u8]>, usize) = match version { + 3 => { + let words = c.i32().and_then(|n| usize::try_from(n).ok()).unwrap_or(0); + let total = usize::try_from(rows_v3).map_err(|_| bad("row count"))?; + if words == 0 { + (None, total) + } else { + c.skip(12).ok_or_else(|| bad("bitmap"))?; + (Some(c.bytes(words.checked_mul(4).ok_or_else(|| bad("bitmap"))?).ok_or_else(|| bad("bitmap"))?), total) + } + } + 4 => { + let total = c.i64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad("row count"))?; + let varying = c.i32().ok_or_else(|| bad("trailer"))?; + if varying != 0 { + return Err(DataError::Unsupported("a row index with missing blocks".into())); + } + (None, total) + } + _ => return Err(DataError::Unsupported(format!("row index version {version}"))), + }; + // Block by block (1024 rows each): a block the bitmap leaves out is passed over whole, and no + // more blocks are looked at than are stored or the bitmap has bits for, whatever the row count + // in the header says. + let in_blocks = match bitmap { + None => total.min(stored).div_ceil(1024), + Some(bits) => total.div_ceil(1024).min(bits.len().saturating_mul(8)), + }; + let mut out = Vec::new(); + let mut stored_before = 0usize; + for block in 0..in_blocks { + let present = bitmap.is_none_or(|bits| bits.get(block / 8).is_some_and(|b| b >> (block % 8) & 1 == 1)); + if !present { + continue; + } + if stored_before >= blocks { + break; + } + let first = stored_before * 1024; + stored_before += 1; + for k in 0..1024 { + let row = block * 1024 + k; + if row >= total { + break; + } + let at = 16 + (first + k) * width; + let Some(bytes) = x.get(at..at + width) else { break }; + let offset = bytes.iter().rev().fold(0u64, |v, &b| v << 8 | u64::from(b)); + if offset == 0 { + continue; + } + if let Ok(offset) = usize::try_from(offset) { + out.push((row as u64 + 1, offset)); + } + } + } + Ok(out) +} + +// --------------------------------------------------------------------------------------------- +// Geometry + +enum Decoded { + /// A geometry, and how many curved segments it draws straight. + Geometry(Geometry, usize), + Patch(Patch), + Empty, +} + +/// A multipatch seen from above. +struct Patch { + footprint: Option, + /// Its lowest and highest Z. + z: Option<(f64, f64)>, + /// Why the footprint is the hull of its points, when it is. + hull: Option, +} + +/// Why a multipatch's footprint is the hull of its points. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Hull { + /// No face shows from above (only walls). + NoFaces, + /// Its faces weren't merged: too many, past the table's budget, or the union failed. + Unmerged, +} + +const HAS_Z: u64 = 0x8000_0000; +const HAS_CURVES: u64 = 0x2000_0000; + +/// `budget` is how many more faces the table's multipatches may merge (see [`MAX_TABLE_FACES`]). +fn decode_geometry(b: &[u8], def: &GeomDef, budget: &mut usize) -> std::result::Result { + let mut c = Cur::new(b, 0); + let ty = c.varuint().ok_or(())?; + let x = |v: f64| v / def.xy_scale + def.x0; + let y = |v: f64| v / def.xy_scale + def.y0; + match ty & 0xFF { + 0 => Ok(Decoded::Empty), + // Points (2D, Z, M, ZM, and the general type). + 1 | 9 | 11 | 21 | 52 => { + let (vx, vy) = (c.varuint().ok_or(())?, c.varuint().ok_or(())?); + if vx == 0 || vy == 0 { + return Ok(Decoded::Empty); + } + Ok(Decoded::Geometry(Geometry::Point(Coord::new(x((vx - 1) as f64), y((vy - 1) as f64))), 0)) + } + // Multipoints. + 8 | 18 | 20 | 28 | 53 => { + let n = c.count(MAX_PARTS, 2).ok_or(())?; + for _ in 0..4 { + c.varuint().ok_or(())?; + } + let pts = deltas(&mut c, n, def)?; + if pts.is_empty() { Ok(Decoded::Empty) } else { Ok(Decoded::Geometry(Geometry::MultiPoint(pts), 0)) } + } + // Lines and polygons (and the general types, with curves). + code @ (3 | 10 | 13 | 23 | 50 | 5 | 15 | 19 | 25 | 51) => { + let general = code == 50 || code == 51; + let polygon = matches!(code, 5 | 15 | 19 | 25 | 51); + let n = c.count(MAX_PARTS, 2).ok_or(())?; + let parts = c.count(MAX_PARTS, 1).ok_or(())?; + let curves = if general && ty & HAS_CURVES != 0 { c.count(MAX_PARTS, 1).ok_or(())? } else { 0 }; + for _ in 0..4 { + c.varuint().ok_or(())?; + } + let sizes = part_sizes(&mut c, n, parts)?; + let pts = deltas(&mut c, n, def)?; + let rings = split(&pts, &sizes); + if rings.is_empty() { + return Ok(Decoded::Empty); + } + let geom = if polygon { + // Clockwise rings are outer; each counterclockwise one is a hole in the one before. + // A ring with no area (its points on a line) is neither, and is left out. + let rings: Vec<(Vec, bool)> = rings + .into_iter() + .filter_map(|r| { + let a = signed_area(&r); + (a != 0.0 && a.is_finite()).then_some((r, a < 0.0)) + }) + .collect(); + if rings.is_empty() { + return Ok(Decoded::Empty); + } + crate::io::group_rings(rings) + } else { + Geometry::Line(rings) + }; + Ok(Decoded::Geometry(geom, curves)) + } + // Multipatches: 3D surfaces (the general type, or the Shapefile ones GDAL writes, which + // always have Z). + code @ (31 | 32 | 54) => { + let has_z = code != 54 || ty & HAS_Z != 0; + let n = c.count(MAX_PARTS, 2).ok_or(())?; + c.varuint().ok_or(())?; + let parts = c.count(MAX_PARTS, 1).ok_or(())?; + for _ in 0..4 { + c.varuint().ok_or(())?; + } + let sizes = part_sizes(&mut c, n, parts)?; + let mut kinds = Vec::with_capacity(parts); + for _ in 0..parts { + kinds.push(c.varuint().ok_or(())? & 0xF); + } + let pts = deltas(&mut c, n, def)?; + // Heights, when stored (a multipatch without them lies flat). + let mut zs = Vec::with_capacity(if has_z { n } else { 0 }); + if has_z { + let mut dz: i64 = 0; + for _ in 0..n { + dz = dz.checked_add(c.varint().ok_or(())?).ok_or(())?; + zs.push(dz as f64 / def.z_scale + def.z0); + } + } + let z = zs.iter().fold(None, |r: Option<(f64, f64)>, &v| match r { + _ if !v.is_finite() => r, + None => Some((v, v)), + Some((lo, hi)) => Some((lo.min(v), hi.max(v))), + }); + let pts: Vec = pts.iter().enumerate().map(|(i, p)| [p.x, p.y, zs.get(i).copied().unwrap_or(0.0)]).collect(); + let (footprint, hull) = footprint(&split(&pts, &sizes), &kinds, budget); + Ok(Decoded::Patch(Patch { footprint, z, hull })) + } + _ => Err(()), + } +} + +/// The point counts of `parts` parts holding `n` points: all but the last are stored. +fn part_sizes(c: &mut Cur, n: usize, parts: usize) -> std::result::Result, ()> { + if parts == 0 { + return Ok(Vec::new()); + } + let mut sizes = Vec::with_capacity(parts); + let mut sum = 0usize; + for _ in 1..parts { + let s = c.count(MAX_PARTS, 1).ok_or(())?; + sum = sum.checked_add(s).ok_or(())?; + sizes.push(s); + } + sizes.push(n.checked_sub(sum).ok_or(())?); + Ok(sizes) +} + +/// `n` points stored as deltas from the one before (across parts). +fn deltas(c: &mut Cur, n: usize, def: &GeomDef) -> std::result::Result, ()> { + let (mut dx, mut dy) = (0i64, 0i64); + let mut pts = Vec::with_capacity(n); + for _ in 0..n { + dx = dx.checked_add(c.varint().ok_or(())?).ok_or(())?; + dy = dy.checked_add(c.varint().ok_or(())?).ok_or(())?; + pts.push(Coord::new(dx as f64 / def.xy_scale + def.x0, dy as f64 / def.xy_scale + def.y0)); + } + Ok(pts) +} + +/// Points cut into parts of the given sizes (sizes past the points are cut short). +fn split(pts: &[T], sizes: &[usize]) -> Vec> { + let mut out = Vec::with_capacity(sizes.len()); + let mut at = 0usize; + for &s in sizes { + let end = at.saturating_add(s).min(pts.len()); + if let Some(part) = pts.get(at..end).filter(|p| !p.is_empty()) { + out.push(part.to_vec()); + } + at = end; + } + out +} + +/// Twice the signed area of a ring (positive counterclockwise). +fn signed_area(ring: &[Coord]) -> f64 { + // From the first point: products of projected coordinates in the millions (or more) would round + // a small ring's area away. + let o = ring.first().copied().unwrap_or(Coord::new(0.0, 0.0)); + let local = |c: &Coord| (c.x - o.x, c.y - o.y); + ring.iter() + .zip(ring.iter().cycle().skip(1)) + .map(|(a, b)| { + let ((ax, ay), (bx, by)) = (local(a), local(b)); + ax * by - bx * ay + }) + .sum() +} + +/// A point with its height. +type P3 = [f64; 3]; + +/// A multipatch's faces, each its outer ring and then its holes: triangles from strips, fans and +/// triangle lists, and rings. At most [`MAX_FACES`]; the flag says some were left out. +fn faces(parts: &[Vec], kinds: &[u64]) -> (Vec>>, bool) { + let mut faces: Vec>> = Vec::new(); + let mut truncated = false; + for (part, &kind) in parts.iter().zip(kinds) { + // Faces past the cap are left out, within a part too (one strip can hold millions). + let room = MAX_FACES.saturating_sub(faces.len()); + let has = match kind { + 0 | 1 => part.len().saturating_sub(2), + 6 => part.len() / 3, + 3 => 0, + _ => 1, + }; + if has > room { + truncated = true; + } + if room == 0 { + break; + } + match kind { + // Triangle strip, fan, triangles. + 0 => faces.extend(part.windows(3).take(room).map(|w| vec![w.to_vec()])), + 1 => { + if let Some((first, rest)) = part.split_first() { + faces.extend( + rest.windows(2) + .filter_map(|w| match w { + [a, b] => Some(vec![vec![*first, *a, *b]]), + _ => None, + }) + .take(room), + ); + } + } + 6 => faces.extend(part.as_chunks::<3>().0.iter().take(room).map(|w| vec![w.to_vec()])), + // An inner ring is a hole in the face before. + 3 => match faces.last_mut() { + Some(face) => face.push(part.clone()), + None => faces.push(vec![part.clone()]), + }, + // Outer, first and plain rings. + _ => faces.push(vec![part.clone()]), + } + } + (faces, truncated) +} + +/// A multipatch's footprint: the union of its faces seen from above. Faces standing on edge, such +/// as walls, show nothing and are left out. The footprint is the hull of its points when no face +/// shows, or when its faces aren't merged: more than [`MAX_FACES`], more seen from above than +/// [`MAX_UNION_FACES`] or than the table's `budget` has left (which they then take from it). +fn footprint(parts: &[Vec], kinds: &[u64], budget: &mut usize) -> (Option, Option) { + use geo::{ConvexHull, Orient, orient::Direction}; + // Where no union runs (the browser), the faces aren't built at all: the hull stands in. + let unions = !cfg!(target_arch = "wasm32"); + let (faces, truncated) = if unions { faces(parts, kinds) } else { (Vec::new(), false) }; + let all = || parts.iter().flatten(); + let (x0, x1) = all().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| (a.min(p[0]), b.max(p[0]))); + let (y0, y1) = all().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), p| (a.min(p[1]), b.max(p[1]))); + let size = (x1 - x0).max(y1 - y0); + if !size.is_finite() { + return (None, None); + } + // A face shows from above by the cosine of its tilt (its projected area over its area): walls, + // even leaning ones, show as slivers, which cover nothing and which the union can't always + // take. Faces narrower than a ten-thousandth of the patch are left out too (a flat patch). + let shows = |ring: &[P3]| { + let (mut nx, mut ny, mut nz) = (0.0, 0.0, 0.0); + let (mut longest, mut area2) = (0.0f64, 0.0); + // From the ring's first point: projected coordinates run to millions, whose products would + // lose the millimetres a small face is measured in. + let o = ring.first().copied().unwrap_or([0.0; 3]); + let local: Vec = ring.iter().map(|p| [p[0] - o[0], p[1] - o[1], p[2] - o[2]]).collect(); + for (a, b) in local.iter().zip(local.iter().cycle().skip(1)) { + nx += (a[1] - b[1]) * (a[2] + b[2]); + ny += (a[2] - b[2]) * (a[0] + b[0]); + nz += (a[0] - b[0]) * (a[1] + b[1]); + longest = longest.max((b[0] - a[0]).hypot(b[1] - a[1])); + area2 += a[0] * b[1] - b[0] * a[1]; + } + let n = (nx * nx + ny * ny + nz * nz).sqrt(); + n > 0.0 && nz.abs() / n > 0.05 && longest > 0.0 && area2.abs() / longest > size * 1e-4 + }; + let ring2 = |r: &[P3]| geo::LineString::from(r.iter().map(|p| (p[0], p[1])).collect::>()); + // Past the cap, a partial union would be wrong: the hull below stands in. + let seen: Vec> = faces + .iter() + .filter(|_| !truncated) + .filter_map(|face| { + let (outer, holes) = face.split_first()?; + (outer.len() >= 3 && shows(outer)) + .then(|| geo::Polygon::new(ring2(outer), holes.iter().map(|h| ring2(h)).collect()).orient(Direction::Default)) + }) + .collect(); + let merge = unions && !truncated && seen.len() <= MAX_UNION_FACES && seen.len() <= *budget; + // The union library may still trip over faces it can't place: the hull stands in then. (In the + // browser, where a panic can't be caught, nothing is merged: see `MAX_UNION_FACES`.) + let union = if seen.is_empty() || !merge { + None + } else { + // Spent before the union runs, so faces whose union panics (and become the hull) count too: + // the budget bounds the work tried, not the footprints made. + *budget -= seen.len(); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| geo::unary_union(seen.iter()))).ok().filter(|u| !u.0.is_empty()) + }; + let (shape, hull) = match union { + Some(u) => (u, None), + None => { + let points: Vec> = all().map(|p| geo::Point::new(p[0], p[1])).collect(); + if points.len() < 3 { + return (None, None); + } + let why = if unions && seen.is_empty() && !truncated { Hull::NoFaces } else { Hull::Unmerged }; + (geo::MultiPolygon::new(vec![geo::MultiPoint::from(points).convex_hull()]), Some(why)) + } + }; + let ring = |ls: &geo::LineString| ls.0.iter().map(|c| Coord::new(c.x, c.y)).collect::>(); + let polys: Vec>> = shape + .0 + .iter() + .filter(|p| p.exterior().0.len() >= 4) + .map(|p| std::iter::once(ring(p.exterior())).chain(p.interiors().iter().map(ring)).collect()) + .collect(); + if polys.is_empty() { (None, None) } else { (Some(Geometry::Polygon(polys)), hull) } +} + +#[cfg(test)] +mod tests; diff --git a/crates/data/src/filegdb/tests.rs b/crates/data/src/filegdb/tests.rs new file mode 100644 index 000000000..f285b190e --- /dev/null +++ b/crates/data/src/filegdb/tests.rs @@ -0,0 +1,625 @@ +use super::*; + +fn varuint(mut v: u64) -> Vec { + let mut out = Vec::new(); + loop { + let b = (v & 0x7F) as u8; + v >>= 7; + if v == 0 { + out.push(b); + return out; + } + out.push(b | 0x80); + } +} + +fn varint(v: i64) -> Vec { + let (neg, mut m) = (v < 0, v.unsigned_abs()); + let mut first = (m & 0x3F) as u8 | if neg { 0x40 } else { 0 }; + m >>= 6; + if m == 0 { + return vec![first]; + } + first |= 0x80; + let mut out = vec![first]; + out.extend(varuint(m)); + out +} + +fn utf16_bytes(s: &str) -> Vec { + s.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +/// A field for the test writer. +enum F { + Oid, + Text(&'static str), + Int(&'static str), + Shape { z: bool, wkt: &'static str }, +} + +/// One row's stored values (the object ID stores nothing): `None` is null. +enum V { + Text(Option<&'static str>), + Int(Option), + Blob(Vec), +} + +/// A minimal table (version 3) and its row index, as ArcGIS lays them out. +fn table(layer_flags: u32, fields: &[F], rows: &[Vec]) -> (Vec, Vec) { + let mut f = Vec::new(); + f.extend(4i32.to_le_bytes()); + f.extend((layer_flags | 0x100).to_le_bytes()); + f.extend((fields.len() as i16).to_le_bytes()); + let name = |f: &mut Vec, n: &str| { + f.push(n.encode_utf16().count() as u8); + f.extend(utf16_bytes(n)); + f.push(0); + }; + for field in fields { + match field { + F::Oid => { + name(&mut f, "OBJECTID"); + f.extend([6, 4, 2]); + } + F::Text(n) => { + name(&mut f, n); + f.push(4); + f.extend(255i32.to_le_bytes()); + f.extend([5, 0]); + } + F::Int(n) => { + name(&mut f, n); + f.extend([1, 4, 5, 0]); + } + F::Shape { z, wkt } => { + name(&mut f, "SHAPE"); + f.extend([7, 0, 7]); + let w = utf16_bytes(wkt); + f.extend((w.len() as i16).to_le_bytes()); + f.extend(w); + f.push(if *z { 3 } else { 1 }); + for v in [0.0f64, 0.0, 1000.0] { + f.extend(v.to_le_bytes()); + } + if *z { + f.extend(0.0f64.to_le_bytes()); + f.extend(1000.0f64.to_le_bytes()); + } + f.extend(0.001f64.to_le_bytes()); + if *z { + f.extend(0.001f64.to_le_bytes()); + } + for v in [0.0f64, 0.0, 100.0, 100.0] { + f.extend(v.to_le_bytes()); + } + if layer_flags & 0x8000_0000 != 0 { + f.extend([0.0f64, 100.0].iter().flat_map(|v| v.to_le_bytes())); + } + f.push(0); + f.extend(1u32.to_le_bytes()); + f.extend(10.0f64.to_le_bytes()); + } + } + } + // Every field but the object ID can be null here (the shape's flag 7 says so), so each has a + // bit in the null flags. + let nullable = fields.iter().filter(|f| !matches!(f, F::Oid)).count(); + let mut t = vec![0u8; 40]; + t.extend((f.len() as i32).to_le_bytes()); + t.extend(&f); + let mut offsets = Vec::new(); + for row in rows { + let mut nulls = vec![0u8; nullable.div_ceil(8)]; + let mut body = Vec::new(); + for (bit, v) in row.iter().enumerate() { + match v { + V::Text(None) | V::Int(None) => { + if let Some(b) = nulls.get_mut(bit / 8) { + *b |= 1 << (bit % 8); + } + } + V::Text(Some(s)) => { + body.extend(varuint(s.len() as u64)); + body.extend(s.as_bytes()); + } + V::Int(Some(i)) => body.extend(i.to_le_bytes()), + V::Blob(b) => { + body.extend(varuint(b.len() as u64)); + body.extend(b); + } + } + } + offsets.push(t.len() as u32); + t.extend(((nulls.len() + body.len()) as i32).to_le_bytes()); + t.extend(nulls); + t.extend(body); + } + let size = t.len() as i64; + t[0..4].copy_from_slice(&3i32.to_le_bytes()); + t[4..8].copy_from_slice(&(rows.len() as i32).to_le_bytes()); + t[12..16].copy_from_slice(&5i32.to_le_bytes()); + t[24..32].copy_from_slice(&size.to_le_bytes()); + t[32..40].copy_from_slice(&40i64.to_le_bytes()); + let mut x = Vec::new(); + for v in [3i32, 1, rows.len() as i32, 4] { + x.extend(v.to_le_bytes()); + } + for i in 0..1024 { + x.extend(offsets.get(i).copied().unwrap_or(0).to_le_bytes()); + } + x.extend([0u8; 16]); + (t, x) +} + +/// A polygon blob (type 5): one ring, scale 1000 from 0. +fn polygon(ring: &[(f64, f64)]) -> Vec { + let mut b = varuint(5); + b.extend(varuint(ring.len() as u64)); + b.extend(varuint(1)); + b.extend([0u8; 4]); + let (mut px, mut py) = (0i64, 0i64); + for &(x, y) in ring { + let (ix, iy) = ((x * 1000.0).round() as i64, (y * 1000.0).round() as i64); + b.extend(varint(ix - px)); + b.extend(varint(iy - py)); + (px, py) = (ix, iy); + } + b +} + +/// A multipatch blob (type 54 with Z): its parts' types and points. +fn multipatch(parts: &[(u64, Vec<(f64, f64, f64)>)]) -> Vec { + let n: usize = parts.iter().map(|(_, p)| p.len()).sum(); + let mut b = varuint(54 | HAS_Z); + b.extend(varuint(n as u64)); + b.extend(varuint(0)); + b.extend(varuint(parts.len() as u64)); + b.extend([0u8; 4]); + for (_, p) in parts.iter().take(parts.len().saturating_sub(1)) { + b.extend(varuint(p.len() as u64)); + } + for (kind, _) in parts { + b.extend(varuint(*kind)); + } + let (mut px, mut py) = (0i64, 0i64); + for (_, p) in parts { + for &(x, y, _) in p { + let (ix, iy) = ((x * 1000.0).round() as i64, (y * 1000.0).round() as i64); + b.extend(varint(ix - px)); + b.extend(varint(iy - py)); + (px, py) = (ix, iy); + } + } + let mut pz = 0i64; + for (_, p) in parts { + for &(_, _, z) in p { + let iz = (z * 1000.0).round() as i64; + b.extend(varint(iz - pz)); + pz = iz; + } + } + b +} + +const LONLAT: &str = r#"GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]"#; + +/// A geodatabase's files: a catalog naming the given tables, which follow as a00000002…. +fn geodatabase(tables: Vec<(&'static str, (Vec, Vec))>) -> Vec<(String, Vec)> { + let rows: Vec> = std::iter::once("GDB_SystemCatalog") + .chain(tables.iter().map(|(n, _)| *n)) + .map(|n| vec![V::Text(Some(n)), V::Int(Some(0))]) + .collect(); + let (t, x) = table(0, &[F::Oid, F::Text("Name"), F::Int("FileFormat")], &rows); + let mut files = vec![("a00000001.gdbtable".to_string(), t), ("a00000001.gdbtablx".to_string(), x)]; + for (i, (_, (t, x))) in tables.into_iter().enumerate() { + files.push((format!("a{:08x}.gdbtable", i + 2), t)); + files.push((format!("a{:08x}.gdbtablx", i + 2), x)); + } + files +} + +fn read_files(files: &[(String, Vec)]) -> Result> { + read("test", &|name: &str| Ok(files.iter().find(|(n, _)| n == name).map(|(_, b)| b.clone())), None).map(|r| r.layers) +} + +/// A square (clockwise, as ArcGIS stores outer rings) from (x, y) with side `s`. +fn square(x: f64, y: f64, s: f64) -> Vec<(f64, f64)> { + vec![(x, y), (x, y + s), (x + s, y + s), (x + s, y), (x, y)] +} + +#[test] +fn numbers_decode() { + for v in [0u64, 1, 63, 64, 127, 128, 300, 1 << 40, u64::MAX >> 1] { + assert_eq!(Cur::new(&varuint(v), 0).varuint(), Some(v), "{v}"); + } + for v in [0i64, 1, -1, 63, -63, 64, -64, -65, 1_000_000, -1_000_000, 1 << 50] { + assert_eq!(Cur::new(&varint(v), 0).varint(), Some(v), "{v}"); + } + // Endless continuation bits and cut-short numbers are refused. + assert_eq!(Cur::new(&[0xFF; 12], 0).varuint(), None); + assert_eq!(Cur::new(&[0xFF; 12], 0).varint(), None); + assert_eq!(Cur::new(&[0x80], 0).varuint(), None); + assert_eq!(guid(&[0x67, 0x45, 0x23, 0x01, 0xAB, 0x89, 0xEF, 0xCD, 1, 2, 3, 4, 5, 6, 7, 8]), "{01234567-89AB-CDEF-0102-030405060708}"); + // 2000-01-01 is day 36,526 after 1899-12-30. + assert_eq!(date(36_526.5, "YYYY-MM-DDTHH:mm:ss"), Value::Text("2000-01-01T12:00:00".into())); +} + +#[test] +fn reads_a_feature_class_with_fields_nulls_and_holes() { + let outer = square(0.0, 0.0, 10.0); + // A hole: counterclockwise, in the same blob as a second part. + let hole: Vec<(f64, f64)> = square(2.0, 2.0, 2.0).into_iter().rev().collect(); + let mut blob = varuint(5); + blob.extend(varuint((outer.len() + hole.len()) as u64)); + blob.extend(varuint(2)); + blob.extend([0u8; 4]); + blob.extend(varuint(outer.len() as u64)); + let (mut px, mut py) = (0i64, 0i64); + for &(x, y) in outer.iter().chain(&hole) { + let (ix, iy) = ((x * 1000.0) as i64, (y * 1000.0) as i64); + blob.extend(varint(ix - px)); + blob.extend(varint(iy - py)); + (px, py) = (ix, iy); + } + let fields = [F::Oid, F::Shape { z: false, wkt: LONLAT }, F::Text("name"), F::Int("count")]; + let rows = vec![ + vec![V::Blob(blob), V::Text(Some("Gebäude")), V::Int(Some(7))], + vec![V::Blob(polygon(&square(20.0, 20.0, 5.0))), V::Text(None), V::Int(None)], + ]; + let files = geodatabase(vec![("Parcels", table(4, &fields, &rows))]); + let layers = read_files(&files).unwrap(); + assert_eq!(layers.len(), 1); + let (ds, report) = &layers[0]; + assert_eq!(ds.name, "Parcels"); + assert_eq!(ds.geometry_type, GeometryType::Polygon); + assert_eq!(report.skipped, 0, "{report:?}"); + let names: Vec<&str> = ds.fields.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["OBJECTID", "name", "count"]); + let rows: Vec<&crate::Feature> = ds.features.iter().collect(); + assert_eq!(rows[0].attrs, vec![Value::Int(1), Value::Text("Gebäude".into()), Value::Int(7)]); + assert_eq!(rows[1].attrs, vec![Value::Int(2), Value::Null, Value::Null]); + match &rows[0].geometry { + Some(Geometry::Polygon(p)) => { + assert_eq!(p.len(), 1, "one polygon"); + assert_eq!(p[0].len(), 2, "with its hole"); + assert_eq!(p[0][0][2], Coord::new(10.0, 10.0)); + } + g => panic!("{g:?}"), + } +} + +#[test] +fn a_multipatch_is_its_footprint_with_its_heights() { + // A 10 m box from z 400 to 410: its roof as two triangles (a strip), a floor ring, and walls, + // which show nothing from above. + let roof = (0, vec![(0.0, 0.0, 410.0), (0.0, 10.0, 410.0), (10.0, 0.0, 410.0), (10.0, 10.0, 410.0)]); + let floor = (2, vec![(0.0, 0.0, 400.0), (0.0, 10.0, 400.0), (10.0, 10.0, 400.0), (10.0, 0.0, 400.0), (0.0, 0.0, 400.0)]); + let wall = (2, vec![(0.0, 0.0, 400.0), (0.0, 0.0, 410.0), (10.0, 0.0, 410.0), (10.0, 0.0, 400.0), (0.0, 0.0, 400.0)]); + // A leaning wall: a sliver from above, which is left out too. + let leaning = (6, vec![(0.0, 10.0, 400.0), (5.0, 10.003, 410.0), (10.0, 10.0, 400.0)]); + // Only walls: the outline of its points stands in. + let walls_only = vec![ + (2, vec![(30.0, 30.0, 0.0), (30.0, 30.0, 5.0), (40.0, 30.0, 5.0), (40.0, 30.0, 0.0), (30.0, 30.0, 0.0)]), + (2, vec![(40.0, 30.0, 0.0), (40.0, 30.0, 5.0), (40.0, 40.0, 5.0), (40.0, 40.0, 0.0), (40.0, 30.0, 0.0)]), + ]; + let fields = [F::Oid, F::Shape { z: true, wkt: LONLAT }, F::Text("name")]; + let rows = vec![ + vec![V::Blob(multipatch(&[roof, floor, wall, leaning])), V::Text(Some("box"))], + vec![V::Blob(multipatch(&walls_only)), V::Text(Some("walls"))], + ]; + let files = geodatabase(vec![("Buildings", table(9 | 0x8000_0000, &fields, &rows))]); + let layers = read_files(&files).unwrap(); + let (ds, report) = &layers[0]; + assert_eq!(ds.geometry_type, GeometryType::Polygon); + assert_eq!(ds.fields.iter().map(|f| f.name.as_str()).collect::>(), ["OBJECTID", "name", "z_min", "z_max", "height"]); + let rows: Vec<&crate::Feature> = ds.features.iter().collect(); + assert_eq!(rows[0].attrs[2..], [Value::Float(400.0), Value::Float(410.0), Value::Float(10.0)]); + let area = |g: &Option| match g { + Some(Geometry::Polygon(p)) => p.iter().map(|poly| poly.first().map_or(0.0, |r| signed_area(r).abs() / 2.0)).sum::(), + _ => 0.0, + }; + assert!((area(&rows[0].geometry) - 100.0).abs() < 1e-6, "{:?}", rows[0].geometry); + assert!((area(&rows[1].geometry) - 50.0).abs() < 1e-6, "the hull of an L of walls: {:?}", rows[1].geometry); + assert!(report.messages.iter().any(|m| m.contains("footprints")), "{report:?}"); + assert!(report.messages.iter().any(|m| m.contains("1 multipatches with no faces")), "{report:?}"); +} + +#[test] +fn row_index_with_left_out_blocks() { + // Rows 1025 and 1026 stored in the one block present (the second of two). + let mut x = Vec::new(); + for v in [3i32, 1, 2048, 4] { + x.extend(v.to_le_bytes()); + } + for i in 0..1024u32 { + x.extend( + match i { + 0 => 100u32, + 1 => 0, + 2 => 200, + _ => 0, + } + .to_le_bytes(), + ); + } + for v in [1i32, 2, 1, 1] { + x.extend(v.to_le_bytes()); + } + x.extend(0b10u32.to_le_bytes()); + assert_eq!(row_offsets(&x).unwrap(), vec![(1025, 100), (1027, 200)]); + // A version 4 index with missing blocks isn't read here. + let mut x = Vec::new(); + for v in [4i32, 0, 0, 4] { + x.extend(v.to_le_bytes()); + } + x.extend(10i64.to_le_bytes()); + x.extend(30i32.to_le_bytes()); + assert!(matches!(row_offsets(&x), Err(DataError::Unsupported(_)))); +} + +/// A row index claiming billions of rows in blocks its bitmap leaves out is passed over block by +/// block, not row by row (it hung before). +#[test] +fn a_huge_row_count_with_absent_blocks_is_quick() { + let mut x = Vec::new(); + for v in [3i32, 0, i32::MAX, 4] { + x.extend(v.to_le_bytes()); + } + for v in [1i32, (i32::MAX / 1024) + 1, 0, 1] { + x.extend(v.to_le_bytes()); + } + x.extend(0u32.to_le_bytes()); + let start = std::time::Instant::now(); + assert_eq!(row_offsets(&x).unwrap(), Vec::new()); + assert!(start.elapsed() < std::time::Duration::from_secs(1), "{:?}", start.elapsed()); +} + +/// One strip of a million points makes no more faces than the cap (each is an allocation). +#[test] +fn a_huge_strip_stops_at_the_face_cap() { + let strip: Vec = (0..1_000_000).map(|i| [f64::from(i), f64::from(i % 2), 0.0]).collect(); + let n = strip.len(); + for kind in [0, 1, 6] { + let (f, truncated) = faces(std::slice::from_ref(&strip), &[kind]); + assert_eq!((f.len(), truncated), (MAX_FACES, true), "kind {kind}"); + } + // Later parts get only the room left. + assert_eq!(faces(&[strip[..10].to_vec(), strip.clone()], &[0, 0]).0.len(), MAX_FACES); + assert!(n / 3 > MAX_FACES); + // Within the cap, nothing is left out. + assert_eq!(faces(&[strip[..10].to_vec()], &[0]), (strip[..10].windows(3).map(|w| vec![w.to_vec()]).collect(), false)); + // A truncated patch is drawn as its points' hull, not as part of its faces. + let mut budget = MAX_TABLE_FACES; + let (shape, hull) = footprint(std::slice::from_ref(&strip), &[0], &mut budget); + assert!(hull == Some(Hull::Unmerged) && shape.is_some()); + assert_eq!(budget, MAX_TABLE_FACES, "nothing was merged"); +} + +/// A ring with no area (its points on a line) is neither an outer ring nor a hole: no sliver. +#[test] +fn a_ring_with_no_area_is_left_out() { + let fields = [F::Oid, F::Shape { z: false, wkt: LONLAT }]; + let line = [(0.0, 0.0), (5.0, 0.0), (10.0, 0.0), (0.0, 0.0)]; + let rows = vec![vec![V::Blob(polygon(&line))], vec![V::Blob(polygon(&square(0.0, 0.0, 10.0)))]]; + let files = geodatabase(vec![("Flat", table(4, &fields, &rows))]); + let layers = read_files(&files).unwrap(); + let (ds, report) = &layers[0]; + let rows: Vec<&crate::Feature> = ds.features.iter().collect(); + assert!(!matches!(rows.first().and_then(|f| f.geometry.as_ref()), Some(Geometry::Polygon(_))), "{report:?}"); + assert!(matches!(rows.get(1).and_then(|f| f.geometry.as_ref()), Some(Geometry::Polygon(p)) if p.len() == 1)); +} + +/// A small ring far from the origin keeps its area: products of its coordinates would round it away. +#[test] +fn a_small_ring_far_from_the_origin_has_an_area() { + let ring: Vec = + [(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)].iter().map(|&(x, y)| Coord::new(1e9 + x, 1e9 + y)).collect(); + assert_eq!(signed_area(&ring), -2.0, "clockwise, twice its area"); +} + +/// A table's multipatches merge no more faces than its budget: past it, they are hulls. +#[test] +fn the_face_budget_bounds_a_tables_unions() { + // A 10 m square roof as a strip of two triangles, and a wall. + let parts = vec![ + vec![[0.0, 0.0, 5.0], [0.0, 10.0, 5.0], [10.0, 0.0, 5.0], [10.0, 10.0, 5.0]], + vec![[0.0, 0.0, 0.0], [0.0, 0.0, 5.0], [10.0, 0.0, 5.0], [10.0, 0.0, 0.0], [0.0, 0.0, 0.0]], + ]; + let kinds = [0, 2]; + let mut budget = 3; + let (shape, hull) = footprint(&parts, &kinds, &mut budget); + assert_eq!((hull, budget), (None, 1), "two faces show and are merged: {shape:?}"); + let (shape, hull) = footprint(&parts, &kinds, &mut budget); + assert_eq!((hull, budget), (Some(Hull::Unmerged), 1), "past the budget: {shape:?}"); + assert!(shape.is_some()); +} + +/// Numbers whose bits run past 64 are refused: one decoding to `i64::MIN` panicked when negated. +#[test] +fn numbers_past_64_bits_are_refused() { + // Sign set, magnitude 2^63: eight continuation bytes reach bit 62, then 0x02. + let mut b = vec![0xC0]; + b.extend([0x80; 8]); + b.push(0x02); + assert_eq!(Cur::new(&b, 0).varint(), None); + // The largest magnitude still reads. + let mut b = vec![0xFF]; + b.extend([0xFF; 8]); + b.push(0x01); + assert_eq!(Cur::new(&b, 0).varint(), Some(-i64::MAX)); + // Unsigned: bit 63 reads, a bit past it doesn't. + let mut b = vec![0x80; 9]; + b.push(0x01); + assert_eq!(Cur::new(&b, 0).varuint(), Some(1 << 63)); + let last = b.len() - 1; + b[last] = 0x02; + assert_eq!(Cur::new(&b, 0).varuint(), None); +} + +/// A table that can't be opened (too large, not readable) is said by name; the others are read. +#[test] +fn an_unreadable_table_leaves_the_others() { + let fields = [F::Oid, F::Text("name")]; + let rows = vec![vec![V::Text(Some("a"))]]; + let files = geodatabase(vec![("Good", table(0, &fields, &rows)), ("Huge", table(0, &fields, &rows))]); + let open = |name: &str| -> Result>> { + if name == "a00000003.gdbtable" { + return Err(DataError::Unsupported(format!("{name} is too large to read here"))); + } + Ok(files.iter().find(|(n, _)| n == name).map(|(_, b)| b.clone())) + }; + let read = read("test", &open, None).unwrap(); + assert_eq!(read.layers.iter().map(|(d, _)| d.name.as_str()).collect::>(), ["Good"]); + assert_eq!(read.unread.len(), 1); + assert_eq!(read.unread[0].0, "Huge"); + assert!(read.unread[0].1.contains("too large"), "{:?}", read.unread); +} + +/// A table the catalog lists whose files are missing is said by name, not passed over. +#[test] +fn a_table_with_missing_files_is_said() { + let fields = [F::Oid, F::Text("name")]; + let rows = vec![vec![V::Text(Some("a"))]]; + let mut files = geodatabase(vec![("Good", table(0, &fields, &rows)), ("Gone", table(0, &fields, &rows))]); + files.retain(|(n, _)| n != "a00000003.gdbtablx"); + let open = |name: &str| -> Result>> { Ok(files.iter().find(|(n, _)| n == name).map(|(_, b)| b.clone())) }; + let read = read("test", &open, None).unwrap(); + assert_eq!(read.layers.len(), 1); + assert_eq!(read.unread.len(), 1, "{:?}", read.unread); + assert_eq!(read.unread[0].0, "Gone"); + assert_eq!(read.unread[0].1, "its file a00000003.gdbtablx is missing"); +} + +/// A feature whose coordinates its coordinate system can't place keeps its row, and is said. +#[test] +fn coordinates_that_cant_be_converted_are_said() { + const LAEA: &str = r#"PROJCS["ETRS89 / LAEA Europe",GEOGCS["ETRS89",DATUM["European_Terrestrial_Reference_System_1989",SPHEROID["GRS 1980",6378137,298.257222101]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Lambert_Azimuthal_Equal_Area"],PARAMETER["latitude_of_center",52],PARAMETER["longitude_of_center",10],PARAMETER["false_easting",4321000],PARAMETER["false_northing",3210000],UNIT["metre",1],AUTHORITY["EPSG","3035"]]"#; + let fields = [F::Oid, F::Shape { z: false, wkt: LAEA }, F::Text("name")]; + let rows = vec![ + vec![V::Blob(polygon(&square(4_321_000.0, 3_210_000.0, 10.0))), V::Text(Some("in"))], + // Far past the far side of the Earth. + vec![V::Blob(polygon(&square(9e8, 9e8, 10.0))), V::Text(Some("out"))], + ]; + let files = geodatabase(vec![("T", table(4, &fields, &rows))]); + let layers = read_files(&files).unwrap(); + let (ds, report) = &layers[0]; + let rows: Vec<&crate::Feature> = ds.features.iter().collect(); + assert_eq!(rows.len(), 2); + assert!(rows[0].geometry.is_some() && rows[1].geometry.is_none(), "{:?}", rows[1].geometry); + assert!(report.messages.iter().any(|m| m.starts_with("1 features have coordinates outside")), "{report:?}"); +} + +#[test] +fn compressed_tables_and_unknown_files_are_said() { + let (t, x) = table( + 0, + &[F::Oid, F::Text("Name"), F::Int("FileFormat")], + &[vec![V::Text(Some("GDB_SystemCatalog")), V::Int(Some(0))], vec![V::Text(Some("Packed")), V::Int(Some(1))]], + ); + let files = vec![("a00000001.gdbtable".to_string(), t), ("a00000001.gdbtablx".to_string(), x)]; + assert!(matches!(read_files(&files), Err(DataError::Unsupported(m)) if m.contains("compressed"))); + assert!(matches!(read_files(&[]), Err(DataError::Format(m)) if m.contains("not a File Geodatabase"))); +} + +/// Damaged files are refused or read in part, never a panic. +#[test] +fn hostile_bytes_never_panic() { + let fields = [F::Oid, F::Shape { z: true, wkt: LONLAT }, F::Text("name"), F::Int("count")]; + let rows = vec![ + vec![V::Blob(multipatch(&[(0, vec![(0.0, 0.0, 1.0), (0.0, 5.0, 1.0), (5.0, 0.0, 2.0)])])), V::Text(Some("a")), V::Int(Some(1))], + vec![V::Blob(polygon(&square(1.0, 1.0, 3.0))), V::Text(None), V::Int(Some(2))], + ]; + let files = geodatabase(vec![("T", table(9 | 0x8000_0000, &fields, &rows))]); + assert!(read_files(&files).is_ok()); + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for _ in 0..3000 { + let mut damaged = files.clone(); + let which = (next() % damaged.len() as u64) as usize; + let bytes = &mut damaged[which].1; + match next() % 3 { + // Flipped bytes. + 0 => { + for _ in 0..1 + next() % 8 { + let at = (next() % bytes.len().max(1) as u64) as usize; + if let Some(b) = bytes.get_mut(at) { + *b = next() as u8; + } + } + } + // Cut short. + 1 => bytes.truncate((next() % (bytes.len() as u64 + 1)) as usize), + // Counts and sizes at their extremes. + _ => { + let at = (next() % bytes.len().max(1) as u64) as usize; + for (k, b) in [0xFFu8, 0xFF, 0xFF, 0x7F].into_iter().enumerate() { + if let Some(x) = bytes.get_mut(at + k) { + *x = b; + } + } + } + } + let _ = read_files(&damaged); + } +} + +/// A geodatabase GDAL writes (when GDAL is installed), read back without it: points with typed +/// fields, and a TIN, which GDAL stores as a multipatch (a triangle fan). +#[test] +fn reads_what_gdal_writes_when_gdal_is_installed() { + if !crate::source::gdal::available() { + return; + } + let dir = std::env::temp_dir().join(format!("rustgis-filegdb-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gdb = dir.join("written.gdb"); + let run = |args: &[&str]| { + let ok = std::process::Command::new("ogr2ogr").args(args).status().is_ok_and(|s| s.success()); + assert!(ok, "ogr2ogr {args:?}"); + }; + let points = dir.join("points.csv"); + std::fs::write(&points, "WKT,name,count,height,day\n\"POINT (8.5 47.4)\",Zürich,3,1.5,2024-05-06\n\"POINT (7.4 46.9)\",,,,\n").unwrap(); + let tins = dir.join("tins.csv"); + std::fs::write( + &tins, + "WKT,name\n\"TIN Z (((0 0 400,0 10 400,10 10 400,0 0 400)),((0 0 400,10 10 400,10 0 400,0 0 400)),((0 0 400,10 0 400,10 0 410,0 0 400)))\",block\n", + ) + .unwrap(); + let (gdb_s, points_s, tins_s) = (gdb.display().to_string(), points.display().to_string(), tins.display().to_string()); + run(&["-f", "OpenFileGDB", "-a_srs", "EPSG:4326", "-nlt", "POINT", "-nln", "places", "-oo", "AUTODETECT_TYPE=YES", &gdb_s, &points_s]); + run(&["-f", "OpenFileGDB", "-update", "-a_srs", "EPSG:2056", "-nlt", "TINZ", "-nln", "blocks", &gdb_s, &tins_s]); + assert!(is_geodatabase(&gdb)); + assert_eq!(layers(&gdb).unwrap(), ["places", "blocks"]); + let read = read_dir(&gdb, None).unwrap().layers; + let (places, report) = &read[0]; + assert_eq!(report.skipped, 0, "{report:?}"); + let rows: Vec<&crate::Feature> = places.features.iter().collect(); + assert_eq!(rows.len(), 2); + assert!( + matches!(rows[0].geometry, Some(Geometry::Point(c)) if (c.x - 8.5).abs() < 1e-7 && (c.y - 47.4).abs() < 1e-7), + "{:?}", + rows[0].geometry + ); + let at = |n: &str| places.field_index(n).unwrap(); + assert_eq!(rows[0].attrs[at("name")], Value::Text("Zürich".into())); + assert_eq!(rows[0].attrs[at("count")], Value::Int(3)); + assert_eq!(rows[0].attrs[at("height")], Value::Float(1.5)); + assert!(matches!(&rows[0].attrs[at("day")], Value::Text(d) if d.starts_with("2024-05-06")), "{:?}", rows[0].attrs); + assert_eq!(rows[1].attrs[at("count")], Value::Null); + let (blocks, _) = &read[1]; + assert_eq!(blocks.crs, Some(Crs::Epsg(2056)), "the coordinate system, by its code"); + let block = blocks.features.iter().next().unwrap(); + let z = |n: &str| block.attrs[blocks.field_index(n).unwrap()].clone(); + assert_eq!((z("z_min"), z("z_max"), z("height")), (Value::Float(400.0), Value::Float(410.0), Value::Float(10.0))); + assert!(matches!(&block.geometry, Some(Geometry::Polygon(p)) if p.len() == 1), "{:?}", block.geometry); + // One layer by name, ignoring case. + assert_eq!(read_dir(&gdb, Some("BLOCKS")).unwrap().layers.len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 9e1b69b34..6f06c19d0 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -6,6 +6,7 @@ pub mod dirs; pub mod dxf; +pub mod filegdb; pub mod geometry; pub mod georss; pub mod gml; diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 427f5a959..8d8c6c969 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -480,6 +480,59 @@ fn gdb_path(path: &str) -> std::borrow::Cow<'_, str> { } } +/// Adds a File Geodatabase's feature classes and tables (`"layer"` picks one), read without GDAL; +/// through GDAL, when it is installed, if they can't be (compressed tables, raster fields). +#[cfg(not(target_arch = "wasm32"))] +fn add_geodatabase(s: &mut Session, p: &Value, path: &str) -> CmdResult { + let layer = ps(p, "layer").map(str::trim).filter(|l| !l.is_empty()); + let read = match rustgis_data::filegdb::read_dir(Path::new(path), layer) { + Ok(read) => read, + Err(e) if rustgis_data::source::gdal::available() => { + return add_gdal(s, p, path).map_err(|g| format!("{path}: {e}; through GDAL: {g}")); + } + Err(e) => return Err(format!("{path}: {e}")), + }; + let reads = + read.layers.into_iter().map(|(dataset, report)| rustgis_data::io::LayerRead { dataset, report, styles: Vec::new() }).collect(); + // The tables read, then those it couldn't read (compressed, raster fields), through GDAL when it + // is installed; else, or if GDAL can't either, said. The second go on whether the first added. + let mut results: Vec = Vec::new(); + let mut failed: Vec = Vec::new(); + match add_reads(s, p, path, reads, Vec::new()) { + Ok(r) => results.push(r), + Err(e) => failed.push(e), + } + let gdal = rustgis_data::source::gdal::available(); + for (table, why) in read.unread { + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.insert("layer".into(), json!(table)); + o.remove("name"); + } + match gdal.then(|| add_gdal(s, &q, path)) { + Some(Ok(r)) => results.push(r), + Some(Err(g)) => s.notify(format!("{path}: {table} not read: {why}; through GDAL: {g}"), true), + None => s.notify(format!("{path}: {table} not read: {why} (GDAL, when installed, reads it)"), true), + } + } + if results.is_empty() { + return Err(if failed.is_empty() { format!("{path}: nothing could be added") } else { failed.join("; ") }); + } + // Something was added (through GDAL): the layers read natively that couldn't be are still said. + for e in failed { + s.notify(e, true); + } + // One result, as adding a single file gives it: every layer, the first, and the features. + let layers: Vec = results.iter().filter_map(|r| r.get("layers").and_then(Value::as_array)).flatten().cloned().collect(); + let tables: Vec = results.iter().filter_map(|r| r.get("tables").and_then(Value::as_array)).flatten().cloned().collect(); + let features: u64 = results.iter().filter_map(|r| r.get("features").and_then(Value::as_u64)).sum(); + let mut out = json!({"layers": layers, "tables": tables, "features": features}); + if let (Some(o), Some(first)) = (out.as_object_mut(), layers.first()) { + o.insert("layer".into(), first.clone()); + } + Ok(out) +} + fn add_data(s: &mut Session, p: &Value) -> CmdResult { let path = s_req(p, "path")?; let path = &*gdb_path(path); @@ -568,6 +621,10 @@ fn add_data(s: &mut Session, p: &Value) -> CmdResult { return super::tiles::add(s, &q); } #[cfg(not(target_arch = "wasm32"))] + if rustgis_data::filegdb::is_geodatabase(Path::new(path)) { + return add_geodatabase(s, p, path); + } + #[cfg(not(target_arch = "wasm32"))] if rustgis_data::source::gdal::is_gdal_name(path) { return add_gdal(s, p, path); } @@ -666,6 +723,18 @@ pub(crate) fn add_layers(s: &mut Session, p: &Value, file_name: &str, bytes: &[u return Err(format!("{file_name}: no layer '{want}' (layers: {})", names.join(", "))); } } + add_reads(s, p, file_name, reads, overlays) +} + +/// Adds the layers a file gave and its ground overlays: a layer that can't be added is skipped +/// and said, the others are still added. A name given goes to the first layer added. +fn add_reads( + s: &mut Session, + p: &Value, + file_name: &str, + reads: Vec, + overlays: Vec<(String, rustgis_data::raster::Raster)>, +) -> CmdResult { let mut added = Vec::new(); let mut failed = Vec::new(); let mut q = p.clone(); @@ -828,6 +897,15 @@ fn file_layers(_s: &mut Session, p: &Value) -> CmdResult { let path = s_req(p, "path")?; let path = &*gdb_path(path); let err = |e: rustgis_data::DataError| format!("{path}: {e}"); + // A geodatabase read without GDAL; through GDAL if it can't be and GDAL is installed. + #[cfg(not(target_arch = "wasm32"))] + if rustgis_data::filegdb::is_geodatabase(Path::new(path)) { + match rustgis_data::filegdb::layers(Path::new(path)) { + Ok(layers) => return Ok(json!({"count": layers.len(), "layers": layers})), + Err(_) if rustgis_data::source::gdal::available() => {} + Err(e) => return Err(err(e)), + } + } #[cfg(not(target_arch = "wasm32"))] if rustgis_data::source::gdal::is_gdal_name(path) { let layers = rustgis_data::source::gdal::layers(Path::new(path)).map_err(err)?; diff --git a/docs/user-guide/data-formats.md b/docs/user-guide/data-formats.md index 4b2bc89d8..a285daa80 100644 --- a/docs/user-guide/data-formats.md +++ b/docs/user-guide/data-formats.md @@ -31,6 +31,7 @@ web app reads GeoPackages without SQLite, with a small SQLite file reader writte | GeoPackage | `.gpkg` | Add Data, or upload in the web app | Every feature table becomes a layer, up to 32 at once (the result counts the rest in `skippedCount` and names up to 100 of them in `skipped`); `"layer"` picks one, and `info.fileLayers` lists them all. Read in place by its R-tree; any EPSG system is reprojected. The web app reads the file with its own SQLite reader (tables scanned, the envelope in each geometry header tested first; no R-tree); UTF-16 databases aren't read there. | | GeoParquet | `.parquet`, `.geoparquet` | Add Data, or upload in the web app | WKB geometry, with the CRS from the file's `geo` metadata. Row groups are pruned by the bbox covering. Snappy, gzip, LZ4 and zstd compression are read, in the browser too; Brotli isn't. | | FlatGeobuf | `.fgb` | Add Data, or upload | Read in place by its packed R-tree. | +| Esri File Geodatabase | `.gdb` folder (desktop) | A file dialog can't pick a folder: in Add Data pick any `.gdbtable` file inside it; or double-click the `.gdb` in the Browser, or drop the folder on the window | Read without GDAL: every feature class becomes a layer and every table a standalone table, with their fields (dates as text) and coordinate system (by its code, EPSG or Esri; else its WKT). `"layer"` picks one, and `info.fileLayers` lists them. 3D multipatches (such as swissBUILDINGS3D's buildings) are drawn as their footprints, the union of their faces seen from above, with their lowest and highest Z in `z_min` and `z_max` and the difference in `height`, which an extrusion (Style › Extrusion) can stand them up by; one with no face seen from above (only walls) is drawn as the outline of its points. Curves are drawn as straight segments between their ends. Compressed tables, raster fields and some newer row indexes aren't read: with GDAL installed, such a geodatabase opens through GDAL instead (see below). Loaded whole, up to 2 GB a table. | | Standalone tables | CSV, or any layer without geometry | Add Data | Listed under the map in Layers; join or relate them to layers. | Files with more than 100,000 features (GeoPackage, GeoParquet, FlatGeobuf, and Shapefile on the @@ -47,7 +48,6 @@ GDAL](data.md#formats-through-gdal). | Format | Extensions or source | Notes | | --- | --- | --- | -| Esri File Geodatabase | `.gdb` folder | Every feature class becomes a layer. A file dialog can't pick a folder: pick any `.gdbtable` file inside it, double-click the `.gdb` in the Browser, or drop the folder on the window. | | AutoCAD DWG | `.dwg` | Through GDAL's CAD driver, when it is built in. Give `"crs"` (such as `EPSG:2263`) when the drawing doesn't say its coordinate system. | | MapInfo tables | `.tab` | Keep the companion files together. | | OpenStreetMap XML | `.osm` | | diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index bf1af26a3..bee7f5738 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -18,7 +18,8 @@ Formats](data-formats.md). | GML, MapInfo MIF/MID, OpenStreetMap PBF, TopoJSON, AutoCAD DXF (ASCII) | Yes | No | | LiDAR point clouds: LAS, LAZ, COPC (an evenly spread sample) | Yes | No | | Geotagged photos (JPEG with EXIF GPS): points with their thumbnails | Yes | No | -| Through GDAL: File Geodatabase (`.gdb` folder), AutoCAD DWG, MapInfo TAB, OpenStreetMap XML… | Yes (desktop, when GDAL is installed) | No | +| Esri File Geodatabase (`.gdb` folder; 3D multipatches as footprints) | Yes (desktop) | No | +| Through GDAL: AutoCAD DWG, MapInfo TAB, OpenStreetMap XML, File Geodatabases RustGIS can't read itself… | Yes (desktop, when GDAL is installed) | No | | PostgreSQL/PostGIS databases, read live (large tables a view at a time) | Yes (desktop) | No | | MySQL and MariaDB databases, read live (large tables a view at a time) | Yes (desktop) | No | | DuckDB databases (`.duckdb`, `.ddb`) and SQL queries | Yes (desktop, when DuckDB is installed) | No | @@ -59,7 +60,8 @@ reprojected on the fly as they draw. When [GDAL](https://gdal.org) is installed (its `ogr2ogr` and `ogrinfo` programs on the `PATH`, or the folder holding them in `RUSTGIS_GDAL_BIN`; GDAL 3.7 or later), the desktop app also opens -the vector formats GDAL reads: File Geodatabases, DWG, MapInfo TAB, OpenStreetMap XML +the vector formats GDAL reads: DWG, MapInfo TAB, OpenStreetMap XML, File Geodatabases RustGIS +can't read itself (compressed ones, or ones with raster fields) and more. RustGIS doesn't link to GDAL: each layer is converted once, by `ogr2ogr`, into a FlatGeobuf with a spatial index in WGS 84 (GDAL reprojects it), kept in the cache folder (`~/.cache/rustgis/gdal` on Linux, `~/Library/Caches/RustGIS/gdal` on macOS, From 91ecfcbbcef19970590ea1e1ed4b793f18771a82 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Thu, 22 Oct 2026 11:18:26 -0400 Subject: [PATCH 033/297] fix(data): unlock Shapefile cache leases explicitly on release (#687) (#704) A flock is released only when every descriptor sharing its open file description is closed. A process spawned on another thread (the cache child-process tests, GDAL, DuckDB) holds duplicates of all descriptors until it execs, so dropping a lease or the directory lock left it held for that window: a release then deferred its cleanup, a sweep counted the entry as pinned, and a re-reservation met WouldBlock. Lock files are now wrapped in a guard that unlocks on drop; unlocking acts on the open file description, duplicates included. A regression test holds a duplicate descriptor across each release. --- crates/data/src/source/shp/cache.rs | 40 +++++++++++++++++++---- crates/data/src/source/shp/cache/tests.rs | 30 +++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/data/src/source/shp/cache.rs b/crates/data/src/source/shp/cache.rs index 981a5182e..ca949ab4b 100644 --- a/crates/data/src/source/shp/cache.rs +++ b/crates/data/src/source/shp/cache.rs @@ -49,8 +49,36 @@ pub fn index_fallback_dir() -> PathBuf { std::env::temp_dir().join(name) } +/// A lock file whose advisory lock is released explicitly when it is dropped. +/// +/// Closing a descriptor releases its `flock` only once every duplicate of it is +/// closed, and a process spawned on another thread (GDAL, DuckDB, a plugin) holds +/// duplicates of all descriptors until it execs. Dropping a plain `File` could +/// then leave a lease or the directory lock held for that window, so a sweep or a +/// release that runs meanwhile finds the entry pinned and leaves it behind. +/// Unlocking acts on the shared open file description, duplicates included, and +/// is a no-op for a lock that was never taken. +#[derive(Debug)] +struct Flock(File); +impl std::ops::Deref for Flock { + type Target = File; + fn deref(&self) -> &File { + &self.0 + } +} +impl std::ops::DerefMut for Flock { + fn deref_mut(&mut self) -> &mut File { + &mut self.0 + } +} +impl Drop for Flock { + fn drop(&mut self) { + let _ = self.0.unlock(); + } +} + /// Never follow a lease symlink or open a special file, including after a rename race. -fn lease_file(path: &Path, create: bool) -> io::Result { +fn lease_file(path: &Path, create: bool) -> io::Result { if std::fs::symlink_metadata(path).is_ok_and(|m| !m.file_type().is_file()) { return Err(io::Error::other("cache lease is not a regular file")); } @@ -73,9 +101,9 @@ fn lease_file(path: &Path, create: bool) -> io::Result { return Err(io::Error::new(io::ErrorKind::PermissionDenied, "cache lease is not private to this user")); } } - Ok(file) + Ok(Flock(file)) } -fn open_lock(path: &Path) -> io::Result { +fn open_lock(path: &Path) -> io::Result { lease_file(path, true) } @@ -116,7 +144,7 @@ fn validate_directory(root: &Path) -> io::Result { #[derive(Debug)] struct Directory { root: PathBuf, - file: File, + file: Flock, } /// Shared validation for blocking checkout and nonblocking release paths. @@ -190,7 +218,7 @@ fn owned(name: &str) -> bool { && parts.next().is_some_and(|s| s.parse::().is_ok()) && parts.next().is_none() } -fn existing_lock(path: &Path) -> io::Result { +fn existing_lock(path: &Path) -> io::Result { if !regular(path) { return Err(io::Error::other("missing regular cache lease")); } @@ -201,7 +229,7 @@ fn existing_lock(path: &Path) -> io::Result { /// whole lifetime, rather than just during I/O. No PID-based assumption for new caches. pub struct Lease { path: PathBuf, - lock: Option, + lock: Option, temporary: bool, ready: bool, budget: u64, diff --git a/crates/data/src/source/shp/cache/tests.rs b/crates/data/src/source/shp/cache/tests.rs index 077f01716..9538a4485 100644 --- a/crates/data/src/source/shp/cache/tests.rs +++ b/crates/data/src/source/shp/cache/tests.rs @@ -132,6 +132,36 @@ fn cache_concurrent_builder_uses_private_index_without_replacing_reader() { assert!(reader.path().exists()); } +// A process spawned on another thread holds duplicates of every descriptor until it +// execs. `try_clone` makes the same duplicate: releases must not wait for it to close. +#[test] +fn cache_releases_unlock_even_while_a_spawned_process_holds_a_duplicate() { + let root = Folder::new(); + let (lease, file) = temporary_index(&root.0); + drop(file); + let path = lease.path().to_path_buf(); + let duplicate = lease.lock.as_ref().unwrap().try_clone().unwrap(); + drop(lease); + assert!(!path.exists(), "a released temporary index is removed at once"); + assert_eq!(maintain(&root.0, 0).unwrap().remaining_bytes, 0); + drop(duplicate); + + let lease = ready(&root.0, 1, 10, 4096); + let shared = lease.path().to_path_buf(); + let duplicate = lease.lock.as_ref().unwrap().try_clone().unwrap(); + drop(lease); + let rebuilt = reserve_index(&root.0, &key(1), 4096).unwrap(); + assert_eq!(rebuilt.path(), shared, "a released index is not still pinned by the duplicate"); + drop(rebuilt); + drop(duplicate); + + let sweep = directory_lock(&root.0).unwrap(); + let duplicate = sweep.file.try_clone().unwrap(); + drop(sweep); + assert!(directory_file(&root.0).unwrap().file.try_lock().is_ok(), "a finished sweep releases the directory"); + drop(duplicate); +} + #[test] fn cache_cleanup_is_bounded_and_preserves_unknown_files_and_orphans_in_use() { let root = Folder::new(); From 5449066cb1907dbfd3cae5c03b189464e982a321 Mon Sep 17 00:00:00 2001 From: Mavrick-F Date: Thu, 22 Oct 2026 10:30:36 -0500 Subject: [PATCH 034/297] Fix undo grouping for ribbon label size and colour edits (#688) * fix(ui): group ribbon label edits into one undo step * test(ui): exercise label drag completion through app frame * fix(ui): preserve label size precision in recorded commands --------- Co-authored-by: giswqs --- crates/ui-egui/src/labelpane.rs | 5 +- crates/ui-egui/src/panes.rs | 1 + crates/ui-egui/src/ribbon.rs | 311 +++++++++++++++++++++++++++++++- 3 files changed, 309 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index beeded40f..89e742601 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -285,7 +285,7 @@ fn map_labeling(app: &mut RustGisApp, ui: &mut Ui) { }); } -/// Where a label colour being dragged in the pane's picker waits. +/// Where a label colour being dragged in the pane or ribbon's picker waits. const COLOR_DRAG: &str = "label_pane_color_drag"; /// A label colour being dragged: it previews in the project, and when the pointer is let go @@ -298,7 +298,8 @@ struct ColorDrag { color: Rgba, } -fn color_drag(app: &mut RustGisApp, ui: &Ui, l: &Layer, color: Rgba) { +/// Previews a colour pick on its layer until `finish_color_drag` commits the completed gesture. +pub(crate) fn color_drag(app: &mut RustGisApp, ui: &Ui, l: &Layer, color: Rgba) { let key = egui::Id::new(COLOR_DRAG); let mut pending = ui.data(|d| d.get_temp::(key)); // One left pending on another layer (the selection changed mid-pick, from the Assistant or a diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index ee61147a7..af0e52ec8 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -77,6 +77,7 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { // go joins the drag instead of running a command of its own. finish_class_color_drag(app, ui); crate::labelpane::finish_color_drag(app, ui); + crate::ribbon::finish_label_size_drag(app, ui); } /// Closes the pane that command `id` (a `ui.pane.*`) opens, on its side; false when `id` isn't a diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 27aefa180..e32a900fd 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -4009,6 +4009,55 @@ fn renderer_icon(r: &Renderer) -> &'static str { } } +/// A ribbon size preview belongs to the layer where the gesture began, even if selection moves. +#[derive(Clone, Default)] +struct LabelSizeDrag { + layer: u32, + before: f32, + // Keep the widget's precision for command recording; layer storage is f32. + size: f64, +} + +/// Retains the starting size and layer while applying each frame's preview. +fn label_size_drag(app: &mut RustGisApp, ui: &Ui, layer: &rustgis_engine::map::Layer, size: f64) { + let key = egui::Id::new("ribbon_label_size_drag"); + let pending = ui.data_mut(|d| d.remove_temp::(key)); + let before = match pending { + Some(drag) if drag.layer == layer.id => drag.before, + Some(drag) => { + apply_label_size_drag(app, drag); + layer.labels.default.size + } + None => layer.labels.default.size, + }; + ui.data_mut(|d| d.insert_temp(key, LabelSizeDrag { layer: layer.id, before, size })); + if let Some(layer) = app.session.map_mut().layer_mut(layer.id) { + layer.labels.default.size = size as f32; + } + app.session.revision = app.session.revision.wrapping_add(1); +} + +/// Called after the panes even if selection is cleared or the ribbon is hidden mid-gesture. +pub(crate) fn finish_label_size_drag(app: &mut RustGisApp, ui: &Ui) { + if ui.input(|i| i.pointer.primary_down() && i.focused) { + return; + } + if let Some(drag) = ui.data_mut(|d| d.remove_temp::(egui::Id::new("ribbon_label_size_drag"))) { + apply_label_size_drag(app, drag); + } +} + +/// Restores the original size before committing on the owning layer, so Undo captures that size. +fn apply_label_size_drag(app: &mut RustGisApp, drag: LabelSizeDrag) { + let Some(layer) = app.session.map_mut().layer_mut(drag.layer) else { return }; + layer.labels.default.size = drag.before; + if drag.before != drag.size as f32 { + let selected = app.session.active_layer.replace(drag.layer); + let _ = app.run("labels.style", json!({"size": drag.size})); + app.session.active_layer = selected; + } +} + fn label_groups(app: &mut RustGisApp, ui: &mut Ui) { let Some(layer) = app.session.active_layer.and_then(|id| app.session.map().layer(id)).cloned() else { return }; group(ui, "Labels", |ui| { @@ -4103,16 +4152,21 @@ fn label_groups(app: &mut RustGisApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Size")).size(11.5)); let range = rustgis_engine::map::symbology::LABEL_SIZE; - if ui.add(egui::DragValue::new(&mut size).range(range).speed(0.1).suffix(" pt")).changed() { - let _ = app.run("labels.style", json!({"size": size})); - } + // Another layer gets a distinct widget, so an in-flight drag cannot move to it. + ui.push_id(("label_size", layer.id), |ui| { + if ui.add(egui::DragValue::new(&mut size).range(range).speed(0.1).suffix(" pt")).changed() { + label_size_drag(app, ui, &layer, size); + } + }); }); let mut c = crate::theme::c32(layer.labels.default.color); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Color")).size(11.5)); - if crate::widgets::color_button(ui, &mut c).changed() { - let _ = app.run("labels.style", json!({"color": crate::theme::rgba(c).hex()})); - } + ui.push_id(("label_color", layer.id), |ui| { + if crate::widgets::color_button(ui, &mut c).changed() { + crate::labelpane::color_drag(app, ui, &layer, crate::theme::rgba(c)); + } + }); }); }); col(ui, |ui| { @@ -4306,8 +4360,253 @@ fn language_menu(app: &mut RustGisApp, ui: &mut Ui) { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; + use egui_kittest::kittest::Queryable; + + /// Isolates the label controls; the interrupted-gesture test below checks full-frame wiring. + fn label_harness() -> egui_kittest::Harness<'static, RustGisApp> { + let mut app = RustGisApp::with_sample(); + app.session.run("layer.select", &json!({"layer": "Cities"})).unwrap(); + app.ws.recording = Some(Vec::new()); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1600.0, 700.0)).build_ui_state( + move |ui, app: &mut RustGisApp| { + if !fonts { + crate::theme::install_fonts(ui.ctx(), &[], None); + fonts = true; + return; + } + if !app.ui.ribbon_minimized { + ui.horizontal(|ui| label_groups(app, ui)); + } + finish_label_size_drag(app, ui); + crate::labelpane::finish_color_drag(app, ui); + }, + app, + ); + h.run(); + h + } + + #[test] + fn ribbon_label_size_drag_is_one_undo_and_recorded_step() { + let mut h = label_harness(); + let id = h.state().session.active_layer.unwrap(); + let size = |app: &RustGisApp| app.session.map().layer(id).unwrap().labels.default.size; + let before = size(h.state()); + let undo = h.state().session.undo_depth(); + let at = h.get_by_role(egui::accesskit::Role::SpinButton).rect().center(); + h.hover_at(at); + h.drag_at(at); + h.step(); + for dx in [16.0, 32.0, 48.0] { + h.hover_at(at + vec2(dx, 0.0)); + h.step(); + assert!(size(h.state()) > before, "the map previews the size while dragging"); + assert_eq!(h.state().session.undo_depth(), undo, "a drag does not run a command each frame"); + assert!(h.state().ws.recording.as_ref().unwrap().is_empty()); + } + let after = size(h.state()); + h.drop_at(at + vec2(48.0, 0.0)); + h.run(); + assert_eq!(h.state().session.undo_depth(), undo + 1); + let steps = h.state().ws.recording.as_ref().unwrap(); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].command, "labels.style"); + assert_eq!(steps[0].params["layer"], "Cities"); + assert_eq!(size(h.state()), after); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(size(h.state()), before); + h.state_mut().run("edit.redo", json!(null)).unwrap(); + assert_eq!(size(h.state()), after); + } + + #[test] + fn ribbon_label_size_drag_keeps_its_layer_when_selection_changes() { + for clear_selection in [false, true] { + let mut h = label_harness(); + let cities = h.state().session.active_layer.unwrap(); + let countries = h.state().session.map().layers.iter().find(|l| l.name == "Countries").unwrap().id; + let size = |app: &RustGisApp, id| app.session.map().layer(id).unwrap().labels.default.size; + let before = size(h.state(), cities); + let countries_before = size(h.state(), countries); + let undo = h.state().session.undo_depth(); + let at = h.get_by_role(egui::accesskit::Role::SpinButton).rect().center(); + h.hover_at(at); + h.drag_at(at); + h.step(); + h.hover_at(at + vec2(32.0, 0.0)); + h.step(); + let after = size(h.state(), cities); + assert!(after > before); + // Selection may also change through the Assistant or relay while the pointer is held. + let selected = if clear_selection { None } else { Some(countries) }; + h.state_mut().session.active_layer = selected; + h.step(); + h.drop_at(at + vec2(32.0, 0.0)); + h.run(); + assert_eq!(size(h.state(), countries), countries_before); + assert_eq!(size(h.state(), cities), after); + assert_eq!(h.state().session.active_layer, selected); + assert_eq!(h.state().session.undo_depth(), undo + 1); + let steps = h.state().ws.recording.as_ref().unwrap(); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].params["layer"], "Cities"); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(size(h.state(), cities), before); + assert_eq!(size(h.state(), countries), countries_before); + h.state_mut().run("edit.redo", json!(null)).unwrap(); + assert_eq!(size(h.state(), cities), after); + } + } + + /// Exercises the app frame so removing the pane finalizer breaks gesture completion. + #[test] + fn ribbon_label_size_drag_finishes_when_hidden_or_unfocused() { + for hide_ribbon in [false, true] { + let mut app = RustGisApp::with_sample(); + app.session.run("layer.select", &json!({"layer": "Cities"})).unwrap(); + app.ui.tab = "Style".into(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run(); + for _ in 0..2 { + h.get_by_label("Scroll the ribbon right").click(); + h.run(); + } + h.state_mut().ws.recording = Some(Vec::new()); + let id = h.state().session.active_layer.unwrap(); + let size = |app: &RustGisApp| app.session.map().layer(id).unwrap().labels.default.size; + let before = size(h.state()); + let undo = h.state().session.undo_depth(); + // Text Symbol follows the Drawing group's numeric controls in the Style ribbon. + let at = h.query_all_by_role(egui::accesskit::Role::SpinButton).last().unwrap().rect().center(); + h.hover_at(at); + h.drag_at(at); + h.step(); + h.hover_at(at + vec2(32.0, 0.0)); + h.step(); + let after = size(h.state()); + assert!(after > before); + if hide_ribbon { + h.state_mut().ui.ribbon_minimized = true; + h.step(); + h.drop_at(at + vec2(32.0, 0.0)); + } else { + h.input_mut().focused = false; + h.event(egui::Event::WindowFocused(false)); + } + h.run(); + assert_eq!(size(h.state()), after); + assert_eq!(h.state().session.undo_depth(), undo + 1); + assert_eq!(h.state().ws.recording.as_ref().unwrap().len(), 1); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(size(h.state()), before); + } + } + + #[test] + fn ribbon_label_colour_drag_is_one_undo_and_recorded_step() { + let mut h = label_harness(); + let id = h.state().session.active_layer.unwrap(); + let color = |app: &RustGisApp| app.session.map().layer(id).unwrap().labels.default.color; + let before = color(h.state()); + let undo = h.state().session.undo_depth(); + h.get_by_role(egui::accesskit::Role::ColorWell).click(); + h.run(); + // The ribbon size precedes the colour picker's R/G/B/A numeric controls. + let at = h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(1).unwrap().rect().center(); + h.hover_at(at); + h.drag_at(at); + h.step(); + for dx in [16.0, 32.0, 48.0] { + h.hover_at(at + vec2(dx, 0.0)); + h.step(); + assert_ne!(color(h.state()), before, "the map previews the colour while dragging"); + assert_eq!(h.state().session.undo_depth(), undo, "the picker does not run a command each frame"); + assert!(h.state().ws.recording.as_ref().unwrap().is_empty()); + } + let after = color(h.state()); + h.drop_at(at + vec2(48.0, 0.0)); + h.run(); + assert_eq!(h.state().session.undo_depth(), undo + 1); + let steps = h.state().ws.recording.as_ref().unwrap(); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].command, "labels.style"); + assert_eq!(steps[0].params["layer"], "Cities"); + assert_eq!(color(h.state()), after); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(color(h.state()), before); + h.state_mut().run("edit.redo", json!(null)).unwrap(); + assert_eq!(color(h.state()), after); + } + + #[test] + fn ribbon_label_colour_drag_keeps_its_layer_when_selection_changes() { + let mut h = label_harness(); + let cities = h.state().session.active_layer.unwrap(); + let countries = h.state().session.map().layers.iter().find(|l| l.name == "Countries").unwrap().id; + let color = |app: &RustGisApp, id| app.session.map().layer(id).unwrap().labels.default.color; + let before = color(h.state(), cities); + let countries_before = color(h.state(), countries); + let undo = h.state().session.undo_depth(); + h.get_by_role(egui::accesskit::Role::ColorWell).click(); + h.run(); + let at = h.query_all_by_role(egui::accesskit::Role::SpinButton).nth(1).unwrap().rect().center(); + h.hover_at(at); + h.drag_at(at); + h.step(); + h.hover_at(at + vec2(32.0, 0.0)); + h.step(); + let after = color(h.state(), cities); + assert_ne!(after, before); + h.state_mut().session.active_layer = Some(countries); + h.step(); + h.hover_at(at + vec2(64.0, 0.0)); + h.step(); + h.drop_at(at + vec2(64.0, 0.0)); + h.run(); + assert_eq!(color(h.state(), countries), countries_before); + assert_eq!(color(h.state(), cities), after); + assert_eq!(h.state().session.active_layer, Some(countries)); + assert_eq!(h.state().session.undo_depth(), undo + 1); + let steps = h.state().ws.recording.as_ref().unwrap(); + assert_eq!(steps.len(), 1); + assert_eq!(steps[0].params["layer"], "Cities"); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(color(h.state(), cities), before); + assert_eq!(color(h.state(), countries), countries_before); + h.state_mut().run("edit.redo", json!(null)).unwrap(); + assert_eq!(color(h.state(), cities), after); + } + + #[test] + fn ribbon_label_size_text_input_preserves_decimal_command_value() { + let mut h = label_harness(); + let id = h.state().session.active_layer.unwrap(); + let before = h.state().session.map().layer(id).unwrap().labels.default.size; + let undo = h.state().session.undo_depth(); + h.get_by_role(egui::accesskit::Role::SpinButton).focus(); + h.run(); + // Inserts the complete value in one text event; per-keystroke undo grouping is separate. + h.get_by_role(egui::accesskit::Role::SpinButton).type_text("14.3"); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().session.map().layer(id).unwrap().labels.default.size, 14.3_f32); + assert_eq!(h.state().session.undo_depth(), undo + 1); + assert_eq!(h.state().ws.recording.as_ref().unwrap().len(), 1); + assert_eq!(h.state().ws.recording.as_ref().unwrap()[0].params["size"], json!(14.3)); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(h.state().session.map().layer(id).unwrap().labels.default.size, before); + } #[test] fn saved_hidden_tabs_never_hide_the_fixed_tabs() { From ae0a8ad4460ffcee79abe9dc6116e33d981271b3 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:32:37 +0200 Subject: [PATCH 035/297] feat(connections): add reusable endpoints, storage and session logins (#701) * feat(connections): add reusable endpoints, storage and session logins * fix(connections): prevent ambiguous names and stabilize CI fixtures * fix(connections): clear destination S3 session on rename --- crates/assistant/src/tests.rs | 16 + crates/data/src/source/duckdb.rs | 5 + crates/engine/src/cmd/connections.rs | 248 ++++++++++++++++ crates/engine/src/cmd/layer.rs | 2 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/plugins.rs | 6 + crates/engine/src/cmd/s3.rs | 4 +- crates/engine/src/cmd/service.rs | 2 +- crates/engine/src/cmd/stac.rs | 2 +- crates/engine/src/connections.rs | 429 +++++++++++++++++++++++++++ crates/engine/src/lib.rs | 4 + crates/engine/src/tests.rs | 127 ++++++++ crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/s3ui.rs | 2 +- crates/ui-egui/src/sourcespane.rs | 31 ++ crates/ui-egui/tests/gpu_fastpath.rs | 5 +- crates/ui-egui/tests/smoke.rs | 41 +++ docs/user-guide/data.md | 49 +++ 18 files changed, 973 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/cmd/connections.rs create mode 100644 crates/engine/src/connections.rs diff --git a/crates/assistant/src/tests.rs b/crates/assistant/src/tests.rs index cb1119619..83a5d02c0 100644 --- a/crates/assistant/src/tests.rs +++ b/crates/assistant/src/tests.rs @@ -148,6 +148,22 @@ fn file_access_needs_approval() { assert!(tool_msg["content"].as_str().is_some_and(|t| t.contains("declined"))); } +#[test] +fn connection_management_requires_fresh_approval_but_can_run_in_app() { + let mut s = Session::new(Project::default()); + let mut a = Agent::new(cfg("openai"), &s); + assert!(matches!(a.ask("save a connection", &s), Step::Send(_))); + let args = json!({"command":"connection.new","params":{"kind":"s3","name":"Approved connection"}}); + assert_eq!(a.receive(a.generation(), ok(call("first", "run_command", args.clone())), &mut s), Step::Confirm); + assert!(s.connections.find("Approved connection").is_none()); + assert!(!a.pending(&s).unwrap().2, "reserved commands cannot be allowed for the whole conversation"); + assert!(matches!(a.decide(true, true, &mut s), Step::Send(_))); + assert_eq!(s.connections.find("Approved connection").unwrap().kind, "s3"); + assert_eq!(a.receive(a.generation(), ok(call("again", "run_command", args)), &mut s), Step::Confirm); + assert!(matches!(a.decide(false, false, &mut s), Step::Send(_))); + assert_eq!(s.connections.connections.len(), 1, "declining a repeat must leave the approved connection unchanged"); +} + #[test] fn gate_rules() { let s = sample(); diff --git a/crates/data/src/source/duckdb.rs b/crates/data/src/source/duckdb.rs index 6040fe464..7812c43d9 100644 --- a/crates/data/src/source/duckdb.rs +++ b/crates/data/src/source/duckdb.rs @@ -144,6 +144,11 @@ pub(crate) fn remembered_password(dsn: &str) -> Option { passwords().lock().unwrap_or_else(|e| e.into_inner()).get(dsn).cloned() } +/// Whether a password is remembered for a connection this session. +pub fn has_password(dsn: &str) -> bool { + passwords().lock().unwrap_or_else(|e| e.into_inner()).contains_key(dsn) +} + /// The remembered password of a query's database, handed to DuckDB's PostgreSQL client in its /// environment (`PGPASSWORD`): never in the script, where the process list and DuckDB's errors /// would show it. diff --git a/crates/engine/src/cmd/connections.rs b/crates/engine/src/cmd/connections.rs new file mode 100644 index 000000000..9b02884c2 --- /dev/null +++ b/crates/engine/src/cmd/connections.rs @@ -0,0 +1,248 @@ +//! Saved connections and session-only logins. Listing handlers are called directly: these +//! commands never edit the project, create undo snapshots or note recently added sources. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; + +use super::ps; +use crate::connections::{Connection, DATABASE_KINDS, DESKTOP_ONLY, KINDS}; +use crate::render::rustgis_tiles::s3; +use crate::{CmdResult, CommandSpec as C, Session}; + +pub fn commands() -> Vec { + vec![ + C::new("connection.list", "List Connections", "Home › Add › Data Sources", list).view().params( + r#"{"kind"?: "postgres"|"mysql"|"sqlserver"|"service"|"wms"|"wmts"|"wfs"|"wcs"|"ogcapi"|"arcgis"|"csw"|"stac"|"s3", "group"?: text}"#, + ), + C::new("connection.new", "New Connection", "Home › Add › Data Sources", new).view().params( + r#"{"kind": "postgres"|"mysql"|"sqlserver"|"service"|"wms"|"wmts"|"wfs"|"wcs"|"ogcapi"|"arcgis"|"csw"|"stac"|"s3", "address"?: "as the kind's add command takes it, without its password or token (postgres:host=… dbname=… user=…, mysql://user@host/db, mssql://user@host/db, a service's or STAC API's https:// address, an S3 endpoint: AWS when absent)", "name"?: "unique (default from the address)", "group"?: text, "options"?: {"region"?: text, "buckets"?: "a,b", "credentials"?: "keys"|"profile"|"environment"|"none", "profile"?: text} (S3 only)}"#, + ), + C::new("connection.edit", "Edit Connection", "Home › Add › Data Sources", edit).view().params( + r#"{"id": "a connection's id or name", "name"?: text, "group"?: text, "address"?: text, "options"?: "as connection.new (replaces them)", "kind"?: "its kind (unchanged)"}"#, + ), + C::new("connection.duplicate", "Duplicate Connection", "Home › Add › Data Sources", duplicate).view().params(r#"{"id": "a connection's id or name", "name"?: text}"#), + C::new("connection.remove", "Remove Connection", "Home › Add › Data Sources", remove).view().params(r#"{"id": "a connection's id or name"}"#), + C::new("connection.login", "Log In to Connection", "Home › Add › Data Sources", login_command).view().params( + r#"{"id": "a connection's id or name", "password"?: "a database connection's, kept for this session only", "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "an S3 connection's keys (credentials: keys), kept for this session only"}"#, + ), + C::new("connection.test", "Test Connection", "Home › Add › Data Sources", test).view().params( + r#"{"id": "a connection's id or name", "password"?: text, "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "as connection.login (logs in first)"}"#, + ), + ] +} + +fn shown(c: &Connection) -> Value { + let database = DATABASE_KINDS.contains(&c.kind.as_str()); + let available = !database || !cfg!(target_arch = "wasm32"); + let logged_in = if c.kind == "s3" { + s3::connection(&c.name).is_some() + } else if database { + #[cfg(not(target_arch = "wasm32"))] + { + use rustgis_data::source::duckdb::{self, Database}; + match Database::parse(&c.address) { + Database::Postgres(d) | Database::Mysql(d) | Database::Sqlserver(d) => duckdb::has_password(&d), + Database::File(_) => false, + } + } + #[cfg(target_arch = "wasm32")] + { + false + } + } else { + false + }; + let add_with = match c.kind.as_str() { + "postgres" | "mysql" | "sqlserver" => "info.databaseTables {database: address}, then layer.addDatabase {database: address, layer}", + "service" => "service.layers {url: address}, then service.add {url, layer}", + "wms" | "wmts" | "wfs" | "ogcapi" | "arcgis" => "service.layers {url: address, type: kind}, then service.add {url, type, layer}", + "wcs" => "service.layers {url: address, type: wcs}, then service.coverage {url, layer}", + "csw" => "catalog.search {url: address, query}", + "stac" => "stac.collections {url: address}, then stac.search {url, collections}", + "s3" => "connection.login {id} once, then s3.list {connection: name}, then layer.addUrl {url: s3://…}", + _ => "", + }; + let mut out = serde_json::to_value(c).unwrap_or(Value::Null); + out["available"] = json!(available); + if !available { + out["why"] = json!(DESKTOP_ONLY); + } + out["loggedIn"] = json!(logged_in); + out["addWith"] = json!(add_with); + out +} + +fn found<'a>(s: &'a Session, p: &Value) -> Result<&'a Connection, String> { + let id = ps(p, "id").unwrap_or(""); + s.connections.find(id).ok_or_else(|| format!("no connection '{id}'")) +} + +fn options(p: &Value) -> Result, String> { + let object = p.as_object().ok_or("options is an object of texts")?; + object + .iter() + .map(|(k, v)| v.as_str().map(|t| (k.clone(), t.to_owned())).ok_or_else(|| "options is an object of texts".into())) + .collect() +} + +fn list(s: &mut Session, p: &Value) -> CmdResult { + let kind = ps(p, "kind"); + if let Some(k) = kind + && !KINDS.contains(&k) + { + return Err(format!("unknown kind '{k}' ({})", KINDS.join(", "))); + } + let group = ps(p, "group"); + Ok(json!({"connections": s.connections.connections.iter() + .filter(|c| kind.is_none_or(|k| c.kind == k) && group.is_none_or(|g| c.group.chars().flat_map(char::to_lowercase).eq(g.chars().flat_map(char::to_lowercase)))) + .map(shown).collect::>() })) +} + +fn new(s: &mut Session, p: &Value) -> CmdResult { + let c = Connection { + kind: ps(p, "kind").ok_or_else(|| format!("kind is required ({})", KINDS.join(", ")))?.trim().into(), + address: ps(p, "address").unwrap_or("").into(), + name: ps(p, "name").unwrap_or("").into(), + group: ps(p, "group").unwrap_or("").into(), + options: p.get("options").map(options).transpose()?.unwrap_or_default(), + ..Connection::default() + }; + let explicit = !c.name.trim().is_empty(); + let (c, stripped) = s.connections.add(c, explicit)?; + Ok(shown_stripped(&c, stripped)) +} + +fn shown_stripped(c: &Connection, stripped: bool) -> Value { + let mut out = shown(c); + if stripped { + out["stripped"] = json!(true); + } + out +} + +fn edit(s: &mut Session, p: &Value) -> CmdResult { + let old = found(s, p)?.clone(); + let mut c = old.clone(); + if let Some(kind) = ps(p, "kind") + && kind.trim() != c.kind + { + return Err("a connection's kind doesn't change: make a new one".into()); + } + for (key, field) in [("name", &mut c.name), ("group", &mut c.group), ("address", &mut c.address)] { + if let Some(value) = p.get(key) { + *field = value.as_str().ok_or_else(|| format!("{key} is text"))?.into(); + } + } + if let Some(v) = p.get("options") { + c.options = options(v)?; + } + let (c, stripped) = s.connections.update(&old.id, c)?; + if c.kind == "s3" && (old.name != c.name || old.address != c.address || old.options != c.options) { + s3::disconnect(&old.name); + if old.name != c.name { + s3::disconnect(&c.name); + } + } + Ok(shown_stripped(&c, stripped)) +} + +fn duplicate(s: &mut Session, p: &Value) -> CmdResult { + let mut c = found(s, p)?.clone(); + c.id.clear(); + let explicit = ps(p, "name").is_some_and(|n| !n.trim().is_empty()); + if let Some(name) = ps(p, "name") { + c.name = name.into(); + } + let (c, _) = s.connections.add(c, explicit)?; + Ok(shown(&c)) +} + +fn remove(s: &mut Session, p: &Value) -> CmdResult { + let c = found(s, p)?.clone(); + s.connections.remove(&c.id); + if c.kind == "s3" { + s3::disconnect(&c.name); + } + Ok(json!({"removed": c.id})) +} + +fn login(s: &mut Session, c: &Connection, p: &Value) -> Result<(), String> { + if DATABASE_KINDS.contains(&c.kind.as_str()) { + #[cfg(target_arch = "wasm32")] + { + return Err(DESKTOP_ONLY.into()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let pw = ps(p, "password").filter(|t| !t.is_empty()).ok_or("password is required")?; + super::layer::database_param(&json!({"database": c.address, "password": pw})); + return Ok(()); + } + } + if c.kind != "s3" { + return Err(format!("{} connections don't log in yet", c.kind)); + } + let mut q = json!({"name": c.name, "endpoint": c.address}); + for key in ["region", "buckets"] { + if let Some(v) = c.options.get(key).filter(|v| !v.is_empty()) { + q[key] = json!(v); + } + } + match c.options.get("credentials").map(String::as_str).unwrap_or("none") { + "keys" => { + if ["accessKeyId", "secretAccessKey"].iter().any(|k| ps(p, k).is_none_or(|v| v.trim().is_empty())) { + return Err("accessKeyId and secretAccessKey are required: an S3 connection's keys are typed each session".into()); + } + for key in ["accessKeyId", "secretAccessKey", "sessionToken"] { + if let Some(v) = p.get(key) { + q[key] = v.clone(); + } + } + } + "profile" => { + let profile = c.options.get("profile").filter(|v| !v.trim().is_empty()).ok_or("the connection names no AWS profile")?; + q["profile"] = json!(profile); + } + "environment" => q["environment"] = json!(true), + _ => q["anonymous"] = json!(true), + } + super::s3::connect(s, &q)?; + Ok(()) +} + +fn login_command(s: &mut Session, p: &Value) -> CmdResult { + let c = found(s, p)?.clone(); + login(s, &c, p)?; + Ok(shown(&c)) +} + +fn test(s: &mut Session, p: &Value) -> CmdResult { + let c = found(s, p)?.clone(); + if ["password", "accessKeyId", "secretAccessKey", "sessionToken"].iter().any(|k| ps(p, k).is_some_and(|v| !v.is_empty())) { + login(s, &c, p)?; + } + let result = match c.kind.as_str() { + "postgres" | "mysql" | "sqlserver" => { + if cfg!(target_arch = "wasm32") { + return Err(DESKTOP_ONLY.into()); + } + super::layer::database_tables(s, &json!({"database": c.address}))? + } + "service" => super::service::layers(s, &json!({"url": c.address}))?, + "wms" | "wmts" | "wfs" | "wcs" | "ogcapi" | "arcgis" => super::service::layers(s, &json!({"url": c.address, "type": c.kind}))?, + "csw" => super::service::catalog_search(s, &json!({"url": c.address}))?, + "stac" => super::stac::collections(s, &json!({"url": c.address}))?, + "s3" => { + if s3::connection(&c.name).is_none() { + if c.options.get("credentials").is_some_and(|v| v == "keys") { + return Err("log in first: connection.login {id, accessKeyId, secretAccessKey}".into()); + } + login(s, &c, &Value::Null)?; + } + super::s3::list(s, &json!({"connection": c.name, "limit": 100}))? + } + _ => return Err(format!("unknown kind '{}'", c.kind)), + }; + Ok(json!({"connection": c.id, "result": result})) +} diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 8d8c6c969..d8b671a8a 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -1256,7 +1256,7 @@ pub fn sql_database_param(p: &Value) -> Result CmdResult { +pub(super) fn database_tables(_s: &mut Session, p: &Value) -> CmdResult { #[cfg(target_arch = "wasm32")] { let _ = p; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 81e572493..fe5bdf9b0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -2,6 +2,7 @@ pub mod catalogs; pub mod clipboard; +pub mod connections; pub mod cube; pub mod deeplearning; pub mod design; @@ -62,6 +63,7 @@ pub fn all() -> Vec { v.extend(service::commands()); v.extend(s3::commands()); v.extend(sources::commands()); + v.extend(connections::commands()); v.extend(layer::commands()); v.extend(group::commands()); v.extend(layer_copy::commands()); diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index 3e946b65f..f71652219 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -270,6 +270,12 @@ const NEVER: &[&str] = &[ "edit.undo", "edit.redo", // Credentials: a plugin never sets where the user's are sent. + "connection.new", + "connection.edit", + "connection.duplicate", + "connection.remove", + "connection.login", + "connection.test", "s3.connect", "s3.disconnect", "earthdata.login", diff --git a/crates/engine/src/cmd/s3.rs b/crates/engine/src/cmd/s3.rs index 1e0fafa5a..a1e8ba4b0 100644 --- a/crates/engine/src/cmd/s3.rs +++ b/crates/engine/src/cmd/s3.rs @@ -79,7 +79,7 @@ fn credentials(p: &Value) -> Result<(Option, Option, Option Ok((None, None, None, "anonymous")) } -fn connect(_s: &mut Session, p: &Value) -> CmdResult { +pub(super) fn connect(_s: &mut Session, p: &Value) -> CmdResult { let name = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("default").to_string(); let endpoint = ps(p, "endpoint").map(str::trim).filter(|e| !e.is_empty()).map(str::to_string); let (creds, region, profile_endpoint, source) = credentials(p)?; @@ -140,7 +140,7 @@ fn target(p: &Value) -> Result<(Option, String), String> { Ok((bucket, prefix)) } -fn list(_s: &mut Session, p: &Value) -> CmdResult { +pub(super) fn list(_s: &mut Session, p: &Value) -> CmdResult { let (bucket, prefix) = target(p)?; let named = match ps(p, "connection").map(str::trim).filter(|n| !n.is_empty()) { Some(n) => Some(s3::connection(n).ok_or_else(|| format!("no S3 connection named '{n}'"))?), diff --git a/crates/engine/src/cmd/service.rs b/crates/engine/src/cmd/service.rs index d8824fbf2..77254bf3d 100644 --- a/crates/engine/src/cmd/service.rs +++ b/crates/engine/src/cmd/service.rs @@ -482,7 +482,7 @@ pub(crate) fn coverage_check(name: &str, bytes: &[u8]) -> Result<(), String> { /// `catalog.search`: a page of a catalogue's (CSW) records matching words and an area, with the /// links RustGIS can follow (those pointing where a document on the web may not send it left out). -fn catalog_search(s: &mut Session, p: &Value) -> CmdResult { +pub(super) fn catalog_search(s: &mut Session, p: &Value) -> CmdResult { let url = url_of(p)?; let request = records_request(s, p)?; let page = match ps(p, "text") { diff --git a/crates/engine/src/cmd/stac.rs b/crates/engine/src/cmd/stac.rs index e9413c500..5928a40ad 100644 --- a/crates/engine/src/cmd/stac.rs +++ b/crates/engine/src/cmd/stac.rs @@ -105,7 +105,7 @@ pub(crate) fn add_collection(s: &mut Session, fc: &Value, name: &str) -> Result< Ok(Some(id)) } -fn collections(_s: &mut Session, p: &Value) -> CmdResult { +pub(super) fn collections(_s: &mut Session, p: &Value) -> CmdResult { let api = stac::api(ps(p, "url"))?; #[cfg(target_arch = "wasm32")] { diff --git a/crates/engine/src/connections.rs b/crates/engine/src/connections.rs new file mode 100644 index 000000000..c29e1c0c1 --- /dev/null +++ b/crates/engine/src/connections.rs @@ -0,0 +1,429 @@ +//! Named, reusable endpoints kept with the app's preferences, not the project. Never a +//! secret: addresses are stripped of passwords, tokens and signatures before they are kept. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::sources::{self, Sources}; + +pub const MAX_CONNECTIONS: usize = 200; +const MAX_TEXT: usize = 2048; +const MAX_NAME: usize = 200; +pub const KINDS: &[&str] = + &["postgres", "mysql", "sqlserver", "service", "wms", "wmts", "wfs", "wcs", "ogcapi", "arcgis", "csw", "stac", "s3"]; +pub const DATABASE_KINDS: &[&str] = &["postgres", "mysql", "sqlserver"]; +pub const S3_CREDENTIALS: &[&str] = &["keys", "profile", "environment", "none"]; +pub const DESKTOP_ONLY: &str = "database connections are used by the desktop app"; + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Connection { + pub id: String, + pub name: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub group: String, + pub kind: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub address: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub options: BTreeMap, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Connections { + #[serde(default)] + pub version: u32, + #[serde(default)] + pub next: u64, + #[serde(default)] + pub connections: Vec, + #[serde(skip)] + pub generation: u64, +} + +pub fn database_kind(address: &str) -> Option<&'static str> { + if rustgis_data::source::myconn::split_password(address).is_some() { + Some("mysql") + } else if rustgis_data::source::msconn::split_password(address).is_some() { + Some("sqlserver") + } else { + let lower = address.trim().to_ascii_lowercase(); + (lower.starts_with("postgres:") || lower.starts_with("postgresql:")).then_some("postgres") + } +} + +fn same_name(a: &str, b: &str) -> bool { + a.chars().flat_map(char::to_lowercase).eq(b.chars().flat_map(char::to_lowercase)) +} + +fn name_text(t: &str) -> String { + t.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() +} + +pub fn clean(mut c: Connection) -> Result<(Connection, bool), String> { + if !KINDS.contains(&c.kind.as_str()) { + return Err(format!("unknown kind '{}' ({})", c.kind, KINDS.join(", "))); + } + let t = c.address.trim(); + if t.len() > MAX_TEXT || t.chars().any(char::is_control) { + return Err("a connection address must be at most 2048 bytes and contain no control characters".into()); + } + let stored = sources::redact(t); + let stripped = stored != t; + if DATABASE_KINDS.contains(&c.kind.as_str()) { + if stored.is_empty() || database_kind(&stored) != Some(c.kind.as_str()) { + return Err(format!( + "'{stored}' isn't a {} address (postgres:host=… dbname=… user=…, mysql://user@host/db, mssql://user@host/db)", + c.kind + )); + } + } else { + let lower = stored.to_ascii_lowercase(); + if !(lower.starts_with("http://") || lower.starts_with("https://") || c.kind == "s3" && stored.is_empty()) { + return Err(format!("a {} connection needs an http:// or https:// address", c.kind)); + } + } + c.address = stored; + c.name = name_text(&c.name); + if c.name.is_empty() { + c.name = if c.kind == "s3" && c.address.is_empty() { "AWS S3".into() } else { sources::default_name(&c.address) }; + } + c.group = name_text(&c.group); + if c.kind != "s3" && !c.options.is_empty() { + return Err(format!("{} connections take no options", c.kind)); + } + for (k, v) in &mut c.options { + match k.as_str() { + "region" | "profile" => { + let max = if k == "region" { 64 } else { MAX_NAME }; + if v.chars().count() > max || v.chars().any(char::is_control) { + return Err(format!("{k} must be at most {max} characters and contain no control characters")); + } + } + "buckets" => { + if !v.is_empty() { + let buckets: Vec<_> = v.split(',').map(str::trim).collect(); + if buckets.iter().any(|b| !crate::render::rustgis_tiles::s3::valid_bucket(b)) { + return Err("invalid S3 bucket name".into()); + } + *v = buckets.join(","); + } + } + "credentials" => { + if !v.is_empty() && !S3_CREDENTIALS.contains(&v.as_str()) { + return Err(format!("credentials must be one of {}", S3_CREDENTIALS.join(", "))); + } + } + _ => return Err(format!("'{k}' isn't an S3 connection option (region, buckets, credentials, profile)")), + } + } + if c.options.get("credentials").map(String::as_str) != Some("profile") { + c.options.remove("profile"); + } + c.options.retain(|_, v| !v.is_empty()); + Ok((c, stripped)) +} + +impl Connections { + pub fn from_json(text: &str) -> Self { + let v: Value = serde_json::from_str(text).unwrap_or(Value::Null); + let mut s = Self { version: 1, next: v.get("next").and_then(Value::as_u64).unwrap_or(1).max(1), ..Self::default() }; + let mut ids = BTreeSet::new(); + if let Some(rows) = v.get("connections").and_then(Value::as_array) { + for row in rows { + let Ok(c) = serde_json::from_value::(row.clone()) else { continue }; + let Ok((c, _)) = clean(c) else { continue }; + if c.id.is_empty() + || c.id.len() > 32 + || !c.id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + || !ids.insert(c.id.clone()) + { + continue; + } + if let Some(n) = c.id.strip_prefix('c').and_then(|n| n.parse::().ok()) { + s.next = s.next.max(n.saturating_add(1)); + } + s.connections.push(c); + if s.connections.len() == MAX_CONNECTIONS { + break; + } + } + } + // All IDs must be known before a name is accepted: a later row can reserve it. + // Normalize in order so the first duplicate name keeps precedence. + for i in 0..s.connections.len() { + let Some(c) = s.connections.get(i) else { continue }; + let name = s.unique_name(&c.name, None, s.connections.get(..i).unwrap_or_default()); + if let Some(c) = s.connections.get_mut(i) { + c.name = name; + } + } + s + } + + pub fn import(saved: &Sources, s3_saved: &str) -> Self { + let mut s = Self::default(); + for old in &saved.saved { + let kind = match old.kind.as_str() { + "service" => old + .extra + .get("type") + .and_then(Value::as_str) + .filter(|k| matches!(*k, "wms" | "wmts" | "wfs" | "wcs" | "ogcapi" | "arcgis" | "csw")) + .unwrap_or("service"), + "database" => match database_kind(&old.address) { + Some(k) => k, + None => continue, + }, + "stac" if old.extra.get("api").is_some() => "stac", + _ => continue, + }; + if s.connections.iter().any(|c| c.kind == kind && c.address == old.address) { + continue; + } + let _ = s.add_unique(Connection { + name: old.name.clone(), + kind: kind.into(), + address: old.address.clone(), + ..Connection::default() + }); + } + let rows: Value = serde_json::from_str(s3_saved).unwrap_or(Value::Null); + if let Some(rows) = rows.as_array() { + for row in rows.iter().take(32) { + let Some(row) = row.as_object() else { continue }; + let text = |k: &str| row.get(k).and_then(Value::as_str).unwrap_or("").to_owned(); + let source = text("source"); + let credentials = if S3_CREDENTIALS.contains(&source.as_str()) { source } else { "keys".into() }; + let _ = s.add_unique(Connection { + name: text("name"), + kind: "s3".into(), + address: text("endpoint"), + options: BTreeMap::from([ + ("region".into(), text("region")), + ("credentials".into(), credentials), + ("profile".into(), text("profile")), + ]), + ..Connection::default() + }); + } + } + s + } + + pub fn to_json(&self) -> String { + serde_json::to_string(&json!({"version": 1, "next": self.next, "connections": self.connections})).unwrap_or_default() + } + + pub fn find(&self, key: &str) -> Option<&Connection> { + self.connections.iter().find(|c| c.id == key).or_else(|| self.connections.iter().find(|c| same_name(&c.name, key.trim()))) + } + + /// IDs win lookup, so names must not occupy either a stored ID or a future generated ID. + fn name_is_id(&self, name: &str) -> bool { + let generated = name.get(..1).is_some_and(|c| c.eq_ignore_ascii_case("c")) + && name.get(1..).is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); + generated || self.connections.iter().any(|c| same_name(&c.id, name)) + } + + // Loading checks names only against earlier rows, but IDs against the whole collection. + fn unique_name(&self, base: &str, except: Option<&str>, named: &[Connection]) -> String { + let taken = |name: &str| self.name_is_id(name) || named.iter().any(|c| Some(c.id.as_str()) != except && same_name(&c.name, name)); + if !taken(base) { + return base.into(); + } + for n in 2.. { + let suffix = format!(" ({n})"); + let name = format!("{}{suffix}", base.chars().take(MAX_NAME.saturating_sub(suffix.len())).collect::()); + if !taken(&name) { + return name; + } + } + base.into() + } + + pub fn add(&mut self, c: Connection, explicit_name: bool) -> Result<(Connection, bool), String> { + let (mut c, stripped) = clean(c)?; + if self.connections.len() >= MAX_CONNECTIONS { + return Err(format!("up to {MAX_CONNECTIONS} connections")); + } + if explicit_name && self.name_is_id(&c.name) { + return Err("a connection name cannot be a connection id".into()); + } + let name = self.unique_name(&c.name, None, &self.connections); + if explicit_name && name != c.name { + return Err(format!("a connection named '{}' exists", c.name)); + } + c.name = name; + let mut next = self.next.max(1); + while self.connections.iter().any(|c| c.id == format!("c{next}")) { + next = next.checked_add(1).ok_or("connection ids exhausted")?; + } + self.next = next.checked_add(1).ok_or("connection ids exhausted")?; + c.id = format!("c{next}"); + self.connections.push(c.clone()); + self.version = 1; + self.generation = self.generation.wrapping_add(1); + Ok((c, stripped)) + } + + fn add_unique(&mut self, c: Connection) -> Result<(Connection, bool), String> { + self.add(c, false) + } + + pub fn update(&mut self, id: &str, c: Connection) -> Result<(Connection, bool), String> { + let explicit_name = !c.name.trim().is_empty(); + let (mut c, stripped) = clean(c)?; + if explicit_name && self.name_is_id(&c.name) { + return Err("a connection name cannot be a connection id".into()); + } + let name = self.unique_name(&c.name, Some(id), &self.connections); + if explicit_name && name != c.name { + return Err(format!("a connection named '{}' exists", c.name)); + } + c.name = name; + let slot = self.connections.iter_mut().find(|c| c.id == id).ok_or_else(|| format!("no connection '{id}'"))?; + c.id = id.into(); + *slot = c.clone(); + self.generation = self.generation.wrapping_add(1); + Ok((c, stripped)) + } + + pub fn remove(&mut self, id: &str) -> Option { + let index = self.connections.iter().position(|c| c.id == id)?; + let c = self.connections.remove(index); + self.generation = self.generation.wrapping_add(1); + Some(c) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn import_connections() { + let saved = Sources::from_json( + &json!({"saved": [ + {"kind":"service", "name":"Shared", "address":"https://w.example/wms", "extra":{"type":"wms"}}, + {"kind":"service", "name":"Shared", "address":"https://s.example"}, + {"kind":"database", "name":"PG", "address":"postgres:host=h dbname=g user=u"}, + {"kind":"database", "name":"My", "address":"mysql://u@h/db"}, + {"kind":"database", "name":"MS", "address":"mssql://u@h/db"}, + {"kind":"database", "name":"File", "address":"/x/a.duckdb"}, + {"kind":"stac", "name":"API", "address":"https://stac.example", "extra":{"api":"true"}}, + {"kind":"stac", "name":"Item", "address":"https://stac.example/item"}, + {"kind":"service", "name":"Duplicate", "address":"https://w.example/wms", "extra":{"type":"wms"}} + ]}) + .to_string(), + ); + let s = Connections::import( + &saved, + r#"[{"name":"Keys","source":""},{"name":"Profile","source":"profile","profile":"work"},{"name":"Public","source":"none"}]"#, + ); + let kinds: Vec<_> = s.connections.iter().map(|c| c.kind.as_str()).collect(); + assert_eq!(kinds, ["wms", "service", "postgres", "mysql", "sqlserver", "stac", "s3", "s3", "s3"]); + assert_eq!(s.connections[1].name, "Shared (2)"); + assert_eq!(s.find("Keys").unwrap().options["credentials"], "keys"); + assert_eq!(s.find("Profile").unwrap().options["profile"], "work"); + assert_eq!(s.find("Public").unwrap().options["credentials"], "none"); + assert_eq!(Connections::from_json(&s.to_json()).connections, s.connections); + } + + #[test] + fn connections_tolerate_junk_and_preserve_identity() { + let mut s = Connections::from_json( + &json!({"next":2,"connections":[ + null, {"id":"bad!","name":"Bad","kind":"wms","address":"https://a"}, + {"id":"c90","name":"A","kind":"wms","address":"https://a"}, + {"id":"c90","name":"B","kind":"wms","address":"https://b"}, + {"id":"custom","name":"a","kind":"wms","address":"https://b"}, + {"id":"c999","name":"Bad","kind":"unknown","address":"https://b"} + ]}) + .to_string(), + ); + assert_eq!(s.connections.len(), 2); + assert_eq!(s.connections[1].name, "a (2)"); + let (c, stripped) = s + .add( + Connection { + kind: "postgres".into(), + name: "DB".into(), + address: "postgresql://u:pw@h/db".into(), + ..Connection::default() + }, + true, + ) + .unwrap(); + assert_eq!(c.id, "c91"); + assert!(stripped); + assert!(!s.to_json().contains("pw")); + let (other, _) = s.add(Connection { name: "Other".into(), ..c.clone() }, true).unwrap(); + assert_ne!(c.id, other.id); + assert_eq!(c.address, other.address); + assert_eq!(Connections::from_json(&s.to_json()).connections, s.connections); + } + + #[test] + fn connections_names_cannot_shadow_stored_or_future_ids() { + let mut s = Connections::from_json( + &json!({"connections": [ + {"id":"first","name":"REMOTE","kind":"s3"}, + {"id":"remote","name":"Remote (2)","kind":"s3"}, + {"id":"third","name":"c4","kind":"s3"}, + {"id":"fourth","name":"remote","kind":"s3"} + ]}) + .to_string(), + ); + assert_eq!(s.connections[0].name, "REMOTE (2)"); + assert_eq!(s.connections[1].name, "Remote (2) (2)"); + assert_eq!(s.connections[2].name, "c4 (2)"); + assert_eq!(s.connections[3].name, "remote (3)"); + for c in &s.connections { + assert_eq!(s.find(&c.name), Some(c)); + } + let template = s.connections[0].clone(); + for name in ["remote", "FIRST", "c1", "c5", "C999"] { + let c = Connection { name: name.into(), ..template.clone() }; + assert!(s.add(c.clone(), true).is_err(), "{name}"); + assert!(s.update("first", c).is_err(), "{name}"); + } + let (generated, _) = s.add(Connection { name: "c1".into(), ..template.clone() }, false).unwrap(); + assert_eq!(generated.id, "c1"); + assert_eq!(generated.name, "c1 (2)"); + assert_eq!(s.find(&generated.name), Some(&generated)); + assert_eq!(Connections::from_json(&s.to_json()).connections, s.connections); + } + + #[test] + fn connections_blank_edit_name_uses_a_unique_default() { + let mut s = Connections::default(); + let c = Connection { kind: "wms".into(), address: "https://a.example/wms".into(), ..Connection::default() }; + let (first, _) = s.add(c.clone(), false).unwrap(); + let (second, _) = s.add(Connection { name: "Other".into(), ..c }, true).unwrap(); + let (edited, _) = s.update(&second.id, Connection { name: " ".into(), ..second.clone() }).unwrap(); + assert_eq!(edited.name, "wms (2)"); + assert_eq!(s.find(&first.name), Some(&first)); + assert!(s.update(&second.id, Connection { name: first.name, ..edited }).is_err()); + } + + #[test] + fn connections_validate_options_and_bounds() { + let c = Connection { + kind: "s3".into(), + options: BTreeMap::from([ + ("credentials".into(), "profile".into()), + ("profile".into(), "work".into()), + ("buckets".into(), " b11, b22 ".into()), + ]), + ..Connection::default() + }; + let (c, _) = clean(c).unwrap(); + assert_eq!(c.options["buckets"], "b11,b22"); + assert!(clean(Connection { options: BTreeMap::from([("password".into(), "secret".into())]), ..c.clone() }).is_err()); + assert!(clean(Connection { options: BTreeMap::from([("buckets".into(), "NO_".into())]), ..c }).is_err()); + let mut s = Connections::from_json(r#"{"next":18446744073709551615}"#); + assert!(s.add(Connection { kind: "s3".into(), ..Connection::default() }, false).is_err()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 57fa6e77c..8144a3004 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod cmd; +pub mod connections; pub mod geometry_calc; pub mod model; pub mod project; @@ -411,6 +412,8 @@ pub struct Session { /// Favourite, recent and saved data sources (kept with the app's preferences, not the /// project). pub sources: sources::Sources, + /// Saved connections (`connection.*`): kept with the app's preferences, not the project; never a secret. + pub connections: connections::Connections, /// How rasters added from now on are drawn (`raster.setDefaultStyle`; kept with the app's /// preferences, not the project). pub raster_defaults: rustgis_map::RasterDefaults, @@ -610,6 +613,7 @@ impl Session { path: None, dirty: false, sources: sources::Sources::default(), + connections: connections::Connections::default(), raster_defaults: rustgis_map::RasterDefaults::default(), registry: Registry { commands: cmd::all() }, revision: 1, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ded5b0f69..331e55c83 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1099,6 +1099,9 @@ fn hostile_params_never_panic() { json!({"transform": "spline", "crs": "EPSG:999999999", "pointsCrs": [7], "resampling": 1, "cellSize": -1e308, "path": 5, "points": [[0, 0, 1, 1]]}), json!({"transform": "affine", "crs": 32613, "pointsCrs": 4326, "cellSize": 1e-300, "path": "/nonexistent/x.tif", "points": [[0, 0, 1, 1], [1, 0, 2, 1], [0, 1, 1, 2]]}), json!({"points": [[0, 0, f64::MAX, f64::MAX], [1, 0, -f64::MAX, f64::MAX], [0, 1, f64::MAX, -f64::MAX]], "compare": true}), + // Connections: junk kinds, options and secrets; no connection exists in a fresh session. + json!({"id": "c1", "kind": "s3", "address": "http://127.0.0.1:9", "name": "\u{7}", "group": 5, "options": {"credentials": "nope", "buckets": "NO_", "region": [1]}, "password": 7, "accessKeyId": "A", "secretAccessKey": 9}), + json!({"id": "", "kind": "postgres", "address": "postgres:host=127.0.0.1 port=9 password=x", "options": "x"}), ]; let ids: Vec<&str> = Session::new(Project::default()).registry.commands.iter().map(|c| c.id).collect(); let streamed = grid_fgb("hostile", 20); @@ -8521,6 +8524,130 @@ fn data_sources_recent_favourites_and_saved() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn connections_commands() { + use crate::render::rustgis_tiles::s3; + let mut s = Session::new(Project::default()); + let a = s.run("connection.new", &json!({"kind":"wms","address":"https://a.example/wms?token=t","name":"A"})).unwrap(); + assert_eq!(a["stripped"], true); + assert_eq!(a["address"], "https://a.example/wms"); + s.run("connection.new", &json!({"kind":"wms","address":"https://a.example/wms","name":"B"})).unwrap(); + assert!(s.run("connection.new", &json!({"kind":"wms","address":"https://b.example/wms","name":"a"})).is_err()); + s.run("connection.edit", &json!({"id":"A","name":"C","group":"Work"})).unwrap(); + let listed = s.run("connection.list", &json!({"group":"work"})).unwrap(); + assert_eq!(listed["connections"][0]["id"], a["id"]); + assert_eq!(listed["connections"][0]["name"], "C"); + let copy = s.run("connection.duplicate", &json!({"id":"C"})).unwrap(); + assert_eq!(copy["name"], "C (2)"); + s.run("connection.remove", &json!({"id":copy["id"]})).unwrap(); + assert!(s.connections.find(copy["id"].as_str().unwrap()).is_none()); + assert!(s.run("connection.edit", &json!({"id":"C","kind":"wfs"})).is_err()); + assert!(s.run("connection.edit", &json!({"id":"C","name":"b"})).is_err()); + assert!(s.run("connection.new", &json!({"kind":"s3","options":{"region":[1]}})).is_err()); + #[cfg(not(target_arch = "wasm32"))] + { + let db = s + .run( + "connection.new", + &json!({"kind":"postgres","address":"postgres:host=127.0.0.1 port=9 dbname=connections_commands user=u"}), + ) + .unwrap(); + s.run("connection.login", &json!({"id":db["id"],"password":"pw"})).unwrap(); + let list = s.run("connection.list", &json!({"kind":"postgres"})).unwrap(); + assert_eq!(list["connections"][0]["loggedIn"], true); + assert!(!s.connections.to_json().contains("pw")); + let copy = s.run("connection.duplicate", &json!({"id":db["id"]})).unwrap(); + assert_eq!(copy["loggedIn"], true, "the existing password store is shared by public DSN"); + let edited = s + .run("connection.edit", &json!({"id":db["id"],"address":"postgres:host=127.0.0.1 port=9 dbname=connections_other user=u"})) + .unwrap(); + assert_eq!(edited["loggedIn"], false, "a different endpoint must not inherit the old password"); + s.run("connection.remove", &json!({"id":db["id"]})).unwrap(); + let list = s.run("connection.list", &json!({"kind":"postgres"})).unwrap(); + assert_eq!(list["connections"][0]["id"], copy["id"]); + assert_eq!(list["connections"][0]["loggedIn"], true, "removing a shortcut must not log out another connection using that DSN"); + } + s.run("connection.new", &json!({"kind":"s3","name":"conn-test-s3","options":{"credentials":"keys","buckets":"b11"}})).unwrap(); + assert!(s.run("connection.test", &json!({"id":"conn-test-s3"})).unwrap_err().contains("log in first")); + s.run("connection.login", &json!({"id":"conn-test-s3","accessKeyId":"AKIDTEST","secretAccessKey":"very-secret-key"})).unwrap(); + assert!(s3::connection("conn-test-s3").is_some()); + assert!(!s.connections.to_json().contains("very-secret")); + s.run("connection.edit", &json!({"id":"conn-test-s3","address":"https://other.example"})).unwrap(); + assert!(s3::connection("conn-test-s3").is_none()); + s.run("connection.new", &json!({"kind":"wms","name":"Closed","address":"http://127.0.0.1:9/wms"})).unwrap(); + assert!(s.run("connection.test", &json!({"id":"Closed"})).unwrap_err().contains("127.0.0.1:9")); + assert!(s.run("connection.login", &json!({"id":"Closed"})).unwrap_err().contains("don't log in")); + s.run("connection.edit", &json!({"id":"Closed","kind":" wms "})).unwrap(); + let default = s.run("connection.new", &json!({"kind":"wms","address":"https://a.example/wms"})).unwrap(); + let renamed = s.run("connection.edit", &json!({"id":"B","name":""})).unwrap(); + assert_eq!(renamed["name"], "wms (2)"); + assert_ne!(default["id"], renamed["id"]); + assert!(crate::sources::holds_secret(&json!({"id":"x","password":"p"}))); + for id in ["connection.new", "connection.edit", "connection.duplicate", "connection.remove", "connection.login", "connection.test"] { + assert!(crate::cmd::plugins::user_only(id), "{id}"); + } + assert!(!crate::cmd::plugins::user_only("connection.list")); + assert!(!s.dirty && !s.can_undo()); +} + +#[test] +fn connections_loaded_names_route_login_and_remove_to_the_right_endpoint() { + use crate::render::rustgis_tiles::s3; + let mut s = Session::new(Project::default()); + s.connections = crate::connections::Connections::from_json(&json!({"connections":[ + {"id":"c1","kind":"s3","name":"connections-alias-target","options":{"credentials":"keys"}}, + {"id":"connections-alias-target","kind":"postgres","name":"Database","address":"postgres:host=127.0.0.1 port=9 dbname=connections_alias user=u"} + ]}).to_string()); + let name = s.connections.find("c1").unwrap().name.clone(); + assert_eq!(name, "connections-alias-target (2)"); + let logged_in = s.run("connection.login", &json!({"id":name,"accessKeyId":"AKIDTEST","secretAccessKey":"alias-test-secret"})).unwrap(); + assert_eq!(logged_in["id"], "c1"); + assert!(s3::connection(&name).is_some()); + let removed = s.run("connection.remove", &json!({"id":name})).unwrap(); + assert_eq!(removed["removed"], "c1"); + assert!(s3::connection(&name).is_none()); + assert_eq!(s.connections.find("connections-alias-target").unwrap().name, "Database"); +} + +#[test] +fn connections_s3_rename_cannot_inherit_a_destination_session() { + use crate::render::rustgis_tiles::s3; + let mut s = Session::new(Project::default()); + let (old, destination, occupied) = ("rename-test-old", "rename-test-destination", "rename-test-occupied"); + let saved = s + .run( + "connection.new", + &json!({ + "kind":"s3","name":old,"address":"https://saved.example","options":{"credentials":"keys"} + }), + ) + .unwrap(); + s.run("connection.login", &json!({"id":old,"accessKeyId":"SAVEDKEY","secretAccessKey":"saved-test-secret"})).unwrap(); + s.run( + "s3.connect", + &json!({ + "name":destination,"endpoint":"https://runtime.example","accessKeyId":"RUNTIMEKEY","secretAccessKey":"runtime-test-secret" + }), + ) + .unwrap(); + s.run("connection.new", &json!({"kind":"s3","name":occupied})).unwrap(); + assert!(s.run("connection.edit", &json!({"id":old,"name":occupied})).is_err()); + assert!(s3::connection(old).is_some(), "a rejected edit must preserve the original login"); + assert!(s3::connection(destination).is_some(), "a rejected edit must not disconnect another session"); + let grouped = s.run("connection.edit", &json!({"id":old,"group":"Work"})).unwrap(); + assert_eq!(grouped["loggedIn"], true, "a group-only edit must preserve the login"); + let renamed = s.run("connection.edit", &json!({"id":old,"name":destination})).unwrap(); + assert_eq!(renamed["id"], saved["id"]); + assert_eq!(renamed["address"], "https://saved.example"); + assert_eq!(renamed["loggedIn"], false); + assert!(s3::connection(old).is_none()); + assert!(s3::connection(destination).is_none()); + assert!(s.run("connection.test", &json!({"id":destination})).unwrap_err().contains("log in first")); + s.run("connection.login", &json!({"id":destination,"accessKeyId":"NEWKEY","secretAccessKey":"new-test-secret"})).unwrap(); + assert_eq!(s3::connection(destination).unwrap().endpoint.as_deref(), Some("https://saved.example")); + s.run("connection.remove", &json!({"id":destination})).unwrap(); +} + fn rustgis_engine_sources_roundtrip(text: &str) -> crate::sources::Sources { crate::sources::Sources::from_json(text) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad3072aa6..8fbf51cdc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -550,6 +550,7 @@ pub struct UiState { /// The Data Sources pane's boxes, and the sources' generation last kept in the preferences. pub sources: sourcespane::State, pub sources_saved: u64, + pub connections_saved: u64, /// The default raster style last kept in the preferences. pub raster_defaults_saved: rustgis_engine::map::RasterDefaults, /// Add Data › Zarr…: its store, choices and fetches. @@ -664,6 +665,7 @@ impl Default for UiState { search: Default::default(), sources: Default::default(), sources_saved: 0, + connections_saved: 0, raster_defaults_saved: Default::default(), zarr: Default::default(), hidden_tabs: Vec::new(), @@ -855,6 +857,7 @@ impl RustGisApp { pub fn with_services(mut self, services: Services) -> Self { self.services = services; + sourcespane::load_connections(&mut self); if let Some(lang) = self.services.load_pref.as_ref().and_then(|load| load("language")) { self.ui.language = lang; } @@ -1442,6 +1445,7 @@ impl RustGisApp { self.note_last_project(); // Sources added, saved or made favourites are kept with the preferences. sourcespane::save(self); + sourcespane::save_connections(self); self.save_raster_defaults(); r } @@ -3146,6 +3150,7 @@ impl RustGisApp { // Sources the AI Assistant or a background add brought in (not through `run`) are kept // too: a generation compared each frame. So is a default raster style it set. sourcespane::save(self); + sourcespane::save_connections(self); self.save_raster_defaults(); cswui::poll(self); stacui::poll(self, ctx); diff --git a/crates/ui-egui/src/s3ui.rs b/crates/ui-egui/src/s3ui.rs index ed838a177..54fa9cc1a 100644 --- a/crates/ui-egui/src/s3ui.rs +++ b/crates/ui-egui/src/s3ui.rs @@ -12,7 +12,7 @@ use crate::{RustGisApp, Slot, i18n}; type Answer = Slot, String>>; /// The preference holding remembered connections (never their secrets). -const SAVED: &str = "s3_connections"; +pub(crate) const SAVED: &str = "s3_connections"; /// A remembered connection: what's needed to connect again, without the secret. #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/ui-egui/src/sourcespane.rs b/crates/ui-egui/src/sourcespane.rs index 286f98bd6..7a3a0d5db 100644 --- a/crates/ui-egui/src/sourcespane.rs +++ b/crates/ui-egui/src/sourcespane.rs @@ -13,6 +13,8 @@ use crate::{RustGisApp, icons}; /// The preference the sources are kept under. pub const PREF: &str = "data_sources"; +/// Named connections, separate from the project's data sources. +pub const CONNECTIONS_PREF: &str = "connections"; /// What the pane's "save" boxes hold. #[derive(Default)] @@ -66,6 +68,35 @@ pub fn save(app: &mut RustGisApp) { } } +/// Loads connections before commands can run, importing legacy saved endpoints only once. +pub fn load_connections(app: &mut RustGisApp) { + use rustgis_engine::{connections::Connections, sources::Sources}; + let Some(load) = &app.services.load_pref else { return }; + app.session.connections = if let Some(text) = load(CONNECTIONS_PREF) { + Connections::from_json(&text) + } else { + let imported = + Connections::import(&Sources::from_json(&load(PREF).unwrap_or_default()), &load(crate::s3ui::SAVED).unwrap_or_default()); + // Even an empty import is saved, so removing everything cannot re-import old entries. + if let Some(save) = &app.services.save_pref { + save(CONNECTIONS_PREF, &imported.to_json()); + } + imported + }; + app.ui.connections_saved = app.session.connections.generation; +} + +/// Keeps command and assistant changes with the app's preferences, never the project. +pub fn save_connections(app: &mut RustGisApp) { + if app.session.connections.generation == app.ui.connections_saved { + return; + } + app.ui.connections_saved = app.session.connections.generation; + if let Some(save) = &app.services.save_pref { + save(CONNECTIONS_PREF, &app.session.connections.to_json()); + } +} + fn icon_of(kind: &str) -> &'static str { match kind { "file" => "add_data", diff --git a/crates/ui-egui/tests/gpu_fastpath.rs b/crates/ui-egui/tests/gpu_fastpath.rs index 7c9c8e507..f3ce47b70 100644 --- a/crates/ui-egui/tests/gpu_fastpath.rs +++ b/crates/ui-egui/tests/gpu_fastpath.rs @@ -30,7 +30,10 @@ fn wait_for_frame(h: &mut egui_kittest::Harness<'_, RustGisApp>) { std::thread::sleep(std::time::Duration::from_millis(2)); h.step(); } - if cache::generation() == tiles { + // Uploading a finished scene changes whether tile generations participate in its key. + // Let an idle frame adopt that live-tile key before the test starts moving the camera. + h.step(); + if !h.state().map.drawing() && cache::in_flight() == 0 && cache::generation() == tiles { break; } } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 18e806a5d..0c7d6e061 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -773,6 +773,47 @@ fn start_up_preferences() { assert_eq!(store.lock().unwrap().get("start_projection").map(String::as_str), Some("")); } +#[test] +fn connections_persist_and_import_once() { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + let old = json!({"saved":[ + {"kind":"service","name":"State WMS","address":"https://w.example/wms","extra":{"type":"wms"}}, + {"kind":"database","name":"Prod","address":"postgres:host=h dbname=g user=u"}, + {"kind":"database","name":"F","address":"/x/a.duckdb"} + ]}) + .to_string(); + let old_s3 = json!([{"name":"Lake","endpoint":"","region":"eu-west-1","source":"profile","profile":"work"}]).to_string(); + let store = + Arc::new(Mutex::new(HashMap::from([("data_sources".to_string(), old.clone()), ("s3_connections".to_string(), old_s3.clone())]))); + let services = || { + let (load, save) = (store.clone(), store.clone()); + rustgis_ui_egui::Services { + load_pref: Some(Box::new(move |k| load.lock().unwrap().get(k).cloned())), + save_pref: Some(Box::new(move |k, v| { + save.lock().unwrap().insert(k.to_string(), v.to_string()); + })), + ..Default::default() + } + }; + let mut app = RustGisApp::with_sample().with_services(services()); + let kinds: Vec<_> = app.session.connections.connections.iter().map(|c| c.kind.as_str()).collect(); + assert_eq!(kinds, ["wms", "postgres", "s3"]); + assert_eq!(app.session.connections.find("Lake").unwrap().options["credentials"], "profile"); + assert!(store.lock().unwrap().contains_key("connections")); + assert_eq!(store.lock().unwrap()["data_sources"], old); + assert_eq!(store.lock().unwrap()["s3_connections"], old_s3); + app.run("connection.remove", json!({"id":"Prod"})).unwrap(); + let mut again = RustGisApp::with_sample().with_services(services()); + assert_eq!(again.session.connections.connections.len(), 2); + assert!(again.session.connections.find("Prod").is_none()); + for id in ["State WMS", "Lake"] { + again.run("connection.remove", json!({"id":id})).unwrap(); + } + let empty = RustGisApp::with_sample().with_services(services()); + assert!(empty.session.connections.connections.is_empty(), "an empty saved list must not import again"); +} + /// Settings › Start-up › Start With a project (#377): the last one opened or saved, or one picked /// once. It opens with its own projection and view, a shell's `blank` still wins, one that no /// longer opens leaves the sample with a notice, and after a crash none is opened. diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index bee7f5738..77400ef5b 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -348,6 +348,55 @@ database is kept without its password. The commands `sources.list`, `sources.sav `sources.favourite` and `sources.remove` read and change the lists, for the command line and the [AI Assistant](assistant.md). On the command line they last for the run. +### Named connections + +The `connection.*` commands keep named, reusable endpoints independently of the project. +Each has a stable id (`c1`, `c2`, …), a case-insensitively unique name and an optional group. +Different names may use the same address. `connection.new`, `connection.edit`, +`connection.duplicate` and `connection.remove` manage them; commands that take `id` accept +either the id or the name. `connection.list` can filter by `kind` or `group`, and reports +availability, session login state and the commands to use to add data. +Names cannot match a stored id or the reserved `c` + digits id pattern (case-insensitive). +Loaded names that conflict get a numeric suffix; ids and endpoints stay unchanged. Clearing +a name with `connection.edit` chooses a unique default from the address. + +```sh +rustgis-cli run \ + --cmd 'connection.new={"kind":"wms","address":"https://ows.terrestris.de/osm/service","name":"OSM WMS","group":"Public"}' \ + --cmd 'connection.test={"id":"OSM WMS"}' \ + --cmd 'connection.list={"group":"public"}' +``` + +Kinds are `postgres`, `mysql` (including MariaDB), `sqlserver`, `service` (detected on +connect), `wms`, `wmts`, `wfs`, `wcs`, `ogcapi`, `arcgis`, `csw`, `stac` and `s3`. +Database connections are used on the desktop only. S3 options are `region`, `buckets` +(comma-separated), `credentials` (`keys`, `profile`, `environment` or `none`) and `profile`. +`connection.test` lists the endpoint's tables, layers, records, collections or buckets; +the web app's network listings still require the existing add dialogs. + +Addresses are saved without passwords or tokens. `connection.login` remembers a database +`password`, or S3 `accessKeyId`, `secretAccessKey` and optional `sessionToken`, for this session +only, using the same credential stores as the existing add commands. With login parameters, +`connection.test` logs in first; a failed listing does not undo that session login. +S3 profiles and environment credentials remain subject to the existing endpoint restrictions; +editing an S3 name, endpoint or options disconnects its session login. S3 shares the name-based +session registry used by the S3 dialog and `s3.connect`, so editing or removing a saved +connection also affects a session entry of the same name created there. +Renaming clears the runtime entries under both the old and new names, so the renamed +connection cannot inherit an unrelated login at its destination name. +Web-service logins are not supported. Plugins may list connections but cannot manage, log in +to or test them. The in-app assistant asks for approval each time for those operations; +external assistants using MCP cannot run them. +Database passwords are shared by public DSN for the session, including existing layers and +other named connections using that DSN. Editing or removing a named connection does not revoke +that shared login; changing its address does not transfer the old password to the new address. + +Desktop and web apps save connections with their preferences (`connections` in desktop +`prefs.json`, `rustgis.connections` in web localStorage). The first load imports saved services, +databases other than DuckDB files, STAC APIs and remembered S3 connections once, without +changing the old lists. The Data Sources pane and add dialogs are unchanged. CLI and Python +connections last only for the run. + ## Large files GeoPackage, GeoParquet, FlatGeobuf and (on the desktop) Shapefile files with more than 100,000 From c81cf9d3b9e2800f446c6082ab6225979dd65d37 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Thu, 22 Oct 2026 12:33:40 -0300 Subject: [PATCH 036/297] Layouts: a publication layout in one command, a sheet or a figure sized to its page (#682, part 1) (#698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Layouts: a publication layout in one command, a sheet or a figure sized to its page (#682, part 1) layout.publication builds a finished A4/A3/A2 sheet (frame, title, subtitle, the map at a round scale with a graticule, a footer of two boxed columns with a note and authorship, every text wrapped with the layout's font) or a journal or thesis figure (the map edge to edge, its texts left to the caption). The legend is sized to what it lists through legend_rows/legend_size_mm, shared with the drawing. Home > New Layout > Publication Layout lists the profiles. Co-Authored-By: Claude Opus 5.5 * layout.publication: the title and authorship wrap, a figure's reference can't crowd out the map, the legend is sized after syncing the frame's view, the result lists the elements; the menu says why it failed Co-Authored-By: Claude Opus 5.5 * layout.publication: footer elements keep their names whatever the headings read, headings filled and wrapped; the legend stops short of the scale bar; profiles as a struct; the menu titles only sheets Co-Authored-By: Claude Opus 5.5 * layout.publication: text measured as drawn (bold titles, live tokens at a typical width); a footer column with nothing in it left out; a legend row's rounding slack Co-Authored-By: Claude Opus 5.5 * Wrapping breaks a word wider than the line by halving, a few measures a line rather than one a character Co-Authored-By: Claude Opus 5.5 * layout.publication: a heading's live tokens keep their case in capitals, so the layout still fills them Co-Authored-By: Claude Opus 5.5 * layout.publication: only a closed {name} keeps its case in a heading's capitals; a lone brace is text Co-Authored-By: Claude Opus 5.5 * layout.publication: a figure whose shape had to be guessed says so Co-Authored-By: Claude Opus 5.5 * layout.publication: the datum from the projection's definition (a preset on NAD83 says so); the legend sized through the complete-source rows the export draws Co-Authored-By: Claude Opus 5.5 * layout.publication: a globe's default extent is the whole Earth; an empty author leaves the line out Co-Authored-By: Claude Opus 5.5 * layout.publication: a profile that isn't text is refused, not read as the default Co-Authored-By: Claude Opus 5.5 * layout.publication: the extent fits within the graticule's label strips; an empty author list leaves the line out too; the map sync noted to come after the last refusal Co-Authored-By: Claude Opus 5.5 * Tests: layout.publication is undone as one command, the map as it was Co-Authored-By: Claude Opus 5.5 * Tests: a publication layout over a streamed layer is undone, the layer streaming as before; the legend's measured width against its ink. Docs: every publication text is measured and wrapped Co-Authored-By: Claude Opus 5.5 * layout.publication: the menu shows the layout's warnings; a frame too small for its label strips says so Co-Authored-By: Claude Opus 5.5 * layout.publication: a figure says it left out an author and headings given to it Co-Authored-By: Claude Opus 5.5 * Publication Layout menu: each profile's name a tl! literal, so the catalogs find it Co-Authored-By: Claude Opus 5.5 * Publication Layout menu: the profiles' literal names in one function, tested against the engine's labels Co-Authored-By: Claude Opus 5.5 * layout.publication: a figure given an empty author doesn't warn it was left out Co-Authored-By: Claude Opus 5.5 * Tests: read a publication heading's words across its wrapped lines Where the reference heading wraps depends on the font the machine finds: on CI it breaks between WEB and MERCATOR, so the test failed there although the heading was right. * layout.publication: a figure's reference a millimetre in from the paper's edges; the menu's warnings in one notice Co-Authored-By: Claude Opus 5.5 * layout.publication: text without tokens measured as it is; a graticule step under a minute's multiple in minutes and seconds (1′12″) Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 Co-authored-by: giswqs --- crates/assistant/skills/print-layout/SKILL.md | 7 +- crates/engine/src/cmd/layout.rs | 490 ++++++++++++++++++ crates/engine/src/tests.rs | 227 ++++++++ crates/render/src/layout.rs | 158 ++++-- crates/render/src/report.rs | 55 +- crates/ui-egui/src/ribbon.rs | 54 ++ docs/user-guide/layouts.md | 33 ++ 7 files changed, 977 insertions(+), 47 deletions(-) diff --git a/crates/assistant/skills/print-layout/SKILL.md b/crates/assistant/skills/print-layout/SKILL.md index c39d2059b..37af7e819 100644 --- a/crates/assistant/skills/print-layout/SKILL.md +++ b/crates/assistant/skills/print-layout/SKILL.md @@ -6,7 +6,12 @@ description: Make a printable page layout — map frame, title, legend, north ar # Print layout 1. Get the map right first: symbology, labels and extent. A layout's map frame shows the map's current view. -2. `layout.new` `{"name": N, "page": "letter"|"a4"|…, "orientation": "landscape"|"portrait"}` creates the layout and +2. For a finished sheet or a figure for a paper in one step, use `layout.publication` with a `"profile"` + (`"a4-portrait"`, `"a3-landscape"`, … for a sheet; `"figure-1col"`, `"thesis-full"`, … for a figure), the `"extent"` + to fit, and the texts: `"title"`, `"subtitle"`, `"sources"`, `"note"`. It places the frame at a round scale, the + graticule, legend, scale bar and north arrow, and wraps the footer. Its result lists every element's id and name + (`"elements"`), so skip to step 5 to adjust them. Otherwise, + `layout.new` `{"name": N, "page": "letter"|"a4"|…, "orientation": "landscape"|"portrait"}` creates the layout and opens it. 3. `layout.addFrame` for the map frame (`search_commands` "map frame" for its position and size parameters, in millimetres from the page's top-left). diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 6139417e5..3bb8298b4 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -28,6 +28,9 @@ pub fn commands() -> Vec { C::new("layout.new", "New Layout", "Home › Project", new_layout).params( r#"{"name"?: string, "page"?: "letter"|"legal"|"tabloid"|"a5"|"a4"|"a3"|"a2"|"a1"|"a0", "orientation"?: "landscape"|"portrait", "width"?: mm, "height"?: mm, "template"?: "map" (frame, title, legend, north arrow, scale bar; the default)|"blank"}"#, ), + C::new("layout.publication", "New Publication Layout", "Publish › Layout", publication).params( + r#"{"profile"?: "a4-portrait" (the default)|"a4-landscape"|"a3-portrait"|"a3-landscape"|"a2-portrait"|"a2-landscape" (a sheet: frame, title, subtitle, the map, a boxed footer)|"figure-1col" (90 mm)|"figure-1.5col" (140 mm)|"figure-2col" (190 mm)|"thesis-full" (160 × 225 mm)|"thesis-half" (160 × 110 mm) (a figure: the map edge to edge, its title and sources left to the caption), "extent"?: [west, south, east, north] in degrees that must fit (default: the map view), "name"?: string, "title"?: text, "subtitle"?: text, "sources"?: text | [texts], "reference"?: text | [texts] (default: the projection, datum and graticule), "note"?: text, "author"?: text (default: "Made with RustGIS · {date}"; "" or [] for none), "headings"?: [the sources' heading, the reference's heading]} → a new layout of the active map at a round scale, its texts wrapped to their boxes; {projection}, {datum} and {grid} in the texts are filled from the map"#, + ), C::new("layout.activate", "Open Layout", "Browser", |s, p| { let i = index(s, &by_name(p))?; s.active_layout = Some(i); @@ -295,6 +298,493 @@ pub(crate) fn new_layout(s: &mut Session, p: &Value) -> CmdResult { Ok(json!({"layout": i, "name": name})) } +/// One of `layout.publication`'s profiles. +pub struct PublicationProfile { + pub id: &'static str, + pub label: &'static str, + /// A sheet (a frame, title and boxed footer), else a figure (the map edge to edge, its title + /// and sources left to the caption). + pub sheet: bool, + /// The page, in mm. A height of 0 follows the map's shape. + pub width: f64, + pub height: f64, +} + +/// `layout.publication`'s profiles: sheets for printing, figures for a journal or a thesis. +pub const PUBLICATION_PROFILES: &[PublicationProfile] = &[ + PublicationProfile { id: "a4-portrait", label: "A4 portrait sheet", sheet: true, width: 210.0, height: 297.0 }, + PublicationProfile { id: "a4-landscape", label: "A4 landscape sheet", sheet: true, width: 297.0, height: 210.0 }, + PublicationProfile { id: "a3-portrait", label: "A3 portrait sheet", sheet: true, width: 297.0, height: 420.0 }, + PublicationProfile { id: "a3-landscape", label: "A3 landscape sheet", sheet: true, width: 420.0, height: 297.0 }, + PublicationProfile { id: "a2-portrait", label: "A2 portrait sheet", sheet: true, width: 420.0, height: 594.0 }, + PublicationProfile { id: "a2-landscape", label: "A2 landscape sheet", sheet: true, width: 594.0, height: 420.0 }, + PublicationProfile { id: "figure-1col", label: "Journal figure, 1 column (90 mm)", sheet: false, width: 90.0, height: 0.0 }, + PublicationProfile { id: "figure-1.5col", label: "Journal figure, 1.5 columns (140 mm)", sheet: false, width: 140.0, height: 0.0 }, + PublicationProfile { id: "figure-2col", label: "Journal figure, 2 columns (190 mm)", sheet: false, width: 190.0, height: 0.0 }, + PublicationProfile { id: "thesis-full", label: "Thesis figure, full page (160 × 225 mm)", sheet: false, width: 160.0, height: 225.0 }, + PublicationProfile { id: "thesis-half", label: "Thesis figure, half page (160 × 110 mm)", sheet: false, width: 160.0, height: 110.0 }, +]; + +/// A figure's text size, in points at its printed size: journals ask for 6 to 8. +const FIGURE_PT: f64 = 7.0; + +/// The graticule steps a publication layout picks from, in degrees. +const GRID_STEPS: &[f64] = &[0.01, 0.02, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0, 30.0]; + +/// The strips a frame's graticule labels take inside its edges, in mm: beside the left and right +/// edges (a latitude such as `10°S` across), and along the top and bottom ones. +const LABEL_ROOM_SIDE: f64 = 9.0; +const LABEL_ROOM_END: f64 = 6.0; + +/// The longest text a publication layout takes in one field, in characters. +const MAX_PUBLICATION_TEXT: usize = 2000; + +/// A scale denominator rounded up to two significant figures (1:1,234,567 to 1:1,300,000), so the +/// extent fitted at it still fits. +pub(crate) fn round_scale(scale: f64) -> f64 { + if !(scale.is_finite() && scale > 0.0) { + return scale; + } + let unit = 10f64.powf(scale.log10().floor() - 1.0); + ((scale / unit) - 1e-9).ceil() * unit +} + +/// The graticule step for an extent: the smallest that draws at most five lines across its longer +/// side. +pub(crate) fn grid_step(e: &Extent) -> f64 { + let span = e.width().max(e.height()); + GRID_STEPS.iter().copied().find(|step| span / step <= 5.0).unwrap_or(30.0) +} + +/// A graticule step as printed: degrees, else minutes, else seconds of arc. +pub(crate) fn step_text(step: f64) -> String { + let minutes = step * 60.0; + if step >= 1.0 { + format!("{step}°") + } else if (minutes - minutes.round()).abs() < 1e-9 { + format!("{}′", minutes.round()) + } else { + // Minutes and seconds (1′12″), or seconds alone under a minute. + let seconds = (step * 3600.0).round(); + let (m, sec) = ((seconds / 60.0).floor(), seconds % 60.0); + if m > 0.0 { format!("{m}′{sec}″") } else { format!("{sec}″") } + } +} + +/// The map's projection and datum for a layout's texts: an EPSG name's two halves (`SIRGAS 2000 / +/// UTM zone 23S`), else the projection's name and the datum its definition names (a preset's +/// `+datum=NAD83`), WGS 84 when it names none. +fn projection_and_datum(s: &Session) -> (String, String) { + let projection = &s.map().projection; + let label = projection.label(); + if let Some((datum, name)) = label.split_once(" / ") { + return (name.trim().to_string(), datum.trim().to_string()); + } + let datum = rustgis_map::projection::lookup(&projection.code()).ok().and_then(|(def, _)| datum_of(&def)); + (label, datum.unwrap_or_else(|| "WGS 84".to_string())) +} + +/// The datum a PROJ definition names: its `+datum`, else its `+ellps`, else a sphere for `+R`. +pub(crate) fn datum_of(def: &str) -> Option { + let param = |k: &str| def.split_whitespace().find_map(|t| t.strip_prefix(k).map(str::to_string)); + if let Some(d) = param("+datum=") { + return Some(if d == "WGS84" { "WGS 84".into() } else { d }); + } + if let Some(e) = param("+ellps=") { + return Some(match e.as_str() { + "WGS84" => "WGS 84".into(), + "GRS80" => "GRS 1980 ellipsoid".into(), + _ => format!("{e} ellipsoid"), + }); + } + param("+R=").map(|_| "a sphere".into()) +} + +/// Texts given as one or a list (up to 50), each cut to [`MAX_PUBLICATION_TEXT`] characters. +fn texts_of(p: &Value, k: &str) -> Result>, String> { + let cut = |t: &str| t.chars().take(MAX_PUBLICATION_TEXT).collect::(); + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(t)) => Ok(Some(vec![cut(t)])), + Some(Value::Array(a)) if a.len() <= 50 => a + .iter() + .map(|v| v.as_str().map(cut).ok_or_else(|| format!("{k} is a text or a list of texts"))) + .collect::>() + .map(Some), + Some(_) => Err(format!("{k} is a text or a list of up to 50 texts")), + } +} + +/// The extent's height over its width on the active map's projection, along its edges. +fn aspect(s: &Session, e: &Extent) -> Option { + let flat = s.map().flat(); + let mut ext = Extent::EMPTY; + for i in 0..=32 { + let t = f64::from(i) / 32.0; + let (x, y) = (e.min_x + e.width() * t, e.min_y + e.height() * t); + for c in [Coord::new(x, e.min_y), Coord::new(x, e.max_y), Coord::new(e.min_x, y), Coord::new(e.max_x, y)] { + if let Some(q) = flat.forward_checked(c) { + ext.expand(q); + } + } + } + (ext.width() > 0.0 && ext.height() > 0.0).then(|| ext.height() / ext.width()) +} + +/// `t` in capitals, its `{tokens}` as they are: the layout fills a token (`{date}`) when it draws +/// only in its own case. A token is a closed `{name}` of letters and digits; a lone brace is text. +pub(crate) fn capitals(t: &str) -> String { + let mut out = String::with_capacity(t.len()); + let mut rest = t; + while let Some(c) = rest.chars().next() { + let token = + (c == '{').then(|| rest.find('}')).flatten().filter(|&end| end > 1 && rest[1..end].chars().all(|c| c.is_ascii_alphanumeric())); + match token { + Some(end) => { + out.push_str(&rest[..=end]); + rest = &rest[end + 1..]; + } + None => { + out.extend(c.to_uppercase()); + rest = &rest[c.len_utf8()..]; + } + } + } + out +} + +/// A text element in a publication layout's style: upright, sans, in `align`. +fn pub_text(text: String, size: f64, color: Rgba, bold: bool, align: TextAlign) -> ElementKind { + ElementKind::Text { text, size, color, bold, italic: false, align, letter_spacing: 0.0, font: FontFamily::Sans } +} + +/// A rectangle with no outline (`width` 0) or no fill (`fill` transparent). +fn pub_box(fill: Rgba, outline: Rgba, width: f64) -> ElementKind { + ElementKind::Rectangle { fill, outline, width } +} + +/// `layout.publication`: a finished sheet or figure of the active map, its extent fitted at a +/// round scale, with a graticule, legend, scale bar and north arrow; a sheet adds a frame, title, +/// subtitle and a footer of two boxed columns (sources, cartographic reference) with a note and +/// authorship under them, each text wrapped to its box with the layout's own font. +fn publication(s: &mut Session, p: &Value) -> CmdResult { + let id = match p.get("profile").filter(|v| !v.is_null()) { + None => "a4-portrait".to_string(), + Some(v) => v.as_str().map(|t| t.trim().to_ascii_lowercase()).ok_or("profile is a profile's name, such as \"a4-portrait\"")?, + }; + let profile = PUBLICATION_PROFILES.iter().find(|pr| pr.id == id).ok_or_else(|| { + let names: Vec<&str> = PUBLICATION_PROFILES.iter().map(|pr| pr.id).collect(); + format!("unknown profile '{id}': {}", names.join(", ")) + })?; + let (sheet, page_w, page_h) = (profile.sheet, profile.width, profile.height); + let extent = match p.get("extent").filter(|v| !v.is_null()) { + // The view; on a globe the whole Earth, as geoprocessing takes it (the globe camera's flat + // extent means nothing there). + None if s.map().projection.is_globe() => Extent::new(-180.0, -90.0, 180.0, 90.0), + None => { + let e = super::workspace::view_extent(s); + Extent::new(e.min_x.max(-180.0), e.min_y.max(-90.0), e.max_x.min(180.0), e.max_y.min(90.0)) + } + Some(v) => { + let b: Option> = + v.as_array().filter(|a| a.len() == 4).and_then(|a| a.iter().map(|x| x.as_f64().filter(|x| x.is_finite())).collect()); + match b.as_deref() { + Some([w, so, e, n]) if w < e && so < n && *w >= -180.0 && *e <= 180.0 && *so >= -90.0 && *n <= 90.0 => { + Extent::new(*w, *so, *e, *n) + } + _ => return Err("extent is [west, south, east, north] in degrees".into()), + } + } + }; + if !(extent.width() > 0.0 && extent.height() > 0.0) { + return Err("the map's extent isn't known; give extent".into()); + } + let one = |k: &str| texts_of(p, k).map(|t| t.map(|t| t.join(" ")).filter(|t| !t.trim().is_empty())); + let (title, subtitle, note) = (one("title")?, one("subtitle")?, one("note")?); + let sources = texts_of(p, "sources")?.unwrap_or_default(); + let reference = + texts_of(p, "reference")?.unwrap_or_else(|| vec!["Projection: {projection}".into(), "Datum: {datum} · Graticule: {grid}".into()]); + // An empty author leaves the line out; none given, the default. + let author = match texts_of(p, "author")? { + None => Some("Made with RustGIS · {date}".to_string()), + Some(t) => Some(t.join(" ")).filter(|t| !t.trim().is_empty()), + }; + let headings = texts_of(p, "headings")?.unwrap_or_default(); + let heading = |i: usize, or: &str| headings.get(i).cloned().unwrap_or_else(|| or.to_string()); + let (projection, datum) = projection_and_datum(s); + let step = grid_step(&extent); + let fill = |t: &str| t.replace("{projection}", &projection).replace("{datum}", &datum).replace("{grid}", &step_text(step)); + // Text is measured as it is drawn: bold as bold, and the tokens the layout fills only when it + // draws (`{date}`, `{scale}`…) as wide as a typical value of theirs. + let (map_name, crs) = (s.map().name.clone(), s.map().projection.label()); + let drawn = move |t: &str| { + // Most text has no token: measured as it is. + if !t.contains('{') { + return t.to_string(); + } + [ + ("{date}", "0000-00-00"), + ("{time}", "00:00"), + ("{scale}", "1:10,000,000"), + ("{pages}", "00"), + ("{page}", "00"), + ("{layers}", "00"), + ("{center}", "00.000°S, 000.000°W"), + ("{map}", map_name.as_str()), + ("{crs}", crs.as_str()), + ] + .iter() + .fold(t.to_string(), |t, (token, like)| t.replace(token, like)) + }; + let wrap_as = |t: &str, w: f64, pt: f64, bold: bool| { + crate::render::report::wrap_with(&fill(t), w.max(1.0), |x| crate::render::report::width_mm(&drawn(x), pt as f32, bold)) + }; + let wrap = |t: &str, w: f64, pt: f64| wrap_as(t, w, pt, false); + let pitch = crate::render::layout::line_pitch_mm; + let ink = Rgba::rgb(0x2B, 0x26, 0x20); + let soft = Rgba::rgb(0x5E, 0x55, 0x47); + let clear = Rgba::new(0, 0, 0, 0); + let mut warnings: Vec = Vec::new(); + // Texts and boxes around the map, placed while its frame is worked out. + let mut around: Vec<(String, (f64, f64, f64, f64), ElementKind)> = Vec::new(); + // A sheet's sizes follow its short side (A4's 210 mm is 1); a figure keeps its texts at their + // printed size, whatever its width. + let k = if sheet { page_w.min(page_h) / 210.0 } else { 1.0 }; + let (w, h, (fx, fy, fw, fh)) = if sheet { + let (m, pad) = (10.0 * k, 5.0 * k); + let (x0, iw) = (m + pad, page_w - 2.0 * (m + pad)); + let (title_pt, sub_pt, head_pt, body_pt, small_pt) = (18.0 * k, 10.5 * k, 7.5 * k, 7.0 * k, 6.5 * k); + around.push(("Frame".into(), (m, m, page_w - 2.0 * m, page_h - 2.0 * m), pub_box(clear, ink, 0.8))); + let mut top = m + pad; + if let Some(t) = &title { + let lines = wrap_as(t, iw, title_pt, true); + let th = lines.len() as f64 * pitch(title_pt) + 1.5 * k; + around.push(("Title".into(), (x0, top, iw, th), pub_text(lines.join("\n"), title_pt, ink, true, TextAlign::Center))); + top += th; + } + if let Some(t) = &subtitle { + let lines = wrap(t, iw, sub_pt); + let sh = lines.len() as f64 * pitch(sub_pt) + 1.0 * k; + around.push(("Subtitle".into(), (x0, top, iw, sh), pub_text(lines.join("\n"), sub_pt, soft, false, TextAlign::Center))); + top += sh; + } + // The footer, from the bottom up: authorship, the note, then the two boxes side by side, + // as tall as the longer column. + let bottom = page_h - m - pad; + let author_lines = author.as_deref().map(|t| wrap(t, iw, small_pt)).unwrap_or_default(); + let author_h = author_lines.len() as f64 * pitch(small_pt); + let author_y = bottom - author_h; + if !author_lines.is_empty() { + around.push(( + "Author".into(), + (x0, author_y, iw, author_h), + pub_text(author_lines.join("\n"), small_pt, soft, false, TextAlign::Right), + )); + } + let note_lines = note.as_deref().map(|t| wrap(t, iw, body_pt)).unwrap_or_default(); + let mut foot = author_y; + if !note_lines.is_empty() { + let nh = note_lines.len() as f64 * pitch(body_pt); + foot -= nh + 1.5 * k; + around.push(("Note".into(), (x0, foot, iw, nh), pub_text(note_lines.join("\n"), body_pt, soft, false, TextAlign::Left))); + } + // The columns with something in them, side by side (one alone takes the width). Each one's + // elements keep their names (`Sources`, `Reference`) whatever its heading reads, the heading + // filled and wrapped like the rest. + let (gap, inner) = (4.0 * k, 2.5 * k); + let given = [("Sources", heading(0, "Sources"), &sources), ("Reference", heading(1, "Cartographic reference"), &reference)]; + let given: Vec<_> = given.into_iter().filter(|(.., texts)| !texts.is_empty()).collect(); + let n = given.len().max(1) as f64; + let cw = (iw - gap * (n - 1.0)) / n; + let tw = cw - 2.0 * inner; + let columns: Vec<_> = given + .into_iter() + .map(|(name, head, texts)| { + // Filled first, so `{projection}` reads in capitals too; the live tokens left keep + // their case (wrap_as fills again, which then finds nothing). + let head = wrap_as(&capitals(&fill(&head)), tw, head_pt, true); + (name, head, texts.iter().flat_map(|t| wrap(t, tw, body_pt)).collect::>()) + }) + .collect(); + let heads = columns.iter().map(|(_, h, _)| h.len()).max().unwrap_or(1); + let rows = columns.iter().map(|(.., l)| l.len()).max().unwrap_or(0); + if rows > 0 { + let head_h = heads as f64 * pitch(head_pt); + let bh = 2.0 * inner + head_h + rows as f64 * pitch(body_pt); + foot -= bh + 2.0 * k; + for (i, (name, head, lines)) in columns.into_iter().enumerate() { + let bx = x0 + i as f64 * (cw + gap); + around.push((format!("{name} Box"), (bx, foot, cw, bh), pub_box(clear, Rgba::new(0x2B, 0x26, 0x20, 140), 0.5))); + let tx = bx + inner; + around.push(( + format!("{name} Heading"), + (tx, foot + inner, tw, head.len() as f64 * pitch(head_pt)), + pub_text(head.join("\n"), head_pt, ink, true, TextAlign::Left), + )); + if !lines.is_empty() { + let body = (tx, foot + inner + head_h, tw, lines.len() as f64 * pitch(body_pt)); + around.push((name.to_string(), body, pub_text(lines.join("\n"), body_pt, soft, false, TextAlign::Left))); + } + } + } + let (frame_top, frame_bottom) = (top + 3.0 * k, foot - 4.0 * k); + if frame_bottom - frame_top < 40.0 * k { + return Err("the texts leave the map no room: shorten the footer, the note or the subtitle".into()); + } + (page_w, page_h, (x0, frame_top, iw, frame_bottom - frame_top)) + } else { + // A figure: the map edge to edge, a line of the projection and datum under it. + // Every text a figure leaves to its caption, given anyway, is said to be left out. + let asked = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + for (field, given) in [ + ("title", title.is_some()), + ("subtitle", subtitle.is_some()), + ("sources", !sources.is_empty()), + ("note", note.is_some()), + // An empty author asks for none: nothing left out. + ("author", asked("author") && author.is_some()), + ("headings", !headings.is_empty()), + ] { + if given { + warnings.push(format!("{field} goes in the figure's caption, not on it: left out")); + } + } + let line = reference.iter().map(|t| fill(t)).collect::>().join(" · "); + // A millimetre in from the paper's edges, which a journal may trim. + let lines = wrap(&line, page_w - 2.0, FIGURE_PT); + if lines.len() > 1 { + warnings.push("the reference is wider than the figure: it wraps under the map".into()); + } + let note_h = lines.len() as f64 * pitch(FIGURE_PT) + 1.0; + let fh = if page_h > 0.0 { + page_h - note_h + } else { + let shape = aspect(s, &extent).unwrap_or_else(|| { + warnings.push("the extent's shape on the map's projection isn't known: the figure is 4 wide by 3 tall".into()); + 0.75 + }); + (page_w * shape).clamp(0.45 * page_w, 1.3 * page_w).round() + }; + if fh < 40.0 { + return Err("the reference leaves the map no room: shorten it".into()); + } + let h = if page_h > 0.0 { page_h } else { (fh + note_h).ceil() }; + around.push(( + "Reference".into(), + (1.0, fh + 1.0, page_w - 2.0, note_h - 1.0), + pub_text(lines.join("\n"), FIGURE_PT, ink, false, TextAlign::Left), + )); + (page_w, h, (0.0, 0.0, page_w, fh)) + }; + // The extent fits within the graticule's label strips (drawn inside the frame along its + // labelled edges), on both sides so it stays centred, and its labels cover none of it. + let edges = GridEdges { top: sheet, bottom: true, left: true, right: sheet }; + let strip_w = if edges.left || edges.right { 2.0 * LABEL_ROOM_SIDE } else { 0.0 }; + let strip_h = if edges.top || edges.bottom { 2.0 * LABEL_ROOM_END } else { 0.0 }; + if fw - strip_w < fw / 2.0 || fh - strip_h < fh / 2.0 { + warnings.push("the frame is too small to keep its graticule's labels off the map's edges: they may cover them".into()); + } + let (center, scale) = fit_scale(s.map(), &extent, (fw - strip_w).max(fw / 2.0), (fh - strip_h).max(fh / 2.0)) + .ok_or("the extent can't be drawn on the map's projection")?; + let scale = round_scale(scale); + let grid = Grid { step, edges, ..Grid::default() }; + let name = unique_name(s, ps(p, "name").unwrap_or("Publication")); + let mut l = Layout::new(name.clone(), w, h); + let frame = l.add( + "Map Frame", + (fx, fy, fw, fh), + ElementKind::MapFrame { + map: s.map().name.clone(), + center, + scale, + shape: FrameShape::Rectangle, + grid: Some(grid), + extent_of: None, + extent_color: rustgis_map::layout::INDICATOR_COLOR, + extent_width: rustgis_map::layout::INDICATOR_WIDTH, + bearing: 0.0, + pitch: 0.0, + }, + ); + // Over the map, in its corners, clear of the graticule's labels (drawn inside the frame along + // its labelled edges): the north arrow at the top right, the legend at the bottom left sized + // to what it lists, the scale bar and the numeric scale under it at the bottom right, each on + // a pale box. + let paper = Rgba::new(0xFF, 0xFF, 0xFF, 225); + let (e, small_pt) = if sheet { (4.0 * k, 6.5 * k) } else { (2.0, 6.0) }; + let room = |labelled: bool, side: bool| { + e + if !labelled { + 0.0 + } else if side { + LABEL_ROOM_SIDE + } else { + LABEL_ROOM_END + } + }; + let (left, right, top, bottom) = (room(edges.left, true), room(edges.right, true), room(edges.top, false), room(edges.bottom, false)); + let (aw, ah) = if sheet { (8.0 * k, 12.0 * k) } else { (5.0, 7.5) }; + l.add("North Arrow", (fx + fw - right - aw, fy + top, aw, ah), ElementKind::north_arrow(frame)); + // The legend lists what the frame draws: its map synced to the frame's view first, as an + // export does, so a streamed layer has its features there. (This changes the session's map, + // so it comes after the last refusal: nothing below fails.) While one is still reading, the + // classes can't be known: the legend gets a fixed size, to fit by hand. Nothing drawn in the + // frame, no legend. + let render_scale = crate::render::layout::frame_render_scale(s.map(), center, scale); + let view = crate::render::View { + width: fw * rustgis_map::layout::PT_PER_MM, + height: fh * rustgis_map::layout::PT_PER_MM, + pixels_per_point: 1.0, + camera: rustgis_map::layout::frame_camera(s.map(), center, scale, 0.0, 0.0), + sky: false, + }; + let lonlat = crate::render::view_lonlat(s.map(), &view); + let reading = s.map_mut().sync_sources(&lonlat, render_scale, true); + let (mut cw, mut ch) = match crate::render::layout::legend_size_mm(s.map_mut(), render_scale, false, false, &[]) { + Some(size) => size, + None => { + // A complete source that can't be read: the export says why; the legend is sized by hand. + warnings.push("a layer's source couldn't be read through, so the legend has a fixed size; exporting says why".into()); + (fw * 0.3, fh * 0.25) + } + }; + let listed = s.map().layers.iter().any(|l| s.map().shows(l) && !l.graphics && !l.is_raster()); + if reading && listed { + (cw, ch) = (cw.max(fw * 0.3), ch.max(fh * 0.25)); + warnings.push("the legend's classes aren't known yet (a layer still reading): it has a fixed size to fit by hand".into()); + } + // The scale bar's box, at the bottom right: the legend stops short of it (and so of the north + // arrow above it, which is narrower), however narrow the frame. + let (sw, sh, nh) = ((fw * 0.3).max(25.0), if sheet { 8.0 * k } else { 6.0 }, pitch(small_pt) + 0.5); + if ch > 0.0 { + let pad = 2.0; + let room_w = (fw - left - right - sw - 2.0).max(10.0); + let (lw, lh) = ((cw + 2.0 * pad).min(fw * 0.45).min(room_w), (ch + 2.0 * pad).min(fh * 0.6)); + if cw + 2.0 * pad > lw || ch + 2.0 * pad > lh { + warnings.push("the legend lists more than fits its corner: its last rows or the ends of long labels are left out".into()); + } + let (lx, ly) = (fx + left, fy + fh - bottom - lh); + l.add("Legend Box", (lx, ly, lw, lh), pub_box(paper, clear, 0.0)); + l.add( + "Legend", + (lx + pad, ly + pad, lw - 2.0 * pad, lh - 2.0 * pad), + ElementKind::Legend { frame, title: String::new(), all_classes: false, hide_other: false, exclude: Vec::new() }, + ); + } + let (sx, sy) = (fx + fw - right - sw, fy + fh - bottom - sh - nh); + l.add("Scale Box", (sx, sy, sw, sh + nh), pub_box(paper, clear, 0.0)); + l.add("Scale Bar", (sx, sy, sw, sh), ElementKind::scale_bar(frame)); + l.add("Scale", (sx, sy + sh, sw, nh), pub_text("{scale}".into(), small_pt, ink, false, TextAlign::Center)); + for (n, r, kind) in around { + l.add(&n, r, kind); + } + // Every element by name and id, for `layout.update`. + let elements: Vec = l.elements.iter().map(|e| json!({"element": e.id, "name": e.name, "kind": e.kind.code()})).collect(); + s.project.layouts.push(l); + let i = s.project.layouts.len() - 1; + s.active_layout = Some(i); + Ok(json!({"layout": i, "name": name, "page": [w, h], "scale": scale, "grid": step, "elements": elements, "warnings": warnings})) +} + fn page(s: &mut Session, p: &Value) -> CmdResult { let i = index(s, p)?; let cur = s.project.layouts.get(i).map(|l| (l.width, l.height)); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 331e55c83..256c62d3d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2265,6 +2265,213 @@ fn layout_colorbars_follow_a_raster() { assert_eq!(s.project.layouts[0].element(id).map(|e| e.visible), Some(true), "undo brings it back"); } +/// `layout.publication` (#682): every profile builds a layout that fits its page, the map frame +/// at a round scale that still fits the extent, the footer boxed in two columns below the map +/// with long lines wrapped, the tokens filled from the map; a figure leaves its title to the +/// caption. +#[test] +fn publication_layouts_fit_their_pages() { + use rustgis_map::layout::ElementKind; + let dir = std::env::temp_dir().join(format!("rustgis-publication-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = world(); + let piaui = json!([-46.8, -11.6, -38.5, -2.2]); + let long = + "NDVI: MOD13Q1 v6.1 (NASA LP DAAC), 16-day composites at 250 m, read through the Brazil Data Cube (INPE) and averaged to 1 km"; + let profiles = [ + "a4-portrait", + "a4-landscape", + "a3-portrait", + "a3-landscape", + "a2-portrait", + "a2-landscape", + "figure-1col", + "figure-1.5col", + "figure-2col", + "thesis-full", + "thesis-half", + ]; + for profile in profiles { + let r = s + .run( + "layout.publication", + &json!({"profile": profile, "extent": piaui, "name": profile, "title": "PIAUÍ", "subtitle": "Greening and browning of vegetation: NDVI trend, 2001–2025", + "sources": [long, "Boundaries: IBGE 2022"], "note": "Only cells with a significant trend (p < 0.05) are coloured."}), + ) + .unwrap_or_else(|e| panic!("{profile}: {e}")); + let l = s.project.layouts.last().unwrap(); + assert_eq!(r["name"], profile); + let sheet = profile.starts_with('a'); + for e in &l.elements { + assert!( + e.x >= -1e-9 && e.y >= -1e-9 && e.x + e.w <= l.width + 1e-6 && e.y + e.h <= l.height + 1e-6, + "{profile}: {} at ({}, {}, {}, {}) is off the {} × {} page", + e.name, + e.x, + e.y, + e.w, + e.h, + l.width, + l.height + ); + } + let frame = l.elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap(); + let ElementKind::MapFrame { scale, grid: Some(grid), .. } = &frame.kind else { panic!("{profile}: no grid") }; + // The legend stops short of the scale bar's box, however narrow the frame. + let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap(); + assert!(named("Legend Box").x + named("Legend Box").w <= named("Scale Box").x, "{profile}: the legend reaches the scale bar"); + // A round scale, two significant figures, at which the extent still fits. + let unit = 10f64.powf(scale.log10().floor() - 1.0); + assert!(((scale / unit) - (scale / unit).round()).abs() < 1e-6, "{profile}: 1:{scale}"); + let extent = rustgis_geom::Extent::new(-46.8, -11.6, -38.5, -2.2); + // Fitted within the graticule's label strips (9 mm beside the frame's sides, 6 mm along its + // top and bottom), so the labels cover none of the extent. + let (_, fits) = rustgis_map::layout::fit_scale(s.map(), &extent, frame.w - 18.0, frame.h - 12.0).unwrap(); + assert!(fits <= *scale && *scale < fits * 1.1, "{profile}: 1:{scale} for 1:{fits}"); + assert_eq!(grid.step, 2.0, "{profile}: Piauí's 9.4° take a 2° graticule"); + let text = |name: &str| { + l.elements.iter().find(|e| e.name == name).map(|e| match &e.kind { + ElementKind::Text { text, .. } => text.clone(), + _ => panic!("{name} isn't text"), + }) + }; + if sheet { + assert_eq!(text("Title").as_deref(), Some("PIAUÍ"), "{profile}"); + // The footer under the map, the long source wrapped within its box. + let foot = l.elements.iter().find(|e| e.name == "Sources Box").unwrap(); + assert!(foot.y >= frame.y + frame.h, "{profile}: the footer is under the map"); + let sources = l.elements.iter().find(|e| e.name == "Sources").unwrap(); + assert!(sources.x + sources.w <= foot.x + foot.w + 1e-6); + let lines = text("Sources").unwrap(); + assert!(lines.lines().count() >= 3, "{profile}: {lines}"); + for line in lines.lines() { + assert!( + crate::render::report::width_mm(line, 7.0 * l.width.min(l.height) as f32 / 210.0, false) <= sources.w + 1e-6, + "{profile}: {line}" + ); + } + // The tokens filled from the map: Web Mercator over WGS 84, the 2° graticule. + let reference = text("Reference").unwrap(); + assert!(reference.contains("Web Mercator") && reference.contains("WGS 84") && reference.contains("2°"), "{reference}"); + assert!(text("Author").unwrap().contains("{date}"), "the date stays a token the layout fills"); + assert!(r["warnings"].as_array().unwrap().is_empty(), "{profile}: {r}"); + } else { + assert!(text("Title").is_none() && text("Sources").is_none(), "{profile}: a figure's texts are its caption's"); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("caption")), "{r}"); + assert!(text("Reference").unwrap().contains("Datum: WGS 84"), "{profile}"); + assert_eq!(l.width, r["page"][0].as_f64().unwrap()); + } + let png = dir.join(format!("{profile}.png")); + s.run("layout.export", &json!({"path": png.display().to_string(), "dpi": 72})).unwrap(); + assert!(std::fs::metadata(&png).unwrap().len() > 1000, "{profile}"); + } + assert_eq!(s.project.layouts.len(), profiles.len(), "a layout each, the earlier ones untouched"); + // A long title wraps within the frame, and pushes the map down; the result names every + // element for layout.update. + let r = s.run("layout.publication", &json!({"extent": piaui, "title": long})).unwrap(); + let l = s.project.layouts.last().unwrap(); + let title = l.elements.iter().find(|e| e.name == "Title").unwrap(); + let ElementKind::Text { text, size, .. } = &title.kind else { panic!() }; + assert!(text.lines().count() >= 2, "{text}"); + for line in text.lines() { + assert!(crate::render::report::width_mm(line, *size as f32, true) <= title.w, "{line}: measured bold, as drawn"); + } + let frame = l.elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap(); + assert!(frame.y >= title.y + title.h); + let listed = r["elements"].as_array().unwrap(); + assert_eq!(listed.len(), l.elements.len()); + assert!(listed.iter().any(|e| e["name"] == "Map Frame" && e["element"] == frame.id && e["kind"] == "mapFrame"), "{r}"); + s.project.layouts.pop(); + // Undone as one command: the layout goes, and the map is as it was (its layers, view and + // projection), the legend's sync of streamed layers included. + let (layouts, map) = (s.project.layouts.len(), s.map().clone()); + s.run("layout.publication", &json!({"extent": piaui})).unwrap(); + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(s.project.layouts.len(), layouts); + let ids = |m: &rustgis_map::Map| m.layers.iter().map(|l| (l.id, l.name.clone(), l.visible)).collect::>(); + assert_eq!(ids(s.map()), ids(&map)); + assert_eq!((s.map().projection.code(), s.map().camera.center), (map.projection.code(), map.camera.center)); + // A figure says it left out an author and headings given to it, as it does a title. + let r = + s.run("layout.publication", &json!({"profile": "figure-1col", "extent": piaui, "author": "Me", "headings": ["A", "B"]})).unwrap(); + let said = r["warnings"].to_string(); + assert!(said.contains("author goes") && said.contains("headings goes"), "{said}"); + s.project.layouts.pop(); + let r = s.run("layout.publication", &json!({"profile": "figure-1col", "extent": piaui, "author": ""})).unwrap(); + assert!(!r["warnings"].to_string().contains("author"), "an empty author asks for none: {r}"); + s.project.layouts.pop(); + // An empty author leaves the authorship out. + for none in [json!(""), json!([]), json!([" "])] { + s.run("layout.publication", &json!({"extent": piaui, "author": none})).unwrap(); + assert!(s.project.layouts.last().unwrap().elements.iter().all(|e| e.name != "Author"), "{none}"); + s.project.layouts.pop(); + } + // With no sources, the reference alone takes the footer's width; no empty box beside it. + s.run("layout.publication", &json!({"extent": piaui})).unwrap(); + let l = s.project.layouts.last().unwrap(); + let frame = l.elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap(); + assert!(l.elements.iter().all(|e| !e.name.starts_with("Sources"))); + let reference = l.elements.iter().find(|e| e.name == "Reference Box").unwrap(); + assert!((reference.x - frame.x).abs() < 1e-9 && (reference.w - frame.w).abs() < 1e-9, "{} of {}", reference.w, frame.w); + s.project.layouts.pop(); + // Headings rename what the columns read, filled and wrapped; their elements keep their names. + let r = s + .run( + "layout.publication", + &json!({"extent": piaui, "sources": "IBGE", "headings": ["Fontes dos dados, {date}", "Referência cartográfica na projeção {projection}, a mais longa que se possa imaginar"]}), + ) + .unwrap(); + let l = s.project.layouts.last().unwrap(); + let heading = l.elements.iter().find(|e| e.name == "Reference Heading").unwrap(); + let ElementKind::Text { text, .. } = &heading.kind else { panic!() }; + // Where the wrap breaks depends on the font found, so the words are read across the lines. + assert!(text.replace('\n', " ").contains("WEB MERCATOR") && !text.contains('{') && text.lines().count() >= 2, "{text}"); + // A live token in a heading keeps its case, so the layout still fills it when it draws. + let sources = l.elements.iter().find(|e| e.name == "Sources Heading").unwrap(); + let ElementKind::Text { text, .. } = &sources.kind else { panic!() }; + assert_eq!(text, "FONTES DOS DADOS, {date}"); + assert!(l.elements.iter().any(|e| e.name == "Sources Heading") && l.elements.iter().any(|e| e.name == "Reference"), "{r}"); + let body = l.elements.iter().find(|e| e.name == "Reference").unwrap(); + assert!(body.y >= heading.y + heading.h - 1e-9, "the reference starts under its wrapped heading"); + s.project.layouts.pop(); + // A journal figure is as tall as the map's shape asks: Piauí in Web Mercator is taller than wide. + let one = s.project.layouts.iter().find(|l| l.name == "figure-1col").unwrap(); + assert!(one.width == 90.0 && one.height > 90.0, "{} × {}", one.width, one.height); + // Graticule steps and round scales. + use crate::cmd::layout::{capitals, datum_of, grid_step, round_scale, step_text}; + assert_eq!(datum_of("+proj=aea +lat_1=29.5 +datum=NAD83 +units=m").as_deref(), Some("NAD83")); + assert_eq!(datum_of("+proj=robin +ellps=WGS84").as_deref(), Some("WGS 84")); + assert_eq!(datum_of("+proj=eqearth +R=6371008.8").as_deref(), Some("a sphere")); + assert_eq!(datum_of("+proj=moll"), None); + // A preset on NAD83 says so, though its name holds no datum. + s.run("map.projection", &json!({"type": "usaAlbers"})).unwrap(); + s.run("layout.publication", &json!({"extent": [-100, 35, -90, 45]})).unwrap(); + let l = s.project.layouts.last().unwrap(); + let ElementKind::Text { text, .. } = &l.elements.iter().find(|e| e.name == "Reference").unwrap().kind else { panic!() }; + assert!(text.contains("Datum: NAD83"), "{text}"); + s.project.layouts.pop(); + assert_eq!(capitals("Fontes, {date} e {pageName}"), "FONTES, {date} E {pageName}"); + assert_eq!(capitals("Dados {2024 e {só} }"), "DADOS {2024 E {SÓ} }", "a lone or odd brace is text"); + assert_eq!(grid_step(&rustgis_geom::Extent::new(10.0, 45.0, 11.0, 46.0)), 0.25); + assert_eq!(grid_step(&rustgis_geom::Extent::new(-180.0, -90.0, 180.0, 90.0)), 30.0); + assert_eq!((step_text(2.0), step_text(0.25), step_text(0.02)), ("2°".to_string(), "15′".to_string(), "1′12″".to_string())); + assert_eq!(step_text(0.01), "36″"); + assert_eq!((round_scale(1_234_567.0), round_scale(4_500_000.0), round_scale(999.0)), (1_300_000.0, 4_500_000.0, 1_000.0)); + // Refused: an unknown profile, a malformed extent, a footer that leaves the map no room. + for (p, said) in [ + (json!({"profile": "a5-portrait"}), "unknown profile"), + (json!({"profile": 5}), "profile is"), + (json!({"extent": [10, 0, 5, 1]}), "extent is"), + (json!({"extent": [10, 0, 11], "profile": "a4-portrait"}), "extent is"), + (json!({"sources": vec![long; 40], "profile": "a4-landscape", "extent": piaui}), "no room"), + (json!({"reference": vec![long; 50], "profile": "thesis-half", "extent": piaui}), "no room"), + ] { + let e = s.run("layout.publication", &p).unwrap_err(); + assert!(e.contains(said), "{p}: {e}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn layouts_compose_export_and_round_trip() { use base64::Engine; @@ -5365,6 +5572,26 @@ fn a_streamed_layer_whose_file_goes_away_shows_an_error_not_stale_features() { assert!(l.source.as_ref().unwrap().error.is_some()); } +/// `layout.publication` over a streamed layer: the frame's view is read for its legend, which lists +/// the layer; undone, the layout goes and the layer is streaming from its source as before, +/// reading a view again when one is drawn. +#[test] +fn a_publication_layout_over_a_streamed_layer_is_undone() { + let path = grid_fgb("publication", 30); + let mut s = Session::new(Project::default()); + s.run("layer.addData", &json!({"path": path.display().to_string(), "stream": true})).unwrap(); + let r = s.run("layout.publication", &json!({"extent": [0, 0, 20, 20], "author": ""})).unwrap(); + assert!(s.project.layouts[0].elements.iter().any(|e| e.name == "Legend"), "the read layer is listed: {r}"); + s.run("edit.undo", &Value::Null).unwrap(); + assert!(s.project.layouts.is_empty()); + let l = &s.map().layers[0]; + assert!(l.source.as_ref().is_some_and(|src| src.error.is_none()), "still streaming from its source"); + let v = view_of(&mut s, rustgis_geom::Extent::new(0.0, 0.0, 20.0, 20.0)); + crate::render::render_png(s.map_mut(), &v).unwrap(); + assert!(!s.map().layers[0].dataset.features.is_empty(), "it reads the view again"); + let _ = std::fs::remove_file(path); +} + #[test] fn any_command_editing_a_streamed_layer_is_undone() { let path = grid_fgb("guarded", 30); diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 38035b350..3b113df22 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -864,25 +864,26 @@ struct LegendOptions<'a> { exclude: &'a [String], } -/// The legend of a frame's map. With `drawn` (from [`shown_legends`]) it shows what the frame -/// draws: only the layers listed there, with their classes. Without, every class of every visible -/// layer. `opts` leaves out `` and the layers it names, either way. -fn legend(pm: &mut Pixmap, r: Rect, map: &Map, title: &str, drawn: Option<&[(u32, Vec)]>, opts: LegendOptions, ppp: f64) { - let hide_other = opts.hide_other; +/// A row of a legend: a layer drawn in one symbol (its swatch and name), a layer's name over its +/// classes, or one of them. +enum LegendRow<'a> { + Plain(&'a rustgis_map::Layer), + Head(&'a str), + Class(GeometryType, LegendEntry), +} + +/// A legend row's height, in points. +const LEGEND_ROW: f32 = 15.0; +/// A legend swatch's width, in points. +const LEGEND_SWATCH: f32 = 16.0; + +/// The rows of the legend of a frame's map. With `drawn` (from [`shown_legends`]) it shows what the +/// frame draws: only the layers listed there, with their classes. Without, every class of every +/// visible layer. `opts` leaves out `` and the layers it names, either way. +fn legend_rows<'a>(map: &'a Map, drawn: Option<&[(u32, Vec)]>, opts: &LegendOptions) -> Vec> { let excluded = |name: &str| opts.exclude.iter().any(|n| n.eq_ignore_ascii_case(name)); - let ppp = ppp as f32; - let dark = Rgba::rgb(0x22, 0x22, 0x22); - let mut y = r.y(); - let line = 15.0 * ppp; - if !title.is_empty() { - text::text_at(pm, title, r.x(), y + line / 2.0, 12.0 * 1.33 * ppp, dark, true, text::Align::Left, false); - y += line * 1.3; - } - let sw = 16.0 * ppp; + let mut rows = Vec::new(); for l in map.layers.iter().filter(|l| map.shows(l) && !l.graphics && !l.is_raster() && !excluded(&l.name)) { - if y + line > r.y() + r.height() { - break; - } let mut entries = match drawn { Some(d) => match d.iter().find(|(id, _)| *id == l.id) { Some((_, entries)) => entries.clone(), @@ -891,37 +892,98 @@ fn legend(pm: &mut Pixmap, r: Rect, map: &Map, title: &str, drawn: Option<&[(u32 None => l.renderer.legend(), }; // The fallback row only (a class that happens to bear its name stays). - if hide_other && entries.iter().any(|e| e.fallback) { + if opts.hide_other && entries.iter().any(|e| e.fallback) { entries.retain(|e| !e.fallback); // Nothing left to show for this layer: leave it out rather than draw its plain swatch. if entries.is_empty() { continue; } } - let gt = l.geometry_type(); if entries.is_empty() { // A Unique Values layer that lists nothing draws nothing visible (#207). - if matches!(l.renderer, rustgis_map::Renderer::Unique { .. }) { - continue; + if !matches!(l.renderer, rustgis_map::Renderer::Unique { .. }) { + rows.push(LegendRow::Plain(l)); } - swatch(pm, r.x(), y, sw, line, gt, &l.renderer.symbol_for(&l.dataset, 0), ppp); - text::text_at(pm, &l.name, r.x() + sw + 6.0 * ppp, y + line / 2.0, 9.0 * 1.33 * ppp, dark, false, text::Align::Left, false); - y += line; continue; } - text::text_at(pm, &l.name, r.x(), y + line / 2.0, 9.0 * 1.33 * ppp, dark, true, text::Align::Left, false); - y += line; - for en in entries { - if y + line > r.y() + r.height() { - break; + rows.push(LegendRow::Head(&l.name)); + let gt = l.geometry_type(); + rows.extend(entries.into_iter().map(|en| LegendRow::Class(gt, en))); + } + rows +} + +/// The legend of a frame's map: its rows ([`legend_rows`]) from the top, as many as fit. +fn legend(pm: &mut Pixmap, r: Rect, map: &Map, title: &str, drawn: Option<&[(u32, Vec)]>, opts: LegendOptions, ppp: f64) { + let ppp = ppp as f32; + let dark = Rgba::rgb(0x22, 0x22, 0x22); + let mut y = r.y(); + let line = LEGEND_ROW * ppp; + if !title.is_empty() { + text::text_at(pm, title, r.x(), y + line / 2.0, 12.0 * 1.33 * ppp, dark, true, text::Align::Left, false); + y += line * 1.3; + } + let sw = LEGEND_SWATCH * ppp; + for row in legend_rows(map, drawn, &opts) { + // The rectangle's own bottom, which a sum of its top and height can miss by a rounding, + // and a thousandth of a row's slack: a legend sized to its rows keeps the last one. + if y + line > r.bottom() + line * 1e-3 { + break; + } + match row { + LegendRow::Plain(l) => { + swatch(pm, r.x(), y, sw, line, l.geometry_type(), &l.renderer.symbol_for(&l.dataset, 0), ppp); + text::text_at(pm, &l.name, r.x() + sw + 6.0 * ppp, y + line / 2.0, 9.0 * 1.33 * ppp, dark, false, text::Align::Left, false); + } + LegendRow::Head(name) => { + text::text_at(pm, name, r.x(), y + line / 2.0, 9.0 * 1.33 * ppp, dark, true, text::Align::Left, false); + } + LegendRow::Class(gt, en) => { + swatch(pm, r.x() + 6.0 * ppp, y, sw, line, gt, &en.symbol, ppp); + text::text_at( + pm, + &en.label, + r.x() + sw + 12.0 * ppp, + y + line / 2.0, + 8.5 * 1.33 * ppp, + dark, + false, + text::Align::Left, + false, + ); } - swatch(pm, r.x() + 6.0 * ppp, y, sw, line, gt, &en.symbol, ppp); - text::text_at(pm, &en.label, r.x() + sw + 12.0 * ppp, y + line / 2.0, 8.5 * 1.33 * ppp, dark, false, text::Align::Left, false); - y += line; } + y += line; } } +/// The size a legend with no title takes, in mm (width, height): its rows as [`legend`] draws +/// them, the classes a frame shows at the renderer's scale denominator `scale` (as the layout +/// finds it for a frame, a complete source read through as [`render`] reads it), or every class +/// with `all_classes`. For sizing a legend to its content. `None` when a complete source can't be +/// read, as [`render`] then draws no page. +pub fn legend_size_mm(map: &mut Map, scale: f64, all_classes: bool, hide_other: bool, exclude: &[String]) -> Option<(f64, f64)> { + let drawn = if all_classes { None } else { Some(complete_shown_legends(map, scale)?) }; + let rows = legend_rows(map, drawn.as_deref(), &LegendOptions { hide_other, exclude }); + let width = rows + .iter() + .map(|row| match row { + LegendRow::Plain(l) => LEGEND_SWATCH + 6.0 + text::measure(&l.name, 9.0, false), + LegendRow::Head(name) => text::measure(name, 9.0, true), + LegendRow::Class(_, en) => LEGEND_SWATCH + 12.0 + text::measure(&en.label, 8.5, false), + }) + .fold(0.0f32, f32::max); + let mm = 25.4 / 96.0; + Some((f64::from(width) * mm, rows.len() as f64 * f64::from(LEGEND_ROW) * mm)) +} + +/// The renderer's scale denominator for a frame showing `map` at paper scale 1:`scale` (the one +/// [`legend_size_mm`] takes). +pub fn frame_render_scale(map: &Map, center: Coord, scale: f64) -> f64 { + let cam = frame_camera(map, center, scale, 0.0, 0.0); + map.ground_res_at(&cam) / rustgis_geom::PIXEL_M +} + fn swatch(pm: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, gt: GeometryType, s: &rustgis_map::Symbol, ppp: f32) { let cy = y + h / 2.0; match gt { @@ -1129,6 +1191,11 @@ struct TextStyle { spacing: f32, } +/// The distance between two lines of text at `size` points, in mm, as [`text_block`] sets them. +pub fn line_pitch_mm(size: f64) -> f64 { + size.clamp(2.0, 400.0) * 1.33 * 1.25 * 25.4 / 96.0 +} + /// Text in an element: lines split at `\n` from the top, each placed by the style's alignment. A /// centred or right-aligned line wider than the element runs out past its left edge, as a /// left-aligned one runs past its right. @@ -1312,6 +1379,33 @@ mod tests { // the font and antialiasing don't matter. assert!(shown > 0, "the drawn class is listed"); assert!(all >= 2 * shown, "{shown} px of {all}"); + // The size a legend takes holds what it draws, to within a row: two rows drawn, six with + // every class. + let row = f64::from(LEGEND_ROW) * 25.4 / 96.0; + // How far right the legend draws, in pixels (points at this scale), as `depth` goes down. + let reach = |map: &Map, drawn: Option<&[(u32, Vec)]>| { + let mut pm = Pixmap::new(300, 400).unwrap(); + pm.fill(tiny_skia::Color::WHITE); + legend( + &mut pm, + Rect::from_xywh(0.0, 0.0, 300.0, 400.0).unwrap(), + map, + "", + drawn, + LegendOptions { hide_other: false, exclude: &[] }, + 1.0, + ); + (0..pm.width()).filter(|&x| (0..pm.height()).any(|y| pm.pixel(x, y).is_some_and(|p| p.red() < 200))).max().unwrap_or(0) + }; + for (all_classes, ink, right) in [(false, shown, reach(&map, Some(&drawn))), (true, all, reach(&map, None))] { + let (w, h) = legend_size_mm(&mut map, 1e6, all_classes, false, &[]).unwrap(); + let (ink, right) = (f64::from(ink) * 25.4 / 96.0, f64::from(right) * 25.4 / 96.0); + assert!(w > 0.0 && ink <= h && h - ink < row, "{ink} mm drawn in {h} mm"); + // The widths in the same units as the drawing: the ink ends within the width measured, + // short of it by no more than a glyph's side bearing. + assert!(right <= w + 0.3 && right > w - 1.5, "{right} mm drawn across {w} mm"); + } + assert!((legend_size_mm(&mut map, 1e6, false, false, &[]).unwrap().1 - 2.0 * row).abs() < 1e-9); // A layer the legend leaves out (by name, any case) isn't listed, drawn or not. let name = map.layer(pts).unwrap().name.to_uppercase(); assert_eq!(depth_without(&map, Some(&drawn), false, std::slice::from_ref(&name)), 0, "its only drawn layer left out"); diff --git a/crates/render/src/report.rs b/crates/render/src/report.rs index 644063256..a40995ad2 100644 --- a/crates/render/src/report.rs +++ b/crates/render/src/report.rs @@ -47,8 +47,8 @@ pub struct Placed { pub lines: Vec, } -/// Text width in millimetres. -fn width_mm(s: &str, pt: f32, bold: bool) -> f64 { +/// Text width in millimetres at `pt` points, as layouts and reports draw it. +pub fn width_mm(s: &str, pt: f32, bold: bool) -> f64 { text::measure(s, pt, bold) as f64 * 25.4 / 96.0 } @@ -66,31 +66,42 @@ fn style(b: &Block) -> (f32, bool, f64) { } } -/// Splits text into lines no wider than `w` millimetres. -fn wrap(s: &str, w: f64, pt: f32) -> Vec { +/// Splits text into lines no wider than `w` millimetres at `pt` points: whole words, a word wider +/// than the line broken across lines. +pub fn wrap(s: &str, w: f64, pt: f32) -> Vec { + wrap_with(s, w, |t| width_mm(t, pt, false)) +} + +/// [`wrap`] with the width of a piece of text (in mm) measured by `width`: bold text, or text whose +/// tokens are filled in only when it is drawn. +pub fn wrap_with(s: &str, w: f64, width: impl Fn(&str) -> f64) -> Vec { let mut out = Vec::new(); for para in s.lines().take(2000) { let mut line = String::new(); for word in para.split_whitespace().take(5000) { - // A word wider than the line (a long URL…) is broken across lines. + // A word wider than the line (a long URL…) is broken across lines: the most of its + // characters that fit, found by halving (a few measures a line, not one a character). let mut word = word.to_string(); - while width_mm(&word, pt, false) > w && word.chars().count() > 1 { - let mut head = String::new(); - for ch in word.chars() { - if !head.is_empty() && width_mm(&format!("{head}{ch}"), pt, false) > w { - break; + while width(&word) > w && word.chars().count() > 1 { + let chars: Vec = word.chars().collect(); + let (mut fits, mut over) = (1, chars.len()); + while over - fits > 1 { + let mid = (fits + over) / 2; + if width(&chars[..mid].iter().collect::()) > w { + over = mid; + } else { + fits = mid; } - head.push(ch); } if !line.is_empty() { out.push(std::mem::take(&mut line)); } - word = word.chars().skip(head.chars().count()).collect(); - out.push(head); + out.push(chars[..fits].iter().collect()); + word = chars[fits..].iter().collect(); } let word = word.as_str(); let next = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if !line.is_empty() && width_mm(&next, pt, false) > w { + if !line.is_empty() && width(&next) > w { out.push(std::mem::take(&mut line)); line = word.to_string(); } else { @@ -449,6 +460,22 @@ mod tests { Doc { title: "Cities".into(), width: 215.9, height: 279.4, blocks } } + /// A word wider than the line breaks into pieces that each fit, nothing lost, found in a few + /// measures a piece rather than one a character (a hostile 2000-character word stays cheap). + #[test] + fn a_long_word_breaks_in_few_measures() { + let word = "x".repeat(2000); + let count = std::cell::Cell::new(0usize); + let lines = wrap_with(&word, 40.0, |t| { + count.set(count.get() + 1); + width_mm(t, 7.0, false) + }); + assert!(lines.len() > 10, "{} lines", lines.len()); + assert!(lines.iter().all(|l| width_mm(l, 7.0, false) <= 40.0), "every piece fits"); + assert_eq!(lines.concat(), word, "nothing lost"); + assert!(count.get() < 20 * lines.len(), "{} measures for {} lines", count.get(), lines.len()); + } + #[test] fn flows_onto_pages_with_repeated_column_headings() { let d = doc(120); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e32a900fd..aae95f093 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -927,6 +927,25 @@ fn labeling_group(app: &mut RustGisApp, ui: &mut Ui) { }); } +/// A publication profile's name in the menu: a `tl!` literal each, so the catalogs find it (the +/// engine's labels, which it matches in English); `None` for a profile added without one. +fn publication_label(id: &str) -> Option<&'static str> { + Some(match id { + "a4-portrait" => tl!("A4 portrait sheet"), + "a4-landscape" => tl!("A4 landscape sheet"), + "a3-portrait" => tl!("A3 portrait sheet"), + "a3-landscape" => tl!("A3 landscape sheet"), + "a2-portrait" => tl!("A2 portrait sheet"), + "a2-landscape" => tl!("A2 landscape sheet"), + "figure-1col" => tl!("Journal figure, 1 column (90 mm)"), + "figure-1.5col" => tl!("Journal figure, 1.5 columns (140 mm)"), + "figure-2col" => tl!("Journal figure, 2 columns (190 mm)"), + "thesis-full" => tl!("Thesis figure, full page (160 × 225 mm)"), + "thesis-half" => tl!("Thesis figure, half page (160 × 110 mm)"), + _ => return None, + }) +} + fn project_group(app: &mut RustGisApp, ui: &mut Ui) { group(ui, "Project", |ui| { big(ui, app, "new_map", "New\nMap", "Add a new map to the project.", "map.new", Value::Null, false, false); @@ -941,6 +960,32 @@ fn project_group(app: &mut RustGisApp, ui: &mut Ui) { ui.close(); } } + // A finished sheet or figure of the map view, its texts to fill in on the page. + ui.menu_button(tl!("Publication Layout"), |ui| { + for profile in rustgis_engine::cmd::layout::PUBLICATION_PROFILES { + let label = publication_label(profile.id).unwrap_or_else(|| tl!(profile.label)); + if ui.button(label).clicked() { + // A sheet is titled with the map's name; a figure's title is its caption's. + let mut p = json!({"profile": profile.id}); + if profile.sheet { + p["title"] = json!(app.session.map().name.clone()); + } + // The view's extent may not be known (an empty map): said, and the tab stays. + match app.run("layout.publication", p) { + Ok(r) => { + // What the layout couldn't do as asked (a legend clipped, a layer still reading). + let said: Vec<&str> = r["warnings"].as_array().into_iter().flatten().filter_map(|w| w.as_str()).collect(); + if !said.is_empty() { + app.session.notify(said.join("\n"), false); + } + app.ui.tab = "Publish".into(); + } + Err(e) => app.session.notify(e, true), + } + ui.close(); + } + } + }); ui.separator(); if ui.button(tl!("Blank Letter page")).clicked() { let _ = app.run("layout.new", json!({"template": "blank"})); @@ -4608,6 +4653,15 @@ mod tests { assert_eq!(h.state().session.map().layer(id).unwrap().labels.default.size, before); } + /// Every publication profile has its literal name in the menu, the engine's label word for word + /// (the test runs in English), so neither can be renamed without the other. + #[test] + fn every_publication_profile_is_named_by_a_literal() { + for p in rustgis_engine::cmd::layout::PUBLICATION_PROFILES { + assert_eq!(publication_label(p.id), Some(p.label), "{}", p.id); + } + } + #[test] fn saved_hidden_tabs_never_hide_the_fixed_tabs() { let saved = ["Settings", "Help", "Edit", "Share", "Edit"].iter().map(|t| t.to_string()).collect(); diff --git a/docs/user-guide/layouts.md b/docs/user-guide/layouts.md index c75c5e44b..a6e4119c2 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -11,6 +11,39 @@ Home › New Layout adds a page layout (Letter to A0, landscape or portrait) wit title, legend, north arrow, scale bar and a dynamic credits line. Layouts open as tabs next to the maps; while one is open, its groups lead the Publish tab: +- **Publication layouts**: Home › New Layout › **Publication Layout** builds a finished sheet or + figure of the map view in one step, with `layout.publication` behind it: + - **Sheets** (A4, A3 or A2, portrait or landscape): a frame and margin, the title and subtitle, + the map, and a footer of two boxed columns (sources, and the cartographic reference; one + with nothing in it is left out, the other taking the width) with a note and the authorship + under them. Text sizes follow the sheet: an A3's are 1.41 times an + A4's. + - **Figures** for a journal (90, 140 or 190 mm wide, as tall as the map's shape asks) or a + thesis (160 × 225 or 160 × 110 mm): the map edge to edge with a line of its projection and + datum under it, at 7 pt. A figure's title and sources belong in its caption, so they are + left out, and the result says so. + - **The map**: the `extent` given (or the view) fitted at a round scale, two significant + figures, rounded up so it still fits within the strips the graticule's labels take inside + the frame. A graticule has its step picked from the extent, at + most five lines across (2° for a state, 15′ for a city), and labels on every edge of a sheet. + The north arrow, the legend (sized to what it lists) and the scale bar with the numeric scale + under it sit in the frame's corners, on pale boxes, clear of the graticule's labels. + - **Texts**: the title, subtitle, footer lines and authorship are measured with the layout's + font (the title and headings bold, as drawn) and wrapped to their box, so none runs past it, + and each box grows to hold them. `{projection}`, `{datum}` and + `{grid}` are filled from the map when the layout is made. `{date}` and the other dynamic text + tokens stay live. The reference defaults to the projection, datum and graticule, and the + authorship to "Made with RustGIS · {date}" (`"author": ""` leaves it out). `headings` renames the two columns. + - Every element is an ordinary one, to move or restyle afterwards. Running it again with + another `profile` makes another layout of the same map. + + ``` + layout.publication={"profile": "a4-portrait", "extent": [-46.8, -11.6, -38.5, -2.2], + "title": "PIAUÍ", "subtitle": "Greening and browning of vegetation: NDVI trend, 2001–2025", + "sources": ["NDVI: MOD13Q1 v6.1 (NASA LP DAAC), via the Brazil Data Cube (INPE)", "Boundaries: IBGE 2022"], + "note": "Only cells with a significant trend (p < 0.05) are coloured."} + ``` + - **Map frames**: rectangle, circle and ellipse frames, or polygon and lasso frames drawn on the page. Each frame shows a map of the project at its own centre and scale. Turn on **Pan/Zoom Map** (Map Frames group, or a frame's properties) to drag inside frames to pan their maps and From d06f9f9e8f03b31a2e44273460dfd19059abacb4 Mon Sep 17 00:00:00 2001 From: liyongyin Date: Thu, 22 Oct 2026 23:34:50 +0800 Subject: [PATCH 037/297] fix(data): preserve existing files on staging collisions (#702) * fix(data): preserve existing files on staging collisions * fix(data): preserve colliding Shapefile staging entries * test(data): harden staging collision test isolation --------- Co-authored-by: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> --- crates/data/src/io.rs | 150 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 3 deletions(-) diff --git a/crates/data/src/io.rs b/crates/data/src/io.rs index 0bf57db20..fe0ebf4dc 100644 --- a/crates/data/src/io.rs +++ b/crates/data/src/io.rs @@ -440,8 +440,11 @@ pub fn write_path_in(ds: &Dataset, path: &Path, crs: Crs) -> Result { // The .shp keeps the name given (`Roads.SHP` stays so); the others take its stem. let target = if *e == "shp" { path.to_path_buf() } else { path.with_extension(e) }; let tmp = temp_beside(&target)?; - written.push((tmp.clone(), target)); - write_new(&tmp, bytes) + let result = write_new(&tmp, bytes); + if result.is_ok() { + written.push((tmp, target)); + } + result }); if let Err(e) = staged { for (tmp, _) in &written { @@ -527,11 +530,13 @@ pub(crate) fn temp_beside(path: &Path) -> std::io::Result { /// removes it on failure. fn write_new(path: &Path, bytes: &[u8]) -> std::io::Result<()> { use std::io::Write; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?; let res = (|| { - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?; f.write_all(bytes)?; f.sync_all() })(); + // Close before removing a failed write, including on Windows. + drop(f); if res.is_err() { let _ = std::fs::remove_file(path); } @@ -1866,6 +1871,145 @@ pub(crate) fn group_rings(rings: Vec<(Vec, bool)>) -> Geometry { mod tests { use super::*; + /// Exercises caller cleanup through the public Shapefile writer, one fresh process per collision. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn shapefile_staging_collisions_preserve_existing_entries() -> std::io::Result<()> { + let (original, _) = + read_geojson(r#"{"type":"Feature","properties":{"name":"old"},"geometry":{"type":"Point","coordinates":[1,2]}}"#, "roads") + .map_err(std::io::Error::other)?; + let (parts, _) = shapefile_parts(&original).map_err(std::io::Error::other)?; + const CHILD: &str = "RUSTGIS_SHAPEFILE_STAGING_COLLISION"; + let Ok(case) = std::env::var(CHILD) else { + let kinds: &[&str] = if cfg!(unix) { &["file", "live", "dangling"] } else { &["file"] }; + for index in 0..parts.len() { + for kind in kinds { + let output = std::process::Command::new(std::env::current_exe()?) + .args([ + "--exact", + "io::tests::shapefile_staging_collisions_preserve_existing_entries", + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD, format!("{index}:{kind}")) + .output()?; + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() + && stdout.contains("test result: ok. 1 passed; 0 failed; 0 ignored;") + && stdout.contains(&format!("collision case completed: {index}:{kind}")), + "part={index} kind={kind}: {}{}", + stdout, + String::from_utf8_lossy(&output.stderr) + ); + } + } + println!("collision scenarios: {}", parts.len() * kinds.len()); + return Ok(()); + }; + let (index, kind) = case.split_once(':').ok_or_else(|| std::io::Error::other("missing collision case"))?; + let index = index.parse::().map_err(std::io::Error::other)?; + if !matches!(kind, "file" | "live" | "dangling") { + return Err(std::io::Error::other("unknown collision kind")); + } + let workspace = tempfile::Builder::new().prefix("rustgis-shp-collision-").tempdir()?; + let root = workspace.path(); + let path = root.join("roads.SHP"); + let destination = |extension: &str| if extension == "shp" { path.clone() } else { path.with_extension(extension) }; + for (extension, bytes) in &parts { + std::fs::write(destination(extension), bytes)?; + } + let (extension, _) = parts.get(index).ok_or_else(|| std::io::Error::other("collision part out of range"))?; + let target = destination(extension); + // This child runs only this test: write_path's first staging counter is zero. + let mut name = std::ffi::OsString::from("."); + name.push(target.file_name().ok_or_else(|| std::io::Error::other("missing part name"))?); + name.push(format!(".{}.{index}.tmp", std::process::id())); + let collision = target.with_file_name(name); + #[cfg(unix)] + let link_target = root.join("link-target"); + if kind == "file" { + std::fs::write(&collision, b"unrelated entry")?; + } + #[cfg(unix)] + if kind != "file" { + if kind == "live" { + std::fs::write(&link_target, b"unrelated target")?; + } + std::os::unix::fs::symlink(&link_target, &collision)?; + } + let (replacement, _) = + read_geojson(r#"{"type":"Feature","properties":{"name":"new"},"geometry":{"type":"Point","coordinates":[3,4]}}"#, "roads") + .map_err(std::io::Error::other)?; + let result = write_path(&replacement, &path); + assert!(matches!(&result, Err(DataError::Io(error)) if error.kind() == std::io::ErrorKind::AlreadyExists), "{result:?}"); + if kind == "file" { + assert_eq!(std::fs::read(&collision)?, b"unrelated entry"); + } + #[cfg(unix)] + if kind != "file" { + assert_eq!(std::fs::read_link(&collision)?, link_target); + if kind == "live" { + assert_eq!(std::fs::read(&link_target)?, b"unrelated target"); + } else { + assert!(!link_target.exists()); + } + } + for (extension, bytes) in &parts { + assert_eq!(&std::fs::read(destination(extension))?, bytes, "original {extension} changed"); + } + assert_eq!(std::fs::read_dir(root)?.count(), parts.len() + 1 + usize::from(kind == "live"), "owned staging files were not cleaned"); + std::fs::remove_file(collision)?; + assert_eq!(write_path(&replacement, &path).map_err(std::io::Error::other)?, 0); + for (extension, bytes) in shapefile_parts(&replacement).map_err(std::io::Error::other)?.0 { + assert_eq!(std::fs::read(destination(extension))?, bytes, "retry changed {extension}"); + } + workspace.close()?; + println!("collision case completed: {case}"); + Ok(()) + } + + #[test] + fn staging_collision_preserves_the_existing_file() -> std::io::Result<()> { + let path = temp_beside(&std::env::temp_dir().join("rustgis-staging-collision"))?; + write_new(&path, b"unrelated existing file")?; + let error = write_new(&path, b"must not replace").err().ok_or_else(|| std::io::Error::other("collision succeeded"))?; + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&path)?, b"unrelated existing file"); + std::fs::remove_file(path) + } + + #[cfg(unix)] + #[test] + fn staging_collision_preserves_symlinks_and_their_targets() -> std::io::Result<()> { + let target = temp_beside(&std::env::temp_dir().join("rustgis-staging-target"))?; + let link = temp_beside(&target)?; + write_new(&target, b"unrelated target")?; + std::os::unix::fs::symlink(&target, &link)?; + assert!(write_new(&link, b"must not follow").is_err()); + assert_eq!(std::fs::read_link(&link)?, target); + assert_eq!(std::fs::read(&target)?, b"unrelated target"); + std::fs::remove_file(&target)?; + assert!(write_new(&link, b"must not replace dangling link").is_err()); + assert_eq!(std::fs::read_link(&link)?, target); + assert!(!target.exists()); + std::fs::remove_file(link) + } + + #[test] + fn write_atomic_failed_install_preserves_destination_and_removes_staging() -> std::io::Result<()> { + let root = temp_beside(&std::env::temp_dir().join("rustgis-atomic-failed-install"))?; + std::fs::create_dir(&root)?; + let destination = root.join("occupied"); + std::fs::create_dir(&destination)?; + let marker = destination.join("keep.txt"); + std::fs::write(&marker, b"keep destination contents")?; + assert!(write_atomic(&destination, b"must not replace directory").is_err()); + assert_eq!(std::fs::read(marker)?, b"keep destination contents"); + assert_eq!(std::fs::read_dir(&root)?.count(), 1, "failed install left a staging file"); + std::fs::remove_dir_all(root) + } + #[test] fn topojson_named_json_from_disk() { let topo = r#"{"type":"Topology","objects":{"pts":{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[5,6]}]},"more":{"type":"GeometryCollection","geometries":[{"type":"Point","coordinates":[1,2]}]}},"arcs":[]}"#; From 60ae3e9f243fa33c9d684662af9b4fe40fdbfef4 Mon Sep 17 00:00:00 2001 From: quanguet0409 Date: Thu, 22 Oct 2026 22:35:31 +0700 Subject: [PATCH 038/297] feat(map): add Vietnam VN-2000 coordinate system support and presets (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(map): add Vietnam VN-2000 coordinate system support and presets * fix(map): address review comments for VN-2000 presets and place-name lookups * fix(crs,map): case-insensitive VN-2000 prefix stripping and PROJ parsing in Crs::parse * fix(map,crs): complete 63-province VN-2000 table and case-insensitive PROJ prefix * fix(crs): use get() over direct slice in PROJ prefix normalization * fix(crs,map): support VN-2000 UTM zones by name and align UI province names * fix: prioritize numeric EPSG/ESRI before alias resolution, use i18n::fmt for button labels, and refine zone matching * fix(crs,map): strictly prefix-bound VN-2000 UTM matching, direct definition build, and clean degrees display * fix(crs,map): treat bare VN-2000 as ambiguous and require explicit TM-3 or UTM zone * test(crs,map,ui): verify all 63 provinces, aliases, degree formatting, and CRS security * fix(crs): resolve all remaining CRS, VN-2000, and localization review findings * fix(crs,map,ui): save province codes as the TM-3 preset, reuse EPSG:5897 - `vn2000:tphcm` is resolved in parse_preset to `vn2000tm3 +lon_0=…`, so Projection::from_code saves the preset code, not the label, which did not round-trip; the alias branch in lookup and the dialog's own workaround go. Spaces around the prefix separator are accepted. - `VN-2000 / TM-3` in Crs::parse is EPSG:5897 (the same definition), so a GeoTIFF in it is written with its code instead of refused. - The PROJ-string check runs only on typed strings, not in Crs::custom, so a file's own `+nadgrids=@null` still loads; it also refuses the geoidgrids, grids and file keys GDAL's check refuses. - The province table cites one source and the EPSG areas of use it was checked against; a test checks every id is its name normalised. The TM-3 description links EPSG:5897. * refactor(crs,map): one VN-2000 Helmert literal, presets checked against EPSG rustgis-data exports the +towgs84 literal as a macro the map presets concat, instead of each crate keeping its own copy; a test checks the three VN-2000 presets equal EPSG:5897, 3405 and 3406 as the EPSG table has them. The typed PROJ-string check also refuses `+catalog`. * test(crs): check rejected PROJ strings by exact membership, not table size The custom table is global, so another test registering a CRS between the two length reads made the count check flaky. * fix(map): drop the 'vietnam' alias for VN-2000 / TM-3 105 Like a bare VN-2000 it names no projection: Vietnam spans two UTM zones and many TM-3 meridians, so it is an unknown projection now. * fix(crs,map,ui): one VN-2000 name matcher; no bare provinces in the dialog - rustgis-data's vn2000_epsg() is now the one table of VN-2000 system names. Crs::parse and the map's presets both use it, so a name one takes (vn2000_utm48, VN200048, VN-2000 / UTM zone 49) the other takes too. A test checks the two agree. - Crs::parse reads a PROJ string only when it starts with "+proj=", not any text starting with "+proj" ("+project…"). - The Coordinate System dialog no longer reads a bare word such as "hue" or "vinh" as a province. Like map.projection, it needs the vn2000: prefix (vn2000:hanoi). The test checks bare names stay as typed and the ambiguity message for a bare "vn2000". * fix(map): add 'vithanh' to Hậu Giang aliases and add VN-2000 project persistence tests * fix(crs): refuse every grid key and pipelines; cap typed PROJ strings - validate_proj_string refuses any key with "grid" in it (vgrids, xy_grids, z_grids, gridlist as well as nadgrids, geoidgrids, grids), +step and +proj=pipeline, besides init, file and catalog. Tests cover them. - PROJ strings typed into Crs::parse (commands, the dialog) get their own cap of 256 entries in the custom table, so they cannot use up the room files' own coordinate systems need. - One squash() helper normalises VN-2000 names for is_bare_vn2000, vn2000_epsg and Crs::parse; parse_preset matches the name once, not once per preset. - The VN-2000 rows in vi.tsv sit in sorted order; a stray blank line in lookup is gone. * fix(crs): check the typed-PROJ cap under the insert's lock; share the key check - Crs::parse registers a typed PROJ string through Crs::register, which checks MAX_TYPED under the same lock as the insertion, so concurrent callers can't overshoot it. - proj_key_refused() is the one check for PROJ keys that read files or build pipelines, used for typed definitions and for those handed to GDAL's programs, whose list now refuses every grid key (vgrids, xy_grids...) and pipelines as well. --------- Co-authored-by: giswqs --- crates/data/src/raster/crs.rs | 282 +++++++++++++++++++- crates/data/src/raster/gdal.rs | 11 +- crates/data/src/raster/geotiff.rs | 51 ++++ crates/engine/src/tests.rs | 44 ++++ crates/map/src/projection.rs | 410 +++++++++++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 154 +++++++++-- crates/ui-egui/src/i18n/tests.rs | 21 ++ crates/ui-egui/src/i18n/vi.tsv | 4 + 8 files changed, 945 insertions(+), 32 deletions(-) diff --git a/crates/data/src/raster/crs.rs b/crates/data/src/raster/crs.rs index 32352e310..972102fe4 100644 --- a/crates/data/src/raster/crs.rs +++ b/crates/data/src/raster/crs.rs @@ -120,16 +120,100 @@ pub struct CustomCrs { /// The most custom systems one run keeps. const MAX_CUSTOM: usize = 1024; +/// The most of them typed as PROJ strings (`Crs::parse`): text from commands can't take the room +/// files' own systems need. +const MAX_TYPED: usize = 256; +/// The name a system typed as a PROJ string gets. +const TYPED_LABEL: &str = "Custom PROJ"; fn custom_table() -> &'static std::sync::Mutex>> { static T: std::sync::OnceLock>>> = std::sync::OnceLock::new(); T.get_or_init(Default::default) } +/// The VN-2000 to WGS 84 Helmert shift as a literal, for `concat!` (rustgis-map's presets): +/// PROJ's position-vector `+towgs84` for EPSG:3405, 3406 and 5897. +#[doc(hidden)] +#[macro_export] +macro_rules! vn2000_towgs84 { + () => { + "+towgs84=-191.90441429,-39.30318279,-111.45032835,0.00928836,-0.01975479,0.00427372,0.252906278" + }; +} + +/// The 7-parameter Helmert shift from VN-2000 to WGS 84, as PROJ gives it for EPSG:4756. +pub const VN2000_TOWGS84: &str = crate::vn2000_towgs84!(); + +/// A name's ASCII letters and digits, upper-cased: how VN-2000 names are matched, whatever the +/// spacing, punctuation and case. +fn squash(text: &str) -> String { + text.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_uppercase()).collect() +} + +/// Returns true if `text` is an ambiguous bare reference to VN-2000 without a specified projection. +pub fn is_bare_vn2000(text: &str) -> bool { + squash(text) == "VN2000" +} + +/// The EPSG code an explicit VN-2000 system name stands for, matched on its letters and digits +/// in any case: `VN-2000 / UTM zone 48N`, `vn2000_utm48` or `vn-2000-48` (3405), the same for +/// 49 (3406), `VN-2000 / TM-3` or `vn2000tm3` (5897). The one table for [`Crs::parse`] and +/// rustgis-map's presets, so a name one takes the other takes too. +pub fn vn2000_epsg(text: &str) -> Option { + match squash(text).as_str() { + "VN2000UTMZONE48N" | "VN2000UTM48N" | "VN2000UTM48" | "VN2000UTMZONE48" | "VN200048" | "VN200048N" => Some(3405), + "VN2000UTMZONE49N" | "VN2000UTM49N" | "VN2000UTM49" | "VN2000UTMZONE49" | "VN200049" | "VN200049N" => Some(3406), + // EPSG:5897, VN-2000 / TM-3 zone 482: central meridian 105°, k 0.9999. + "VN2000TM3" | "VN2000TM3VIETNAM" | "VN2000TM3105" | "VN2000TM3LON0105" => Some(5897), + _ => None, + } +} + +/// The error message returned when an ambiguous bare VN-2000 name is provided. +pub fn bare_vn2000_error(text: &str) -> String { + format!( + "coordinate system '{text}' is ambiguous: specify 'VN-2000 / UTM zone 48N' (vn2000Utm48, EPSG:3405), 'VN-2000 / UTM zone 49N' (vn2000Utm49, EPSG:3406), or TM-3 ('vn2000tm3' / 'VN-2000 / TM-3')" + ) +} + +/// Checks a PROJ string typed by a user (not one read from a file, whose `+nadgrids=@null` is +/// fine): no keys naming grid or definition files, as for GDAL (`raster::gdal`), no pipelines, +/// and no paths. Every key with `grid` in it is refused (`nadgrids`, `geoidgrids`, `vgrids`, +/// `xy_grids`, `z_grids`, `gridlist`…), so a grid key PROJ adds later is refused too. +fn validate_proj_string(proj4: &str) -> Result<(), String> { + for token in proj4.split_whitespace() { + let t = token.trim_start_matches('+'); + let (key, val) = t.split_once('=').unwrap_or((t, "")); + if proj_key_refused(key, val) { + return Err(format!("parameter '+{t}' is not allowed in coordinate system definition")); + } + if val.contains("..") || val.contains('/') || val.contains('\\') { + return Err("file path in parameter is not allowed in coordinate system definition".into()); + } + } + Ok(()) +} + +/// Whether a PROJ parameter names files PROJ would read (any key with `grid` in it, `init`, +/// `file`, `catalog`) or builds a pipeline (`step`, `proj=pipeline`): refused in definitions +/// typed by a user and in those handed to GDAL's programs (`raster::gdal`). +pub(crate) fn proj_key_refused(key: &str, val: &str) -> bool { + let key = key.trim().to_ascii_lowercase(); + key.contains("grid") + || ["init", "file", "catalog", "step"].contains(&key.as_str()) + || (key == "proj" && val.trim().eq_ignore_ascii_case("pipeline")) +} + impl Crs { /// A file's own coordinate system: checked (its PROJ definition must parse, or be a /// sinusoidal one RustGIS computes) and added to the run's table, once per definition. pub fn custom(def: CustomCrs) -> Result { + Crs::register(def, false) + } + + /// Adds a system to the run's table; one `typed` as a PROJ string counts against + /// `MAX_TYPED` too, checked under the same lock as the insertion. + fn register(def: CustomCrs, typed: bool) -> Result { let checked = match rustgis_geom::Sinusoidal::from_proj4(&def.proj4) { Some(sinu) => sinu.map(|_| ()), None => proj4rs::Proj::from_proj_string(&def.proj4).map(|_| ()).map_err(|e| e.to_string()), @@ -150,6 +234,9 @@ impl Crs { if t.len() >= MAX_CUSTOM { return Err("too many coordinate systems defined by files".into()); } + if typed && t.iter().filter(|d| d.label == TYPED_LABEL).count() >= MAX_TYPED { + return Err("too many coordinate systems typed as PROJ strings".into()); + } t.push(std::sync::Arc::new(def)); index(t.len() - 1) } @@ -175,21 +262,40 @@ impl Crs { } /// A coordinate system given as text: `EPSG:2263`, `2263`, an OGC URN or URL ending in the - /// code, or WKT. + /// code, WKT, a PROJ string (`+proj=…`, naming no grid or definition files), or an explicit + /// VN-2000 system: `VN-2000 / UTM zone 48N` (EPSG:3405), `… 49N` (EPSG:3406) or + /// `VN-2000 / TM-3` (EPSG:5897, central meridian 105°). A bare `VN-2000` is ambiguous. pub fn parse(text: &str) -> Result { let t = text.trim(); if t.contains('[') { return super::wkt::crs_from_wkt(t); } + if t.get(..6).is_some_and(|p| p.eq_ignore_ascii_case("+proj=")) { + if t.len() > 1024 { + return Err("coordinate system definition is too long".into()); + } + let proj4 = format!("+proj{}", t.get(5..).unwrap_or("")); + validate_proj_string(&proj4)?; + return Crs::register( + CustomCrs { proj4, label: TYPED_LABEL.to_string(), geokeys: Vec::new(), doubles: Vec::new(), ascii: String::new() }, + true, + ); + } let up = t.to_ascii_uppercase(); if up.ends_with("CRS84") { return Ok(Crs::LonLat); } + if is_bare_vn2000(t) { + return Err(bare_vn2000_error(t)); + } + if squash(t).starts_with("VN2000") { + return vn2000_epsg(t).and_then(|c| Crs::from_epsg(c.into())).ok_or_else(|| format!("unknown coordinate system '{t}'")); + } let code = t.rsplit([':', '/']).next().unwrap_or(t).trim(); let known = t.chars().all(|c| c.is_ascii_digit()) || up.contains("EPSG"); match code.parse::() { Ok(c) if known => Crs::from_epsg(c).ok_or_else(|| format!("unknown coordinate system EPSG:{c}")), - _ => Err(format!("'{t}': give a coordinate system as EPSG: or WKT")), + _ => Err(format!("'{t}': give a coordinate system as EPSG:, WKT, or +proj string")), } } @@ -375,6 +481,178 @@ mod tests { check(9377, (-74.08, 4.65), (4_880_256.854, 2_071_911.388), 0.01); assert_eq!(Crs::parse("EPSG:9377"), Ok(Crs::Epsg(9377))); assert_eq!(Crs::parse("urn:ogc:def:crs:EPSG::9377"), Ok(Crs::Epsg(9377))); + // VN-2000 UTM zones 48N and 49N (opengeos/RustGIS#684) + assert_eq!(Crs::parse("EPSG:3405"), Ok(Crs::Epsg(3405))); + assert_eq!(Crs::parse("EPSG:3406"), Ok(Crs::Epsg(3406))); + for bare in ["VN-2000", "VN2000", "vn-2000", "vn2000", " VN-2000 ", "VN 2000", "vn_2000"] { + let err = Crs::parse(bare).unwrap_err(); + assert!(err.contains("ambiguous"), "{bare}: {err}"); + } + for name in [ + "VN-2000 / UTM zone 48N", + "VN2000 / UTM zone 48N", + "VN-2000 / UTM 48N", + "VN2000 / UTM 48N", + "VN-2000-48", + "VN2000-48", + "vn-2000 / utm zone 48n", + "vn2000 / utm 48n", + " VN-2000 / UTM 48N ", + ] { + let crs = Crs::parse(name).unwrap(); + assert_eq!(crs, Crs::Epsg(3405), "{name}"); + assert_eq!(crs.epsg(), 3405, "{name}"); + assert!(!crs.is_geographic(), "{name}"); + } + for name in [ + "VN-2000 / UTM zone 49N", + "VN2000 / UTM zone 49N", + "VN-2000 / UTM 49N", + "VN2000 / UTM 49N", + "VN-2000-49", + "VN2000-49", + "vn-2000 / utm zone 49n", + "vn2000 / utm 49n", + " VN-2000 / UTM 49N ", + ] { + let crs = Crs::parse(name).unwrap(); + assert_eq!(crs, Crs::Epsg(3406), "{name}"); + assert_eq!(crs.epsg(), 3406, "{name}"); + assert!(!crs.is_geographic(), "{name}"); + } + for name in [ + "VN-2000 / TM-3", + "VN2000 / TM-3", + "VN-2000 / TM3", + "VN2000 / TM3", + "VN-2000-TM3", + "VN2000-TM3", + "vn2000tm3", + "VN-2000 TM-3", + "vn-2000 / tm-3", + " VN-2000 / TM-3 ", + ] { + let crs = Crs::parse(name).unwrap(); + assert_eq!(crs, Crs::Epsg(5897), "{name}"); + assert!(!crs.is_geographic(), "{name}"); + let def = epsg_proj4(5897).unwrap(); + assert!(def.contains("+proj=tmerc") && def.contains("+lon_0=105") && def.contains("+k=0.9999"), "{name}"); + assert!(def.contains("+x_0=500000") && def.contains(VN2000_TOWGS84), "{name}"); + let tr = crs.transformer().unwrap(); + let pt = Coord::new(105.8525, 21.0285); + let proj_pt = tr.from_lonlat(pt).unwrap(); + assert!(proj_pt.x > 500_000.0 && proj_pt.x < 700_000.0); + assert!(proj_pt.y > 2_000_000.0 && proj_pt.y < 2_500_000.0); + let unproj_pt = tr.to_lonlat(proj_pt).unwrap(); + assert!((unproj_pt.x - pt.x).abs() < 1e-6 && (unproj_pt.y - pt.y).abs() < 1e-6); + } + // Numeric EPSG codes for VN-2000 UTM 48N/49N + assert_eq!(Crs::parse("3405"), Ok(Crs::Epsg(3405))); + assert_eq!(Crs::parse("3406"), Ok(Crs::Epsg(3406))); + assert_eq!(Crs::parse(" 3405 "), Ok(Crs::Epsg(3405))); + assert_eq!(Crs::parse("epsg:3405"), Ok(Crs::Epsg(3405))); + assert_eq!(Crs::parse("EPSG:3406"), Ok(Crs::Epsg(3406))); + assert_eq!(Crs::parse("urn:ogc:def:crs:EPSG::3405"), Ok(Crs::Epsg(3405))); + assert_eq!(Crs::parse("urn:ogc:def:crs:EPSG::3406"), Ok(Crs::Epsg(3406))); + + // Coordinate transformation for EPSG:3405 and EPSG:3406 + let crs48 = Crs::from_epsg(3405).unwrap(); + let tr48 = crs48.transformer().unwrap(); + let pt48 = Coord::new(105.8525, 21.0285); + let proj48 = tr48.from_lonlat(pt48).unwrap(); + assert!(proj48.x > 500_000.0 && proj48.x < 700_000.0); + assert!(proj48.y > 2_000_000.0 && proj48.y < 2_500_000.0); + let unproj48 = tr48.to_lonlat(proj48).unwrap(); + assert!((unproj48.x - pt48.x).abs() < 1e-6 && (unproj48.y - pt48.y).abs() < 1e-6); + + let crs49 = Crs::from_epsg(3406).unwrap(); + let tr49 = crs49.transformer().unwrap(); + let pt49 = Coord::new(109.1967, 12.2388); + let proj49 = tr49.from_lonlat(pt49).unwrap(); + assert!(proj49.x > 200_000.0 && proj49.x < 400_000.0); + assert!(proj49.y > 1_300_000.0 && proj49.y < 1_500_000.0); + let unproj49 = tr49.to_lonlat(proj49).unwrap(); + assert!((unproj49.x - pt49.x).abs() < 1e-6 && (unproj49.y - pt49.y).abs() < 1e-6); + + // Over-broad UTM matching and unrelated CRS names must NOT match VN-2000 + assert!(Crs::parse("WGS 84 / UTM zone 48N").is_err()); + assert!(Crs::parse("WGS 84 / UTM zone 49N").is_err()); + assert!(Crs::parse("NAD83 / UTM zone 48N").is_err()); + assert!(Crs::parse("NAD83 / UTM zone 49N").is_err()); + assert!(Crs::parse("VN-2000 CUSTOM UTM ZONE 48N").is_err()); + assert!(Crs::parse("VN2000 EXTRA TEXT UTM ZONE 49N").is_err()); + assert!(Crs::parse("VN-2000 UTM 50N").is_err()); + assert!(Crs::parse("VN2000XYZ").is_err()); + assert!(Crs::parse("VN-2000 unknown").is_err()); + + // Shared bare VN-2000 helper verification + assert!(is_bare_vn2000("VN-2000")); + assert!(is_bare_vn2000("vn2000")); + assert!(is_bare_vn2000("VN 2000")); + assert!(is_bare_vn2000("vn_2000")); + assert!(!is_bare_vn2000("VN-2000 / UTM zone 48N")); + assert!(!is_bare_vn2000("vn2000tm3")); + assert!(!is_bare_vn2000("WGS 84")); + assert!(bare_vn2000_error("VN-2000").contains("ambiguous")); + + // Embedded EPSG definitions verification + let p3405 = epsg_proj4(3405).expect("EPSG:3405 must be present in crs_definitions"); + assert!(p3405.contains("+proj=utm") && p3405.contains("+zone=48"), "{p3405}"); + let p3406 = epsg_proj4(3406).expect("EPSG:3406 must be present in crs_definitions"); + assert!(p3406.contains("+proj=utm") && p3406.contains("+zone=49"), "{p3406}"); + assert_eq!(Crs::from_epsg(65534), None); + assert!(Crs::parse("EPSG:65534").is_err()); + + // PROJ string validation: case normalization, prefix validation, and size limit + let custom_proj = Crs::parse("+proj=utm +zone=48 +ellps=WGS84").unwrap(); + assert_eq!(custom_proj.label(), "Custom PROJ"); + let custom_proj_upper = Crs::parse("+PROJ=utm +zone=48 +ellps=WGS84").unwrap(); + assert_eq!(custom_proj_upper.label(), "Custom PROJ"); + + // Custom CRS deduplication + let custom_dup = Crs::parse("+proj=utm +zone=48 +ellps=WGS84").unwrap(); + assert_eq!(custom_proj, custom_dup); + + // Security validation: unsafe parameter forms (+init, +nadgrids, path traversal) + assert!(Crs::parse("+proj=utm +zone=48 +init=epsg:4326").is_err()); + assert!(Crs::parse("+proj=utm +zone=48 +nadgrids=@null").is_err()); + assert!(Crs::parse("+proj=utm +zone=48 +file=../secret").is_err()); + assert!(Crs::parse("+proj=utm +zone=48 +file=C:\\secret").is_err()); + assert!(Crs::parse("+proj=utm +zone=33 +geoidgrids=egm96_15.gtx").is_err()); + // Any key naming grids (legacy `vgrids`, `deformation`'s `xy_grids`), pipelines and steps. + for bad in [ + "+proj=utm +zone=33 +vgrids=egm96_15.gtx", + "+proj=deformation +xy_grids=a.tif +z_grids=b.tif", + "+proj=utm +zone=33 +gridlist=x", + "+proj=pipeline +step +proj=utm +zone=33", + "+PROJ=Pipeline +step +proj=utm +zone=33", + ] { + assert!(Crs::parse(bad).is_err(), "{bad}"); + } + // A file's own definition may carry `+nadgrids=@null` (Web Mercator as GDAL writes it). + let merc = "+proj=merc +a=6378137 +b=6378137 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs"; + let file = CustomCrs { proj4: merc.into(), label: "m".into(), geokeys: Vec::new(), doubles: Vec::new(), ascii: String::new() }; + assert!(Crs::custom(file).is_ok()); + + let long_proj = format!("+proj={}", "a".repeat(1025)); + let long_utm = format!("+proj=utm {}", "a".repeat(1025)); + let rejected = [ + "+proj", + "+project", + "+proj=", + "+proj= ", + "+proj=invalid_projection", + "+PROJ=invalid_projection", + long_proj.as_str(), + long_utm.as_str(), + ]; + for r in rejected { + assert!(Crs::parse(r).is_err(), "{r}"); + } + // None of them was registered (an exact check: other tests add to the table meanwhile). + let table = custom_table().lock().unwrap_or_else(|e| e.into_inner()); + assert!(!table.iter().any(|d| rejected.iter().any(|r| d.proj4.eq_ignore_ascii_case(r.trim())))); + drop(table); // VN-2000 / TM-3 103-00: its datum shift (+towgs84) is applied. check(9205, (103.065, 21.855), (506_521.959, 2_417_609.479), 1.0); // NAD27 / US National Atlas Equal Area: Washington, DC, without NAD27's datum shift diff --git a/crates/data/src/raster/gdal.rs b/crates/data/src/raster/gdal.rs index f426e43a1..7afc5dad9 100644 --- a/crates/data/src/raster/gdal.rs +++ b/crates/data/src/raster/gdal.rs @@ -227,11 +227,14 @@ fn valid_crs(c: &str) -> bool { .iter() .any(|k| up.starts_with(k)) && c.contains('['); - // PROJ parameters that name grid or definition files PROJ would look up are refused. - let file_keys = ["nadgrids", "geoidgrids", "grids", "init", "file"]; + // PROJ parameters that name grid or definition files PROJ would look up, or build a + // pipeline, are refused (the same check as for typed definitions). let proj = c.starts_with("+proj=") && c.chars().all(|ch| ch.is_ascii_alphanumeric() || " +=_.,-".contains(ch)) - && !c.split('+').filter_map(|kv| kv.split('=').next()).any(|k| file_keys.contains(&k.trim().to_ascii_lowercase().as_str())); + && !c.split('+').any(|kv| { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + super::crs::proj_key_refused(k, v) + }); let urn = up.starts_with("URN:OGC:DEF:CRS:") && c.chars().all(|ch| ch.is_ascii_alphanumeric() || ":._-".contains(ch)); !c.chars().any(char::is_control) && (code(&up) || wkt || proj || urn) } @@ -451,6 +454,8 @@ mod tests { "+proj=utm +zone=33 +geoidgrids=egm96_15.gtx", "+proj=longlat +init=epsg", "+proj=pipeline +step +proj=hgridshift +grids=ntv2.gsb", + "+proj=utm +zone=33 +vgrids=egm96_15.gtx", + "+proj=deformation +xy_grids=a.tif", ] { assert!(!valid_crs(bad), "{bad}"); } diff --git a/crates/data/src/raster/geotiff.rs b/crates/data/src/raster/geotiff.rs index 61e37a313..54d77b4f8 100644 --- a/crates/data/src/raster/geotiff.rs +++ b/crates/data/src/raster/geotiff.rs @@ -2314,4 +2314,55 @@ mod tests { } } } + + #[test] + fn custom_crs_geotiff_round_trip() { + let r = small(); + let custom_proj = + Crs::parse("+proj=tmerc +lat_0=0 +lon_0=105 +k=0.9999 +x_0=500000 +y_0=0 +datum=WGS84 +units=m +no_defs").unwrap(); + let raster_custom = Raster::new( + "cust", + r.width, + r.height, + r.bands.iter().map(|b| b.data.clone()).collect(), + r.transform, + custom_proj, + r.nodata, + r.color.clone(), + "Float32", + ) + .unwrap(); + let bytes = write(&raster_custom, 0).expect("custom CRS with standard projection parameters should serialize to GeoTIFF"); + let back = read(std::io::Cursor::new(&bytes), "back").unwrap(); + assert_eq!((back.width, back.height), (r.width, r.height)); + assert_eq!(back.transform, r.transform); + assert!(!back.crs.is_geographic()); + // Verify custom CRS definition is equivalent and usable + let def = back.crs.custom_def().expect("re-opened raster has custom CRS definition"); + assert!(def.proj4.contains("+proj=tmerc"), "{}", def.proj4); + assert!(def.proj4.contains("+lon_0=105"), "{}", def.proj4); + let tr = back.crs.transformer().expect("transformer available"); + let pt = rustgis_geom::Coord::new(105.5, 21.0); + let projected = tr.from_lonlat(pt).unwrap(); + let back_pt = tr.to_lonlat(projected).unwrap(); + assert!((back_pt.x - pt.x).abs() < 1e-6 && (back_pt.y - pt.y).abs() < 1e-6); + + // VN-2000 / TM-3 is EPSG:5897, written as its code and read back as it. + let vn2000_tm3 = Crs::parse("VN-2000 / TM-3").unwrap(); + let raster_vn2000 = Raster::new( + "vn", + r.width, + r.height, + r.bands.iter().map(|b| b.data.clone()).collect(), + r.transform, + vn2000_tm3, + r.nodata, + r.color, + "Float32", + ) + .unwrap(); + let bytes = write(&raster_vn2000, 0).unwrap(); + let back = read(std::io::Cursor::new(&bytes), "vn").unwrap(); + assert_eq!(back.crs, Crs::Epsg(5897)); + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 256c62d3d..fcbe959a2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -842,6 +842,50 @@ fn projection_presets_take_parameters() { assert!(png.len() > 1000); } +#[test] +fn vn2000_projection_command_and_project_persistence() { + let mut s = world(); + // Explicit prefixed province syntax works and canonicalizes to the preset + s.run("map.projection", &json!({"type": "vn2000:danang"})).unwrap(); + assert_eq!(s.map().projection.code(), "vn2000tm3 +lon_0=107.75"); + assert_eq!(s.map().projection.label(), "VN-2000 / TM-3 (Vietnam) (lon_0=107.75)"); + + // Capital Hanoi on central meridian 105.0 drops redundant parameter + s.run("map.projection", &json!({"type": "vn2000:hanoi"})).unwrap(); + assert_eq!(s.map().projection.code(), "vn2000tm3"); + assert_eq!(s.map().projection.label(), "VN-2000 / TM-3 (Vietnam)"); + + // UTM zones work + s.run("map.projection", &json!({"type": "vn2000Utm48"})).unwrap(); + assert_eq!(s.map().projection.code(), "vn2000Utm48"); + assert_eq!(s.map().projection.label(), "VN-2000 / UTM zone 48N"); + + s.run("map.projection", &json!({"type": "vn2000Utm49"})).unwrap(); + assert_eq!(s.map().projection.code(), "vn2000Utm49"); + assert_eq!(s.map().projection.label(), "VN-2000 / UTM zone 49N"); + + // Bare province names and "vietnam" are rejected in strict command context + for bare in ["danang", "vinh", "hanoi", "hue", "hcm", "vietnam"] { + assert!(s.run("map.projection", &json!({"type": bare})).is_err(), "{bare}"); + } + // Ambiguous bare VN-2000 is rejected + for bare in ["vn2000", "VN-2000", "VN2000"] { + let err = s.run("map.projection", &json!({"type": bare})).unwrap_err(); + assert!(err.contains("ambiguous"), "{bare}: {err}"); + } + + // Set a provincial TM-3 projection and verify project save and reload + s.run("map.projection", &json!({"type": "VN-2000 / Đà Nẵng"})).unwrap(); + assert_eq!(s.map().projection.code(), "vn2000tm3 +lon_0=107.75"); + let text = serde_json::to_string(&s.project).unwrap(); + + // Reload project from serialized JSON text + let mut s2 = world(); + s2.run("project.openText", &json!({"text": text})).unwrap(); + assert_eq!(s2.map().projection.code(), "vn2000tm3 +lon_0=107.75"); + assert_eq!(s2.map().projection.label(), "VN-2000 / TM-3 (Vietnam) (lon_0=107.75)"); +} + #[test] fn custom_projections_switch_keep_scale_query_and_render() { let mut s = with_raster(); diff --git a/crates/map/src/projection.rs b/crates/map/src/projection.rs index fd6db267f..48e76e81e 100644 --- a/crates/map/src/projection.rs +++ b/crates/map/src/projection.rs @@ -7,6 +7,8 @@ use rustgis_geom::{Coord, Extent, lonlat_to_merc, merc_to_lonlat}; +use rustgis_data::vn2000_towgs84; + /// A named projection: (id, label, PROJ definition). pub const PRESETS: &[(&str, &str, &str)] = &[ ("equirectangular", "Equirectangular (Plate Carrée)", "+proj=eqc +lat_ts=0 +lon_0=0 +datum=WGS84 +units=m +no_defs"), @@ -32,6 +34,121 @@ pub const PRESETS: &[(&str, &str, &str)] = &[ ), ("albers", "Albers Equal Area Conic", "+proj=aea +lat_0=0 +lon_0=0 +lat_1=20 +lat_2=50 +datum=WGS84 +units=m +no_defs"), ("lambertConformal", "Lambert Conformal Conic", "+proj=lcc +lat_0=0 +lon_0=0 +lat_1=20 +lat_2=50 +datum=WGS84 +units=m +no_defs"), + ( + "vn2000tm3", + "VN-2000 / TM-3 (Vietnam)", + concat!("+proj=tmerc +lat_0=0 +lon_0=105 +k=0.9999 +x_0=500000 +y_0=0 +ellps=WGS84 ", vn2000_towgs84!(), " +units=m +no_defs"), + ), + ("vn2000Utm48", "VN-2000 / UTM zone 48N", concat!("+proj=utm +zone=48 +ellps=WGS84 ", vn2000_towgs84!(), " +units=m +no_defs")), + ("vn2000Utm49", "VN-2000 / UTM zone 49N", concat!("+proj=utm +zone=49 +ellps=WGS84 ", vn2000_towgs84!(), " +units=m +no_defs")), +]; + +/// The VN-2000 presets and the EPSG systems they are: a name `Crs::parse` reads as one of these +/// (`VN-2000 / UTM zone 48N`, `vn2000_utm48`…) picks the preset too. +const VN2000_PRESET_EPSG: &[(&str, u16)] = &[("vn2000tm3", 5897), ("vn2000Utm48", 3405), ("vn2000Utm49", 3406)]; + +/// A province or regional hub in Vietnam and its VN-2000 3-degree central meridian +/// (Circular 25/2014/TT-BTNMT Annex 02, inheriting QĐ 05/2007/QĐ-BTNMT). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Vn2000Province { + /// Name in Vietnamese, e.g. "Hà Nội". + pub name: &'static str, + /// Central meridian in degrees east (lon_0). + pub lon_0: f64, + /// Normalized search identifier (ASCII lowercase without diacritics). + pub id: &'static str, + /// Additional search aliases (ASCII lowercase normalized). + pub aliases: &'static [&'static str], +} + +/// The central meridians of Vietnam's 63 provinces and cities for VN-2000 / TM-3 (Circular +/// 973/2001/TT-TCĐC, kept by Circular 25/2014/TT-BTNMT). Every meridian matches the area of use +/// EPSG gives its VN-2000 / TM-3 system (EPSG:5899 and 9205 to 9218 name the provinces of each); +/// the provinces at 105° and 108° are those EPSG lists under no other meridian. The table predates +/// the 2025 mergers of provinces, as the cadastral maps made with it do. +pub const VN2000_PROVINCES: &[Vn2000Province] = &[ + Vn2000Province { name: "An Giang", lon_0: 104.75, id: "angiang", aliases: &[] }, + Vn2000Province { name: "Bà Rịa - Vũng Tàu", lon_0: 107.75, id: "bariavungtau", aliases: &["vungtau"] }, + Vn2000Province { name: "Bạc Liêu", lon_0: 105.0, id: "baclieu", aliases: &[] }, + Vn2000Province { name: "Bắc Giang", lon_0: 107.0, id: "bacgiang", aliases: &[] }, + Vn2000Province { name: "Bắc Kạn", lon_0: 106.5, id: "backan", aliases: &[] }, + Vn2000Province { name: "Bắc Ninh", lon_0: 105.5, id: "bacninh", aliases: &[] }, + Vn2000Province { name: "Bến Tre", lon_0: 105.75, id: "bentre", aliases: &[] }, + Vn2000Province { name: "Bình Dương", lon_0: 105.75, id: "binhduong", aliases: &[] }, + Vn2000Province { name: "Bình Định", lon_0: 108.25, id: "binhdinh", aliases: &["quynhon"] }, + Vn2000Province { name: "Bình Phước", lon_0: 106.25, id: "binhphuoc", aliases: &[] }, + Vn2000Province { name: "Bình Thuận", lon_0: 108.5, id: "binhthuan", aliases: &["phantiet"] }, + Vn2000Province { name: "Cà Mau", lon_0: 104.5, id: "camau", aliases: &[] }, + Vn2000Province { name: "Cao Bằng", lon_0: 105.75, id: "caobang", aliases: &[] }, + Vn2000Province { name: "Cần Thơ", lon_0: 105.0, id: "cantho", aliases: &[] }, + Vn2000Province { name: "Đà Nẵng", lon_0: 107.75, id: "danang", aliases: &[] }, + Vn2000Province { name: "Đắk Lắk", lon_0: 108.5, id: "daklak", aliases: &["buonmathuot", "daclac"] }, + Vn2000Province { name: "Đắk Nông", lon_0: 108.5, id: "daknong", aliases: &["dacnong"] }, + Vn2000Province { name: "Điện Biên", lon_0: 103.0, id: "dienbien", aliases: &["dienbienphu"] }, + Vn2000Province { name: "Đồng Nai", lon_0: 107.75, id: "dongnai", aliases: &["bienhoa"] }, + Vn2000Province { name: "Đồng Tháp", lon_0: 105.0, id: "dongthap", aliases: &["caolanh"] }, + Vn2000Province { name: "Gia Lai", lon_0: 108.5, id: "gialai", aliases: &["pleiku"] }, + Vn2000Province { name: "Hà Giang", lon_0: 105.5, id: "hagiang", aliases: &[] }, + Vn2000Province { name: "Hà Nam", lon_0: 105.0, id: "hanam", aliases: &["phuly"] }, + Vn2000Province { name: "Hà Nội", lon_0: 105.0, id: "hanoi", aliases: &[] }, + Vn2000Province { name: "Hà Tĩnh", lon_0: 105.5, id: "hatinh", aliases: &[] }, + Vn2000Province { name: "Hải Dương", lon_0: 105.5, id: "haiduong", aliases: &[] }, + Vn2000Province { name: "Hải Phòng", lon_0: 105.75, id: "haiphong", aliases: &[] }, + Vn2000Province { name: "Hậu Giang", lon_0: 105.0, id: "haugiang", aliases: &["vithanh", "vitri"] }, + Vn2000Province { name: "Hòa Bình", lon_0: 106.0, id: "hoabinh", aliases: &[] }, + Vn2000Province { name: "Hưng Yên", lon_0: 105.5, id: "hungyen", aliases: &[] }, + Vn2000Province { name: "Khánh Hòa", lon_0: 108.25, id: "khanhhoa", aliases: &["nhatrang"] }, + Vn2000Province { name: "Kiên Giang", lon_0: 104.5, id: "kiengiang", aliases: &["rachgia", "phuquoc"] }, + Vn2000Province { name: "Kon Tum", lon_0: 107.5, id: "kontum", aliases: &[] }, + Vn2000Province { name: "Lai Châu", lon_0: 103.0, id: "laichau", aliases: &[] }, + Vn2000Province { name: "Lạng Sơn", lon_0: 107.25, id: "langson", aliases: &[] }, + Vn2000Province { name: "Lào Cai", lon_0: 104.75, id: "laocai", aliases: &["sapa"] }, + Vn2000Province { name: "Lâm Đồng", lon_0: 107.75, id: "lamdong", aliases: &["dalat"] }, + Vn2000Province { name: "Long An", lon_0: 105.75, id: "longan", aliases: &["tanan"] }, + Vn2000Province { name: "Nam Định", lon_0: 105.5, id: "namdinh", aliases: &[] }, + Vn2000Province { name: "Nghệ An", lon_0: 104.75, id: "nghean", aliases: &["vinh"] }, + Vn2000Province { name: "Ninh Bình", lon_0: 105.0, id: "ninhbinh", aliases: &[] }, + Vn2000Province { name: "Ninh Thuận", lon_0: 108.25, id: "ninhthuan", aliases: &["phanrang"] }, + Vn2000Province { name: "Phú Thọ", lon_0: 104.75, id: "phutho", aliases: &["viettri"] }, + Vn2000Province { name: "Phú Yên", lon_0: 108.5, id: "phuyen", aliases: &["tuyhoa"] }, + Vn2000Province { name: "Quảng Bình", lon_0: 106.0, id: "quangbinh", aliases: &["donghoi"] }, + Vn2000Province { name: "Quảng Nam", lon_0: 107.75, id: "quangnam", aliases: &["tamky", "hoian"] }, + Vn2000Province { name: "Quảng Ngãi", lon_0: 108.0, id: "quangngai", aliases: &[] }, + Vn2000Province { name: "Quảng Ninh", lon_0: 107.75, id: "quangninh", aliases: &["halong"] }, + Vn2000Province { name: "Quảng Trị", lon_0: 106.25, id: "quangtri", aliases: &["dongha"] }, + Vn2000Province { name: "Sóc Trăng", lon_0: 105.5, id: "soctrang", aliases: &[] }, + Vn2000Province { name: "Sơn La", lon_0: 104.0, id: "sonla", aliases: &[] }, + Vn2000Province { name: "Tây Ninh", lon_0: 105.5, id: "tayninh", aliases: &[] }, + Vn2000Province { name: "Thái Bình", lon_0: 105.5, id: "thaibinh", aliases: &[] }, + Vn2000Province { name: "Thái Nguyên", lon_0: 106.5, id: "thainguyen", aliases: &[] }, + Vn2000Province { name: "Thanh Hóa", lon_0: 105.0, id: "thanhhoa", aliases: &[] }, + Vn2000Province { name: "Thừa Thiên Huế", lon_0: 107.0, id: "thuathienhue", aliases: &["hue"] }, + Vn2000Province { name: "Tiền Giang", lon_0: 105.75, id: "tiengiang", aliases: &["mytho"] }, + Vn2000Province { name: "TP. Hồ Chí Minh", lon_0: 105.75, id: "tphochiminh", aliases: &["tphcm", "saigon", "hochiminh", "hcm"] }, + Vn2000Province { name: "Trà Vinh", lon_0: 105.5, id: "travinh", aliases: &[] }, + Vn2000Province { name: "Tuyên Quang", lon_0: 106.0, id: "tuyenquang", aliases: &[] }, + Vn2000Province { name: "Vĩnh Long", lon_0: 105.5, id: "vinhlong", aliases: &[] }, + Vn2000Province { name: "Vĩnh Phúc", lon_0: 105.0, id: "vinhphuc", aliases: &["vinhyen"] }, + Vn2000Province { name: "Yên Bái", lon_0: 104.75, id: "yenbai", aliases: &[] }, +]; + +/// Distinct representative regional hubs for UI central meridian shortcut buttons. +/// +/// This is not an exhaustive list of all 63 provinces, but provides quick-access +/// shortcuts covering each major geographic zone: +/// - Northern delta: Hà Nội (105.0°) +/// - Southern region: TP. Hồ Chí Minh (105.75°) +/// - North Central: Thừa Thiên Huế (107.0°) +/// - Central Coast: Đà Nẵng (107.75°) +/// - South Central Coast: Khánh Hòa (108.25°) +/// - Central Highlands: Đắk Lắk (108.5°) +pub const VN2000_UI_PROVINCES: &[(&str, f64)] = &[ + ("Hà Nội", 105.0), + ("TP. Hồ Chí Minh", 105.75), + ("Thừa Thiên Huế", 107.0), + ("Đà Nẵng", 107.75), + ("Khánh Hòa", 108.25), + ("Đắk Lắk", 108.5), ]; /// A parameter a preset takes (opengeos/RustGIS#517): its PROJ name and the range, in degrees, @@ -63,7 +180,8 @@ pub fn preset_params(id: &str) -> &'static [PresetParam] { | "winkelTripel" | "eckertIV" | "northPolar" - | "southPolar" => &[LON_0], + | "southPolar" + | "vn2000tm3" => &[LON_0], "albers" | "lambertConformal" => &[LON_0, LAT_1, LAT_2], _ => &[], } @@ -85,7 +203,22 @@ pub fn preset_value(id: &str, key: &str) -> Option { pub fn parse_preset(code: &str) -> Result)>, String> { let (head, rest) = code.trim().split_once('+').unwrap_or((code.trim(), "")); let head = head.trim(); - let Some((id, _, _)) = PRESETS.iter().find(|(id, label, _)| id.eq_ignore_ascii_case(head) || label.eq_ignore_ascii_case(head)) else { + if rustgis_data::raster::crs::is_bare_vn2000(head) { + return Err(rustgis_data::raster::crs::bare_vn2000_error(head)); + } + // Matched before `resolve_vn2000_alias` below, which splits `VN-2000 / ` on `/`. + let vn2000 = rustgis_data::raster::crs::vn2000_epsg(head); + let Some((id, _, _)) = PRESETS.iter().find(|(id, label, _)| { + id.eq_ignore_ascii_case(head) || label.eq_ignore_ascii_case(head) || vn2000.is_some_and(|c| VN2000_PRESET_EPSG.contains(&(*id, c))) + }) else { + // `vn2000:hanoi`: VN-2000 / TM-3 on a province's meridian, written as that preset so a + // saved project's code never depends on the province names. + if rest.trim().is_empty() + && let Some(lon) = resolve_vn2000_alias(head) + { + let own = preset_value("vn2000tm3", "lon_0"); + return Ok(Some(("vn2000tm3", if own == Some(lon) { Vec::new() } else { vec![("lon_0", lon)] }))); + } return match (head, rest) { ("", _) if !rest.is_empty() => Err("PROJ strings aren't taken: pick a preset, or an EPSG or ESRI code".into()), (_, r) if r.contains('=') => Err(format!("only presets take parameters: '{head}' isn't one")), @@ -147,6 +280,61 @@ pub fn local_views_supported() -> bool { *SUPPORTED.get_or_init(|| Projector::new("local:0,0").is_ok()) } +/// Strips Vietnamese diacritics and converts to lowercase alphanumeric ASCII. +pub fn normalize_vietnamese(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + for lower in c.to_lowercase() { + match lower { + 'à' | 'á' | 'ả' | 'ã' | 'ạ' | 'â' | 'ầ' | 'ấ' | 'ẩ' | 'ẫ' | 'ậ' | 'ă' | 'ằ' | 'ắ' | 'ẳ' | 'ẵ' | 'ặ' => { + out.push('a') + } + 'è' | 'é' | 'ẻ' | 'ẽ' | 'ẹ' | 'ê' | 'ề' | 'ế' | 'ể' | 'ễ' | 'ệ' => out.push('e'), + 'ì' | 'í' | 'ỉ' | 'ĩ' | 'ị' => out.push('i'), + 'ò' | 'ó' | 'ỏ' | 'õ' | 'ọ' | 'ô' | 'ồ' | 'ố' | 'ổ' | 'ỗ' | 'ộ' | 'ơ' | 'ờ' | 'ớ' | 'ở' | 'ỡ' | 'ợ' => { + out.push('o') + } + 'ù' | 'ú' | 'ủ' | 'ũ' | 'ụ' | 'ư' | 'ừ' | 'ứ' | 'ử' | 'ữ' | 'ự' => out.push('u'), + 'ỳ' | 'ý' | 'ỷ' | 'ỹ' | 'ỵ' => out.push('y'), + 'đ' => out.push('d'), + c if c.is_ascii_alphanumeric() => out.push(c), + _ => {} + } + } + } + out +} + +/// Resolves a bare Vietnamese province name, ID, or alias to its VN-2000 central meridian (lon_0). +pub fn resolve_vn2000_province(name: &str) -> Option { + let norm = normalize_vietnamese(name.trim()); + if norm.is_empty() { + return None; + } + for prov in VN2000_PROVINCES { + if norm == prov.id || prov.aliases.iter().any(|&a| norm == a) { + return Some(prov.lon_0); + } + } + None +} + +/// Resolves an explicitly prefixed VN-2000 alias (e.g. `vn2000:hcm`, `vn2000/danang`, `vn2000tm3:vinh`) +/// to its central meridian (lon_0). Explicit prefix is required to prevent bare names from hijacking lookup. +pub fn resolve_vn2000_alias(code: &str) -> Option { + let trimmed = code.trim(); + let rest = [":", "/"].iter().find_map(|sep| { + let (prefix, rest) = trimmed.split_once(sep)?; + let prefix = prefix.trim(); + (prefix.eq_ignore_ascii_case("vn2000") + || prefix.eq_ignore_ascii_case("vn-2000") + || prefix.eq_ignore_ascii_case("vn2000tm3") + || prefix.eq_ignore_ascii_case("vn-2000-tm3")) + .then_some(rest) + })?; + resolve_vn2000_province(rest) +} + /// The PROJ definition and label of a projection code: a preset id, `EPSG:`, `ESRI:` /// or `local:lon,lat`. pub fn lookup(code: &str) -> Result<(String, String), String> { @@ -702,7 +890,7 @@ mod tests { assert!(lookup("epsg:+3035").unwrap_err().contains("unknown")); // Every preset is classified: the regional grids alone take nothing. for (id, _, _) in PRESETS { - assert_eq!(preset_params(id).is_empty(), ["europeEqualArea", "usaAlbers"].contains(id), "{id}"); + assert_eq!(preset_params(id).is_empty(), ["europeEqualArea", "usaAlbers", "vn2000Utm48", "vn2000Utm49"].contains(id), "{id}"); } for bad in [ "robinson +lon_0=181", @@ -846,11 +1034,16 @@ mod tests { fn presets_and_epsg_codes() { for (id, _, _) in PRESETS { let p = Projector::new(id).unwrap_or_else(|e| panic!("{id}: {e}")); - let c = Coord::new(10.0, if id.contains("south") { -75.0 } else { 50.0 }); + let c = match *id { + "vn2000tm3" | "vn2000Utm48" | "vn2000Utm49" => Coord::new(105.0, 21.0), + _ => Coord::new(10.0, if id.contains("south") { -75.0 } else { 50.0 }), + }; let m = p.forward(c).unwrap_or_else(|| panic!("{id}")); let back = p.inverse(m).unwrap(); assert!((back.x - c.x).abs() < 1e-6 && (back.y - c.y).abs() < 1e-6, "{id}: {back:?}"); } + assert_eq!(preset_params("vn2000tm3"), &[LON_0]); + assert_eq!(preset_params("vn2000"), &[]); assert!(Projector::new("EPSG:3035").is_ok()); assert!(Projector::new("epsg:32633").is_ok()); assert!(Projector::new("3857").is_ok()); @@ -1089,4 +1282,213 @@ mod tests { "PDC Mercator: lon_0 = 150" ); } + + #[test] + fn vn2000_vietnam_projections() { + // The presets are EPSG's VN-2000 systems word for word. + for (id, code) in [("vn2000tm3", 5897), ("vn2000Utm48", 3405), ("vn2000Utm49", 3406)] { + let def = PRESETS.iter().find(|(p, _, _)| *p == id).map(|(_, _, d)| *d); + assert_eq!(def, rustgis_data::raster::crs::epsg_proj4(code), "{id}"); + } + + // `vietnam` names no projection: the country spans two UTM zones and many TM-3 meridians. + assert!(lookup("vietnam").is_err()); + // Bare VN-2000 is ambiguous and rejected + for bare in ["vn2000", "VN-2000", "VN2000", "vn-2000", "VN 2000", "vn_2000"] { + let err = lookup(bare).unwrap_err(); + assert!(err.contains("ambiguous"), "{bare}: {err}"); + assert!(parse_preset(bare).is_err(), "{bare}"); + } + + // The presets and `Crs::parse` (rasters, command parameters) take the same names. + for (name, id, code) in [ + ("vn2000_utm48", "vn2000Utm48", 3405), + ("vn-2000-utm48", "vn2000Utm48", 3405), + ("VN200048", "vn2000Utm48", 3405), + ("VN-2000 / UTM zone 49", "vn2000Utm49", 3406), + ("VN2000TM3105", "vn2000tm3", 5897), + ("vn2000 tm-3", "vn2000tm3", 5897), + ] { + assert_eq!(parse_preset(name).unwrap().map(|(p, _)| p), Some(id), "{name}"); + assert_eq!(rustgis_data::raster::crs::Crs::parse(name).map(|c| c.epsg()), Ok(code), "{name}"); + } + + // Explicit TM-3 alias lookups + for name in ["vn2000tm3", "vn2000-tm3", "vn2000_tm3", "vn-2000-tm3", "VN-2000 / TM-3", "vn-2000 / tm-3", "VN2000 / TM-3"] { + let (def, label) = lookup(name).unwrap(); + assert!(def.contains("+proj=tmerc") && def.contains("+towgs84="), "{name}"); + assert!(def.contains("+lon_0=105") && def.contains("+k=0.9999"), "{name}"); + assert_eq!(label, "VN-2000 / TM-3 (Vietnam)", "{name}"); + } + for name in [ + "vn2000Utm48", + "vn-2000-48", + "vn2000-48", + "vn2000utm48", + "VN-2000 / UTM zone 48N", + "vn-2000 / utm zone 48n", + "VN-2000 / UTM 48N", + ] { + let (def, label) = lookup(name).unwrap(); + assert!(def.contains("+proj=utm") && def.contains("+zone=48"), "{name}"); + assert_eq!(label, "VN-2000 / UTM zone 48N", "{name}"); + } + for name in [ + "vn2000Utm49", + "vn-2000-49", + "vn2000-49", + "vn2000utm49", + "VN-2000 / UTM zone 49N", + "vn-2000 / utm zone 49n", + "VN-2000 / UTM 49N", + ] { + let (def, label) = lookup(name).unwrap(); + assert!(def.contains("+proj=utm") && def.contains("+zone=49"), "{name}"); + assert_eq!(label, "VN-2000 / UTM zone 49N", "{name}"); + } + + // Bare province names and aliases must NOT hijack lookup: they must return unknown projection error + for bare in [ + "hanoi", + "Hà Nội", + "tphcm", + "TP. Hồ Chí Minh", + "saigon", + "Sài Gòn", + "danang", + "Đà Nẵng", + "Gia Lai", + "Đắk Lắk", + "Huế", + "Nghệ An", + "vinh", + "hue", + "sapa", + "hcm", + "daknong", + "Đắk Nông", + "haugiang", + "Hậu Giang", + "atlantis", + "unknown_province_xyz", + ] { + assert!(lookup(bare).is_err(), "{bare} should not be recognized as bare projection"); + } + + // Explicitly prefixed aliases resolve correctly + assert_eq!(lookup("vn2000:hanoi").unwrap().0, lookup("vn2000tm3 +lon_0=105").unwrap().0); + assert_eq!(lookup("VN2000:hanoi").unwrap().0, lookup("vn2000tm3 +lon_0=105").unwrap().0); + assert_eq!(lookup("VN-2000:Hà Nội").unwrap().0, lookup("vn2000tm3 +lon_0=105").unwrap().0); + assert_eq!(lookup("VN2000/Hà Nội").unwrap().0, lookup("vn2000tm3 +lon_0=105").unwrap().0); + assert_eq!(lookup("vn2000:tphcm").unwrap().0, lookup("vn2000tm3 +lon_0=105.75").unwrap().0); + assert_eq!(lookup("VN2000:TP. Hồ Chí Minh").unwrap().0, lookup("vn2000tm3 +lon_0=105.75").unwrap().0); + assert_eq!(lookup("vn2000:saigon").unwrap().0, lookup("vn2000tm3 +lon_0=105.75").unwrap().0); + assert_eq!(lookup("vn2000:danang").unwrap().0, lookup("vn2000tm3 +lon_0=107.75").unwrap().0); + assert_eq!(lookup("VN2000/Đà Nẵng").unwrap().0, lookup("vn2000tm3 +lon_0=107.75").unwrap().0); + assert_eq!(lookup("vn2000tm3:danang").unwrap().0, lookup("vn2000tm3 +lon_0=107.75").unwrap().0); + assert_eq!(lookup("vn2000:vinh").unwrap().0, lookup("vn2000tm3 +lon_0=104.75").unwrap().0); + assert_eq!(lookup("vn2000:hue").unwrap().0, lookup("vn2000tm3 +lon_0=107").unwrap().0); + assert_eq!(lookup("vn2000:sapa").unwrap().0, lookup("vn2000tm3 +lon_0=104.75").unwrap().0); + assert_eq!(lookup("vn2000:hcm").unwrap().0, lookup("vn2000tm3 +lon_0=105.75").unwrap().0); + assert_eq!(lookup("vn2000:daknong").unwrap().0, lookup("vn2000tm3 +lon_0=108.5").unwrap().0); + assert_eq!(lookup("vn2000:haugiang").unwrap().0, lookup("vn2000tm3 +lon_0=105").unwrap().0); + + // Numeric EPSG codes and unrelated projections must never be intercepted by province alias resolution + assert_eq!(lookup("3405").unwrap().1, "EPSG:3405"); + assert_eq!(lookup("3406").unwrap().1, "EPSG:3406"); + assert_eq!(lookup("3035").unwrap().1, "EPSG:3035"); + assert_eq!(lookup("EPSG:3035").unwrap().1, "EPSG:3035"); + assert_eq!(lookup("ESRI:102033").unwrap().1, "ESRI:102033"); + assert_eq!(lookup("robinson").unwrap().1, "Robinson"); + + // Unknown aliases and malformed parameters must return errors + assert!(lookup("unknown_province_xyz").is_err()); + assert!(lookup("atlantis").is_err()); + assert!(lookup("vn2000tm3 +lon_0=abc").is_err()); + assert!(lookup("vn2000tm3 +lon_0=999").is_err()); + assert!(lookup("vn2000tm3 +invalid=10").is_err()); + + // Parameter replacement preserves all other PROJ parameters uncorrupted + let (danang_def, _) = lookup("vn2000:danang").unwrap(); + assert!(danang_def.contains("+proj=tmerc")); + assert!(danang_def.contains("+lat_0=0")); + assert!(danang_def.contains("+lon_0=107.75")); + assert!(danang_def.contains("+k=0.9999")); + assert!(danang_def.contains("+x_0=500000")); + assert!(danang_def.contains("+y_0=0")); + assert!(danang_def.contains("+ellps=WGS84")); + assert!(danang_def.contains("+towgs84=")); + assert!(danang_def.contains("+units=m")); + assert!(danang_def.contains("+no_defs")); + + // All 63 provinces in VN2000_PROVINCES resolve via resolve_vn2000_province and prefixed lookup + assert_eq!(VN2000_PROVINCES.len(), 63); + for prov in VN2000_PROVINCES { + let lon = resolve_vn2000_province(prov.name).unwrap(); + assert_eq!(lon, prov.lon_0, "{}", prov.name); + let prefixed = format!("vn2000:{}", prov.id); + let (def, _) = lookup(&prefixed).unwrap(); + if (prov.lon_0 - 105.0).abs() < 1e-6 { + assert!(def.contains("+lon_0=105"), "{}: {def}", prov.name); + } else { + assert!(def.contains(&format!("+lon_0={}", prov.lon_0)), "{}: {def}", prov.name); + } + assert!(Projector::new(&prefixed).is_ok(), "{}", prov.name); + } + + // Every id is its name normalised. + for prov in VN2000_PROVINCES { + assert_eq!(prov.id, normalize_vietnamese(prov.name), "{}", prov.name); + } + // A province is saved as the preset on its meridian, so projects don't depend on the names. + assert_eq!(parse_preset("vn2000:tphcm"), Ok(Some(("vn2000tm3", vec![("lon_0", 105.75)])))); + assert_eq!(parse_preset("VN2000/Hà Nội"), Ok(Some(("vn2000tm3", vec![])))); + assert_eq!(parse_preset("VN-2000 / Đà Nẵng"), Ok(Some(("vn2000tm3", vec![("lon_0", 107.75)])))); + assert_eq!(parse_preset("hanoi"), Ok(None)); + assert!(parse_preset("vn2000:hanoi +lon_0=106").is_err(), "a province takes no parameters"); + assert_eq!(lookup("vn2000:tphcm").unwrap().1, "VN-2000 / TM-3 (Vietnam) (lon_0=105.75)"); + assert_eq!(resolve_vn2000_province("vithanh"), Some(105.0)); + assert_eq!(resolve_vn2000_province("vitri"), Some(105.0)); + assert_eq!(parse_preset("vn2000:vithanh"), Ok(Some(("vn2000tm3", vec![])))); + assert_eq!(parse_preset("vn2000:vitri"), Ok(Some(("vn2000tm3", vec![])))); + + // All UI shortcut hubs are canonical entries with matching central meridians + for &(ui_name, ui_lon) in VN2000_UI_PROVINCES { + let prov = VN2000_PROVINCES.iter().find(|p| p.name == ui_name).unwrap(); + assert_eq!(prov.lon_0, ui_lon, "{ui_name}"); + } + + // Decomposed NFD Vietnamese unicode normalization + let nfd_hanoi = "Ha\u{300} N\u{f4}\u{323}i"; + assert_eq!(lookup(&format!("vn2000:{nfd_hanoi}")).unwrap().0, lookup("vn2000tm3 +lon_0=105").unwrap().0); + + // Forward and inverse coordinate projection accuracy + // Hanoi (Hoan Kiem lake: 105.8525°E, 21.0285°N) in VN-2000 TM-3 (lon_0 = 105.0) + let p_hn = Projector::new("vn2000tm3 +lon_0=105").unwrap(); + let c_hn = Coord::new(105.8525, 21.0285); + let m_hn = p_hn.forward(c_hn).unwrap(); + // False easting is 500,000m, point is east of 105°E so x > 500,000; latitude ~21° so y ~ 2,326,000m + assert!(m_hn.x > 500_000.0 && m_hn.x < 700_000.0); + assert!(m_hn.y > 2_000_000.0 && m_hn.y < 2_500_000.0); + let back_hn = p_hn.inverse(m_hn).unwrap(); + assert!((back_hn.x - c_hn.x).abs() < 1e-6 && (back_hn.y - c_hn.y).abs() < 1e-6); + + // Ho Chi Minh City (Ben Thanh: 106.6983°E, 10.7725°N) in VN-2000 TM-3 (lon_0 = 105.75) + let p_hcm = Projector::new("vn2000tm3 +lon_0=105.75").unwrap(); + let c_hcm = Coord::new(106.6983, 10.7725); + let m_hcm = p_hcm.forward(c_hcm).unwrap(); + assert!(m_hcm.x > 500_000.0 && m_hcm.x < 700_000.0); + assert!(m_hcm.y > 1_100_000.0 && m_hcm.y < 1_300_000.0); + let back_hcm = p_hcm.inverse(m_hcm).unwrap(); + assert!((back_hcm.x - c_hcm.x).abs() < 1e-6 && (back_hcm.y - c_hcm.y).abs() < 1e-6); + + // UTM 48N and 49N roundtrip + for utm in ["vn2000Utm48", "vn2000Utm49"] { + let p = Projector::new(utm).unwrap(); + let c = Coord::new(106.0, 15.0); + let m = p.forward(c).unwrap(); + let back = p.inverse(m).unwrap(); + assert!((back.x - c.x).abs() < 1e-6 && (back.y - c.y).abs() < 1e-6); + } + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9f1c80e8d..906465381 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1195,7 +1195,7 @@ fn coordinate_system(app: &mut RustGisApp, ui: &mut egui::Ui, t: &Tokens) -> boo ui.separator(); ui.horizontal(|ui| { ui.label(tl!("EPSG or ESRI code")); - let r = ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text("3035").desired_width(110.0)); + let r = ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text("3035, vn2000tm3").desired_width(120.0)); if r.changed() && !app.dialogs.a.trim().is_empty() { app.dialogs.text = typed_crs(&app.dialogs.a); } @@ -1232,16 +1232,38 @@ fn coordinate_system(app: &mut RustGisApp, ui: &mut egui::Ui, t: &Tokens) -> boo close } +/// Formats degrees as an integer if whole (e.g. 105°), or with decimal places if fractional (e.g. 105.75°). +pub(crate) fn format_degrees(deg: f64) -> String { + if (deg - deg.round()).abs() < 1e-6 { format!("{:.0}°", deg) } else { format!("{deg}°") } +} + /// The chosen preset's parameters (opengeos/RustGIS#517): the central meridian, and a conic's /// standard parallels, each written back into the chosen code. Nothing for a preset without any. fn preset_fields(app: &mut RustGisApp, ui: &mut egui::Ui) { - use rustgis_engine::map::projection::{parse_preset, preset_code, preset_params, preset_value}; + use rustgis_engine::map::projection::{VN2000_UI_PROVINCES, parse_preset, preset_code, preset_params, preset_value}; + // A province typed by name is already the preset (`typed_crs`), and `vn2000:hanoi` is one too. let Ok(Some((id, params))) = parse_preset(&app.dialogs.text) else { return }; let takes = preset_params(id); if takes.is_empty() { return; } let mut next = params.clone(); + if id == "vn2000tm3" { + let default_lon = preset_value(id, "lon_0").unwrap_or(105.0); + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(tl!("Provinces:")).weak()); + for &(p_name, lon) in VN2000_UI_PROVINCES { + let label = crate::i18n::fmt("{name} ({lon})", &[("name", p_name), ("lon", &format_degrees(lon))]); + if ui.small_button(&label).clicked() { + next.retain(|(k, _)| *k != "lon_0"); + if (lon - default_lon).abs() > 1e-6 { + next.push(("lon_0", lon)); + } + } + } + }); + ui.add_space(2.0); + } egui::Grid::new("preset_params").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { for p in takes { let label = match p.key { @@ -1275,10 +1297,17 @@ fn preset_fields(app: &mut RustGisApp, ui: &mut egui::Ui) { } /// A typed coordinate system code: `ESRI:102033` or `Epsg: 3035` as it is (`lookup` reads the -/// prefix in any case and spacing), a bare number as an EPSG code. +/// prefix in any case and spacing), a bare number as an EPSG code, or a Vietnamese province with +/// the `vn2000:` prefix (`vn2000:hanoi`); a bare name such as `hue` is not taken for one. pub(crate) fn typed_crs(text: &str) -> String { let text = text.trim(); - if text.starts_with(|c: char| c.is_ascii_digit()) { format!("EPSG:{text}") } else { text.to_string() } + if text.starts_with(|c: char| c.is_ascii_digit()) { + format!("EPSG:{text}") + } else if let Some(lon) = rustgis_engine::map::projection::resolve_vn2000_alias(text) { + format!("vn2000tm3 +lon_0={lon}") + } else { + text.to_string() + } } /// What a coordinate system is: its kind, area of use, definition and epsg.io page. @@ -1302,29 +1331,43 @@ fn crs_details(ui: &mut egui::Ui, chosen: &str, current: &str, t: &Tokens) { let preset = rustgis_engine::map::projection::parse_preset(&code).ok().flatten(); let tuned = preset.as_ref().is_some_and(|(_, p)| !p.is_empty()); let (kind, page): (&str, Option) = match preset.map_or(code.as_str(), |(id, _)| id) { - "mercator" => ("World, conformal: the usual web map; areas grow towards the poles.", Some("3857".into())), - "globe" => ("A 3D globe seen from space.", None), - "equirectangular" => ("World, cylindrical: equal steps of longitude and latitude.", Some("4087".into())), - "miller" => ("World, cylindrical: a compromise between Mercator and equal steps.", Some("54003".into())), - "mollweide" => ("World, equal-area: true sizes, shapes bend towards the edges.", Some("54009".into())), - "cylindricalEqualArea" => ("World, equal-area, cylindrical: true sizes, flattened near the poles.", Some("54034".into())), - "robinson" => ("World, compromise: neither equal-area nor conformal, balanced for wall maps.", Some("54030".into())), - "equalEarth" => ("World, equal-area: true sizes with a Robinson-like look.", Some("8857".into())), - "naturalEarth" => ("World, compromise: rounded corners, gentle shapes near the poles.", None), - "winkelTripel" => ("World, compromise: low overall distortion, used for world atlases.", Some("54042".into())), - "eckertIV" => ("World, equal-area: true sizes with rounded poles.", Some("54012".into())), - "northPolar" => ("Polar, conformal: the Arctic and the northern lands.", None), - "southPolar" => ("Polar, conformal: Antarctica and the Southern Ocean.", Some("3031".into())), - "europeEqualArea" => ("Regional, equal-area: the European standard for statistics.", Some("3035".into())), - "usaAlbers" => ("Regional, equal-area: the contiguous United States.", Some("5070".into())), - "albers" => ("Conic, equal-area: true sizes across a mid-latitude region; set its meridian and parallels to the region.", None), + "mercator" => (tl!("World, conformal: the usual web map; areas grow towards the poles."), Some("3857".into())), + "globe" => (tl!("A 3D globe seen from space."), None), + "equirectangular" => (tl!("World, cylindrical: equal steps of longitude and latitude."), Some("4087".into())), + "miller" => (tl!("World, cylindrical: a compromise between Mercator and equal steps."), Some("54003".into())), + "mollweide" => (tl!("World, equal-area: true sizes, shapes bend towards the edges."), Some("54009".into())), + "cylindricalEqualArea" => (tl!("World, equal-area, cylindrical: true sizes, flattened near the poles."), Some("54034".into())), + "robinson" => (tl!("World, compromise: neither equal-area nor conformal, balanced for wall maps."), Some("54030".into())), + "equalEarth" => (tl!("World, equal-area: true sizes with a Robinson-like look."), Some("8857".into())), + "naturalEarth" => (tl!("World, compromise: rounded corners, gentle shapes near the poles."), None), + "winkelTripel" => (tl!("World, compromise: low overall distortion, used for world atlases."), Some("54042".into())), + "eckertIV" => (tl!("World, equal-area: true sizes with rounded poles."), Some("54012".into())), + "northPolar" => (tl!("Polar, conformal: the Arctic and the northern lands."), None), + "southPolar" => (tl!("Polar, conformal: Antarctica and the Southern Ocean."), Some("3031".into())), + "europeEqualArea" => (tl!("Regional, equal-area: the European standard for statistics."), Some("3035".into())), + "usaAlbers" => (tl!("Regional, equal-area: the contiguous United States."), Some("5070".into())), + "albers" => { + (tl!("Conic, equal-area: true sizes across a mid-latitude region; set its meridian and parallels to the region."), None) + } "lambertConformal" => { - ("Conic, conformal: true shapes across a mid-latitude region; set its meridian and parallels to the region.", None) + (tl!("Conic, conformal: true shapes across a mid-latitude region; set its meridian and parallels to the region."), None) } - _ => ("A projected coordinate system.", epsg.clone()), + "vn2000tm3" => ( + tl!( + "Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84." + ), + Some("5897".into()), + ), + "vn2000Utm48" => { + (tl!("Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E)."), Some("3405".into())) + } + "vn2000Utm49" => { + (tl!("Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E)."), Some("3406".into())) + } + _ => (tl!("A projected coordinate system."), epsg.clone()), }; let page = page.filter(|_| !tuned); - ui.label(tl!(kind)); + ui.label(kind); if let Ok(p) = Projector::new(&code) { let area = match p.domain() { Domain::World => tl!("Area of use: the whole world").to_string(), @@ -1430,4 +1473,69 @@ mod tests { h.run_steps(3); assert_eq!(h.state().session.map().projection.code(), "robinson +lon_0=150"); } + + #[test] + fn vn2000_in_coordinate_system_dialog() { + let mut app = RustGisApp::with_sample(); + app.run("ui.dialog", json!({"name": "coordinateSystem"})).unwrap(); + // A bare "vn2000" names no projection: it is passed on, and refused as ambiguous. + app.dialogs.a = "vn2000".into(); + app.dialogs.text = typed_crs(&app.dialogs.a); + assert_eq!(app.dialogs.text, "vn2000"); + let err = rustgis_engine::map::Projection::from_code(&app.dialogs.text).unwrap_err(); + assert!(err.contains("ambiguous"), "{err}"); + + // Explicit "vn2000tm3" resolves to VN-2000 TM-3 preset + app.dialogs.a = "vn2000tm3".into(); + app.dialogs.text = typed_crs(&app.dialogs.a); + assert_eq!(app.dialogs.text, "vn2000tm3"); + let proj = rustgis_engine::map::Projection::from_code(&app.dialogs.text).unwrap(); + assert_eq!(proj.label(), "VN-2000 / TM-3 (Vietnam)"); + + // Explicit "VN-2000 / UTM zone 48N" resolves to UTM 48N + let proj_utm = rustgis_engine::map::Projection::from_code("VN-2000 / UTM zone 48N").unwrap(); + assert_eq!(proj_utm.label(), "VN-2000 / UTM zone 48N"); + + // A province with the vn2000: prefix, in ASCII or Vietnamese. + for (name, code) in [ + ("vn2000:hanoi", "vn2000tm3"), + ("vn2000:Hà Nội", "vn2000tm3"), + ("VN2000/Đà Nẵng", "vn2000tm3 +lon_0=107.75"), + ("vn2000:tphcm", "vn2000tm3 +lon_0=105.75"), + ("vn2000:vithanh", "vn2000tm3"), + ("vn2000:vitri", "vn2000tm3"), + ] { + app.dialogs.a = name.into(); + app.dialogs.text = typed_crs(&app.dialogs.a); + let proj_p = rustgis_engine::map::Projection::from_code(&app.dialogs.text).unwrap(); + assert!(proj_p.label().contains("VN-2000"), "{name}"); + assert_eq!(proj_p.code(), code, "{name}"); + } + // A bare province name is not a coordinate system: words such as "hue" stay as typed, + // as `map.projection` reads them. + for name in ["hanoi", "Hà Nội", "hue", "vinh", "tphcm", "vithanh", "vitri", "vietnam"] { + assert_eq!(typed_crs(name), name); + assert!(rustgis_engine::map::Projection::from_code(name).is_err(), "{name}"); + } + // A prefixed province given to the command is saved as the preset too. + app.run("map.projection", json!({"type": "VN2000/Đà Nẵng"})).unwrap(); + assert_eq!(app.session.map().projection.code(), "vn2000tm3 +lon_0=107.75"); + + // Applying VN-2000 TM-3 + app.run("map.projection", json!({"type": "vn2000tm3"})).unwrap(); + assert_eq!(app.session.map().projection.code(), "vn2000tm3"); + + // Custom meridian for HCMC (105.75) + app.run("map.projection", json!({"type": "vn2000tm3 +lon_0=105.75"})).unwrap(); + assert_eq!(app.session.map().projection.code(), "vn2000tm3 +lon_0=105.75"); + } + + #[test] + fn ui_degree_formatting_preserves_fractions() { + assert_eq!(format_degrees(105.0), "105°"); + assert_eq!(format_degrees(107.0), "107°"); + assert_eq!(format_degrees(105.75), "105.75°"); + assert_eq!(format_degrees(108.5), "108.5°"); + assert_eq!(format_degrees(104.25), "104.25°"); + } } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index f1874ab40..efa46b98b 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -226,3 +226,24 @@ fn registry_process_plugin_notes_are_in_every_catalog() { } } } + +#[test] +fn vn2000_translations_in_vietnamese_catalog() { + let vi = Lang::from_code("vi").unwrap(); + assert_eq!(tr(vi, "Provinces:"), "Tỉnh thành:"); + assert_eq!( + tr( + vi, + "Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84." + ), + "Hệ tọa độ quốc gia Việt Nam (VN-2000) dùng phép chiếu Transverse Mercator 3 độ với phép chuyển đổi 7 tham số Helmert sang WGS 84." + ); + assert_eq!( + tr(vi, "Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E)."), + "Hệ tọa độ quốc gia Việt Nam (VN-2000) múi chiếu UTM 48N (102°Đ–108°Đ, kinh tuyến trục 105°Đ)." + ); + assert_eq!( + tr(vi, "Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E)."), + "Hệ tọa độ quốc gia Việt Nam (VN-2000) múi chiếu UTM 49N (108°Đ–114°Đ, kinh tuyến trục 111°Đ)." + ); +} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 1f6491006..225286fea 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2010,6 +2010,7 @@ project's path is required cần có path của project Projection Phép chiếu Projections Các phép chiếu Proportional Symbols Ký hiệu tỷ lệ +Provinces: Tỉnh thành: Provider Nhà cung cấp Proximity Lân cận Public GIS portals of US counties and cities, by state. Các cổng GIS công khai của các quận và thành phố Hoa Kỳ, theo bang. @@ -2937,6 +2938,9 @@ View Xem View Clipping Cắt khung nhìn View Effects Hiệu ứng khung nhìn View Extent Phạm vi khung nhìn +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Hệ tọa độ quốc gia Việt Nam (VN-2000) múi chiếu UTM 48N (102°Đ–108°Đ, kinh tuyến trục 105°Đ). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Hệ tọa độ quốc gia Việt Nam (VN-2000) múi chiếu UTM 49N (108°Đ–114°Đ, kinh tuyến trục 111°Đ). +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Hệ tọa độ quốc gia Việt Nam (VN-2000) dùng phép chiếu Transverse Mercator 3 độ với phép chuyển đổi 7 tham số Helmert sang WGS 84. View In Google Earth Xem trong Google Earth View In Google Maps Xem trong Google Maps View Unplaced Xem nhãn chưa đặt From f1daf6a7322ed581b9028ccc9de40e8e5b52cee9 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:00:41 +0200 Subject: [PATCH 039/297] feat(ui): manage connections in the Data Sources pane (#705) * feat(ui): manage connections in the Data Sources pane * fix(ui): invalidate connection test feedback after edits --- crates/engine/src/cmd/connections.rs | 107 +++++++- crates/engine/src/cmd/stac.rs | 29 +- crates/engine/src/tests.rs | 48 ++++ crates/ui-egui/src/chrome.rs | 1 + crates/ui-egui/src/connectionui.rs | 384 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/lib.rs | 15 ++ crates/ui-egui/src/s3ui.rs | 2 +- crates/ui-egui/src/sourcespane.rs | 169 +++++++----- crates/ui-egui/tests/smoke.rs | 203 ++++++++++++++ docs/comparison/index.md | 2 +- docs/user-guide/data.md | 34 ++- 12 files changed, 899 insertions(+), 99 deletions(-) create mode 100644 crates/ui-egui/src/connectionui.rs diff --git a/crates/engine/src/cmd/connections.rs b/crates/engine/src/cmd/connections.rs index 9b02884c2..833ffc6d4 100644 --- a/crates/engine/src/cmd/connections.rs +++ b/crates/engine/src/cmd/connections.rs @@ -5,9 +5,9 @@ use std::collections::BTreeMap; use serde_json::{Value, json}; -use super::ps; +use super::{b, ps}; use crate::connections::{Connection, DATABASE_KINDS, DESKTOP_ONLY, KINDS}; -use crate::render::rustgis_tiles::s3; +use crate::render::rustgis_tiles::{s3, stac}; use crate::{CmdResult, CommandSpec as C, Session}; pub fn commands() -> Vec { @@ -27,7 +27,7 @@ pub fn commands() -> Vec { r#"{"id": "a connection's id or name", "password"?: "a database connection's, kept for this session only", "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "an S3 connection's keys (credentials: keys), kept for this session only"}"#, ), C::new("connection.test", "Test Connection", "Home › Add › Data Sources", test).view().params( - r#"{"id": "a connection's id or name", "password"?: text, "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "as connection.login (logs in first)"}"#, + r#"{"id"?: "a connection's id or name", "kind"?: "postgres"|"mysql"|"sqlserver"|"service"|"wms"|"wmts"|"wfs"|"wcs"|"ogcapi"|"arcgis"|"csw"|"stac"|"s3", "name"?: text, "group"?: text, "address"?: text, "options"?: "an unsaved connection, as connection.new (when no id)", "password"?: text, "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "as connection.login (logs in first)", "probe"?: "true: only the address the test reads, {fetch} (null for databases, which their driver tests)", "answer"?: "non-database kinds only: what that address returned, when the app fetched it (no network request then); databases ignore answer and test through their driver"}"#, ), ] } @@ -217,11 +217,94 @@ fn login_command(s: &mut Session, p: &Value) -> CmdResult { Ok(shown(&c)) } +fn tested(s: &Session, p: &Value) -> Result { + if ps(p, "id").is_some() { + return Ok(found(s, p)?.clone()); + } + let c = Connection { + kind: ps(p, "kind").ok_or_else(|| format!("id, or kind and address, is required ({})", KINDS.join(", ")))?.trim().into(), + address: ps(p, "address").unwrap_or("").into(), + name: ps(p, "name").unwrap_or("").into(), + group: ps(p, "group").unwrap_or("").into(), + options: p.get("options").map(options).transpose()?.unwrap_or_default(), + ..Connection::default() + }; + Ok(crate::connections::clean(c)?.0) +} + +fn first_bucket(c: &Connection) -> Option { + c.options.get("buckets")?.split(',').map(str::trim).find(|b| !b.is_empty()).map(str::to_owned) +} + +fn probe_url(s: &Session, c: &Connection) -> Result, String> { + let url = match c.kind.as_str() { + "postgres" | "mysql" | "sqlserver" => return Ok(None), + "service" => rustgis_data::ogc::capabilities_url(&c.address, None), + "wms" | "wmts" | "wfs" | "wcs" | "ogcapi" | "arcgis" => rustgis_data::ogc::capabilities_url(&c.address, Some(&c.kind)), + "csw" => super::service::records_request(s, &json!({"url": c.address}))?, + "stac" => { + let api = stac::api(Some(&c.address))?; + if stac::catalog::is_static(&api) { api } else { stac::collections_url(&api) } + } + "s3" => { + let conn = s3::connection(&c.name).ok_or("log in first: connection.login {id, accessKeyId, secretAccessKey}")?; + if let Some(bucket) = first_bucket(c) { + s3::grant(&conn.name, &bucket); + s3::list_url(&bucket, "", None, Some(&conn)) + } else if conn.credentials.is_some() { + s3::buckets_url(&conn) + } else { + return Err("name a bucket under Buckets to test an anonymous S3 connection".into()); + } + } + _ => return Err(format!("unknown kind '{}'", c.kind)), + }; + Ok(Some(url)) +} + +fn answered(s: &mut Session, c: &Connection, text: &str) -> CmdResult { + match c.kind.as_str() { + "service" => super::service::layers(s, &json!({"url": c.address, "text": text})), + "wms" | "wmts" | "wfs" | "wcs" | "ogcapi" | "arcgis" => { + super::service::layers(s, &json!({"url": c.address, "type": c.kind, "text": text})) + } + "csw" => super::service::catalog_search(s, &json!({"url": c.address, "text": text})), + "stac" => super::stac::collections_json(&stac::api(Some(&c.address))?, text.as_bytes()), + "s3" => { + if let Some(bucket) = first_bucket(c) { + let l = s3::parse_list(text.as_bytes())?; + Ok(json!({"bucket": bucket, "count": l.prefixes.len() + l.objects.len(), "truncated": l.next.is_some()})) + } else { + Ok(json!({"buckets": s3::parse_buckets(text.as_bytes())?})) + } + } + _ => Err(format!("unknown kind '{}'", c.kind)), + } +} + fn test(s: &mut Session, p: &Value) -> CmdResult { - let c = found(s, p)?.clone(); - if ["password", "accessKeyId", "secretAccessKey", "sessionToken"].iter().any(|k| ps(p, k).is_some_and(|v| !v.is_empty())) { + let c = tested(s, p)?; + let secrets = ["password", "accessKeyId", "secretAccessKey", "sessionToken"].iter().any(|k| ps(p, k).is_some_and(|v| !v.is_empty())); + if secrets { login(s, &c, p)?; } + if c.kind == "s3" && !secrets && c.options.get("credentials").is_none_or(|v| v != "keys") { + login(s, &c, &Value::Null)?; + } + let mut out = json!({}); + if !c.id.is_empty() { + out["connection"] = json!(c.id); + } + if b(p, "probe").unwrap_or(false) { + out["fetch"] = json!(probe_url(s, &c)?); + return Ok(out); + } + if let Some(text) = ps(p, "answer") + && !DATABASE_KINDS.contains(&c.kind.as_str()) + { + out["result"] = answered(s, &c, text)?; + return Ok(out); + } let result = match c.kind.as_str() { "postgres" | "mysql" | "sqlserver" => { if cfg!(target_arch = "wasm32") { @@ -234,15 +317,15 @@ fn test(s: &mut Session, p: &Value) -> CmdResult { "csw" => super::service::catalog_search(s, &json!({"url": c.address}))?, "stac" => super::stac::collections(s, &json!({"url": c.address}))?, "s3" => { - if s3::connection(&c.name).is_none() { - if c.options.get("credentials").is_some_and(|v| v == "keys") { - return Err("log in first: connection.login {id, accessKeyId, secretAccessKey}".into()); - } - login(s, &c, &Value::Null)?; + s3::connection(&c.name).ok_or("log in first: connection.login {id, accessKeyId, secretAccessKey}")?; + let mut q = json!({"connection": c.name, "limit": 100}); + if let Some(bucket) = first_bucket(&c) { + q["bucket"] = json!(bucket); } - super::s3::list(s, &json!({"connection": c.name, "limit": 100}))? + super::s3::list(s, &q)? } _ => return Err(format!("unknown kind '{}'", c.kind)), }; - Ok(json!({"connection": c.id, "result": result})) + out["result"] = result; + Ok(out) } diff --git a/crates/engine/src/cmd/stac.rs b/crates/engine/src/cmd/stac.rs index 5928a40ad..3a6f167b2 100644 --- a/crates/engine/src/cmd/stac.rs +++ b/crates/engine/src/cmd/stac.rs @@ -114,19 +114,26 @@ pub(super) fn collections(_s: &mut Session, p: &Value) -> CmdResult { } #[cfg(not(target_arch = "wasm32"))] { - // A static catalog: its child catalogs and collections, by their addresses. - if stac::catalog::is_static(&api) { + let url = if stac::catalog::is_static(&api) { crate::render::rustgis_tiles::address::check(&api, None)?; - let bytes = crate::render::rustgis_tiles::cache::download_blocking(&api)?; - let c = stac::catalog::parse_children(&bytes, &api)?; - return Ok( - json!({"count": c.len(), "static": true, "collections": c.iter().map(|c| json!({"id": c.url, "title": c.title})).collect::>()}), - ); - } - let bytes = crate::render::rustgis_tiles::cache::download_blocking(&stac::collections_url(&api))?; - let c = stac::parse_collections(&bytes)?; - Ok(json!({"count": c.len(), "collections": c.iter().map(|c| json!({"id": c.id, "title": c.title})).collect::>()})) + api.clone() + } else { + stac::collections_url(&api) + }; + let bytes = crate::render::rustgis_tiles::cache::download_blocking(&url)?; + collections_json(&api, &bytes) + } +} + +pub(super) fn collections_json(api: &str, bytes: &[u8]) -> CmdResult { + if stac::catalog::is_static(api) { + let c = stac::catalog::parse_children(bytes, api)?; + return Ok( + json!({"count": c.len(), "static": true, "collections": c.iter().map(|c| json!({"id": c.url, "title": c.title})).collect::>()}), + ); } + let c = stac::parse_collections(bytes)?; + Ok(json!({"count": c.len(), "collections": c.iter().map(|c| json!({"id": c.id, "title": c.title})).collect::>()})) } fn search(s: &mut Session, p: &Value) -> CmdResult { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fcbe959a2..11bd4e55a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1146,6 +1146,8 @@ fn hostile_params_never_panic() { // Connections: junk kinds, options and secrets; no connection exists in a fresh session. json!({"id": "c1", "kind": "s3", "address": "http://127.0.0.1:9", "name": "\u{7}", "group": 5, "options": {"credentials": "nope", "buckets": "NO_", "region": [1]}, "password": 7, "accessKeyId": "A", "secretAccessKey": 9}), json!({"id": "", "kind": "postgres", "address": "postgres:host=127.0.0.1 port=9 password=x", "options": "x"}), + json!({"kind": "wms", "address": "http://127.0.0.1:9/wms", "probe": "x", "answer": 7}), + json!({"kind": "stac", "address": "http://127.0.0.1:9/stac", "answer": "\u{0}{"}), ]; let ids: Vec<&str> = Session::new(Project::default()).registry.commands.iter().map(|c| c.id).collect(); let streamed = grid_fgb("hostile", 20); @@ -8919,6 +8921,52 @@ fn connections_s3_rename_cannot_inherit_a_destination_session() { s.run("connection.remove", &json!({"id":destination})).unwrap(); } +#[test] +fn connection_test_probes_and_answers() { + use crate::render::rustgis_tiles::s3; + let mut s = Session::new(Project::default()); + let mut wms = json!({"kind":"wms","address":"https://a.example/wms","probe":true}); + let v = s.run("connection.test", &wms).unwrap(); + let fetch = v["fetch"].as_str().unwrap(); + assert!(fetch.starts_with("https://a.example/wms") && fetch.contains("REQUEST=GetCapabilities")); + assert!(v.get("connection").is_none()); + assert!(s.connections.connections.is_empty()); + wms.as_object_mut().unwrap().remove("probe"); + wms["answer"] = json!( + r#"Localimage/pngEPSG:3857aA"# + ); + assert_eq!(s.run("connection.test", &wms).unwrap()["result"]["layers"][0]["name"], "a"); + assert_eq!( + s.run("connection.test", &json!({"kind":"stac","address":"https://s.example/v1","probe":true})).unwrap()["fetch"], + "https://s.example/v1/collections" + ); + assert_eq!( + s.run( + "connection.test", + &json!({"kind":"stac","address":"https://s.example/v1","answer":r#"{"collections":[{"id":"c1","title":"C"}]}"#}) + ) + .unwrap()["result"]["count"], + 1 + ); + let mut p = json!({"kind":"s3","name":"probe-s3","options":{"credentials":"none","buckets":"bkt1"},"probe":true}); + assert!(s.run("connection.test", &p).unwrap()["fetch"].as_str().unwrap().contains("bkt1")); + p.as_object_mut().unwrap().remove("probe"); + p["answer"] = json!("a.tif1"); + assert_eq!(s.run("connection.test", &p).unwrap()["result"]["count"], 1); + p["options"].as_object_mut().unwrap().remove("buckets"); + p["probe"] = json!(true); + assert!(s.run("connection.test", &p).unwrap_err().contains("name a bucket")); + s3::disconnect("probe-s3"); + #[cfg(not(target_arch = "wasm32"))] + assert!( + s.run("connection.test", &json!({"kind":"postgres","address":"postgres:host=127.0.0.1 port=9 dbname=x user=u","probe":true})) + .unwrap()["fetch"] + .is_null() + ); + assert!(s.run("connection.test", &json!({"probe":true})).unwrap_err().contains("id, or kind and address")); + assert!(s.connections.connections.is_empty()); +} + fn rustgis_engine_sources_roundtrip(text: &str) -> crate::sources::Sources { crate::sources::Sources::from_json(text) } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0e4b0b7ec..2a9937468 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -682,6 +682,7 @@ fn search_action(id: &str) -> Option { "layer.addData" | "layer.addPhotos" => Command("ui.addData"), "layer.addUrl" => Command("ui.addUrl"), "service.add" | "service.import" => Command("ui.addService"), + "connection.new" => Command("ui.connection"), "layer.addSql" => Pane(RightPane::Sql), "layer.addDatabase" => Dialog("addDatabase"), "layer.addZarr" => Command("ui.addZarr"), diff --git a/crates/ui-egui/src/connectionui.rs b/crates/ui-egui/src/connectionui.rs new file mode 100644 index 000000000..f93b8587f --- /dev/null +++ b/crates/ui-egui/src/connectionui.rs @@ -0,0 +1,384 @@ +//! Named endpoint definitions and session-only logins, with web probes fetched off the frame. + +use egui::vec2; +use rustgis_engine::connections::{Connection, DATABASE_KINDS}; +use rustgis_engine::render::rustgis_tiles::{cache, s3}; +use rustgis_engine::sources::Source; +use serde_json::{Value, json}; + +use crate::{RustGisApp, Slot, i18n}; + +#[derive(Default)] +pub struct State { + pub id: Option, + pub kind: String, + pub name: String, + pub group: String, + pub address: String, + pub region: String, + pub buckets: String, + pub credentials: String, + pub profile: String, + pub password: String, + pub access_key: String, + pub secret_key: String, + pub session_token: String, + pub test: Option<(Value, Slot, String>>)>, +} + +pub(crate) const KINDS: [(&str, &str); 13] = [ + ("postgres", "PostgreSQL/PostGIS"), + ("mysql", "MySQL/MariaDB"), + ("sqlserver", "SQL Server"), + ("service", "Web service (detected)"), + ("wms", "WMS"), + ("wmts", "WMTS"), + ("wfs", "WFS"), + ("wcs", "WCS"), + ("ogcapi", "OGC API"), + ("arcgis", "ArcGIS REST"), + ("csw", "Catalogue (CSW)"), + ("stac", "STAC API"), + ("s3", "S3"), +]; + +pub(crate) fn label(kind: &str) -> &'static str { + KINDS.iter().find(|(k, _)| *k == kind).map(|(_, l)| *l).unwrap_or("Connection") +} + +pub fn open_new(app: &mut RustGisApp, kind: &str) -> Result<(), String> { + if !KINDS.iter().any(|(k, _)| *k == kind) { + return Err(format!("unknown kind '{kind}'")); + } + if cfg!(target_arch = "wasm32") && DATABASE_KINDS.contains(&kind) { + return Err(tl!("Database connections are used by the desktop app").into()); + } + app.ui.connection = + State { kind: kind.into(), credentials: if kind == "s3" { "keys".into() } else { String::new() }, ..Default::default() }; + app.dialogs = crate::dialogs::Dialogs { open: Some("newConnection"), ..Default::default() }; + Ok(()) +} + +pub fn open_edit(app: &mut RustGisApp, id: &str) -> Result<(), String> { + let c = app.session.connections.find(id).cloned().ok_or_else(|| format!("no connection '{id}'"))?; + app.ui.connection = State { + region: c.options.get("region").cloned().unwrap_or_default(), + buckets: c.options.get("buckets").cloned().unwrap_or_default(), + credentials: c.options.get("credentials").cloned().unwrap_or_else(|| "none".into()), + profile: c.options.get("profile").cloned().unwrap_or_default(), + id: Some(c.id), + kind: c.kind, + name: c.name, + group: c.group, + address: c.address, + ..Default::default() + }; + app.dialogs = crate::dialogs::Dialogs { open: Some("editConnection"), ..Default::default() }; + Ok(()) +} + +fn definition(st: &State) -> Value { + let address = st.address.trim(); + let lower = address.to_ascii_lowercase(); + let address = if st.kind == "postgres" && !address.is_empty() && !lower.starts_with("postgres:") && !lower.starts_with("postgresql:") { + format!("postgres:{address}") + } else { + address.to_owned() + }; + let mut p = json!({"kind":st.kind, "name":st.name, "group":st.group, "address":address}); + if st.kind == "s3" { + p["options"] = json!({"region":st.region, "buckets":st.buckets, "credentials":st.credentials}); + if st.credentials == "profile" { + p["options"]["profile"] = json!(st.profile); + } + } + p +} + +fn secrets(st: &State, p: &mut Value) { + if DATABASE_KINDS.contains(&st.kind.as_str()) && !st.password.is_empty() { + p["password"] = json!(st.password); + } + if st.kind == "s3" && st.credentials == "keys" { + for (key, value) in [("accessKeyId", &st.access_key), ("secretAccessKey", &st.secret_key), ("sessionToken", &st.session_token)] { + if !value.is_empty() { + p[key] = json!(value); + } + } + } +} + +pub fn start_test(app: &mut RustGisApp) { + app.dialogs.error = None; + app.dialogs.info = None; + app.ui.connection.test = None; + let mut p = definition(&app.ui.connection); + secrets(&app.ui.connection, &mut p); + if DATABASE_KINDS.contains(&app.ui.connection.kind.as_str()) { + match app.run("connection.test", p) { + Ok(v) => app.dialogs.info = Some(summary(&app.ui.connection.kind, &v["result"])), + Err(e) => app.dialogs.error = Some(e), + } + return; + } + p["probe"] = json!(true); + let v = match app.run("connection.test", p) { + Ok(v) => v, + Err(e) => { + app.dialogs.error = Some(e); + return; + } + }; + let Some(url) = v["fetch"].as_str() else { + app.dialogs.error = Some(tl!("Nothing to test").into()); + return; + }; + let q = definition(&app.ui.connection); + let slot: Slot, String>> = Slot::default(); + let land = slot.clone(); + cache::download(url, Box::new(move |r| *land.lock().unwrap_or_else(|e| e.into_inner()) = Some(r))); + app.ui.connection.test = Some((q, slot)); + app.dialogs.info = Some(tl!("Testing…").into()); +} + +pub fn poll(app: &mut RustGisApp, ctx: &egui::Context) { + if !matches!(app.dialogs.open, Some("newConnection" | "editConnection")) { + app.ui.connection.test = None; + return; + } + let Some((q, slot)) = app.ui.connection.test.as_mut() else { return }; + let result = slot.lock().unwrap_or_else(|e| e.into_inner()).take(); + let Some(result) = result else { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + return; + }; + let mut q = q.take(); + app.ui.connection.test = None; + app.dialogs.info = None; + match result { + Ok(bytes) => { + q["answer"] = json!(String::from_utf8_lossy(&bytes)); + let kind = q["kind"].as_str().unwrap_or("").to_owned(); + match app.run("connection.test", q) { + Ok(v) => app.dialogs.info = Some(summary(&kind, &v["result"])), + Err(e) => app.dialogs.error = Some(e), + } + } + Err(e) => app.dialogs.error = Some(e), + } +} + +fn summary(kind: &str, r: &Value) -> String { + let count = r["count"].as_u64().unwrap_or(0).to_string(); + match kind { + "postgres" | "mysql" | "sqlserver" => i18n::fmt("{n} tables with geometry", &[("n", &count)]), + "csw" => { + let n = r["matched"].as_u64().map(|n| n.to_string()).unwrap_or(count); + i18n::fmt("{n} records in the catalogue", &[("n", &n)]) + } + "stac" => i18n::fmt("{n} collections", &[("n", &count)]), + "s3" => { + if let Some(bucket) = r["bucket"].as_str() { + let n = if r["truncated"] == true { format!("{count}+") } else { count }; + i18n::fmt("{n} items in {bucket}", &[("n", &n), ("bucket", bucket)]) + } else { + i18n::fmt("{n} buckets", &[("n", &r["buckets"].as_array().map_or(0, Vec::len).to_string())]) + } + } + _ => { + let kind = r["kind"].as_str().unwrap_or(""); + let title = r["title"].as_str().unwrap_or(""); + let count = r["layers"].as_array().map_or(0, Vec::len); + if count == 0 && r["next"].is_string() { + i18n::fmt("Connected to {kind} {title}", &[("kind", kind), ("title", title)]) + } else { + i18n::fmt("{kind} {title}: {count} layers", &[("kind", kind), ("title", title), ("count", &count.to_string())]) + } + } + } +} + +fn save(app: &mut RustGisApp) -> Result<(), String> { + let st = &app.ui.connection; + let mut p = definition(st); + let command = if let Some(id) = &st.id { + p["id"] = json!(id); + if st.kind != "s3" + && let Some(object) = p.as_object_mut() + { + object.remove("options"); + } + "connection.edit" + } else { + if st.name.trim().is_empty() + && let Some(object) = p.as_object_mut() + { + object.remove("name"); + } + "connection.new" + }; + let v = app.run(command, p)?; + if v["stripped"] == true { + app.session.notify(tl!("A password or token in the address was left out: connections never keep secrets.").to_string(), false); + } + let mut login = json!({"id": v["id"]}); + secrets(&app.ui.connection, &mut login); + if login.as_object().is_some_and(|p| p.len() > 1) + && let Err(e) = app.run("connection.login", login) + { + app.ui.connection.id = v["id"].as_str().map(str::to_owned); + app.dialogs.open = Some("editConnection"); + return Err(e); + } + let st = &mut app.ui.connection; + st.password.clear(); + st.access_key.clear(); + st.secret_key.clear(); + st.session_token.clear(); + Ok(()) +} + +pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { + ui.label( + egui::RichText::new(tl!("A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved.")).weak(), + ); + let st = &mut app.ui.connection; + let database = DATABASE_KINDS.contains(&st.kind.as_str()); + let mut changed = false; + egui::Grid::new("connection").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + ui.label(tl!("Type")); + ui.label(tl!(label(&st.kind))); + ui.end_row(); + ui.label(tl!("Name")); + changed |= ui.add(egui::TextEdit::singleline(&mut st.name).hint_text(tl!("From the address")).desired_width(360.0)).changed(); + ui.end_row(); + ui.label(tl!("Group")); + changed |= ui.add(egui::TextEdit::singleline(&mut st.group).hint_text(tl!("None")).desired_width(360.0)).changed(); + ui.end_row(); + if database { + ui.label(tl!("Connection")); + let hint = match st.kind.as_str() { + "mysql" => "mysql://user@host:3306/db", + "sqlserver" => "mssql://user@host:1433/db", + _ => "host=… dbname=… user=…", + }; + changed |= ui.add(egui::TextEdit::singleline(&mut st.address).hint_text(hint).desired_width(360.0)).changed(); + ui.end_row(); + ui.label(tl!("Password")); + changed |= ui + .add( + egui::TextEdit::singleline(&mut st.password) + .password(true) + .hint_text(tl!("This session only (optional)")) + .desired_width(360.0), + ) + .changed(); + ui.end_row(); + } else if st.kind == "s3" { + for (label, field, hint) in [ + (tl!("Endpoint"), &mut st.address, tl!("Blank for Amazon S3")), + (tl!("Region"), &mut st.region, s3::DEFAULT_REGION), + (tl!("Buckets"), &mut st.buckets, tl!("Comma-separated (optional)")), + ] { + ui.label(label); + changed |= ui.add(egui::TextEdit::singleline(field).hint_text(hint).desired_width(360.0)).changed(); + ui.end_row(); + } + ui.label(tl!("Keys from")); + let shown = crate::s3ui::SOURCES.iter().find(|(c, _)| *c == st.credentials).map(|(_, l)| tl!(*l)).unwrap_or_default(); + egui::ComboBox::from_id_salt("connection_credentials").selected_text(shown).width(360.0).show_ui(ui, |ui| { + for (code, label) in crate::s3ui::SOURCES { + if cfg!(target_arch = "wasm32") && matches!(code, "profile" | "environment") { + continue; + } + changed |= ui.selectable_value(&mut st.credentials, code.to_owned(), tl!(label)).changed(); + } + }); + ui.end_row(); + if st.credentials == "profile" { + ui.label(tl!("Profile")); + changed |= ui.add(egui::TextEdit::singleline(&mut st.profile).desired_width(360.0)).changed(); + ui.end_row(); + } + if st.credentials == "keys" { + for (label, field, secret) in [ + (tl!("Access key ID"), &mut st.access_key, false), + (tl!("Secret access key"), &mut st.secret_key, true), + (tl!("Session token"), &mut st.session_token, true), + ] { + ui.label(label); + changed |= ui.add(egui::TextEdit::singleline(field).password(secret).desired_width(360.0)).changed(); + ui.end_row(); + } + } + } else { + ui.label(tl!("Address")); + let hint = match st.kind.as_str() { + "stac" => "https://…/stac/v1", + "arcgis" => "https://…/rest/services", + "csw" => "https://…/csw", + _ => "https://…", + }; + changed |= ui.add(egui::TextEdit::singleline(&mut st.address).hint_text(hint).desired_width(360.0)).changed(); + ui.end_row(); + } + }); + if changed { + // A late download may finish, but its detached slot cannot report on the edited form. + st.test = None; + app.dialogs.info = None; + app.dialogs.error = None; + } + let testing = st.test.is_some(); + let unavailable = database && cfg!(target_arch = "wasm32"); + let clicked = ui + .horizontal(|ui| { + let response = ui + .add_enabled_ui(!testing && !unavailable, |ui| crate::widgets::dialog_button(ui, tl!("Test"), None, vec2(84.0, 28.0))) + .inner; + let response = if unavailable { + response.on_disabled_hover_text(tl!("Database connections are used by the desktop app")) + } else { + response + }; + if testing { + ui.spinner(); + } + response.clicked() + }) + .inner; + if clicked { + start_test(app); + } + crate::dialogs::buttons(app, ui, "Save", save) +} + +pub fn open_connection(app: &mut RustGisApp, c: &Connection) -> Result<(), String> { + let (kind, extra) = match c.kind.as_str() { + "postgres" | "mysql" | "sqlserver" => { + if cfg!(target_arch = "wasm32") { + return Err(tl!("Database connections are used by the desktop app").into()); + } + ("database", Value::Null) + } + "service" => ("service", json!({"type":"auto"})), + "wms" | "wmts" | "wfs" | "wcs" | "ogcapi" | "arcgis" | "csw" => ("service", json!({"type":c.kind})), + "stac" => ("stac", json!({"api":"true"})), + "s3" => { + if s3::connection(&c.name).is_none() { + if c.options.get("credentials").is_some_and(|v| v == "keys") { + open_edit(app, &c.id)?; + app.dialogs.info = Some(tl!("Type the keys to log in for this session, then Save.").into()); + return Ok(()); + } + app.run("connection.login", json!({"id":c.id}))?; + } + return crate::sourcespane::open( + app, + &Source { kind: "s3".into(), name: c.name.clone(), address: c.name.clone(), extra: Value::Null }, + ); + } + _ => return Err(format!("unknown kind '{}'", c.kind)), + }; + crate::sourcespane::open(app, &Source { kind: kind.into(), name: c.name.clone(), address: c.address.clone(), extra }) +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 906465381..c7041460f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -152,6 +152,8 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "addUrl" => "Add Data From URL", "addService" => "Add From Service", "addDatabase" => "Add From Database", + "newConnection" => "New Connection", + "editConnection" => "Edit Connection", "s3" => "S3 Buckets", "zarr" => "Add Zarr Variable", "exportLayer" => "Export Features", @@ -177,6 +179,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { ui.set_width(match which { "coordinateSystem" | "zarr" => 660.0, "addTiles" | "addUrl" | "addService" | "s3" => 560.0, + "newConnection" | "editConnection" => 520.0, _ => 440.0, }); ui.spacing_mut().item_spacing = vec2(8.0, 8.0); @@ -242,6 +245,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "addUrl" => close = add_url(app, ui), "addService" => close = add_service(app, ui), "addDatabase" => close = add_database(app, ui), + "newConnection" | "editConnection" => close = crate::connectionui::dialog(app, ui), "s3" => crate::s3ui::dialog(app, ui), "zarr" => close = crate::zarrui::dialog(app, ui), "exportLayer" => close = export_layer(app, ui), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8fbf51cdc..e4040665c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ mod bookmarkpane; mod catalogsui; mod chrome; mod comets; +mod connectionui; #[cfg(not(target_arch = "wasm32"))] mod connectui; mod cswui; @@ -525,6 +526,8 @@ pub struct UiState { pub stac: stacui::State, /// S3…: the connection form, the bucket browsed and the listing under way. pub s3: s3ui::State, + /// New/Edit Connection: the form and the Test under way. + pub connection: connectionui::State, /// OpenStreetMap and Boundaries: their choices and the request under way. pub portals: portalsui::State, /// Overture Maps and Source Cooperative: choices, results and the request under way. @@ -652,6 +655,7 @@ impl Default for UiState { csw: Default::default(), stac: Default::default(), s3: Default::default(), + connection: Default::default(), portals: Default::default(), catalogs: Default::default(), opendata: Default::default(), @@ -2046,6 +2050,16 @@ impl RustGisApp { let id = self.session.download_started(); self.ui.downloads.push((id, url, q, slot)); } + "ui.connection" => { + if let Some(id) = p.get("id").and_then(Value::as_str) { + connectionui::open_edit(self, id)?; + } else { + connectionui::open_new(self, p.get("kind").and_then(Value::as_str).unwrap_or("service"))?; + } + if p.get("test").and_then(Value::as_bool) == Some(true) { + connectionui::start_test(self); + } + } // A map or feature service: the dialog, then (with an address) its capabilities, // fetched off the frame; the dialog lists its layers when they land. "ui.importService" => serviceui::start(self, p, false)?, @@ -3155,6 +3169,7 @@ impl RustGisApp { cswui::poll(self); stacui::poll(self, ctx); s3ui::poll(self, ctx); + connectionui::poll(self, ctx); portalsui::poll(self, ctx); catalogsui::poll(self, ctx); opendataui::poll(self, ctx); diff --git a/crates/ui-egui/src/s3ui.rs b/crates/ui-egui/src/s3ui.rs index 54fa9cc1a..3091fec3b 100644 --- a/crates/ui-egui/src/s3ui.rs +++ b/crates/ui-egui/src/s3ui.rs @@ -27,7 +27,7 @@ struct Saved { /// Where a connection's keys come from: typed, an AWS profile or the environment (desktop), or /// none (public buckets). The profile and the environment are used only when chosen here. -const SOURCES: [(&str, &str); 4] = [ +pub(crate) const SOURCES: [(&str, &str); 4] = [ ("keys", "Typed below"), ("profile", "An AWS profile"), ("environment", "The environment (AWS_ACCESS_KEY_ID…)"), diff --git a/crates/ui-egui/src/sourcespane.rs b/crates/ui-egui/src/sourcespane.rs index 7a3a0d5db..0d01ae54d 100644 --- a/crates/ui-egui/src/sourcespane.rs +++ b/crates/ui-egui/src/sourcespane.rs @@ -1,10 +1,9 @@ -//! The Data Sources pane: favourites, recent sources, folders, saved services, databases and STAC -//! APIs, S3 connections and a library of well-known public sources. A source is added (or its -//! browser dialog opened, connected) through the commands that add it; the lists are the -//! session's `sources`, kept with the app's preferences (never a secret). +//! The Data Sources pane: favourites, recent sources, folders, named connections and a library +//! of public sources. Rows add a source or open its browser through the existing commands. +//! Sources and connections are kept with the app's preferences, never with secrets. use egui::{RichText, Sense, Ui, vec2}; -use rustgis_engine::render::rustgis_tiles::s3; +use rustgis_engine::connections::{Connection, DATABASE_KINDS}; use rustgis_engine::sources::{Source, library}; use serde_json::{Value, json}; @@ -16,15 +15,13 @@ pub const PREF: &str = "data_sources"; /// Named connections, separate from the project's data sources. pub const CONNECTIONS_PREF: &str = "connections"; -/// What the pane's "save" boxes hold. +/// Preference loading and generation-keyed snapshots for the pane. #[derive(Default)] pub struct State { loaded: bool, - service: String, - database: String, - stac: String, /// The sources as last shown, and their generation. shown: Option<(u64, std::sync::Arc)>, + connections: Option<(u64, std::sync::Arc>)>, } /// The sources to show this frame: the last snapshot unless they changed since. @@ -40,6 +37,18 @@ fn snapshot(app: &mut RustGisApp) -> std::sync::Arc std::sync::Arc> { + let generation = app.session.connections.generation; + match &app.ui.sources.connections { + Some((g, list)) if *g == generation => list.clone(), + _ => { + let list = std::sync::Arc::new(app.session.connections.connections.clone()); + app.ui.sources.connections = Some((generation, list.clone())); + list + } + } +} + /// Reads the sources kept with the preferences, once. pub fn load(app: &mut RustGisApp) { if app.ui.sources.loaded { @@ -226,30 +235,93 @@ fn empty(ui: &mut Ui, what: &str) { ui.label(RichText::new(what).small().weak()); } -/// A box to save an address as a source of `kind`. -fn save_box(app: &mut RustGisApp, ui: &mut Ui, kind: &str, hint: &str, field: fn(&mut State) -> &mut String) { - ui.horizontal(|ui| { - let r = ui.add(egui::TextEdit::singleline(field(&mut app.ui.sources)).hint_text(hint).desired_width(ui.available_width() - 52.0)); - let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if (ui.small_button(tl!("Save")).clicked() || enter) && !field(&mut app.ui.sources).trim().is_empty() { - let mut address = field(&mut app.ui.sources).trim().to_string(); - // A bare connection string is a PostgreSQL one (as Add From Database takes it). - if kind == "database" - && address.contains('=') - && !address.contains("://") - && !address.to_ascii_lowercase().starts_with("postgres:") - { - address = format!("postgres:{address}"); +fn connections(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, list: &[Connection]) { + ui.menu_button(tl!("New Connection ▾"), |ui| { + for (kind, label) in crate::connectionui::KINDS { + if cfg!(target_arch = "wasm32") && DATABASE_KINDS.contains(&kind) { + continue; } - let mut p = json!({"kind": kind, "address": address}); - if kind == "stac" { - p["extra"] = json!({"api": "true"}); + if ui.button(tl!(label)).clicked() { + if let Err(e) = app.run("ui.connection", json!({"kind": kind})) { + app.session.notify(e, true); + } + ui.close(); } - match app.run("sources.save", p) { - Ok(_) => field(&mut app.ui.sources).clear(), - Err(e) => app.session.notify(e, true), + } + }); + if list.is_empty() { + empty(ui, tl!("Connections you add are listed here")); + } + let mut groups = std::collections::BTreeMap::new(); + for c in list.iter().filter(|c| !c.group.is_empty()) { + groups.entry(c.group.to_lowercase()).or_insert(c.group.as_str()); + } + for (lower, group) in groups { + egui::CollapsingHeader::new(group).id_salt(("connections", &lower)).default_open(true).show(ui, |ui| { + for c in list.iter().filter(|c| c.group.to_lowercase() == lower) { + connection_row(app, ui, t, c); } + }); + } + for c in list.iter().filter(|c| c.group.is_empty()) { + connection_row(app, ui, t, c); + } +} + +fn connection_row(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connection) { + let database = DATABASE_KINDS.contains(&c.kind.as_str()); + let unavailable = database && cfg!(target_arch = "wasm32"); + ui.horizontal(|ui| { + let icon = if database { + "table" + } else { + match c.kind.as_str() { + "stac" => "search", + "s3" => "package", + _ => "globe_view", + } + }; + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint(ui.painter(), r, icon, t.icon, t.accent); + let response = if unavailable { + ui.link(RichText::new(&c.name).weak()).on_hover_text(tl!("Database connections are used by the desktop app")) + } else { + ui.link(&c.name).on_hover_text(if c.kind == "s3" && c.address.is_empty() { tl!("Amazon S3") } else { &c.address }) + }; + if response.clicked() + && !unavailable + && let Err(e) = crate::connectionui::open_connection(app, c) + { + app.session.notify(e, true); } + ui.label(RichText::new(tl!(crate::connectionui::label(&c.kind))).small().weak()); + response.context_menu(|ui| { + for (label, command, params) in [ + (tl!("Edit…"), "ui.connection", json!({"id":c.id})), + (tl!("Test"), "ui.connection", json!({"id":c.id,"test":true})), + (tl!("Duplicate"), "connection.duplicate", json!({"id":c.id})), + ] { + if params.get("test") == Some(&Value::Bool(true)) && unavailable { + continue; + } + if ui.button(label).clicked() { + if let Err(e) = app.run(command, params) { + app.session.notify(e, true); + } + ui.close(); + } + } + if !c.address.is_empty() && ui.button(tl!("Copy Address")).clicked() { + ui.ctx().copy_text(c.address.clone()); + ui.close(); + } + if ui.button(tl!("Remove")).clicked() { + if let Err(e) = app.run("connection.remove", json!({"id":c.id})) { + app.session.notify(e, true); + } + ui.close(); + } + }); }); } @@ -299,43 +371,8 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }); } - let saved = |kind: &str| src.saved.iter().filter(|s| s.kind == kind).cloned().collect::>(); - header(ui, tl!("Services"), false, |ui| { - for s in saved("service") { - row(app, ui, t, &s, Some("saved")); - } - save_box(app, ui, "service", "https://…/wms, …/FeatureServer", |st| &mut st.service); - }); - if !cfg!(target_arch = "wasm32") { - header(ui, tl!("Databases"), false, |ui| { - for s in saved("database") { - row(app, ui, t, &s, Some("saved")); - } - save_box(app, ui, "database", "host=… dbname=… user=…, or a .duckdb file", |st| &mut st.database); - }); - } - header(ui, tl!("S3"), false, |ui| { - let conns = s3::connections(); - if conns.is_empty() { - empty(ui, tl!("No connections this session")); - } - for c in conns { - let s = Source { kind: "s3".into(), name: c.name.clone(), address: c.name.clone(), extra: Value::Null }; - row(app, ui, t, &s, None); - } - if ui.small_button(tl!("Connect…")).clicked() { - let _ = app.run("ui.dialog", json!({"name": "s3"})); - } - }); - header(ui, tl!("STAC APIs"), false, |ui| { - for (_, s) in library().iter().filter(|(_, s)| s.kind == "stac") { - row(app, ui, t, s, None); - } - for s in src.saved.iter().filter(|s| s.kind == "stac") { - row(app, ui, t, s, Some("saved")); - } - save_box(app, ui, "stac", "https://…/stac/v1", |st| &mut st.stac); - }); + let list = connections_snapshot(app); + header(ui, tl!("Connections"), true, |ui| connections(app, ui, t, &list)); header(ui, tl!("Library"), false, |ui| { let mut groups: Vec<&str> = Vec::new(); let lib = library(); diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 0c7d6e061..a2c79dcc6 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -414,6 +414,7 @@ fn every_tab_pane_and_dialog_renders() { ("ui.dialog", json!({"name": "selectByAttributes"})), ("ui.dialog", json!({"name": "goToXY"})), ("ui.dialog", json!({"name": "calculateField"})), + ("ui.connection", json!({"kind": "s3"})), ("ui.commandSearch", json!(null)), ("ui.about", json!(null)), ("ui.shortcuts", json!(null)), @@ -1489,6 +1490,208 @@ fn a_service_is_listed_when_its_capabilities_land() { h.step(); } +#[test] +fn connections_section_and_dialog() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let body = br#"Localimage/pngEPSG:3857aA"#; + for stream in listener.incoming().take(1) { + let Ok(mut s) = stream else { continue }; + let mut buf = [0u8; 2048]; + let _ = s.read(&mut buf); + let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + let _ = s.write_all(body); + } + }); + let mut h = harness(); + h.state_mut().run("connection.new", json!({"kind":"wms","address":"https://w.example/wms","name":"State WMS","group":"Work"})).unwrap(); + h.state_mut().run("ui.pane.sources", json!({})).unwrap(); + h.step(); + h.get_by_label("Work"); + h.get_by_label("State WMS"); + assert!(h.query_by_label("STAC APIs").is_none()); + assert!(h.state_mut().run("ui.dialog", json!({"name":"newConnection"})).is_err()); + h.state_mut().run("ui.connection", json!({"kind":"wms"})).unwrap(); + assert_eq!(h.state().dialogs.open, Some("newConnection")); + let address = format!("http://{addr}/wms"); + h.state_mut().ui.connection.name = "Local".into(); + h.state_mut().ui.connection.address = address.clone(); + h.step(); + h.step(); + h.get_by_label("Test").click(); + for _ in 0..100 { + h.step(); + if h.state().dialogs.info.as_ref().is_some_and(|s| s.contains("1 layers")) || h.state().dialogs.error.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(h.state().dialogs.error, None); + assert!( + h.state().dialogs.info.as_ref().is_some_and(|s| s.contains("Local: 1 layers")), + "info: {:?}, pending: {}", + h.state().dialogs.info, + h.state().ui.connection.test.is_some() + ); + assert_eq!(h.state().session.connections.connections.len(), 1); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Save").click(); + h.step(); + assert_eq!(h.state().dialogs.open, None); + let c = h.state().session.connections.find("Local").unwrap(); + assert_eq!(c.address, address); + let id = c.id.clone(); + h.state_mut().run("ui.connection", json!({"id":"Local"})).unwrap(); + assert_eq!(h.state().dialogs.open, Some("editConnection")); + assert_eq!(h.state().ui.connection.name, "Local"); + h.state_mut().ui.connection.group = "Lab".into(); + h.step(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Save").click(); + h.step(); + let c = h.state().session.connections.find("Local").unwrap(); + assert_eq!(c.group, "Lab"); + assert_eq!(c.id, id); + h.step(); + h.step(); + h.get_by_label("Local").click_secondary(); + h.step(); + h.get_by_label("Remove").click(); + h.step(); + assert!(h.state().session.connections.find("Local").is_none()); +} + +#[test] +fn connections_postgres_requires_an_address() { + use egui::accesskit::Role; + let mut h = harness(); + for address in ["", " \t "] { + h.state_mut().run("ui.connection", json!({"kind":"postgres"})).unwrap(); + h.state_mut().ui.connection.address = address.into(); + h.step(); + h.step(); + h.get_by_role_and_label(Role::Button, "Save").click(); + h.step(); + assert_eq!(h.state().dialogs.open, Some("newConnection")); + assert!(h.state().dialogs.error.is_some()); + assert!(h.state().session.connections.connections.is_empty()); + h.get_by_role_and_label(Role::Button, "Test").click(); + h.step(); + assert!(h.state().dialogs.error.as_ref().unwrap().contains("isn't a postgres address")); + assert!(h.state().dialogs.info.is_none()); + assert!(h.state().session.connections.connections.is_empty()); + } + let address = "host=127.0.0.1 port=9 dbname=connection_form user=u"; + h.state_mut().ui.connection.name = "Bare PostgreSQL".into(); + h.state_mut().ui.connection.address = address.into(); + h.step(); + h.get_by_role_and_label(Role::Button, "Save").click(); + h.step(); + assert_eq!(h.state().dialogs.open, None); + let connection = h.state().session.connections.find("Bare PostgreSQL").unwrap(); + let id = connection.id.clone(); + assert_eq!(connection.address, format!("postgres:{address}")); + h.state_mut().run("ui.connection", json!({"id":id})).unwrap(); + h.state_mut().ui.connection.address = " ".into(); + h.step(); + h.step(); + h.get_by_role_and_label(Role::Button, "Save").click(); + h.step(); + assert_eq!(h.state().dialogs.open, Some("editConnection")); + assert!(h.state().dialogs.error.is_some()); + assert_eq!(h.state().session.connections.find(&id).unwrap().address, format!("postgres:{address}")); +} + +#[test] +fn connections_test_feedback_tracks_edited_inputs() { + use egui::accesskit::Role; + use std::io::{Read, Write}; + use std::time::Duration; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = format!("http://{}/stac", listener.local_addr().unwrap()); + let (started, requests) = std::sync::mpsc::channel(); + let (release, wait) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let bodies: [&[u8]; 3] = + [br#"{"collections":[{"id":"old","title":"Old"}]}"#, br#"{"collections":[{"id":"current","title":"Current"}]}"#, b"not JSON"]; + for body in bodies { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0; 2048]; + assert!(stream.read(&mut buf).unwrap() > 0, "the client must send a request before the response is released"); + if started.send(()).is_err() || wait.recv_timeout(Duration::from_secs(5)).is_err() { + break; + } + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).unwrap(); + stream.write_all(body).unwrap(); + } + }); + let mut h = harness(); + h.state_mut().run("ui.connection", json!({"kind":"stac"})).unwrap(); + h.state_mut().ui.connection.name = "Endpoint".into(); + h.state_mut().ui.connection.address = address.clone(); + h.step(); + h.step(); + h.get_by_role_and_label(Role::Button, "Test").click(); + h.step(); + requests.recv_timeout(Duration::from_secs(5)).unwrap(); + let old = h.state().ui.connection.test.as_ref().unwrap().1.clone(); + h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some(&address)).unwrap().focus(); + h.step(); + h.key_press(egui::Key::End); + h.event(egui::Event::Text("/changed".into())); + h.step(); + assert_eq!(h.state().ui.connection.address, format!("{address}/changed")); + assert!(h.state().ui.connection.test.is_none(), "editing must discard the pending test"); + assert_eq!(h.state().dialogs.info, None); + release.send(()).unwrap(); + for _ in 0..250 { + h.step(); + if old.lock().unwrap().is_some() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(old.lock().unwrap().is_some(), "the discarded request must actually finish"); + assert_eq!(h.state().dialogs.info, None); + assert_eq!(h.state().dialogs.error, None); + + // A new test can run immediately; editing also clears its completed success or failure. + for succeeds in [true, false] { + h.get_by_role_and_label(Role::Button, "Test").click(); + h.step(); + requests.recv_timeout(Duration::from_secs(5)).unwrap(); + release.send(()).unwrap(); + for _ in 0..250 { + h.step(); + if h.state().ui.connection.test.is_none() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + assert!(h.state().ui.connection.test.is_none()); + if succeeds { + assert!(h.state().dialogs.info.as_ref().unwrap().contains("1 collections")); + assert_eq!(h.state().dialogs.error, None); + } else { + assert!(h.state().dialogs.error.is_some()); + assert_eq!(h.state().dialogs.info, None); + } + let name = h.state().ui.connection.name.clone(); + h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some(&name)).unwrap().focus(); + h.step(); + h.key_press(egui::Key::End); + h.event(egui::Event::Text(" changed".into())); + h.step(); + assert_eq!(h.state().ui.connection.name, format!("{name} changed")); + assert_eq!(h.state().dialogs.info, None); + assert_eq!(h.state().dialogs.error, None); + } + server.join().unwrap(); + assert!(h.state().session.connections.connections.is_empty()); +} + /// Save in the web app (issue #197): the browser has no save dialog, so the first Save and every /// Save As ask for the file name, and Save then downloads under it; an opened file keeps its name. #[test] diff --git a/docs/comparison/index.md b/docs/comparison/index.md index efbf5c2f8..8ae760418 100644 --- a/docs/comparison/index.md +++ b/docs/comparison/index.md @@ -68,7 +68,7 @@ engine, choose RustGIS. Both keep your data on your machine. See | **Coordinate systems** | Rasters in any EPSG code, reprojected on the fly; the map in presets or projected EPSG and ESRI codes | Reprojects on import, EPSG catalog | Full PROJ support | Full support | Web Mercator focus | | **Geocoding** | Find Place and address tables through Nominatim or Photon (OSM's public servers or your own) | Geocoding search and batch geocoding | Through plugins | ArcGIS World Geocoder (credits) | Yes | | **Open-data portals** | OpenStreetMap by area (Overpass), administrative boundaries (geoBoundaries), and datasets searched by text on data.gov and CKAN sites, Socrata and ArcGIS Hub | ArcGIS Hub, Socrata, CKAN, US portals, geoBoundaries, Overpass, Natural Earth | Through plugins (QuickOSM) | Living Atlas | Living Atlas | -| **STAC, databases, Esri services** | STAC search (Earth Search, Planetary Computer or any STAC API, and `?stac=` links) with footprints and assets by address; Overture Maps (a theme's features in view, read in place, or the whole world as tiles) and Source Cooperative products; PostgreSQL/PostGIS, MySQL and MariaDB databases read live (desktop), and DuckDB databases (desktop, through DuckDB); WMS, WMTS, WFS, WCS, OGC API Features and Tiles, and ArcGIS REST services; CSW catalogue search; Planetary Computer assets signed; a Data Sources pane with favourites, recent sources, saved services, databases and STAC APIs, and a library of public services | STAC browser, PostGIS, DuckDB, Iceberg, ArcGIS services, File Geodatabase (desktop) | STAC, PostGIS, SpatiaLite, Oracle, SQL Server, ArcGIS services | Enterprise geodatabases, Esri services | Hosted only | +| **STAC, databases, Esri services** | STAC search (Earth Search, Planetary Computer or any STAC API, and `?stac=` links) with footprints and assets by address; Overture Maps (a theme's features in view, read in place, or the whole world as tiles) and Source Cooperative products; PostgreSQL/PostGIS, MySQL and MariaDB databases read live (desktop), and DuckDB databases (desktop, through DuckDB); WMS, WMTS, WFS, WCS, OGC API Features and Tiles, and ArcGIS REST services; CSW catalogue search; Planetary Computer assets signed; a Data Sources pane with favourites, recent sources, grouped named connections for services, databases, STAC and S3, and a library of public services | STAC browser, PostGIS, DuckDB, Iceberg, ArcGIS services, File Geodatabase (desktop) | STAC, PostGIS, SpatiaLite, Oracle, SQL Server, ArcGIS services | Enterprise geodatabases, Esri services | Hosted only | | **3D** | A 3D globe; 2.5D extrusion with lighting and shadows; glTF scene export; LiDAR (LAS/LAZ/COPC) as point layers; geotagged photos as points with thumbnails | Cesium globe, 3D Tiles, I3S, LiDAR, Gaussian splats | Point clouds, 3D map view | **Deepest 3D**: scenes, I3S, mesh, LAS | Scene Viewer | ## Analysis and processing diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 77400ef5b..c7fc11601 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -333,10 +333,10 @@ places data comes from: - **Recent**: the last 20 sources added (files, web addresses, tile layers, services, database tables, Zarr variables, STAC assets, sample data), newest first. - **Folders**: the folders connected to the Browser (**Add Folder…**; desktop). -- **Services**, **Databases** (desktop) and **STAC APIs**: addresses saved with the box under each. - A click opens Add From Service, Add From Database or STAC Search connected to it, which lists - its layers, tables or collections. -- **S3**: this session's connections; a click opens the S3 browser on it. +- **Connections**: named services, databases, STAC APIs and S3 endpoints, organised by optional + groups. **New Connection ▾** opens a kind-specific form; right-click a row to edit, test, + duplicate, copy its address or remove it. A click opens the existing service, database, + STAC or S3 add dialog, connected or prefilled. - **Library**: well-known public sources: OpenStreetMap tiles, EOX Sentinel-2 cloudless and NASA GIBS (WMTS), USGS imagery, topographic and hydrography maps (ArcGIS), GEBCO bathymetry (WMS), swisstopo's maps and imagery (geo.admin.ch, WMTS), Earth Search and Planetary Computer (STAC), and the Natural Earth sample layers. @@ -350,6 +350,17 @@ database is kept without its password. The commands `sources.list`, `sources.sav ### Named connections +In the pane, **New Connection** and **Edit…** let you set the name, group and address. +Leave a new connection's name blank to derive it from the address. **Test** checks the form +without saving it: web-service, catalogue, STAC and S3 requests run in the background on both +desktop and web; database tests run synchronously on desktop. Database rows remain visible +but cannot be opened or tested in the web app. Earth Search and Planetary Computer remain +available under **Library**. + +Changing a form field clears pending or completed Test feedback; a late response from the +previous inputs is ignored. PostgreSQL accepts a bare `host=… dbname=… user=…` connection +string, but a blank address cannot be saved or tested. + The `connection.*` commands keep named, reusable endpoints independently of the project. Each has a stable id (`c1`, `c2`, …), a case-insensitively unique name and an optional group. Different names may use the same address. `connection.new`, `connection.edit`, @@ -371,8 +382,12 @@ Kinds are `postgres`, `mysql` (including MariaDB), `sqlserver`, `service` (detec connect), `wms`, `wmts`, `wfs`, `wcs`, `ogcapi`, `arcgis`, `csw`, `stac` and `s3`. Database connections are used on the desktop only. S3 options are `region`, `buckets` (comma-separated), `credentials` (`keys`, `profile`, `environment` or `none`) and `profile`. -`connection.test` lists the endpoint's tables, layers, records, collections or buckets; -the web app's network listings still require the existing add dialogs. +`connection.test` lists the endpoint's tables, layers, records, collections or buckets. +It also accepts an unsaved definition (`kind`, `address`, `name`, `group`, `options`) instead +of `id`. With `probe: true`, it returns the secret-free address to fetch (`fetch: null` for +databases). For non-database kinds, pass that response text as `answer` with the same definition +to parse it without a network request. Database kinds ignore `answer` and test through their +driver. An anonymous S3 test needs at least one named bucket. Addresses are saved without passwords or tokens. `connection.login` remembers a database `password`, or S3 `accessKeyId`, `secretAccessKey` and optional `sessionToken`, for this session @@ -394,8 +409,11 @@ that shared login; changing its address does not transfer the old password to th Desktop and web apps save connections with their preferences (`connections` in desktop `prefs.json`, `rustgis.connections` in web localStorage). The first load imports saved services, databases other than DuckDB files, STAC APIs and remembered S3 connections once, without -changing the old lists. The Data Sources pane and add dialogs are unchanged. CLI and Python -connections last only for the run. +changing the old lists. Legacy saved endpoint lists are no longer shown in the pane; remembered +S3 connections remain available in the S3 dialog. Testing S3 can register or replace the +session endpoint and credentials under the form's connection name, including a same-name +session created in the S3 dialog. Cancel does not disconnect that login or restore the +previous one. CLI and Python connections last only for the run. ## Large files From 247fe24d9d212c504857c14ce9ecb1688d63cd62 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Thu, 22 Oct 2026 16:10:04 -0400 Subject: [PATCH 040/297] test(ui): let the connection context menu settle before clicking Remove (#710) The menu lays out again on its second frame, moving Remove down; a click aimed at the first layout lands between items on some font setups (Windows CI), so step a few frames first. --- crates/ui-egui/tests/smoke.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index a2c79dcc6..76cf33860 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -1556,7 +1556,10 @@ fn connections_section_and_dialog() { h.step(); h.step(); h.get_by_label("Local").click_secondary(); - h.step(); + // The menu settles over a few frames; clicking its first layout can miss "Remove". + for _ in 0..4 { + h.step(); + } h.get_by_label("Remove").click(); h.step(); assert!(h.state().session.connections.find("Local").is_none()); From 4a7b03b9e80e7732a1da5a2b64ddd75a9bc92c6d Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:57:11 +0200 Subject: [PATCH 041/297] feat(connections): browse and add datasets from the Connections tree (#708) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(connections): browse and add datasets from the Connections tree (#699) Expanding a saved connection in Data Sources › Connections lists its datasets a level at a time, with Refresh, an inline error and Retry/Log In…. Click selects, double-click adds, Add Selected adds each as its own undo step, Add With Options… opens the kind's dialog. New engine command connection.browse (probe/answer, like connection.test) for all 13 kinds, so the CLI and the assistant can browse too; user-only for plugins. * feat(connections): add the selection from a dataset's menu instead of a bar The Add Selected / Clear Selection bar shifted the list when the first dataset was picked, which also moved the row between the two clicks of a double-click. They are now entries in a dataset's right-click menu; a double-click adds that dataset. * docs: say where Add With Options… is offered in the Connections tree * fix(connections): address review of the Connections tree - a dataset without an add (grouped OGC API features) can't be selected or double-clicked, and has no Add entry: it keeps Add With Options… - a CSW download link too long to follow is left out (and the list says it is truncated); the name and title kept with it are cut at 200 characters - connection.browse refuses an answer larger than a service document may be (64 MB) - the engine test runs the add a browsed WCS coverage lists * test(connections): cover the answer size limit, long catalogue links and grouped OGC API features * feat(connections): list a static STAC catalog's items, each opened for its assets A static catalog or collection now lists its item links as nodes next to its child catalogs; opening an item reads its own file and lists the assets that can be added. * test(connections): a static item's relative asset address resolves against its file * fix(connections): address second review round - an asset's key names its item, so the same file under two items is two rows (selection and fold state no longer collide) - the documents of a browse count against the same size limit as its answer - the probe's S3 login is documented in the command's parameters * fix(connections): address third review round - an ArcGIS folder or service whose address is too long to follow is left out (and the list is marked truncated), as for catalogues - an API item's key names its address, so items with one id in two collections stay apart - the smoke test checks that one undo takes one layer of Add Selected --- crates/engine/src/cmd/connection_browse.rs | 515 ++++++++++++++++++++ crates/engine/src/cmd/connections.rs | 17 +- crates/engine/src/cmd/layer.rs | 19 + crates/engine/src/cmd/plugins.rs | 1 + crates/engine/src/tests.rs | 212 ++++++++- crates/tiles/src/stac/catalog.rs | 32 ++ crates/ui-egui/src/connectiontree.rs | 525 +++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 63 +-- crates/ui-egui/src/lib.rs | 131 ++--- crates/ui-egui/src/serviceui.rs | 68 +++ crates/ui-egui/src/sourcespane.rs | 138 +++--- crates/ui-egui/tests/smoke.rs | 83 ++++ docs/user-guide/data.md | 28 +- 13 files changed, 1657 insertions(+), 175 deletions(-) create mode 100644 crates/engine/src/cmd/connection_browse.rs create mode 100644 crates/ui-egui/src/connectiontree.rs diff --git a/crates/engine/src/cmd/connection_browse.rs b/crates/engine/src/cmd/connection_browse.rs new file mode 100644 index 000000000..9d49dc822 --- /dev/null +++ b/crates/engine/src/cmd/connection_browse.rs @@ -0,0 +1,515 @@ +//! `connection.browse`: the datasets a saved connection offers, one level at a time, each with +//! the command that adds it. +//! +//! The app reads over the network in the background, so browsing is a probe/answer pair like +//! `connection.test`: `probe` names the address a level reads, the app fetches it and asks again +//! with the `answer`. Without an answer, the command line and the assistant read it themselves. + +use serde_json::{Value, json}; +use std::borrow::Cow; + +use super::super::{b, ps}; +use crate::connections::{Connection, DESKTOP_ONLY}; +use crate::render::rustgis_tiles::{address, s3, stac}; +use crate::{CmdResult, Session}; + +/// The most children of one level that are listed. +const MAX_CHILDREN: usize = 500; +/// The items of a STAC collection that are listed. +const ITEMS: usize = 20; +/// The longest node a caller may give. +const MAX_NODE: usize = 4096; + +/// What a node of a connection stands for. +enum Level { + Database, + Service { url: String, kind: Option }, + Catalogue, + StacRoot { api: String }, + StacCollection { api: String, id: String }, + StacCatalog { url: String }, + StacItem { url: String }, + S3Root, + S3Prefix { bucket: String, prefix: String }, +} + +/// The types a `service::` node names (`auto` leaves the type to be guessed). +const SERVICE_TYPES: [&str; 7] = ["auto", "wms", "wmts", "wfs", "wcs", "ogcapi", "arcgis"]; + +/// The level a connection's `node` names (its top level when empty). +fn level(c: &Connection, node: &str) -> Result { + let kind = c.kind.as_str(); + let not_a_node = || format!("'{node}' isn't a node of {}", c.name); + if node.is_empty() { + return Ok(match kind { + "postgres" | "mysql" | "sqlserver" => { + if cfg!(target_arch = "wasm32") { + return Err(DESKTOP_ONLY.into()); + } + Level::Database + } + "service" => Level::Service { url: c.address.clone(), kind: None }, + "wms" | "wmts" | "wfs" | "wcs" | "ogcapi" | "arcgis" => Level::Service { url: c.address.clone(), kind: Some(c.kind.clone()) }, + "csw" => Level::Catalogue, + "stac" => Level::StacRoot { api: stac::api(Some(&c.address))? }, + "s3" => Level::S3Root, + _ => return Err(format!("unknown kind '{}'", c.kind)), + }); + } + if let Some(rest) = node.strip_prefix("service:") { + if !matches!(kind, "service" | "wms" | "wmts" | "wfs" | "wcs" | "ogcapi" | "arcgis" | "csw") { + return Err(not_a_node()); + } + let (t, url) = rest.split_once(':').filter(|(t, _)| SERVICE_TYPES.contains(t)).ok_or_else(not_a_node)?; + // Judged as a document of the connection would be: a link may not lead into the local network. + address::check(url, Some(&c.address))?; + return Ok(Level::Service { url: url.to_string(), kind: (t != "auto").then(|| t.to_string()) }); + } + if let Some(id) = node.strip_prefix("collection:") { + if kind != "stac" || id.is_empty() { + return Err(not_a_node()); + } + let api = stac::api(Some(&c.address))?; + if stac::catalog::is_static(&api) { + return Err(not_a_node()); + } + return Ok(Level::StacCollection { api, id: id.to_string() }); + } + if let Some(url) = node.strip_prefix("catalog:") { + if kind != "stac" { + return Err(not_a_node()); + } + address::check(url, Some(&c.address))?; + return Ok(Level::StacCatalog { url: url.to_string() }); + } + if let Some(url) = node.strip_prefix("item:") { + // A static catalog's item: its own file, read when opened. + if kind != "stac" { + return Err(not_a_node()); + } + address::check(url, Some(&c.address))?; + return Ok(Level::StacItem { url: url.to_string() }); + } + if let Some(rest) = node.strip_prefix("s3:") { + let (bucket, prefix) = rest + .split_once('/') + .filter(|(bucket, prefix)| kind == "s3" && s3::valid_bucket(bucket) && prefix.len() <= 1024) + .ok_or_else(not_a_node)?; + return Ok(Level::S3Prefix { bucket: bucket.to_string(), prefix: prefix.to_string() }); + } + Err(not_a_node()) +} + +/// The session's S3 connection for an S3 level, logging in with what needs no typing. +/// (A probe logs in too, as `connection.test` does: the address it names, and the signature the +/// fetch needs, depend on the connection.) +fn s3_login(s: &mut Session, c: &Connection, level: &Level) -> Result, String> { + if !matches!(level, Level::S3Root | Level::S3Prefix { .. }) { + return Ok(None); + } + if s3::connection(&c.name).is_none() && c.options.get("credentials").is_none_or(|v| v != "keys") { + super::login(s, c, &Value::Null)?; + } + let conn = s3::connection(&c.name).ok_or("log in first: connection.login {id, accessKeyId, secretAccessKey}")?; + if let Level::S3Prefix { bucket, .. } = level { + s3::grant(&conn.name, bucket); + } + Ok(Some(conn)) +} + +/// The address a level reads (`None`: nothing is fetched, or the database driver reads it). +fn fetch_url(s: &Session, c: &Connection, level: &Level, conn: Option<&s3::Connection>) -> Result, String> { + Ok(match level { + Level::Database => None, + Level::Service { url, kind } => Some(rustgis_data::ogc::capabilities_url(url, kind.as_deref())), + Level::Catalogue => Some(super::super::service::records_request(s, &json!({"url": c.address}))?), + Level::StacRoot { api } => Some(if stac::catalog::is_static(api) { api.clone() } else { stac::collections_url(api) }), + Level::StacCollection { api, id } => { + Some(stac::search_url(api, &stac::Search { collections: vec![id.clone()], limit: ITEMS, ..stac::Search::default() })?) + } + Level::StacCatalog { url } | Level::StacItem { url } => Some(url.clone()), + Level::S3Root => match (super::first_bucket(c), conn) { + (Some(_), _) => None, + (None, Some(conn)) if conn.credentials.is_some() => Some(s3::buckets_url(conn)), + _ => return Err("name a bucket under Buckets to browse an anonymous S3 connection".into()), + }, + Level::S3Prefix { bucket, prefix } => Some(s3::list_url(bucket, prefix, None, conn)), + }) +} + +/// What a level reads, fetched here (waiting for it). +#[cfg(not(target_arch = "wasm32"))] +fn download(url: &str) -> Result { + let bytes = crate::render::rustgis_tiles::cache::download_blocking(url)?; + Ok(String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())) +} + +#[cfg(target_arch = "wasm32")] +fn download(_: &str) -> Result { + Err("in the web app, expand the connection under Data Sources › Connections".into()) +} + +/// The children of a level, and what is known of the rest. +#[derive(Default)] +struct Found { + children: Vec, + truncated: bool, + matched: Option, +} + +impl Found { + /// Keeps the first [`MAX_CHILDREN`] of a list (collected with one more, to know it was cut). + fn new(mut children: Vec) -> Found { + let truncated = children.len() > MAX_CHILDREN; + children.truncate(MAX_CHILDREN); + Found { children, truncated, matched: None } + } +} + +/// A node that fits what [`browse`] accepts. +fn node_ok(node: &str) -> bool { + node.len() <= MAX_NODE && !node.chars().any(char::is_control) +} + +pub(super) fn browse(s: &mut Session, p: &Value) -> CmdResult { + let c = super::found(s, p)?.clone(); + let node = ps(p, "node").unwrap_or(""); + if !node_ok(node) { + return Err("node is a child's node, as connection.browse lists it".into()); + } + // The app's answers come from a fetch the cache already limits; a caller's may be any size. + let documents = p.get("documents").and_then(Value::as_array).into_iter().flatten().filter_map(|d| ps(d, "text")).map(str::len); + if ps(p, "answer").map(str::len).into_iter().chain(documents).sum::() > super::super::service::MAX_GROUP_BYTES { + return Err("answer and documents are too large to read".into()); + } + let level = level(&c, node)?; + let conn = s3_login(s, &c, &level)?; + let url = fetch_url(s, &c, &level, conn.as_ref())?; + let mut out = json!({"connection": c.id, "node": node}); + if b(p, "probe").unwrap_or(false) { + out["fetch"] = json!(url); + return Ok(out); + } + let found = match &level { + Level::Database => database(&c)?, + Level::Service { url, kind } => service(s, p, &mut out, url, kind.as_deref())?, + Level::S3Root if url.is_none() => { + buckets(c.options.get("buckets").map(String::as_str).unwrap_or("").split(',').map(str::to_string)) + } + _ => { + let at = url.as_deref().unwrap_or(""); + let text: Cow = match ps(p, "answer") { + Some(a) => Cow::Borrowed(a), + None => Cow::Owned(download(at)?), + }; + let bytes = text.as_bytes(); + match &level { + Level::Catalogue => catalogue(s, &c, &text, &mut out)?, + Level::StacRoot { api } if stac::catalog::is_static(api) => catalogs(bytes, api)?, + Level::StacRoot { .. } => collections(bytes)?, + Level::StacCatalog { url } => catalogs(bytes, url)?, + Level::StacItem { url } => item_file(bytes, url)?, + Level::StacCollection { .. } => items(bytes, at)?, + Level::S3Root => buckets(s3::parse_buckets(bytes)?.into_iter()), + Level::S3Prefix { bucket, prefix } => objects(bytes, bucket, prefix)?, + Level::Database | Level::Service { .. } => Found::default(), + } + } + }; + out["children"] = json!(found.children); + if found.truncated { + out["truncated"] = json!(true); + } + if let Some(m) = found.matched { + out["matched"] = json!(m); + } + Ok(out) +} + +/// A live database's geometry tables, under their schemas. +fn database(c: &Connection) -> Result { + #[cfg(target_arch = "wasm32")] + { + let _ = c; + Err(DESKTOP_ONLY.into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + use std::collections::BTreeMap; + + let tables = super::super::layer::database_table_details(&json!({"database": c.address}))?; + let text = |t: &Value, k: &str| t.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let mut schemas: BTreeMap> = BTreeMap::new(); + let truncated = tables.len() > MAX_CHILDREN; + for t in tables.iter().take(MAX_CHILDREN) { + let (label, name) = (text(t, "label"), text(t, "name")); + let shown = if label.contains(':') { format!("{name}:{}", text(t, "column")) } else { name }; + schemas.entry(text(t, "schema")).or_default().push(json!({ + "key": label, + "name": shown, + "what": "table", + "detail": text(t, "kind"), + "add": {"command": "layer.addDatabase", "params": {"database": c.address, "layer": label}}, + "options": true, + })); + } + let children = schemas + .into_iter() + .map(|(s, tables)| json!({"key": format!("schema:{s}"), "name": s, "what": "schema", "children": tables})) + .collect(); + Ok(Found { children, truncated, matched: None }) + } +} + +/// A service's layers (capabilities read here, or the app's `answer` and `documents`). +fn service(s: &mut Session, p: &Value, out: &mut Value, url: &str, kind: Option<&str>) -> Result { + let mut q = json!({"url": url}); + if let Some(k) = kind { + q["type"] = json!(k); + } + if let Some(a) = ps(p, "answer") { + q["text"] = json!(a); + if let Some(d) = p.get("documents") { + q["documents"] = d.clone(); + } + } + let r = super::super::service::layers(s, &q)?; + out["service"] = json!({"url": url, "type": kind.unwrap_or("auto")}); + if let Some(n) = r.get("notes").filter(|n| n.as_array().is_some_and(|a| !a.is_empty())) { + out["notes"] = n.clone(); + } + if let Some(next) = r.get("next").filter(|n| !n.is_null()) { + out["next"] = next.clone(); + } + // The dialog imports an OGC API's collections together: a features layer isn't added alone there. + let grouped = r.get("kind").and_then(Value::as_str).is_some_and(|k| k.starts_with("OGC API")); + let text = |l: &Value, k: &str| l.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let children = r + .get("layers") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(MAX_CHILDREN + 1) + .map(|l| { + let (name, title) = (text(l, "name"), text(l, "title")); + let offer = l.get("offer").cloned().unwrap_or(Value::Null); + let offer_type = offer.get("type").and_then(Value::as_str).unwrap_or(""); + if offer_type == "service" { + let node = format!("service:arcgis:{}", text(&offer, "url")); + if !node_ok(&node) { + return Value::Null; + } + let what = if name.ends_with('/') { "folder" } else { "service" }; + return json!({"key": node, "node": node, "name": title.trim_end_matches('/'), "what": what}); + } + let what = match offer_type { + "vectorTiles" => "vectorTiles", + "features" => "features", + "coverage" => "coverage", + "style" => "style", + _ => "map", + }; + let source = text(l, "source_url"); + let mut params = json!({"url": url, "layer": name, "source_url": source}); + if let Some(k) = kind { + params["type"] = json!(k); + } + let mut child = json!({ + "key": format!("{source}#{name}"), + "name": if title.is_empty() { name.as_str() } else { title.as_str() }, + "what": what, + "detail": name, + "layer": l, + "options": true, + }); + if !(grouped && what == "features") { + child["add"] = json!({"command": "service.add", "params": params}); + } + child + }) + .collect::>(); + // A folder or service whose address is too long to follow as a node is left out, and said so. + let listed = children.len(); + let children: Vec = children.into_iter().filter(|c| !c.is_null()).collect(); + let left_out = children.len() < listed; + let mut found = Found::new(children); + found.truncated |= left_out; + Ok(found) +} + +/// A catalogue's records, each with the services and files it links to. +fn catalogue(s: &mut Session, c: &Connection, text: &str, out: &mut Value) -> Result { + let r = super::super::service::catalog_search(s, &json!({"url": c.address, "text": text}))?; + if let Some(n) = r.get("notes") { + out["notes"] = n.clone(); + } + let str_of = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let mut dropped = false; + let children = r + .get("records") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(MAX_CHILDREN + 1) + .enumerate() + .map(|(i, rec)| { + let id = str_of(rec, "id"); + let title = str_of(rec, "title"); + let links: Vec = rec + .get("links") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|l| { + let (url, kind, name) = (str_of(l, "url"), str_of(l, "kind"), str_of(l, "name")); + match kind.as_str() { + "wms" | "wmts" | "wfs" | "wcs" | "ogcapi" | "arcgis" => { + let node = format!("service:{kind}:{url}"); + if !node_ok(&node) { + dropped = true; + return None; + } + Some(json!({"key": node, "node": node, "name": if name.is_empty() { url.as_str() } else { name.as_str() }, "what": "service", "detail": kind})) + } + "download" => { + let last = url.split(['?', '#']).next().unwrap_or("").trim_end_matches('/').rsplit('/').next().unwrap_or(""); + let shown: String = if name.is_empty() { last } else { name.as_str() }.chars().take(200).collect(); + let named: String = title.chars().take(200).collect(); + Some(json!({ + "key": url, + "name": shown, + "what": "download", + "add": {"command": "layer.addUrl", "params": {"url": url, "name": named}}, + })) + } + _ => None, + } + }) + .collect(); + let summary: String = str_of(rec, "abstract").chars().take(200).collect(); + json!({"key": format!("record:{}", if id.is_empty() { i.to_string() } else { id }), "name": title, "what": "record", "detail": summary, "children": links}) + }) + .collect(); + let mut found = Found::new(children); + found.matched = r.get("matched").and_then(Value::as_u64); + found.truncated |= dropped || r.get("next").is_some_and(|n| !n.is_null()); + Ok(found) +} + +/// A STAC API's collections. +fn collections(bytes: &[u8]) -> Result { + let list = stac::parse_collections(bytes)?; + Ok(Found::new( + list.into_iter() + .take(MAX_CHILDREN + 1) + .map(|c| { + let node = format!("collection:{}", c.id); + json!({"key": node, "node": node, "name": if c.title.is_empty() { c.id.as_str() } else { c.title.as_str() }, "what": "collection", "detail": c.id, "options": true}) + }) + .collect(), + )) +} + +/// What a static catalog's document lists below it: its catalogs and collections (`child` links) +/// and its items (`item` links), each a node read when it is opened (an item's file holds its +/// assets). +fn catalogs(bytes: &[u8], from: &str) -> Result { + let mut dropped = false; + let mut children = Vec::new(); + for (prefix, what, list) in [ + ("catalog", "catalog", stac::catalog::parse_children(bytes, from)?), + ("item", "item", stac::catalog::parse_item_links(bytes, from)?), + ] { + for c in list { + let node = format!("{prefix}:{}", c.url); + if node_ok(&node) { + children.push(json!({"key": node, "node": node, "name": c.title, "what": what})); + } else { + dropped = true; + } + } + } + let mut found = Found::new(children); + found.truncated |= dropped; + Ok(found) +} + +/// The assets of an item that can be added, each with the `stac.addAsset` that adds it. +fn assets_of(item: &stac::Item) -> Vec { + item.assets + .iter() + .take(MAX_CHILDREN) + .filter_map(|a| { + let (key, href) = item.asset_href(Some(&a.key)).ok()?; + let mut params = json!({"asset": key, "name": format!("{} {key}", item.id)}); + match &item.url { + Some(u) => params["url"] = json!(u), + None => params["item"] = stac::item_json_with(item, false), + } + Some(json!({ + "key": format!("{}#{key}", item.url.as_deref().unwrap_or(&item.id)), + "name": if a.title.is_empty() { key.as_str() } else { a.title.as_str() }, + "what": "asset", + "detail": a.media_type, + "href": href, + "add": {"command": "stac.addAsset", "params": params}, + })) + }) + .collect() +} + +/// A static catalog's item (its own file): its assets. +fn item_file(bytes: &[u8], from: &str) -> Result { + Ok(Found::new(assets_of(&stac::parse_item(bytes, from)?))) +} + +/// The items of a collection's search, each with the assets that can be added. +fn items(bytes: &[u8], from: &str) -> Result { + let (list, next) = stac::parse_items(bytes, from)?; + let matched = stac::parse_matched(bytes); + let truncated = next.is_some() || matched.is_some_and(|m| m > list.len() as u64); + let children = list + .iter() + .take(MAX_CHILDREN) + .map(|item| json!({"key": format!("item:{}", item.url.as_deref().unwrap_or(&item.id)), "name": item.id, "what": "item", "detail": item.datetime, "children": assets_of(item)})) + .collect(); + Ok(Found { children, truncated, matched }) +} + +/// Buckets to browse, each a node. +fn buckets(names: impl Iterator) -> Found { + Found::new( + names + .map(|n| n.trim().to_string()) + .filter(|n| s3::valid_bucket(n)) + .take(MAX_CHILDREN + 1) + .map(|n| { + let node = format!("s3:{n}/"); + json!({"key": node, "node": node, "name": n, "what": "bucket"}) + }) + .collect(), + ) +} + +/// One level of a bucket: its folders (each a node) and its files. +fn objects(bytes: &[u8], bucket: &str, prefix: &str) -> Result { + let list = s3::parse_list(bytes)?; + let last = |k: &str| k.trim_end_matches('/').rsplit('/').next().unwrap_or("").to_string(); + let folders = list.prefixes.iter().map(|p| { + let node = format!("s3:{bucket}/{p}"); + json!({"key": node, "node": node, "name": format!("{}/", last(p)), "what": "prefix"}) + }); + let files = list.objects.iter().filter(|o| o.key != prefix && !o.key.ends_with('/')).map(|o| { + let url = format!("s3://{bucket}/{}", o.key); + json!({ + "key": url, + "name": last(&o.key), + "what": "object", + "detail": o.size.to_string(), + "add": {"command": "layer.addUrl", "params": {"url": url}}, + }) + }); + let mut found = Found::new(folders.chain(files).take(MAX_CHILDREN + 1).collect()); + found.truncated |= list.next.is_some(); + Ok(found) +} diff --git a/crates/engine/src/cmd/connections.rs b/crates/engine/src/cmd/connections.rs index 833ffc6d4..b4f2e3c5a 100644 --- a/crates/engine/src/cmd/connections.rs +++ b/crates/engine/src/cmd/connections.rs @@ -10,6 +10,9 @@ use crate::connections::{Connection, DATABASE_KINDS, DESKTOP_ONLY, KINDS}; use crate::render::rustgis_tiles::{s3, stac}; use crate::{CmdResult, CommandSpec as C, Session}; +#[path = "connection_browse.rs"] +mod browse; + pub fn commands() -> Vec { vec![ C::new("connection.list", "List Connections", "Home › Add › Data Sources", list).view().params( @@ -29,6 +32,9 @@ pub fn commands() -> Vec { C::new("connection.test", "Test Connection", "Home › Add › Data Sources", test).view().params( r#"{"id"?: "a connection's id or name", "kind"?: "postgres"|"mysql"|"sqlserver"|"service"|"wms"|"wmts"|"wfs"|"wcs"|"ogcapi"|"arcgis"|"csw"|"stac"|"s3", "name"?: text, "group"?: text, "address"?: text, "options"?: "an unsaved connection, as connection.new (when no id)", "password"?: text, "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "as connection.login (logs in first)", "probe"?: "true: only the address the test reads, {fetch} (null for databases, which their driver tests)", "answer"?: "non-database kinds only: what that address returned, when the app fetched it (no network request then); databases ignore answer and test through their driver"}"#, ), + C::new("connection.browse", "Browse Connection", "Home › Add › Data Sources", browse::browse).view().params( + r#"{"id": "a connection's id or name", "node"?: "a child's node, as a listing gives it (the connection's top level when absent)", "probe"?: "true: only the address this level reads, {fetch} (null: read without a fetch, or by the database driver); an S3 connection logs in first, as connection.test does", "answer"?: "what that address returned, when the app fetched it", "documents"?: [{"url": "a further document a listing asked for (next)", "text": "its answer"}]} → {connection, node, children: [{key, name, what, detail?, node? (browse it), children? (listed already), add? {command, params}: the command that adds it}], truncated?, matched?, notes?, service? {url, type}, next? (fetch it, then ask again with answer and documents)}"#, + ), ] } @@ -53,16 +59,7 @@ fn shown(c: &Connection) -> Value { } else { false }; - let add_with = match c.kind.as_str() { - "postgres" | "mysql" | "sqlserver" => "info.databaseTables {database: address}, then layer.addDatabase {database: address, layer}", - "service" => "service.layers {url: address}, then service.add {url, layer}", - "wms" | "wmts" | "wfs" | "ogcapi" | "arcgis" => "service.layers {url: address, type: kind}, then service.add {url, type, layer}", - "wcs" => "service.layers {url: address, type: wcs}, then service.coverage {url, layer}", - "csw" => "catalog.search {url: address, query}", - "stac" => "stac.collections {url: address}, then stac.search {url, collections}", - "s3" => "connection.login {id} once, then s3.list {connection: name}, then layer.addUrl {url: s3://…}", - _ => "", - }; + let add_with = "connection.browse {id}, then {id, node} for a child's node; run a dataset's add {command, params}"; let mut out = serde_json::to_value(c).unwrap_or(Value::Null); out["available"] = json!(available); if !available { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index d8b671a8a..9d7246ab2 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -1280,6 +1280,15 @@ pub(super) fn database_tables(_s: &mut Session, p: &Value) -> CmdResult { } } +/// The geometry tables of a live database server, with the schema, name, column and type of +/// each (`connection.browse` groups them by schema). +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn database_table_details(p: &Value) -> Result, String> { + let db = database_param(p).ok_or("database is required")?; + let live = LiveDb::connect(&db).ok_or("not a database server")?.map_err(|e| format!("{}: {e}", db.short()))?; + Ok(live.tables().map_err(|e| format!("{}: {e}", db.short()))?.iter().map(LiveTable::detail).collect()) +} + /// Adds the tables of a database (a PostgreSQL connection or a DuckDB file) that have a geometry /// column, or the one `layer` names. fn add_database(s: &mut Session, p: &Value) -> CmdResult { @@ -1399,6 +1408,16 @@ impl LiveTable { LiveTable::Sqlserver(t) => t.label(), } } + + /// The table as a browser lists it: its label for `layer.addDatabase`, and what it is made of. + fn detail(&self) -> Value { + let (schema, name, column, kind) = match self { + LiveTable::Postgis(t) => (&t.schema, &t.name, &t.column, &t.kind), + LiveTable::Mysql(t) => (&t.schema, &t.name, &t.column, &t.kind), + LiveTable::Sqlserver(t) => (&t.schema, &t.name, &t.column, &t.kind), + }; + json!({"label": self.label(), "schema": schema, "name": name, "column": column, "kind": kind}) + } } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index f71652219..bc00ee10b 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -276,6 +276,7 @@ const NEVER: &[&str] = &[ "connection.remove", "connection.login", "connection.test", + "connection.browse", "s3.connect", "s3.disconnect", "earthdata.login", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 11bd4e55a..9bfe9a1e1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1148,6 +1148,7 @@ fn hostile_params_never_panic() { json!({"id": "", "kind": "postgres", "address": "postgres:host=127.0.0.1 port=9 password=x", "options": "x"}), json!({"kind": "wms", "address": "http://127.0.0.1:9/wms", "probe": "x", "answer": 7}), json!({"kind": "stac", "address": "http://127.0.0.1:9/stac", "answer": "\u{0}{"}), + json!({"id": "c1", "node": "s3:/\u{7}", "probe": true, "answer": 7, "documents": "x"}), ]; let ids: Vec<&str> = Session::new(Project::default()).registry.commands.iter().map(|c| c.id).collect(); let streamed = grid_fgb("hostile", 20); @@ -6800,6 +6801,18 @@ fn wcs_coverages_and_catalogue_records() { ); let full = s.run("service.coverage", &json!({"url": url, "layer": "soc", "text": caps, "describe": describe, "bbox": "full"})).unwrap(); assert_eq!(full["bbox"], json!([5.0, 49.2, 5.8, 50.0])); + // The same coverage browsed from a saved connection: the add it lists runs as it is. + s.run("connection.new", &json!({"kind": "wcs", "address": url, "name": "Soils"})).unwrap(); + let browsed = s.run("connection.browse", &json!({"id": "Soils", "answer": caps})).unwrap(); + let child = &browsed["children"][0]; + assert_eq!((child["what"].as_str(), child["add"]["command"].as_str()), (Some("coverage"), Some("service.add")), "{browsed}"); + let mut listed = child["add"]["params"].clone(); + listed["text"] = json!(caps); + listed["describe"] = json!(describe); + listed["bbox"] = json!("full"); + let layers = s.map().layers.len(); + s.run("service.add", &listed).unwrap(); + assert_eq!(s.map().layers.len(), layers + 1); let (err, _) = serve_file(b"no".to_vec(), false); let p = json!({"url": format!("{err}/wcs"), "layer": "soc", "text": caps, "describe": describe, "bbox": "full"}); assert!(s.run("service.add", &p).unwrap_err().contains("error")); @@ -8856,7 +8869,15 @@ fn connections_commands() { assert_eq!(renamed["name"], "wms (2)"); assert_ne!(default["id"], renamed["id"]); assert!(crate::sources::holds_secret(&json!({"id":"x","password":"p"}))); - for id in ["connection.new", "connection.edit", "connection.duplicate", "connection.remove", "connection.login", "connection.test"] { + for id in [ + "connection.new", + "connection.edit", + "connection.duplicate", + "connection.remove", + "connection.login", + "connection.test", + "connection.browse", + ] { assert!(crate::cmd::plugins::user_only(id), "{id}"); } assert!(!crate::cmd::plugins::user_only("connection.list")); @@ -8967,6 +8988,195 @@ fn connection_test_probes_and_answers() { assert!(s.connections.connections.is_empty()); } +/// Browsing a saved connection lists what each kind offers, a level at a time, with the command +/// that adds each dataset; offline, with the answers supplied. A node that isn't the +/// connection's is refused, and nothing browsed is saved or undoable. +#[test] +fn connection_browse_levels_and_adds() { + use crate::render::rustgis_tiles::s3; + let mut s = Session::new(Project::default()); + let new = |s: &mut Session, p: Value| s.run("connection.new", &p).unwrap(); + let wms_caps = r#"Localimage/pngEPSG:3857aAbB"#; + + // WMS: the address read, its layers, and the add that adds one. + new(&mut s, json!({"kind": "wms", "address": "https://a.example/wms", "name": "W"})); + let v = s.run("connection.browse", &json!({"id": "W", "probe": true})).unwrap(); + assert!(v["fetch"].as_str().is_some_and(|f| f.contains("REQUEST=GetCapabilities")), "{v}"); + let v = s.run("connection.browse", &json!({"id": "W", "answer": wms_caps})).unwrap(); + assert_eq!(v["service"], json!({"url": "https://a.example/wms", "type": "wms"})); + let first = v["children"][0].clone(); + assert_eq!(first["what"], "map"); + assert_eq!(first["name"], "A"); + assert_eq!(first["add"]["command"], "service.add"); + assert_eq!(first["add"]["params"]["url"], "https://a.example/wms"); + assert_eq!(first["add"]["params"]["type"], "wms"); + assert_eq!(first["add"]["params"]["layer"], "a"); + assert!(first["add"]["params"].get("source_url").is_some()); + assert_eq!(v["children"].as_array().unwrap().len(), 2); + let before = s.map().tiles.len(); + let mut add = first["add"]["params"].clone(); + add["text"] = json!(wms_caps); + s.run("service.add", &add).unwrap(); + assert_eq!(s.map().tiles.len(), before + 1); + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(s.map().tiles.len(), before); + + // ArcGIS: a directory's folder is a node of its own address. + new(&mut s, json!({"kind": "arcgis", "address": "https://x.example.org/arcgis/rest/services", "name": "Arc"})); + let dir = r#"{"currentVersion": 11, "folders": ["Hydro"], "services": [{"name": "Topo", "type": "MapServer"}]}"#; + let v = s.run("connection.browse", &json!({"id": "Arc", "answer": dir})).unwrap(); + let folder = "service:arcgis:https://x.example.org/arcgis/rest/services/Hydro"; + let child = v["children"].as_array().unwrap().iter().find(|c| c["node"] == folder).unwrap_or_else(|| panic!("{v}")); + assert_eq!(child["what"], "folder"); + assert_eq!(child["name"], "Hydro"); + let v = s.run("connection.browse", &json!({"id": "Arc", "node": folder, "probe": true})).unwrap(); + assert!(v["fetch"].as_str().is_some_and(|f| f.starts_with("https://x.example.org/arcgis/rest/services/Hydro")), "{v}"); + + // CSW: a record, the service it links to as a node, and more records than listed. + new(&mut s, json!({"kind": "csw", "address": "https://cat.example.org/csw", "name": "Cat"})); + let records = r#" + Forests + https://geo.example.org/wmshttp://192.168.1.2/wfs + "#; + let v = s.run("connection.browse", &json!({"id": "Cat", "answer": records})).unwrap(); + assert_eq!(v["children"].as_array().unwrap().len(), 1, "{v}"); + assert_eq!(v["children"][0]["what"], "record"); + assert_eq!(v["children"][0]["children"][0]["node"], "service:wms:https://geo.example.org/wms"); + assert_eq!((v["matched"].as_u64(), v["truncated"].as_bool()), (Some(3), Some(true))); + assert!(v.get("next").is_none()); + + // STAC: collections, a collection's search, and an item's asset with the add of it. + new(&mut s, json!({"kind": "stac", "address": "https://s.example/v1", "name": "Stac"})); + let v = s.run("connection.browse", &json!({"id": "Stac", "probe": true})).unwrap(); + assert_eq!(v["fetch"], "https://s.example/v1/collections"); + let v = s.run("connection.browse", &json!({"id": "Stac", "answer": r#"{"collections":[{"id":"c1","title":"C"}]}"#})).unwrap(); + assert_eq!(v["children"][0]["node"], "collection:c1"); + assert_eq!(v["children"][0]["options"], true); + let v = s.run("connection.browse", &json!({"id": "Stac", "node": "collection:c1", "probe": true})).unwrap(); + let fetch = v["fetch"].as_str().unwrap(); + assert!(fetch.contains("/search?") && fetch.contains("collections=c1") && fetch.contains("limit=20"), "{fetch}"); + let self_link = "https://s.example/v1/collections/c1/items/i1"; + let items = json!({"type": "FeatureCollection", "numberMatched": 1, "features": [{ + "type": "Feature", "id": "i1", "collection": "c1", "bbox": [0, 0, 1, 1], "geometry": null, + "properties": {"datetime": "2024-01-01T00:00:00Z"}, + "assets": {"visual": {"href": "https://s.example/a.tif", "type": "image/tiff; application=geotiff"}}, + "links": [{"rel": "self", "href": self_link}]}]}); + let v = s.run("connection.browse", &json!({"id": "Stac", "node": "collection:c1", "answer": items.to_string()})).unwrap(); + assert_eq!(v["children"][0]["what"], "item"); + assert_eq!( + v["children"][0]["children"][0]["add"], + json!({"command": "stac.addAsset", "params": {"url": self_link, "asset": "visual", "name": "i1 visual"}}) + ); + assert!(v.get("truncated").is_none()); + + // A static STAC catalog: catalogs and collections, then a collection's items, each opened + // for its assets. + new(&mut s, json!({"kind": "stac", "address": "https://s.example/static/catalog.json", "name": "Static"})); + let root = json!({"type": "Catalog", "id": "r", "links": [{"rel": "child", "href": "./c1/collection.json", "title": "C1"}]}); + let v = s.run("connection.browse", &json!({"id": "Static", "answer": root.to_string()})).unwrap(); + assert_eq!(v["children"][0]["node"], "catalog:https://s.example/static/c1/collection.json"); + let collection = json!({"type": "Collection", "id": "c1", "links": [ + {"rel": "item", "href": "./i1/i1.json", "title": "Scene 1"}, {"rel": "child", "href": "./sub/catalog.json", "title": "Sub"}]}); + let node = "catalog:https://s.example/static/c1/collection.json"; + let v = s.run("connection.browse", &json!({"id": "Static", "node": node, "answer": collection.to_string()})).unwrap(); + let kinds: Vec<_> = + v["children"].as_array().unwrap().iter().map(|c| (c["what"].as_str().unwrap(), c["node"].as_str().unwrap())).collect(); + assert_eq!( + kinds, + [("catalog", "catalog:https://s.example/static/c1/sub/catalog.json"), ("item", "item:https://s.example/static/c1/i1/i1.json")] + ); + let item_node = "item:https://s.example/static/c1/i1/i1.json"; + let v = s.run("connection.browse", &json!({"id": "Static", "node": item_node, "probe": true})).unwrap(); + assert_eq!(v["fetch"], "https://s.example/static/c1/i1/i1.json"); + let item = json!({"type": "Feature", "id": "i1", "collection": "c1", "bbox": [0, 0, 1, 1], "geometry": null, + "properties": {"datetime": "2024-01-01T00:00:00Z"}, + "assets": {"visual": {"href": "https://s.example/static/a.tif", "type": "image/tiff; application=geotiff"}}, + "links": [{"rel": "self", "href": "https://s.example/static/c1/i1/i1.json"}]}); + let v = s.run("connection.browse", &json!({"id": "Static", "node": item_node, "answer": item.to_string()})).unwrap(); + assert_eq!( + v["children"][0]["add"], + json!({"command": "stac.addAsset", "params": {"url": "https://s.example/static/c1/i1/i1.json", "asset": "visual", "name": "i1 visual"}}) + ); + // An asset's key names its item, so the same file under two items is two rows. + assert_eq!(v["children"][0]["key"], "https://s.example/static/c1/i1/i1.json#visual"); + // A relative asset address is resolved against the item's own file. + let relative = item.to_string().replace(r#""assets":{"#, r#""assets":{"data":{"href":"./data/b.tif","type":"image/tiff"},"#); + let v = s.run("connection.browse", &json!({"id": "Static", "node": item_node, "answer": relative})).unwrap(); + let hrefs: Vec<&str> = v["children"].as_array().unwrap().iter().filter_map(|c| c["href"].as_str()).collect(); + assert!(hrefs.contains(&"https://s.example/static/c1/i1/data/b.tif"), "{hrefs:?}"); + // An item node belongs to a STAC connection, and is judged as its documents are. + assert!(s.run("connection.browse", &json!({"id": "W", "node": item_node})).is_err()); + assert!(s.run("connection.browse", &json!({"id": "Static", "node": "item:http://127.0.0.1:9/i.json"})).is_err()); + // S3: buckets from the connection's options need no fetch; a bucket's level is read. + new(&mut s, json!({"kind": "s3", "name": "BrowseB", "options": {"credentials": "none", "buckets": "bkt1"}})); + let v = s.run("connection.browse", &json!({"id": "BrowseB", "probe": true})).unwrap(); + assert!(v["fetch"].is_null(), "{v}"); + let v = s.run("connection.browse", &json!({"id": "BrowseB"})).unwrap(); + assert_eq!(v["children"][0]["node"], "s3:bkt1/"); + let v = s.run("connection.browse", &json!({"id": "BrowseB", "node": "s3:bkt1/", "probe": true})).unwrap(); + assert!(v["fetch"].as_str().is_some_and(|f| f.contains("bkt1")), "{v}"); + let listing = "p/p.tif5q/0"; + let v = s.run("connection.browse", &json!({"id": "BrowseB", "node": "s3:bkt1/", "answer": listing})).unwrap(); + assert_eq!(v["children"][0]["node"], "s3:bkt1/p/"); + assert_eq!(v["children"][0]["name"], "p/"); + assert_eq!(v["children"][1]["add"]["params"]["url"], "s3://bkt1/p.tif"); + assert_eq!(v["children"].as_array().unwrap().len(), 2, "{v}"); + s3::disconnect("BrowseB"); + + // Nodes that aren't the connection's, and ones that are hostile. + let long = "x".repeat(5000); + for (id, node) in [ + ("W", "collection:x"), + ("BrowseB", "s3:BAD_/"), + ("W", long.as_str()), + ("W", "service:ftp:x"), + ("W", "service:wms:http://127.0.0.1:9/wms"), + ("Stac", "catalog:http://127.0.0.1:9/c.json"), + ("Stac", "s3:bkt1/"), + ("W", "nope"), + ("W", "a\u{7}b"), + ] { + let e = s.run("connection.browse", &json!({"id": id, "node": node})).unwrap_err(); + assert!(!e.is_empty()); + } + assert!(s.run("connection.browse", &json!({"id": "missing"})).unwrap_err().contains("no connection")); + #[cfg(not(target_arch = "wasm32"))] + { + new(&mut s, json!({"kind": "postgres", "address": "postgres:host=127.0.0.1 port=9 dbname=x user=u", "name": "Pg"})); + assert!(!s.run("connection.browse", &json!({"id": "Pg"})).unwrap_err().is_empty()); + } + // A catalogue's download link too long to be one (the parser keeps addresses to 2000 bytes) is not listed. + let long_link = format!("https://files.example.org/{}.tif", "a".repeat(5000)); + let records = format!( + r#"Files + https://files.example.org/a.tif{long_link}"# + ); + let v = s.run("connection.browse", &json!({"id": "Cat", "answer": records})).unwrap(); + let links = v["children"][0]["children"].as_array().unwrap(); + assert_eq!(links.len(), 1, "{v}"); + assert_eq!(links[0]["add"]["params"]["url"], "https://files.example.org/a.tif"); + assert!(v.get("truncated").is_none(), "{v}"); + // An answer larger than a service document may be is refused before it is parsed. + let huge = "x".repeat(65 << 20); + assert!(s.run("connection.browse", &json!({"id": "W", "answer": huge})).unwrap_err().contains("too large")); + assert!( + s.run("connection.browse", &json!({"id": "W", "answer": "", "documents": [{"url": "https://a.example/x", "text": huge}]})) + .unwrap_err() + .contains("too large") + ); + // An OGC API's feature collections are imported together: listed, with options, not added alone. + new(&mut s, json!({"kind": "ogcapi", "address": "https://o.example/collections", "name": "Ogc"})); + let collections = r#"{"collections":[{"id":"lakes","title":"Lakes","links":[{"rel":"items","type":"application/geo+json","href":"https://o.example/collections/lakes/items"}]}]}"#; + let v = s.run("connection.browse", &json!({"id": "Ogc", "answer": collections})).unwrap(); + assert_eq!((v["children"][0]["what"].as_str(), v["children"][0]["options"].as_bool()), (Some("features"), Some(true)), "{v}"); + assert!(v["children"][0].get("add").is_none(), "{v}"); + let saved = s.connections.connections.len(); + assert!(saved >= 5); + s.run("connection.browse", &json!({"id": "W", "answer": wms_caps})).unwrap(); + assert_eq!(s.connections.connections.len(), saved); + assert!(!s.can_undo()); +} + fn rustgis_engine_sources_roundtrip(text: &str) -> crate::sources::Sources { crate::sources::Sources::from_json(text) } diff --git a/crates/tiles/src/stac/catalog.rs b/crates/tiles/src/stac/catalog.rs index 1bd505334..c7a11a441 100644 --- a/crates/tiles/src/stac/catalog.rs +++ b/crates/tiles/src/stac/catalog.rs @@ -69,6 +69,22 @@ pub fn parse_children(bytes: &[u8], from: &str) -> Result, String> { Ok(out) } +/// The items (`item` links) of a catalog's or collection's document fetched from `from`: web +/// addresses only, in the order the document gives them. +pub fn parse_item_links(bytes: &[u8], from: &str) -> Result, String> { + let v = super::json(bytes)?; + if !v.is_object() || !matches!(v.get("type").and_then(Value::as_str), Some("Catalog" | "Collection")) { + return Err("the answer isn't a STAC catalog or collection".into()); + } + let mut seen = HashSet::new(); + Ok(links(&v, "item", from) + .into_iter() + .filter(|(url, _)| is_web(url) && crate::address::check(url, Some(from)).is_ok() && seen.insert(url.clone())) + .take(MAX_CHILDREN) + .map(|(url, title)| Child { title: if title.is_empty() { label_of(&url) } else { title }, url }) + .collect()) +} + /// The links of a relation, resolved against `from`: (address, title), the first /// [`MAX_LINKS`] of them (counted among that relation's alone). fn links(v: &Value, rel: &str, from: &str) -> Vec<(String, String)> { @@ -499,6 +515,22 @@ mod tests { assert_eq!(parse_children(br#"{"type": "Catalog", "links": 7}"#, "https://x.org/c.json"), Ok(vec![])); assert_eq!(label_of("https://x.org/events/catalog.json"), "events"); assert_eq!(label_of("https://x.org/events/a.json"), "a"); + // A collection lists its items by `item` links, in the order it gives them. + let coll = json!({"type": "Collection", "id": "c", "links": [ + {"rel": "item", "href": "./i2/i2.json", "title": "Second"}, + {"rel": "item", "href": "./i1/i1.json"}, + {"rel": "item", "href": "./i2/i2.json"}, + {"rel": "item", "href": "file:///etc/passwd"}, + {"rel": "child", "href": "./sub/catalog.json"}]}); + let i = parse_item_links(coll.to_string().as_bytes(), "https://x.org/c/collection.json").unwrap(); + assert_eq!( + i, + vec![ + Child { title: "Second".into(), url: "https://x.org/c/i2/i2.json".into() }, + Child { title: "i1".into(), url: "https://x.org/c/i1/i1.json".into() }, + ] + ); + assert!(parse_item_links(br#"{"type": "Feature"}"#, "https://x.org/c.json").is_err()); } #[test] diff --git a/crates/ui-egui/src/connectiontree.rs b/crates/ui-egui/src/connectiontree.rs new file mode 100644 index 000000000..f952a2885 --- /dev/null +++ b/crates/ui-egui/src/connectiontree.rs @@ -0,0 +1,525 @@ +//! The datasets a saved connection offers, listed under it in Data Sources › Connections: one +//! level at a time as it is expanded (`connection.browse` names what each level reads, the app +//! fetches it off the frame and asks again with the answer), with the selection that +//! Add Selected (in a dataset's menu) adds. Each dataset added is its own undo step. + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::collapsing_header::CollapsingState; +use egui::{Label, RichText, Sense, Ui}; +use rustgis_engine::connections::{Connection, DATABASE_KINDS}; +use rustgis_engine::render::rustgis_tiles::stac; +use rustgis_engine::sources::Source; +use serde_json::{Value, json}; + +use crate::serviceui::{self, ServiceSource}; +use crate::theme::Tokens; +use crate::{RustGisApp, Slot, i18n}; + +type Key = (String, String); + +#[derive(Default)] +pub struct State { + /// What each level of each connection lists: (connection id, node; empty for the top). + entries: HashMap, + /// The definition each connection's entries were made for: an edit makes them stale. + seen: HashMap, + pub picks: Vec, + /// Signed addresses of STAC assets waiting on a token: (token address, asset, name, answer). + tokens: Vec<(String, String, String, Slot, String>>)>, +} + +pub enum Entry { + Loading(Fetch), + Ready(Arc), + Failed(String), +} + +/// A level whose answer is on its way. +pub struct Fetch { + node: String, + /// The first answer, and the further documents a service's discovery asked for. + first: Option, + documents: Vec, + /// The address being fetched. + pending: String, + slot: Slot, String>>, +} + +pub struct Listing { + pub children: Vec, + pub truncated: bool, + pub matched: Option, + pub notes: Vec, + /// The service its layers belong to, to add them from the documents already read. + pub source: Option>, +} + +/// A dataset selected for Add Selected. +pub struct Pick { + pub conn: String, + pub key: String, + pub name: String, + pub child: Value, + pub source: Option>, +} + +fn params(id: &str, node: &str) -> Value { + let mut p = json!({"id": id}); + if !node.is_empty() { + p["node"] = json!(node); + } + p +} + +/// Starts reading a level: what it needs fetched is asked for, and a level read without a fetch +/// (a database's tables, an S3 connection's own buckets) is read now. Errors go into the entry. +fn start(app: &mut RustGisApp, c: &Connection, node: &str) { + let mut probe = params(&c.id, node); + probe["probe"] = json!(true); + let entry = match app.session.run("connection.browse", &probe) { + Err(e) => Entry::Failed(e), + Ok(v) => match v["fetch"].as_str() { + None => match app.session.run("connection.browse", ¶ms(&c.id, node)) { + Ok(v) => Entry::Ready(Arc::new(listing(&v, None, Vec::new()))), + Err(e) => Entry::Failed(e), + }, + Some(url) => Entry::Loading(Fetch { + node: node.to_string(), + first: None, + documents: Vec::new(), + pending: url.to_string(), + slot: crate::fetch_into(url), + }), + }, + }; + app.ui.connection_tree.entries.insert((c.id.clone(), node.to_string()), entry); +} + +fn listing(v: &Value, first: Option, documents: Vec) -> Listing { + let source = v.get("service").filter(|s| s.is_object()).map(|s| { + Arc::new(ServiceSource { + url: s["url"].as_str().unwrap_or("").to_string(), + kind: serviceui::kind_of(s["type"].as_str().unwrap_or("")), + text: first.unwrap_or_default(), + documents, + }) + }); + Listing { + children: v["children"].as_array().cloned().unwrap_or_default(), + truncated: v["truncated"] == true, + matched: v["matched"].as_u64(), + notes: v["notes"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(), + source, + } +} + +/// An answer arrived: asks again with it (and what came before). A service's discovery may need +/// further documents, which are fetched in turn. +fn advance(app: &mut RustGisApp, id: &str, mut f: Fetch, bytes: Vec) -> Entry { + let Ok(text) = crate::service_text(bytes) else { + return Entry::Failed(tl!("The answer isn't UTF-8 text").to_string()); + }; + if f.first.is_none() { + f.first = Some(text); + } else { + f.documents.push(json!({"url": f.pending, "text": text})); + } + let mut p = params(id, &f.node); + p["answer"] = json!(f.first); + if !f.documents.is_empty() { + p["documents"] = json!(f.documents); + } + match app.session.run("connection.browse", &p) { + Err(e) => Entry::Failed(e), + Ok(v) => match v["next"].as_str() { + Some(next) => { + f.pending = next.to_string(); + f.slot = crate::fetch_into(next); + Entry::Loading(f) + } + None => Entry::Ready(Arc::new(listing(&v, f.first, f.documents))), + }, + } +} + +pub fn poll(app: &mut RustGisApp, ctx: &egui::Context) { + let tree = &mut app.ui.connection_tree; + let mut landed = Vec::new(); + for (key, entry) in &tree.entries { + if let Entry::Loading(f) = entry + && let Some(r) = f.slot.lock().unwrap_or_else(|e| e.into_inner()).take() + { + landed.push((key.clone(), r)); + } + } + for (key, r) in landed { + let Some(Entry::Loading(f)) = app.ui.connection_tree.entries.remove(&key) else { continue }; + let entry = match r { + Ok(bytes) => advance(app, &key.0, f, bytes), + Err(e) => Entry::Failed(e), + }; + app.ui.connection_tree.entries.insert(key, entry); + } + // STAC assets that needed a token: signed and added when it lands. + for (token_url, href, name, slot) in std::mem::take(&mut app.ui.connection_tree.tokens) { + let Some(r) = slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { + app.ui.connection_tree.tokens.push((token_url, href, name, slot)); + continue; + }; + match r.and_then(|b| stac::parse_sas(&b)) { + Ok(token) => { + stac::remember_token(&token_url, &token); + if let Err(e) = app.run("ui.addUrl", json!({"url": stac::sign(&href, &token), "name": name, "zoom": true})) { + app.session.notify(e, true); + } + app.map.invalidate(); + } + Err(e) => app.session.notify(e, true), + } + } + let tree = &app.ui.connection_tree; + if tree.entries.values().any(|e| matches!(e, Entry::Loading(_))) || !tree.tokens.is_empty() || !app.ui.service_steps.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Adds one dataset a listing offers. Each branch is one command, so each is one undo step. +pub fn add(app: &mut RustGisApp, child: &Value, source: Option<&ServiceSource>) -> Result<(), String> { + let add = child.get("add").filter(|a| a.is_object()).ok_or_else(|| tl!("Nothing to add here").to_string())?; + let command = add["command"].as_str().unwrap_or(""); + let params = add["params"].clone(); + match (source, child.get("layer").filter(|l| l.is_object())) { + (Some(source), Some(layer)) => { + serviceui::add_layer(app, source, layer, "", false)?; + } + _ => match command { + "layer.addUrl" => { + app.run("ui.addUrl", params)?; + } + "stac.addAsset" => add_asset(app, child["href"].as_str().unwrap_or(""), params["name"].as_str().unwrap_or(""))?, + _ => { + app.run(command, params)?; + } + }, + } + app.map.invalidate(); + Ok(()) +} + +/// A STAC asset by its address, signed first when it needs a token (one in hand is used; one to +/// fetch is fetched off the frame and the asset added when it lands). +fn add_asset(app: &mut RustGisApp, href: &str, name: &str) -> Result<(), String> { + if !stac::needs_signing(href) { + app.run("ui.addUrl", json!({"url": href, "name": name, "zoom": true}))?; + return Ok(()); + } + let token_url = stac::sas_token_url(href)?; + if let Some(token) = stac::cached_token(&token_url) { + app.run("ui.addUrl", json!({"url": stac::sign(href, &token), "name": name, "zoom": true}))?; + } else { + let slot = crate::fetch_into(&token_url); + app.ui.connection_tree.tokens.push((token_url, href.to_string(), name.to_string(), slot)); + } + Ok(()) +} + +/// Opens the kind's own add dialog on a dataset: a table in the database dialog, a service layer +/// in the service dialog, a STAC collection in the STAC dialog. +fn add_with_options(app: &mut RustGisApp, c: &Connection, child: &Value, source: Option<&ServiceSource>) -> Result<(), String> { + match child["what"].as_str().unwrap_or("") { + "table" => crate::sourcespane::open( + app, + &Source { kind: "database".into(), name: c.name.clone(), address: c.address.clone(), extra: json!({"layer": child["key"]}) }, + ), + "collection" => { + crate::sourcespane::open( + app, + &Source { kind: "stac".into(), name: c.name.clone(), address: c.address.clone(), extra: json!({"api": "true"}) }, + )?; + app.ui.stac.collection = child["detail"].as_str().unwrap_or("").to_string(); + Ok(()) + } + _ => match (source, child.get("layer").filter(|l| l.is_object())) { + (Some(source), Some(layer)) => { + app.run("ui.addService", json!({"url": source.url, "type": source.kind}))?; + app.ui.service_browse.filter = layer["name"].as_str().unwrap_or("").to_string(); + Ok(()) + } + _ => Err(tl!("Nothing to add here").to_string()), + }, + } +} + +/// What a listed child is, in words. +fn what_label(what: &str) -> &'static str { + match what { + "schema" => tl!("schema"), + "table" => tl!("table"), + "folder" => tl!("folder"), + "service" => tl!("service"), + "map" => tl!("map"), + "vectorTiles" => tl!("vector tiles"), + "features" => tl!("features"), + "coverage" => tl!("coverage"), + "style" => tl!("style"), + "record" => tl!("record"), + "download" => tl!("download"), + "collection" => tl!("collection"), + "catalog" => tl!("catalog"), + "item" => tl!("item"), + "asset" => tl!("asset"), + "bucket" => tl!("bucket"), + "prefix" => tl!("folder"), + "object" => tl!("file"), + _ => "", + } +} + +fn picked(app: &RustGisApp, conn: &str, key: &str) -> bool { + app.ui.connection_tree.picks.iter().any(|p| p.conn == conn && p.key == key) +} + +fn toggle(app: &mut RustGisApp, c: &Connection, child: &Value, source: Option<&Arc>) { + let key = child["key"].as_str().unwrap_or(""); + let picks = &mut app.ui.connection_tree.picks; + if let Some(i) = picks.iter().position(|p| p.conn == c.id && p.key == key) { + picks.remove(i); + } else { + picks.push(Pick { + conn: c.id.clone(), + key: key.to_string(), + name: child["name"].as_str().unwrap_or("").to_string(), + child: child.clone(), + source: source.cloned(), + }); + } +} + +/// Forgets what a connection (or, with `None`, every connection) listed and what was selected +/// from it: the next expansion reads it again. +pub fn refresh(app: &mut RustGisApp, id: Option<&str>) { + let tree = &mut app.ui.connection_tree; + match id { + Some(id) => { + tree.entries.retain(|(c, _), _| c != id); + tree.picks.retain(|p| p.conn != id); + } + None => { + tree.entries.clear(); + tree.picks.clear(); + } + } +} + +/// Drops the picks of a connection that was edited or removed since (even while its row is +/// folded, when its list isn't refreshed): they would add from the old definition. +pub fn prune(app: &mut RustGisApp) { + let (connections, tree) = (&app.session.connections, &mut app.ui.connection_tree); + let seen = &tree.seen; + tree.picks.retain(|p| connections.find(&p.conn).is_some_and(|c| seen.get(&p.conn) == Some(c))); +} + +/// Adds every selected dataset, each as its own undo step, and clears the selection. +fn add_picks(app: &mut RustGisApp) { + prune(app); + for pick in std::mem::take(&mut app.ui.connection_tree.picks) { + if let Err(e) = add(app, &pick.child, pick.source.as_deref()) { + app.session.notify(e, true); + } + } +} + +/// A connection's expanded body: its top level, listed (or its error). +pub fn show(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connection) { + // An edited connection lists what it now offers. + if app.ui.connection_tree.seen.get(&c.id) != Some(c) { + refresh(app, Some(&c.id)); + app.ui.connection_tree.seen.insert(c.id.clone(), c.clone()); + } + level(app, ui, t, c, ""); +} + +/// One level: loading, its error with the ways to go on, or its children. +fn level(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connection, node: &str) { + let key = (c.id.clone(), node.to_string()); + let shown = match app.ui.connection_tree.entries.get(&key) { + None => None, + Some(Entry::Loading(_)) => Some(Err(None)), + Some(Entry::Failed(e)) => Some(Err(Some(e.clone()))), + Some(Entry::Ready(l)) => Some(Ok(l.clone())), + }; + match shown { + None => { + start(app, c, node); + ui.ctx().request_repaint(); + } + Some(Err(None)) => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(tl!("Loading…")); + }); + } + Some(Err(Some(e))) => { + ui.label(RichText::new(e).small().color(t.red)); + ui.horizontal(|ui| { + if ui.small_button(tl!("Retry")).clicked() { + app.ui.connection_tree.entries.remove(&key); + } + let keys = c.kind == "s3" && c.options.get("credentials").is_some_and(|v| v == "keys"); + if (DATABASE_KINDS.contains(&c.kind.as_str()) || keys) && ui.small_button(tl!("Log In…")).clicked() { + match app.run("ui.connection", json!({"id": c.id})) { + Ok(_) => { + app.dialogs.info = Some(tl!("Type the password or keys to log in for this session, then Save.").to_string()) + } + Err(e) => app.session.notify(e, true), + } + } + }); + } + Some(Ok(l)) => { + children(app, ui, t, c, &l, &l.children); + if l.children.is_empty() { + ui.label(RichText::new(tl!("Nothing here")).small().weak()); + } + if l.truncated { + let n = l.children.len().to_string(); + let text = match l.matched { + Some(m) => i18n::fmt("{n} of {m} are listed.", &[("n", &n), ("m", &m.to_string())]), + None => i18n::fmt("The first {n} are listed.", &[("n", &n)]), + }; + ui.label(RichText::new(text).small().weak()); + } + for note in &l.notes { + ui.label(RichText::new(note).small().weak()); + } + } + } +} + +/// A folder in the tree: a header that opens on a click, and a body drawn when it is open. +/// `node` is set when the body is a level of its own (read when first opened, refreshed from +/// the header's menu). +fn branch( + app: &mut RustGisApp, + ui: &mut Ui, + c: &Connection, + child: &Value, + node: Option<&str>, + body: impl FnOnce(&mut RustGisApp, &mut Ui), +) { + let salt = node.or(child["key"].as_str()).unwrap_or(""); + let id = ui.make_persistent_id(("conntree", &c.id, salt)); + let state = CollapsingState::load_with_default_open(ui.ctx(), id, false); + let open = state.is_open(); + let (mut clicked, mut refreshed) = (false, false); + let mut header = state.show_header(ui, |ui| { + ui.label(RichText::new(what_label(child["what"].as_str().unwrap_or(""))).small().weak()); + let r = ui.add(Label::new(child["name"].as_str().unwrap_or("")).sense(Sense::click())); + clicked = r.clicked(); + if node.is_some() { + r.context_menu(|ui| { + if ui.button(tl!("Refresh")).clicked() { + refreshed = true; + ui.close(); + } + }); + } + }); + if clicked { + header.set_open(!open); + } + header.body(|ui| body(app, ui)); + if refreshed && let Some(node) = node { + app.ui.connection_tree.entries.remove(&(c.id.clone(), node.to_string())); + } +} + +fn children(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connection, listing: &Listing, list: &[Value]) { + for child in list { + if let Some(node) = child["node"].as_str() { + branch(app, ui, c, child, Some(node), |app, ui| level(app, ui, t, c, node)); + continue; + } + if let Some(inline) = child["children"].as_array() { + branch(app, ui, c, child, None, |app, ui| { + children(app, ui, t, c, listing, inline); + if inline.is_empty() { + ui.label(RichText::new(tl!("Nothing here")).small().weak()); + } + }); + continue; + } + dataset(app, ui, c, listing, child); + } +} + +/// A dataset: click selects it, double-click adds it, the menu adds it (with options). +fn dataset(app: &mut RustGisApp, ui: &mut Ui, c: &Connection, listing: &Listing, child: &Value) { + let name = child["name"].as_str().unwrap_or(""); + let is_picked = picked(app, &c.id, child["key"].as_str().unwrap_or("")); + let r = ui + .horizontal(|ui| { + let r = ui.selectable_label(is_picked, name); + ui.label(RichText::new(what_label(child["what"].as_str().unwrap_or(""))).small().weak()); + r + }) + .inner; + // A dataset without an add (an OGC API feature collection, imported together) is added + // with options only. + let addable = child.get("add").is_some_and(Value::is_object); + let hint = if addable { tl!("Click to select, double-click to add, right-click for more") } else { tl!("Right-click for more") }; + let mut hover = hint.to_string(); + if let Some(detail) = child["detail"].as_str().filter(|d| !d.is_empty()) { + hover = format!("{hover}\n{detail}"); + } + let r = r.on_hover_text(hover); + let (mut add_now, mut with_options, mut add_selected, mut clear) = (false, false, false, false); + let selected = app.ui.connection_tree.picks.len(); + r.context_menu(|ui| { + if addable && ui.button(tl!("Add")).clicked() { + add_now = true; + ui.close(); + } + if child["options"] == true && ui.button(tl!("Add With Options…")).clicked() { + with_options = true; + ui.close(); + } + if selected > 0 { + ui.separator(); + if ui.button(i18n::fmt("Add Selected ({n})", &[("n", &selected.to_string())])).clicked() { + add_selected = true; + ui.close(); + } + if ui.button(tl!("Clear Selection")).clicked() { + clear = true; + ui.close(); + } + } + }); + if addable && r.double_clicked() { + // The first click selected it: a double-click adds it alone. + let key = child["key"].as_str().unwrap_or(""); + app.ui.connection_tree.picks.retain(|p| !(p.conn == c.id && p.key == key)); + add_now = true; + } else if addable && r.clicked() { + toggle(app, c, child, listing.source.as_ref()); + } + if clear { + app.ui.connection_tree.picks.clear(); + } + if add_selected { + add_picks(app); + } + let result = if with_options { + add_with_options(app, c, child, listing.source.as_deref()) + } else if add_now { + add(app, child, listing.source.as_deref()) + } else { + Ok(()) + }; + if let Err(e) = result { + app.session.notify(e, true); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c7041460f..06cfdb4ca 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -800,60 +800,17 @@ fn add_service(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { }); } if let (Some(l), Some(conn)) = (add, &app.ui.service) { - let (url, kind, text) = (conn.url.clone(), conn.kind, conn.text.clone().unwrap_or_default()); - let name = l.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - let title = l.get("title").and_then(Value::as_str).unwrap_or(&name).to_string(); - let offer = l.get("offer").cloned().unwrap_or_default(); - // Features are downloaded in the background, as From URL… does; map layers are added now. - let r = if offer.get("type").and_then(Value::as_str) == Some("features") { - let items = offer.get("url").and_then(Value::as_str).unwrap_or("").to_string(); - app.run("ui.addUrl", json!({"url": items, "name": title, "service": true})).map(|_| tl!("Downloading…")) - } else if offer.get("type").and_then(Value::as_str) == Some("coverage") { - // A coverage: its description first, off the frame (poll_describe asks for it). - let describe = offer.get("describe").and_then(Value::as_str).unwrap_or("").to_string(); - let mut p = json!({"url": url, "layer": name, "text": text, "name": title, "documents": conn.documents}); - if kind != "auto" { - p["type"] = json!(kind); - } - let slot = crate::fetch_into(&describe); - if let Some(c) = &mut app.ui.service { - c.describing = Some((p, slot)); - } - Ok(tl!("Describing")) - } else { - let mut p = json!({"url": url, "layer": name, "text": text, "documents": conn.documents, "source_url": l.get("source_url")}); - if kind != "auto" { - p["type"] = json!(kind); - } - let time = app.ui.service_browse.time.trim(); - if l.get("time").is_some() && !time.is_empty() { - p["time"] = json!(time); - } - match l.get("tilesets").and_then(Value::as_str) { - // An OGC API layer: its tileset's advertised template is read first, off the - // frame (poll_tiles_resolve adds the layer). - Some(list) => { - p["name"] = json!(title); - let fallback = offer.get("url").and_then(Value::as_str).unwrap_or("").to_string(); - let vector = offer.get("type").and_then(Value::as_str) == Some("vectorTiles"); - let slot = crate::fetch_into(list); - if let Some(c) = &mut app.ui.service { - c.resolving = Some(crate::TilesResolve { - params: p, - list: list.to_string(), - doc: list.to_string(), - read: 0, - vector, - fallback, - slot, - }); - } - Ok(tl!("Reading the tileset of")) - } - None => app.run("service.add", p).map(|_| tl!("Added")), - } + let src = crate::serviceui::ServiceSource { + url: conn.url.clone(), + kind: conn.kind, + text: conn.text.clone().unwrap_or_default(), + documents: conn.documents.clone(), }; - match r { + let name = l.get("name").and_then(Value::as_str).unwrap_or(""); + let title = l.get("title").and_then(Value::as_str).unwrap_or(name).to_string(); + // Features are downloaded in the background, as From URL… does; map layers are added now. + let time = app.ui.service_browse.time.trim().to_string(); + match crate::serviceui::add_layer(app, &src, &l, &time, true) { Ok(m) => app.dialogs.info = Some(format!("{m} {title}")), Err(e) => app.dialogs.error = Some(e), } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e4040665c..100a0abbd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ mod bookmarkpane; mod catalogsui; mod chrome; mod comets; +mod connectiontree; mod connectionui; #[cfg(not(target_arch = "wasm32"))] mod connectui; @@ -291,11 +292,6 @@ pub struct ServiceConn { pub error: Option, /// A capabilities request that failed, with what the server said (#509). pub failure: Option, - /// An OGC API layer whose advertised tile template is being read before it is added. - pub resolving: Option, - /// A WCS coverage whose description is being read before it is asked for: `service.coverage`'s - /// parameters and the description on its way. - pub describing: Option<(Value, Slot, String>>)>, } /// How Add From Service's list is browsed. @@ -324,6 +320,16 @@ pub struct TilesResolve { pub slot: Slot, String>>, } +/// A service layer waiting on a document before it is added. `dialog` tells whether the add came +/// from the add-service dialog (its result shows there) or from the Connections tree (a notice). +pub enum ServiceStep { + /// An OGC API layer whose advertised tile template is being read. + Tiles { resolve: TilesResolve, dialog: bool }, + /// A WCS coverage whose description is being read before it is asked for: `service.coverage`'s + /// parameters and the description on its way. + Coverage { params: Value, slot: Slot, String>>, dialog: bool }, +} + /// Fetches an address into a slot, off the frame. pub(crate) fn fetch_into(url: &str) -> Slot, String>> { fetch_into_limited(url, rustgis_engine::render::rustgis_tiles::cache::MAX_DOWNLOAD) @@ -365,7 +371,7 @@ pub enum LayersView { } /// Retains the GUI's legacy XML decoding while refusing malformed JSON text. -fn service_text(bytes: Vec) -> Result { +pub(crate) fn service_text(bytes: Vec) -> Result { String::from_utf8(bytes).or_else(|error| { let bytes = error.as_bytes(); let without_bom = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(bytes); @@ -515,6 +521,9 @@ pub struct UiState { /// Add From Service's list: its filter, a time-enabled layer's date, and the directories /// opened on the way to the service shown (Back returns to them). pub service_browse: ServiceBrowse, + /// Service layers added from the dialog or the Connections tree that wait on a document + /// (a tileset list, a coverage's description) before they are added. + pub service_steps: Vec, /// The Web Data panels of single services (NASA Earthdata, EnviroAtlas, FEMA NFHL, The /// National Map, Fields of the World): their documents, searches and choices. pub web_services: webdataui::State, @@ -528,6 +537,8 @@ pub struct UiState { pub s3: s3ui::State, /// New/Edit Connection: the form and the Test under way. pub connection: connectionui::State, + /// The datasets saved connections list under Data Sources › Connections, and the selection. + pub connection_tree: connectiontree::State, /// OpenStreetMap and Boundaries: their choices and the request under way. pub portals: portalsui::State, /// Overture Maps and Source Cooperative: choices, results and the request under way. @@ -649,6 +660,7 @@ impl Default for UiState { link_zoomed: false, service: None, service_browse: ServiceBrowse::default(), + service_steps: Vec::new(), web_services: Default::default(), service_import: None, service_group_name: "My OGC Layer".into(), @@ -656,6 +668,7 @@ impl Default for UiState { stac: Default::default(), s3: Default::default(), connection: Default::default(), + connection_tree: Default::default(), portals: Default::default(), catalogs: Default::default(), opendata: Default::default(), @@ -2113,8 +2126,6 @@ impl RustGisApp { listing: None, error: None, failure: None, - resolving: None, - describing: None, }); } "ui.addData" => { @@ -2993,60 +3004,71 @@ impl RustGisApp { } } - /// Moves an OGC API layer's template reading along: the list, then the tileset when the list - /// doesn't carry the template; the layer is added with the template read, or the usual path. - fn poll_tiles_resolve(&mut self) { - use rustgis_engine::data::ogc::{TilesStep, resolve_tiles}; - let Some(conn) = &mut self.ui.service else { return }; - let Some(res) = &mut conn.resolving else { return }; - let Some(r) = res.slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { return }; - let step = r.ok().and_then(|body| resolve_tiles(&res.list, &res.doc, &body, res.vector)); - let template = match step { - Some(TilesStep::Next(next)) if res.read < 2 => { - res.read += 1; - res.doc = next.clone(); - res.slot = fetch_into(&next); - return; - } - Some(TilesStep::Template(t)) => t, - _ => res.fallback.clone(), - }; - let Some(res) = conn.resolving.take() else { return }; - let mut p = res.params; - p["tiles"] = json!(template); - let title = p["name"].as_str().unwrap_or("").to_string(); - match self.run("service.add", p) { - Ok(_) => self.dialogs.info = Some(format!("{} {title}", tl!("Added"))), - Err(e) => self.dialogs.error = Some(e), + /// Tells how a service layer added outside the add-service dialog (or in it) went: in the + /// dialog's own line, or as a notice for the Connections tree. + fn report_step(&mut self, dialog: bool, r: Result) { + match (dialog, r) { + (true, Ok(m)) => self.dialogs.info = Some(m), + (true, Err(e)) => self.dialogs.error = Some(e), + (false, Ok(m)) => self.session.notify(m, false), + (false, Err(e)) => self.session.notify(e, true), } - self.map.invalidate(); } - /// Asks for a WCS coverage once its description has arrived: its GetCoverage address for the - /// view, downloaded as From URL… does. - fn poll_describe(&mut self) { - let Some(conn) = &mut self.ui.service else { return }; - let Some((_, slot)) = &conn.describing else { return }; - let Some(r) = slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { return }; - let Some((mut p, _)) = conn.describing.take() else { return }; - let title = p["name"].as_str().unwrap_or("").to_string(); - let r = r.and_then(|bytes| { - p["describe"] = json!(String::from_utf8_lossy(&bytes)); - // Not `self.run`: an error belongs in the dialog, not the notices. - let c = self.session.run("service.coverage", &p)?; - let get = c.get("url").and_then(Value::as_str).unwrap_or("").to_string(); - self.run("ui.addUrl", json!({"url": get, "name": title, "coverage": true})) - }); - match r { - Ok(_) => self.dialogs.info = Some(format!("{} {title}", tl!("Downloading…"))), - Err(e) => self.dialogs.error = Some(e), + /// Moves the service layers waiting on a document along: an OGC API layer's tile template + /// (read from its tileset list, then the tileset when the list doesn't carry it; the layer is + /// added with the template read, or the usual path) and a WCS coverage's description (the + /// coverage is then asked for the view and downloaded as From URL… does). + fn poll_service_steps(&mut self) { + use rustgis_engine::data::ogc::{TilesStep, resolve_tiles}; + for step in std::mem::take(&mut self.ui.service_steps) { + match step { + ServiceStep::Tiles { mut resolve, dialog } => { + let Some(r) = resolve.slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { + self.ui.service_steps.push(ServiceStep::Tiles { resolve, dialog }); + continue; + }; + let next = r.ok().and_then(|body| resolve_tiles(&resolve.list, &resolve.doc, &body, resolve.vector)); + let template = match next { + Some(TilesStep::Next(next)) if resolve.read < 2 => { + resolve.read += 1; + resolve.doc = next.clone(); + resolve.slot = fetch_into(&next); + self.ui.service_steps.push(ServiceStep::Tiles { resolve, dialog }); + continue; + } + Some(TilesStep::Template(t)) => t, + _ => resolve.fallback.clone(), + }; + let mut p = resolve.params; + p["tiles"] = json!(template); + let title = p["name"].as_str().unwrap_or("").to_string(); + let r = self.run("service.add", p).map(|_| format!("{} {title}", tl!("Added"))); + self.report_step(dialog, r); + self.map.invalidate(); + } + ServiceStep::Coverage { mut params, slot, dialog } => { + let Some(r) = slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { + self.ui.service_steps.push(ServiceStep::Coverage { params, slot, dialog }); + continue; + }; + let title = params["name"].as_str().unwrap_or("").to_string(); + let r = r.and_then(|bytes| { + params["describe"] = json!(String::from_utf8_lossy(&bytes)); + // Not `self.run`: an error belongs where the step reports, not the notices. + let c = self.session.run("service.coverage", ¶ms)?; + let get = c.get("url").and_then(Value::as_str).unwrap_or("").to_string(); + self.run("ui.addUrl", json!({"url": get, "name": title, "coverage": true})) + }); + self.report_step(dialog, r.map(|_| format!("{} {title}", tl!("Downloading…")))); + } + } } } /// Lists a service's layers once its capabilities have arrived. fn poll_service(&mut self) { - self.poll_tiles_resolve(); - self.poll_describe(); + self.poll_service_steps(); let Some(conn) = &mut self.ui.service else { return }; let Some(r) = conn.slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { return }; let document = conn.pending.take().unwrap_or_else(|| conn.url.clone()); @@ -3170,6 +3192,7 @@ impl RustGisApp { stacui::poll(self, ctx); s3ui::poll(self, ctx); connectionui::poll(self, ctx); + connectiontree::poll(self, ctx); portalsui::poll(self, ctx); catalogsui::poll(self, ctx); opendataui::poll(self, ctx); diff --git a/crates/ui-egui/src/serviceui.rs b/crates/ui-egui/src/serviceui.rs index c000294fd..834c6e5a9 100644 --- a/crates/ui-egui/src/serviceui.rs +++ b/crates/ui-egui/src/serviceui.rs @@ -20,6 +20,74 @@ pub struct Import { bytes: usize, } +/// A service whose layers are listed: its address, type, capabilities document and the further +/// documents its discovery read. `service.add` reads layers from them without fetching again. +pub struct ServiceSource { + pub url: String, + /// `wms`, `wmts`, `wfs`, `wcs`, `ogcapi`, `arcgis`, or `auto`. + pub kind: &'static str, + pub text: String, + pub documents: Vec, +} + +/// A service type as the add dialog and `ui.addService` name it, `auto` for any other text. +pub(crate) fn kind_of(t: &str) -> &'static str { + match t { + "wms" => "wms", + "wmts" => "wmts", + "wfs" => "wfs", + "wcs" => "wcs", + "ogcapi" => "ogcapi", + "arcgis" => "arcgis", + _ => "auto", + } +} + +/// Adds one of a service's layers (as `service.layers` lists it): features are downloaded in the +/// background as From URL… does, map layers are added now, and an OGC API layer or a WCS coverage +/// waits on a document (a step the app moves along). `dialog` tells where the result is reported. +pub(crate) fn add_layer(app: &mut RustGisApp, src: &ServiceSource, l: &Value, time: &str, dialog: bool) -> Result<&'static str, String> { + let name = l.get("name").and_then(Value::as_str).unwrap_or("").to_string(); + let title = l.get("title").and_then(Value::as_str).unwrap_or(&name).to_string(); + let offer = l.get("offer").cloned().unwrap_or_default(); + if offer.get("type").and_then(Value::as_str) == Some("features") { + let items = offer.get("url").and_then(Value::as_str).unwrap_or("").to_string(); + return app.run("ui.addUrl", json!({"url": items, "name": title, "service": true})).map(|_| tl!("Downloading…")); + } + if offer.get("type").and_then(Value::as_str) == Some("coverage") { + // A coverage: its description first, off the frame (a step asks for it). + let describe = offer.get("describe").and_then(Value::as_str).unwrap_or("").to_string(); + let mut p = json!({"url": src.url, "layer": name, "text": src.text, "name": title, "documents": src.documents}); + if src.kind != "auto" { + p["type"] = json!(src.kind); + } + let slot = crate::fetch_into(&describe); + app.ui.service_steps.push(crate::ServiceStep::Coverage { params: p, slot, dialog }); + return Ok(tl!("Describing")); + } + let mut p = json!({"url": src.url, "layer": name, "text": src.text, "documents": src.documents, "source_url": l.get("source_url")}); + if src.kind != "auto" { + p["type"] = json!(src.kind); + } + if l.get("time").is_some() && !time.is_empty() { + p["time"] = json!(time); + } + match l.get("tilesets").and_then(Value::as_str) { + // An OGC API layer: its tileset's advertised template is read first, off the frame (a + // step adds the layer). + Some(list) => { + p["name"] = json!(title); + let fallback = offer.get("url").and_then(Value::as_str).unwrap_or("").to_string(); + let vector = offer.get("type").and_then(Value::as_str) == Some("vectorTiles"); + let slot = crate::fetch_into(list); + let resolve = crate::TilesResolve { params: p, list: list.to_string(), doc: list.to_string(), read: 0, vector, fallback, slot }; + app.ui.service_steps.push(crate::ServiceStep::Tiles { resolve, dialog }); + Ok(tl!("Reading the tileset of")) + } + None => app.run("service.add", p).map(|_| tl!("Added")), + } +} + pub fn start(app: &mut RustGisApp, p: &Value, refresh: bool) -> Result<(), String> { if !p.is_object() { return Err(tl!("Give the service's web address").into()); diff --git a/crates/ui-egui/src/sourcespane.rs b/crates/ui-egui/src/sourcespane.rs index 0d01ae54d..7ac0d7ae4 100644 --- a/crates/ui-egui/src/sourcespane.rs +++ b/crates/ui-egui/src/sourcespane.rs @@ -236,17 +236,23 @@ fn empty(ui: &mut Ui, what: &str) { } fn connections(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, list: &[Connection]) { - ui.menu_button(tl!("New Connection ▾"), |ui| { - for (kind, label) in crate::connectionui::KINDS { - if cfg!(target_arch = "wasm32") && DATABASE_KINDS.contains(&kind) { - continue; - } - if ui.button(tl!(label)).clicked() { - if let Err(e) = app.run("ui.connection", json!({"kind": kind})) { - app.session.notify(e, true); + crate::connectiontree::prune(app); + ui.horizontal(|ui| { + ui.menu_button(tl!("New Connection ▾"), |ui| { + for (kind, label) in crate::connectionui::KINDS { + if cfg!(target_arch = "wasm32") && DATABASE_KINDS.contains(&kind) { + continue; + } + if ui.button(tl!(label)).clicked() { + if let Err(e) = app.run("ui.connection", json!({"kind": kind})) { + app.session.notify(e, true); + } + ui.close(); } - ui.close(); } + }); + if ui.small_button("⟳").on_hover_text(tl!("Refresh every connection's lists")).clicked() { + crate::connectiontree::refresh(app, None); } }); if list.is_empty() { @@ -269,60 +275,86 @@ fn connections(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, list: &[Connection } fn connection_row(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connection) { - let database = DATABASE_KINDS.contains(&c.kind.as_str()); - let unavailable = database && cfg!(target_arch = "wasm32"); - ui.horizontal(|ui| { - let icon = if database { - "table" - } else { - match c.kind.as_str() { - "stac" => "search", - "s3" => "package", - _ => "globe_view", - } - }; - let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); - icons::paint(ui.painter(), r, icon, t.icon, t.accent); - let response = if unavailable { - ui.link(RichText::new(&c.name).weak()).on_hover_text(tl!("Database connections are used by the desktop app")) - } else { - ui.link(&c.name).on_hover_text(if c.kind == "s3" && c.address.is_empty() { tl!("Amazon S3") } else { &c.address }) - }; - if response.clicked() - && !unavailable - && let Err(e) = crate::connectionui::open_connection(app, c) - { - app.session.notify(e, true); + let unavailable = DATABASE_KINDS.contains(&c.kind.as_str()) && cfg!(target_arch = "wasm32"); + if unavailable { + ui.horizontal(|ui| { + connection_header(app, ui, t, c, true); + }); + return; + } + // A click on the name opens the row: the datasets the connection offers are listed under it. + let state = + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), ui.make_persistent_id(("connection", &c.id)), false); + let open = state.is_open(); + let mut clicked = false; + let mut header = state.show_header(ui, |ui| clicked = connection_header(app, ui, t, c, false)); + if clicked { + header.set_open(!open); + } + header.body(|ui| crate::connectiontree::show(app, ui, t, c)); +} + +/// A connection's icon, name, kind and menu. True when the name was clicked. +fn connection_header(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connection, unavailable: bool) -> bool { + let icon = if DATABASE_KINDS.contains(&c.kind.as_str()) { + "table" + } else { + match c.kind.as_str() { + "stac" => "search", + "s3" => "package", + _ => "globe_view", } - ui.label(RichText::new(tl!(crate::connectionui::label(&c.kind))).small().weak()); - response.context_menu(|ui| { - for (label, command, params) in [ - (tl!("Edit…"), "ui.connection", json!({"id":c.id})), - (tl!("Test"), "ui.connection", json!({"id":c.id,"test":true})), - (tl!("Duplicate"), "connection.duplicate", json!({"id":c.id})), - ] { - if params.get("test") == Some(&Value::Bool(true)) && unavailable { - continue; - } - if ui.button(label).clicked() { - if let Err(e) = app.run(command, params) { - app.session.notify(e, true); - } - ui.close(); + }; + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + icons::paint(ui.painter(), r, icon, t.icon, t.accent); + let response = if unavailable { + ui.link(RichText::new(&c.name).weak()).on_hover_text(tl!("Database connections are used by the desktop app")) + } else { + ui.link(&c.name).on_hover_text(if c.kind == "s3" && c.address.is_empty() { tl!("Amazon S3") } else { &c.address }) + }; + ui.label(RichText::new(tl!(crate::connectionui::label(&c.kind))).small().weak()); + response.context_menu(|ui| { + let mut items = vec![ + (tl!("Edit…"), "ui.connection", json!({"id":c.id})), + (tl!("Test"), "ui.connection", json!({"id":c.id,"test":true})), + (tl!("Duplicate"), "connection.duplicate", json!({"id":c.id})), + ]; + if unavailable { + items.remove(1); + } + for (label, command, params) in items { + if ui.button(label).clicked() { + if let Err(e) = app.run(command, params) { + app.session.notify(e, true); } + ui.close(); } - if !c.address.is_empty() && ui.button(tl!("Copy Address")).clicked() { - ui.ctx().copy_text(c.address.clone()); + } + if !unavailable { + if ui.button(tl!("Refresh")).clicked() { + crate::connectiontree::refresh(app, Some(&c.id)); ui.close(); } - if ui.button(tl!("Remove")).clicked() { - if let Err(e) = app.run("connection.remove", json!({"id":c.id})) { + // The kind's own add dialog, as a click on the connection opened it before. + if ui.button(tl!("Add With Options…")).clicked() { + if let Err(e) = crate::connectionui::open_connection(app, c) { app.session.notify(e, true); } ui.close(); } - }); + } + if !c.address.is_empty() && ui.button(tl!("Copy Address")).clicked() { + ui.ctx().copy_text(c.address.clone()); + ui.close(); + } + if ui.button(tl!("Remove")).clicked() { + if let Err(e) = app.run("connection.remove", json!({"id":c.id})) { + app.session.notify(e, true); + } + ui.close(); + } }); + !unavailable && response.clicked() } pub fn show(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 76cf33860..e488d14d4 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -1695,6 +1695,89 @@ fn connections_test_feedback_tracks_edited_inputs() { assert!(h.state().session.connections.connections.is_empty()); } +/// Expanding a connection lists its datasets, a click selects and Add Selected adds each as its +/// own undo step, and Refresh forgets the list. +#[test] +fn connections_tree_browses_and_adds() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let body = format!( + r#"Localimage/pngEPSG:3857aAbB"# + ); + for stream in listener.incoming().take(1) { + let Ok(mut s) = stream else { continue }; + let mut buf = [0u8; 2048]; + let _ = s.read(&mut buf); + let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + let _ = s.write_all(body.as_bytes()); + } + }); + // Frames 0.05 s apart, so two clicks fall within egui's double-click delay. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).with_step_dt(0.05).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + RustGisApp::with_sample(), + ); + h.state_mut().run("connection.new", json!({"kind":"wms","address":format!("http://{addr}/wms"),"name":"Local"})).unwrap(); + h.state_mut().run("ui.pane.sources", json!({})).unwrap(); + h.step(); + assert!(h.query_by_label("A").is_none(), "nothing is listed before the connection is opened"); + h.get_by_label("Local").click(); + for _ in 0..100 { + h.step(); + if h.query_by_label("A").is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(h.query_by_label("A").is_some(), "the layers are listed under the connection"); + let before = h.state().session.map().tiles.len(); + h.get_by_label("A").click(); + h.step(); + h.get_by_label("B").click(); + h.step(); + assert_eq!(h.state().ui.connection_tree.picks.len(), 2); + // The selection is added from a dataset's menu: each layer is its own undo step. + h.get_by_label("B").click_secondary(); + h.step(); + h.step(); + h.get_by_label("Add Selected (2)").click(); + h.step(); + h.step(); + assert_eq!(h.state().session.map().tiles.len(), before + 2); + assert!(h.state().ui.connection_tree.picks.is_empty()); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + // Each layer was its own step: one undo takes one layer. + assert_eq!(h.state().session.map().tiles.len(), before + 1); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(h.state().session.map().tiles.len(), before); + // A double-click adds the one dataset, leaving nothing selected. + // (egui counts clicks in a row: let the earlier ones age out of its triple-click window first.) + for _ in 0..14 { + h.step(); + } + h.get_by_label("A").click(); + h.step(); + h.get_by_label("A").click(); + h.step(); + h.step(); + assert_eq!(h.state().session.map().tiles.len(), before + 1); + assert!(h.state().ui.connection_tree.picks.is_empty()); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(h.state().session.map().tiles.len(), before); + h.get_by_label("Local").click_secondary(); + h.step(); + h.get_by_label("Refresh").click(); + h.step(); + h.step(); + assert!(h.query_by_label("A").is_none(), "refreshing forgets the list until it is read again"); +} + /// Save in the web app (issue #197): the browser has no save dialog, so the first Save and every /// Save As ask for the file name, and Save then downloads under it; an opened file keeps its name. #[test] diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index c7fc11601..30e51d982 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -334,9 +334,16 @@ places data comes from: tables, Zarr variables, STAC assets, sample data), newest first. - **Folders**: the folders connected to the Browser (**Add Folder…**; desktop). - **Connections**: named services, databases, STAC APIs and S3 endpoints, organised by optional - groups. **New Connection ▾** opens a kind-specific form; right-click a row to edit, test, - duplicate, copy its address or remove it. A click opens the existing service, database, - STAC or S3 add dialog, connected or prefilled. + groups. **New Connection ▾** opens a kind-specific form. Click a connection to open it: its + datasets are listed under it, a level at a time as you open its folders, with **Refresh** + (a connection's or a folder's right-click menu; **⟳** refreshes every connection) and an error + with **Retry** (and **Log In…** for databases and keyed S3) when it can't be read. Click a + dataset to select it (click again to clear it); double-click, or right-click › **Add**, adds + one, and right-click › **Add Selected (N)** adds them all (**Clear Selection** empties the + selection). Each dataset is added by one command, so each is one undo step. On a table, a + service layer or a STAC collection, **Add With Options…** opens the database, service or STAC + add dialog on it; on a connection, it opens the kind's add dialog. Right-click a connection to + edit, test, duplicate, copy its address or remove it. - **Library**: well-known public sources: OpenStreetMap tiles, EOX Sentinel-2 cloudless and NASA GIBS (WMTS), USGS imagery, topographic and hydrography maps (ArcGIS), GEBCO bathymetry (WMS), swisstopo's maps and imagery (geo.admin.ch, WMTS), Earth Search and Planetary Computer (STAC), and the Natural Earth sample layers. @@ -389,6 +396,19 @@ databases). For non-database kinds, pass that response text as `answer` with the to parse it without a network request. Database kinds ignore `answer` and test through their driver. An anonymous S3 test needs at least one named bucket. +`connection.browse` lists what a connection offers, a level at a time: the tables of a database +(under their schemas), the layers of a service, the records of a catalogue with the services and +files they link to, the collections of a STAC API and the items and assets of a collection (for a +static STAC catalog, its catalogs, collections and items, each item opened for its assets), or +the buckets, folders and files of an S3 connection. Each child has a `key`, a `name` and what it +is (`what`); a child with a `node` is browsed by asking again with that `node`; one with `add` +names the command and parameters that add it (`service.add`, `layer.addDatabase`, +`stac.addAsset`, `layer.addUrl`). A level lists at most 500 children and says `truncated` when +it left some out. Like `connection.test`, it reads the address itself on the desktop and the +command line; with `probe: true` it returns the address to fetch, and the app passes what it +fetched back as `answer` (and further `documents` when a service asks for them, as `next`). +Browsing never changes the project or the saved connections. + Addresses are saved without passwords or tokens. `connection.login` remembers a database `password`, or S3 `accessKeyId`, `secretAccessKey` and optional `sessionToken`, for this session only, using the same credential stores as the existing add commands. With login parameters, @@ -400,7 +420,7 @@ connection also affects a session entry of the same name created there. Renaming clears the runtime entries under both the old and new names, so the renamed connection cannot inherit an unrelated login at its destination name. Web-service logins are not supported. Plugins may list connections but cannot manage, log in -to or test them. The in-app assistant asks for approval each time for those operations; +to, test or browse them. The in-app assistant asks for approval each time for those operations; external assistants using MCP cannot run them. Database passwords are shared by public DSN for the session, including existing layers and other named connections using that DSN. Editing or removing a named connection does not revoke From bfaf350c8bb7afdef0b0e5abdcbf033de608c6ad Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:51:20 +0200 Subject: [PATCH 042/297] feat(connections): pick and save connections in add dialogs (#711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(connections): browse and add datasets from the Connections tree (#699) Expanding a saved connection in Data Sources › Connections lists its datasets a level at a time, with Refresh, an inline error and Retry/Log In…. Click selects, double-click adds, Add Selected adds each as its own undo step, Add With Options… opens the kind's dialog. New engine command connection.browse (probe/answer, like connection.test) for all 13 kinds, so the CLI and the assistant can browse too; user-only for plugins. * feat(connections): add the selection from a dataset's menu instead of a bar The Add Selected / Clear Selection bar shifted the list when the first dataset was picked, which also moved the row between the two clicks of a double-click. They are now entries in a dataset's right-click menu; a double-click adds that dataset. * docs: say where Add With Options… is offered in the Connections tree * fix(connections): address review of the Connections tree - a dataset without an add (grouped OGC API features) can't be selected or double-clicked, and has no Add entry: it keeps Add With Options… - a CSW download link too long to follow is left out (and the list says it is truncated); the name and title kept with it are cut at 200 characters - connection.browse refuses an answer larger than a service document may be (64 MB) - the engine test runs the add a browsed WCS coverage lists * test(connections): cover the answer size limit, long catalogue links and grouped OGC API features * feat(connections): list a static STAC catalog's items, each opened for its assets A static catalog or collection now lists its item links as nodes next to its child catalogs; opening an item reads its own file and lists the assets that can be added. * test(connections): a static item's relative asset address resolves against its file * fix(connections): address second review round - an asset's key names its item, so the same file under two items is two rows (selection and fold state no longer collide) - the documents of a browse count against the same size limit as its answer - the probe's S3 login is documented in the command's parameters * feat(connections): pick and save connections in add dialogs * fix(connections): address dialog review and cover fresh approvals --- crates/assistant/src/tests.rs | 52 ++++++ crates/ui-egui/src/connectiontree.rs | 19 +- crates/ui-egui/src/connectionui.rs | 109 +++++++++++- crates/ui-egui/src/cswui.rs | 29 ++- crates/ui-egui/src/dialogs.rs | 75 +++++++- crates/ui-egui/src/s3ui.rs | 255 ++++++++++++++++----------- crates/ui-egui/src/sourcespane.rs | 2 +- crates/ui-egui/src/stacui.rs | 74 ++++++-- crates/ui-egui/tests/smoke.rs | 171 ++++++++++++++++++ docs/user-guide/data.md | 27 ++- 10 files changed, 668 insertions(+), 145 deletions(-) diff --git a/crates/assistant/src/tests.rs b/crates/assistant/src/tests.rs index 83a5d02c0..fd268ee1c 100644 --- a/crates/assistant/src/tests.rs +++ b/crates/assistant/src/tests.rs @@ -164,6 +164,58 @@ fn connection_management_requires_fresh_approval_but_can_run_in_app() { assert_eq!(s.connections.connections.len(), 1, "declining a repeat must leave the approved connection unchanged"); } +#[test] +fn connection_commands_require_fresh_approval_before_login_or_scripts() { + use rustgis_engine::render::rustgis_tiles::s3; + + for command in ["connection.browse", "connection.test", "connection.login"] { + for scripted in [false, true] { + let mut s = Session::new(Project::default()); + let name = format!("assistant-approval-{command}-{scripted}"); + let c = s + .run( + "connection.new", + &json!({"kind": "s3", "name": name, + "options": {"credentials": "none", "buckets": "approval-bucket"}}), + ) + .unwrap(); + // Probes need no network, but browse/test still log in before returning the fetch address. + let params = json!({"id": c["id"], "probe": true}); + let (tool, args) = if scripted { + ("run", json!({"script": format!("map.rename {{\"name\":\"Approved map\"}}\n{command} {params}")})) + } else { + ("run_command", json!({"command": command, "params": params})) + }; + let mut a = Agent::new(cfg("openai"), &s); + assert!(matches!(a.ask("use this connection", &s), Step::Send(_))); + let map_name = s.map().name.clone(); + assert_eq!(a.receive(a.generation(), ok(call("first", tool, args.clone())), &mut s), Step::Confirm, "{command}/{tool}"); + assert!(s3::connection(&name).is_none(), "a pending call must not log in"); + assert_eq!(s.map().name, map_name, "a script's earlier lines must wait too"); + assert!(!a.pending(&s).unwrap().2, "connection operations cannot be allowed for the whole conversation"); + assert!(matches!(a.decide(false, false, &mut s), Step::Send(_))); + assert!(s3::connection(&name).is_none(), "a declined call must not log in"); + assert_eq!(s.map().name, map_name, "declining must leave every script line unexecuted"); + + assert_eq!(a.receive(a.generation(), ok(call("allowed", tool, args.clone())), &mut s), Step::Confirm); + assert!(matches!(a.decide(true, true, &mut s), Step::Send(_))); + assert!(s3::connection(&name).is_some(), "a once-approved call must execute"); + if scripted { + assert_eq!(s.map().name, "Approved map"); + } + s3::disconnect(&name); + assert_eq!( + a.receive(a.generation(), ok(call("again", tool, args)), &mut s), + Step::Confirm, + "asking to remember an approval must not bypass the next confirmation" + ); + assert!(s3::connection(&name).is_none()); + assert!(matches!(a.decide(false, false, &mut s), Step::Send(_))); + assert!(s3::connection(&name).is_none(), "declining a repeat must leave it disconnected"); + } + } +} + #[test] fn gate_rules() { let s = sample(); diff --git a/crates/ui-egui/src/connectiontree.rs b/crates/ui-egui/src/connectiontree.rs index f952a2885..d8d6f1051 100644 --- a/crates/ui-egui/src/connectiontree.rs +++ b/crates/ui-egui/src/connectiontree.rs @@ -229,21 +229,32 @@ fn add_asset(app: &mut RustGisApp, href: &str, name: &str) -> Result<(), String> /// in the service dialog, a STAC collection in the STAC dialog. fn add_with_options(app: &mut RustGisApp, c: &Connection, child: &Value, source: Option<&ServiceSource>) -> Result<(), String> { match child["what"].as_str().unwrap_or("") { - "table" => crate::sourcespane::open( - app, - &Source { kind: "database".into(), name: c.name.clone(), address: c.address.clone(), extra: json!({"layer": child["key"]}) }, - ), + "table" => { + crate::sourcespane::open( + app, + &Source { + kind: "database".into(), + name: c.name.clone(), + address: c.address.clone(), + extra: json!({"layer": child["key"]}), + }, + )?; + app.dialogs.connection = Some(c.id.clone()); + Ok(()) + } "collection" => { crate::sourcespane::open( app, &Source { kind: "stac".into(), name: c.name.clone(), address: c.address.clone(), extra: json!({"api": "true"}) }, )?; app.ui.stac.collection = child["detail"].as_str().unwrap_or("").to_string(); + app.ui.stac.connection = Some(c.id.clone()); Ok(()) } _ => match (source, child.get("layer").filter(|l| l.is_object())) { (Some(source), Some(layer)) => { app.run("ui.addService", json!({"url": source.url, "type": source.kind}))?; + app.dialogs.connection = Some(c.id.clone()); app.ui.service_browse.filter = layer["name"].as_str().unwrap_or("").to_string(); Ok(()) } diff --git a/crates/ui-egui/src/connectionui.rs b/crates/ui-egui/src/connectionui.rs index f93b8587f..bd65b8e33 100644 --- a/crates/ui-egui/src/connectionui.rs +++ b/crates/ui-egui/src/connectionui.rs @@ -42,6 +42,76 @@ pub(crate) const KINDS: [(&str, &str); 13] = [ ("s3", "S3"), ]; +/// The kinds Add From Service connects to. +pub(crate) const SERVICE_KINDS: &[&str] = &["service", "wms", "wmts", "wfs", "wcs", "ogcapi", "arcgis", "csw"]; + +/// The connection a dialog was opened on, while its trimmed, redacted address still matches. +/// Never infers identity from an address alone. +pub(crate) fn picked<'a>(list: &'a [Connection], id: Option<&str>, address: &str) -> Option<&'a Connection> { + let id = id?; + let c = list.iter().find(|c| c.id == id)?; + (c.address == rustgis_engine::sources::redact(address.trim())).then_some(c) +} + +/// Draws a saved-connection row when these kinds exist; returns only a newly picked connection. +pub(crate) fn picker(ui: &mut egui::Ui, id: &str, list: &[Connection], kinds: &[&str], current: Option<&Connection>) -> Option { + if !list.iter().any(|c| kinds.contains(&c.kind.as_str())) { + return None; + } + ui.horizontal(|ui| { + ui.label(tl!("Saved connection")); + picker_combo(ui, id, list, kinds, current) + }) + .inner +} + +/// The picker control without its label, for a dialog's existing form grid. +pub(crate) fn picker_combo( + ui: &mut egui::Ui, + id: &str, + list: &[Connection], + kinds: &[&str], + current: Option<&Connection>, +) -> Option { + let label = |c: &Connection| if c.group.is_empty() { c.name.clone() } else { format!("{} › {}", c.group, c.name) }; + let mut picked = None; + egui::ComboBox::from_id_salt(id) + .width(280.0) + .selected_text(current.map(label).unwrap_or_else(|| tl!("Pick one…").to_string())) + .show_ui(ui, |ui| { + for c in list.iter().filter(|c| kinds.contains(&c.kind.as_str())) { + let selected = current.is_some_and(|x| x.id == c.id); + let hover = if c.kind == "s3" && c.address.is_empty() { tl!("Amazon S3") } else { &c.address }; + if ui.selectable_label(selected, label(c)).on_hover_text(hover).clicked() && !selected { + picked = Some(c.clone()); + } + } + }); + picked +} + +/// Keeps an endpoint, never its secrets, as a new connection even at an existing address. +pub(crate) fn keep(app: &mut RustGisApp, p: Value) -> Result { + let v = app.run("connection.new", p)?; + let id = v["id"].as_str().map(str::to_owned).ok_or_else(|| "connection.new returned no id".to_string())?; + stripped_notice(app, &v); + app.session.notify(i18n::fmt("Saved as the connection {name}", &[("name", v["name"].as_str().unwrap_or(""))]), false); + Ok(id) +} + +/// Offers to keep the endpoint connected by an add dialog. +pub(crate) fn keep_button(ui: &mut egui::Ui) -> bool { + ui.button(tl!("Save as Connection")) + .on_hover_text(tl!("Keep this endpoint under Data Sources › Connections, without its password or keys")) + .clicked() +} + +fn stripped_notice(app: &mut RustGisApp, v: &Value) { + if v["stripped"] == true { + app.session.notify(tl!("A password or token in the address was left out: connections never keep secrets.").to_string(), false); + } +} + pub(crate) fn label(kind: &str) -> &'static str { KINDS.iter().find(|(k, _)| *k == kind).map(|(_, l)| *l).unwrap_or("Connection") } @@ -218,9 +288,7 @@ fn save(app: &mut RustGisApp) -> Result<(), String> { "connection.new" }; let v = app.run(command, p)?; - if v["stripped"] == true { - app.session.notify(tl!("A password or token in the address was left out: connections never keep secrets.").to_string(), false); - } + stripped_notice(app, &v); let mut login = json!({"id": v["id"]}); secrets(&app.ui.connection, &mut login); if login.as_object().is_some_and(|p| p.len() > 1) @@ -373,12 +441,43 @@ pub fn open_connection(app: &mut RustGisApp, c: &Connection) -> Result<(), Strin } app.run("connection.login", json!({"id":c.id}))?; } - return crate::sourcespane::open( + crate::s3ui::fill(app, c); + let r = crate::sourcespane::open( app, &Source { kind: "s3".into(), name: c.name.clone(), address: c.name.clone(), extra: Value::Null }, ); + if r.is_ok() { + app.ui.s3.connection = Some(c.id.clone()); + } + return r; } _ => return Err(format!("unknown kind '{}'", c.kind)), }; - crate::sourcespane::open(app, &Source { kind: kind.into(), name: c.name.clone(), address: c.address.clone(), extra }) + let r = crate::sourcespane::open(app, &Source { kind: kind.into(), name: c.name.clone(), address: c.address.clone(), extra }); + if r.is_ok() { + if kind == "stac" { + app.ui.stac.connection = Some(c.id.clone()); + } else { + app.dialogs.connection = Some(c.id.clone()); + } + } + r +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picked_requires_identity_and_the_unchanged_public_address() { + let list = vec![ + Connection { id: "c1".into(), address: "https://example.com/wms".into(), ..Default::default() }, + Connection { id: "c2".into(), address: "https://example.com/wms".into(), ..Default::default() }, + ]; + assert!(picked(&list, None, &list[0].address).is_none()); + assert_eq!(picked(&list, Some("c2"), " https://example.com/wms ").map(|c| c.id.as_str()), Some("c2")); + assert!(picked(&list, Some("removed"), &list[0].address).is_none()); + assert!(picked(&list, Some("c1"), "https://example.com/other").is_none()); + assert_eq!(picked(&list, Some("c1"), "https://example.com/wms?token=secret").map(|c| c.id.as_str()), Some("c1")); + } } diff --git a/crates/ui-egui/src/cswui.rs b/crates/ui-egui/src/cswui.rs index 85cb2abea..10e72a199 100644 --- a/crates/ui-egui/src/cswui.rs +++ b/crates/ui-egui/src/cswui.rs @@ -136,18 +136,27 @@ pub fn form(app: &mut RustGisApp, ui: &mut egui::Ui, kinds: impl FnOnce(&mut Rus /// The records found: each with its abstract (on hover), a Zoom to its bounds and a button per /// link RustGIS can follow. fn records(app: &mut RustGisApp, ui: &mut egui::Ui) { + let list = crate::sourcespane::connections_snapshot(app); let st = &app.ui.csw; if st.records.is_empty() && st.matched == 0 { return; } ui.separator(); - ui.label( - egui::RichText::new(crate::i18n::fmt( - "{shown} of {matched} records", - &[("shown", &st.records.len().to_string()), ("matched", &st.matched.to_string())], - )) - .strong(), - ); + let url = st.asked["url"].as_str().unwrap_or("").to_string(); + let keepable = !url.is_empty() && crate::connectionui::picked(&list, app.dialogs.connection.as_deref(), &url).is_none(); + let mut keep = false; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(crate::i18n::fmt( + "{shown} of {matched} records", + &[("shown", &st.records.len().to_string()), ("matched", &st.matched.to_string())], + )) + .strong(), + ); + if keepable { + keep = crate::connectionui::keep_button(ui); + } + }); for n in &st.notes { ui.label(egui::RichText::new(n).small().weak()); } @@ -224,4 +233,10 @@ fn records(app: &mut RustGisApp, ui: &mut egui::Ui) { app.ui.csw.error = Some(e); } } + if keep { + match crate::connectionui::keep(app, json!({"kind": "csw", "address": url})) { + Ok(id) => app.dialogs.connection = Some(id), + Err(e) => app.ui.csw.error = Some(e), + } + } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 06cfdb4ca..d1d3b33d6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -21,8 +21,12 @@ pub struct Dialogs { pub flag: bool, pub error: Option, pub info: Option, + /// The saved connection this dialog was opened on, by id (see `connectionui::picked`). + pub connection: Option, /// Choices found for the dialog (Add From Database's tables; Add Delimited Text's columns). pub list: Vec, + /// Add From Database: the database whose tables were last listed (what Save as Connection keeps). + pub listed: Option, /// Add Delimited Text's file, picked before the dialog opens. pub csv: Option, } @@ -616,6 +620,15 @@ fn add_service(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { if app.dialogs.mode == "new" { app.dialogs.mode = "auto"; } + let list = crate::sourcespane::connections_snapshot(app); + let address = if app.dialogs.mode == "csw" { &app.ui.csw.url } else { &app.dialogs.text }; + let current = crate::connectionui::picked(&list, app.dialogs.connection.as_deref(), address); + if let Some(c) = crate::connectionui::picker(ui, "service_saved", &list, crate::connectionui::SERVICE_KINDS, current) { + if let Err(e) = crate::connectionui::open_connection(app, &c) { + app.dialogs.error = Some(e); + } + return false; + } let kinds = |app: &mut RustGisApp, ui: &mut egui::Ui| { ui.label(tl!("Type")); let shown = KINDS.iter().find(|(c, _)| *c == app.dialogs.mode).map(|(_, l)| tl!(*l).to_string()).unwrap_or_default(); @@ -661,6 +674,11 @@ fn add_service(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { // A directory's folder or service to open, or Back to the directory before. let mut open: Option = None; let mut back = false; + let keepable = app.ui.service.as_ref().and_then(|conn| { + let (url, kind) = app.ui.service_browse.back.first().map(|(u, k)| (u.as_str(), *k)).unwrap_or((&conn.url, conn.kind)); + crate::connectionui::picked(&list, app.dialogs.connection.as_deref(), url).is_none().then(|| (url.to_string(), kind)) + }); + let mut keep_root = false; if let Some(conn) = &app.ui.service { ui.separator(); if let Some(e) = &conn.error { @@ -685,13 +703,18 @@ fn add_service(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { let none = Vec::new(); let layers = v.get("layers").and_then(Value::as_array).unwrap_or(&none); let grouped = kind.starts_with("OGC API"); - ui.label( - egui::RichText::new(crate::i18n::fmt( - "{kind} {title}: {count} layers", - &[("kind", kind), ("title", title), ("count", &layers.len().to_string())], - )) - .strong(), - ); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(crate::i18n::fmt( + "{kind} {title}: {count} layers", + &[("kind", kind), ("title", title), ("count", &layers.len().to_string())], + )) + .strong(), + ); + if keepable.is_some() && conn.pending.is_none() && conn.error.is_none() && conn.failure.is_none() { + keep_root = crate::connectionui::keep_button(ui); + } + }); for n in v.get("notes").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) { ui.label(egui::RichText::new(n).small().weak()); } @@ -775,6 +798,13 @@ fn add_service(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { }); } } + if keep_root && let Some((url, kind)) = keepable { + // "auto" is the dialog's detection mode; saved connections call that kind "service". + match crate::connectionui::keep(app, json!({"kind": if kind == "auto" { "service" } else { kind }, "address": url})) { + Ok(id) => app.dialogs.connection = Some(id), + Err(e) => app.dialogs.error = Some(e), + } + } if let Some(p) = import && let Err(e) = app.run("ui.importService", p) { @@ -862,8 +892,18 @@ fn add_database(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { a.to_string() } }; + let connections = crate::sourcespane::connections_snapshot(app); + let typed = database(&app.dialogs.a); + let current = crate::connectionui::picked(&connections, app.dialogs.connection.as_deref(), &typed); + let mut pick = None; let mut list = false; egui::Grid::new("database").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + let kinds = rustgis_engine::connections::DATABASE_KINDS; + if connections.iter().any(|c| kinds.contains(&c.kind.as_str())) { + ui.label(tl!("Saved connection")); + pick = crate::connectionui::picker_combo(ui, "database_saved", &connections, kinds, current); + ui.end_row(); + } ui.label(tl!("Connection")); ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text("host=localhost dbname=gis user=postgres").desired_width(280.0)); ui.end_row(); @@ -891,20 +931,41 @@ fn add_database(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text(tl!("of a geometry that doesn't say")).desired_width(280.0)); ui.end_row(); }); + if let Some(c) = pick { + if let Err(e) = crate::connectionui::open_connection(app, &c) { + app.dialogs.error = Some(e); + } + return false; + } if list { let p = json!({"database": database(&app.dialogs.a), "password": app.dialogs.b}); match app.run("info.databaseTables", p) { Ok(v) => { app.dialogs.list = v["layers"].as_array().into_iter().flatten().filter_map(|t| t.as_str().map(str::to_string)).collect(); + app.dialogs.listed = Some(database(&app.dialogs.a)); app.dialogs.error = None; app.dialogs.info = Some(crate::i18n::fmt("{n} tables with geometry", &[("n", &app.dialogs.list.len().to_string())])); } Err(e) => { + app.dialogs.listed = None; app.dialogs.info = None; app.dialogs.error = Some(e); } } } + ui.horizontal(|ui| { + let address = database(&app.dialogs.a); + if app.dialogs.listed.as_deref() == Some(address.as_str()) + && let Some(kind) = rustgis_engine::connections::database_kind(&address) + && crate::connectionui::picked(&connections, app.dialogs.connection.as_deref(), &address).is_none() + && crate::connectionui::keep_button(ui) + { + match crate::connectionui::keep(app, json!({"kind": kind, "address": address})) { + Ok(id) => app.dialogs.connection = Some(id), + Err(e) => app.dialogs.error = Some(e), + } + } + }); buttons(app, ui, "Add", |app| { let mut p = json!({"database": database(&app.dialogs.a), "password": app.dialogs.b}); for (key, v) in [("layer", &app.dialogs.c), ("crs", &app.dialogs.text)] { diff --git a/crates/ui-egui/src/s3ui.rs b/crates/ui-egui/src/s3ui.rs index 3091fec3b..e605765d9 100644 --- a/crates/ui-egui/src/s3ui.rs +++ b/crates/ui-egui/src/s3ui.rs @@ -4,6 +4,7 @@ //! `rustgis_tiles::s3` when the connection has credentials. use egui::vec2; +use rustgis_engine::connections::Connection; use rustgis_engine::render::rustgis_tiles::{cache, s3}; use serde_json::{Value, json}; @@ -11,20 +12,9 @@ use crate::{RustGisApp, Slot, i18n}; type Answer = Slot, String>>; -/// The preference holding remembered connections (never their secrets). +/// The legacy preference of remembered connections, read once by the connections import (never written). pub(crate) const SAVED: &str = "s3_connections"; -/// A remembered connection: what's needed to connect again, without the secret. -#[derive(Clone, Debug, Default, PartialEq)] -struct Saved { - name: String, - endpoint: String, - region: String, - /// Where its keys come from ([`SOURCES`]); typed keys are typed again. - source: String, - profile: String, -} - /// Where a connection's keys come from: typed, an AWS profile or the environment (desktop), or /// none (public buckets). The profile and the environment are used only when chosen here. pub(crate) const SOURCES: [(&str, &str); 4] = [ @@ -37,6 +27,10 @@ pub(crate) const SOURCES: [(&str, &str); 4] = [ /// The dialog's form, browsing place and requests under way. #[derive(Default)] pub struct State { + /// The saved connection this dialog was opened on, by id (see `connectionui::picked`). + pub(crate) connection: Option, + /// What Connect last connected, as Save as Connection keeps it. + connected: Option, name: String, endpoint: String, region: String, @@ -45,9 +39,6 @@ pub struct State { /// Where the keys come from ([`SOURCES`]; empty: typed). source: String, profile: String, - remember: bool, - saved: Vec, - saved_loaded: bool, /// The connection browsed (a session connection's name; none: anonymous AWS). conn: String, bucket: String, @@ -142,40 +133,73 @@ fn list(st: &mut State) { st.listing_job = Some(fetch(&url)); } -fn load_saved(app: &mut RustGisApp) { - if app.ui.s3.saved_loaded { - return; +/// Puts a saved connection in the form, without carrying secrets from the previous one. +pub(crate) fn fill(app: &mut RustGisApp, c: &Connection) { + let st = &mut app.ui.s3; + st.name = c.name.clone(); + st.endpoint = c.address.clone(); + st.region = c.options.get("region").cloned().unwrap_or_default(); + st.source = c.options.get("credentials").cloned().unwrap_or_else(|| "none".into()); + st.profile = c.options.get("profile").cloned().unwrap_or_default(); + st.key.clear(); + st.secret.clear(); + st.connected = None; + st.error = None; +} + +fn pick(app: &mut RustGisApp, c: &Connection) { + fill(app, c); + app.ui.s3.connection = Some(c.id.clone()); + if s3::connection(&c.name).is_some() { + open_connection(app, &c.name); + } else if app.ui.s3.source != "keys" { + match app.run("connection.login", json!({"id": c.id})) { + Ok(_) => open_connection(app, &c.name), + Err(e) => app.ui.s3.error = Some(e), + } } - app.ui.s3.saved_loaded = true; - let text = app.services.load_pref.as_ref().and_then(|load| load(SAVED)).unwrap_or_default(); - let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null); - let s = |c: &Value, k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").chars().take(200).collect::(); - app.ui.s3.saved = v - .as_array() - .into_iter() - .flatten() - .take(32) - .map(|c| Saved { - name: s(c, "name"), - endpoint: s(c, "endpoint"), - region: s(c, "region"), - source: s(c, "source"), - profile: s(c, "profile"), - }) - .filter(|c| !c.name.is_empty()) - .collect(); } -fn store_saved(app: &RustGisApp) { - let v: Vec = app - .ui - .s3 - .saved - .iter() - .map(|c| json!({"name": c.name, "endpoint": c.endpoint, "region": c.region, "source": c.source, "profile": c.profile})) - .collect(); - if let Some(save) = &app.services.save_pref { - save(SAVED, &Value::Array(v).to_string()); +fn connect(app: &mut RustGisApp) { + let st = &app.ui.s3; + let name = if st.name.trim().is_empty() { "default".to_string() } else { st.name.trim().to_string() }; + let endpoint = st.endpoint.trim().to_string(); + let region = st.region.trim().to_string(); + let profile = if st.profile.trim().is_empty() { "default".to_string() } else { st.profile.trim().to_string() }; + let mut p = json!({"name": name, "region": region}); + if !endpoint.is_empty() { + p["endpoint"] = json!(endpoint); + } + match st.source.as_str() { + "profile" => p["profile"] = json!(profile), + "environment" => p["environment"] = json!(true), + "keys" if !st.key.trim().is_empty() || !st.secret.is_empty() => { + p["accessKeyId"] = json!(st.key.trim()); + p["secretAccessKey"] = json!(st.secret.trim()); + } + // Nothing typed, or none chosen: anonymous. + _ => {} + } + match app.run("s3.connect", p) { + Ok(v) => { + let st = &mut app.ui.s3; + // The secret stays with the session's connection, not in the form. + st.secret.clear(); + st.conn = name.clone(); + st.error = None; + st.buckets.clear(); + let credentials = v["credentials"].as_str().filter(|c| matches!(*c, "keys" | "profile" | "environment")).unwrap_or("none"); + let mut def = json!({"kind": "s3", "name": name, "address": endpoint, + "options": {"region": region, "credentials": credentials}}); + if credentials == "profile" { + def["options"]["profile"] = json!(profile); + } + st.connected = Some(def); + } + Err(e) => { + app.ui.s3.connected = None; + app.ui.s3.error = Some(e); + } } } @@ -194,8 +218,7 @@ pub fn open_connection(app: &mut RustGisApp, name: &str) { /// The dialog: a connection, then a bucket browsed a level at a time. pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { - load_saved(app); - ui.label(egui::RichText::new(tl!("Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret.")).weak()); + ui.label(egui::RichText::new(tl!("Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret.")).weak()); egui::CollapsingHeader::new(tl!("Connection")).default_open(s3::connections().is_empty()).show(ui, |ui| connection_form(app, ui)); ui.separator(); browse(app, ui); @@ -205,22 +228,12 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { } fn connection_form(app: &mut RustGisApp, ui: &mut egui::Ui) { - let saved = app.ui.s3.saved.clone(); - if !saved.is_empty() { - ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(tl!("Remembered:")).small().weak()); - for c in &saved { - if ui.small_button(&c.name).on_hover_text(if c.endpoint.is_empty() { "AWS" } else { c.endpoint.as_str() }).clicked() { - let st = &mut app.ui.s3; - st.name = c.name.clone(); - st.endpoint = c.endpoint.clone(); - st.region = c.region.clone(); - st.source = c.source.clone(); - st.profile = c.profile.clone(); - st.remember = true; - } - } - }); + let list = crate::sourcespane::connections_snapshot(app); + let st = &app.ui.s3; + let current = list.iter().find(|c| c.kind == "s3" && Some(c.id.as_str()) == st.connection.as_deref() && c.name == st.name.trim()); + if let Some(c) = crate::connectionui::picker(ui, "s3_saved", &list, &["s3"], current) { + pick(app, &c); + return; } let st = &mut app.ui.s3; if !SOURCES.iter().any(|(c, _)| *c == st.source) { @@ -261,45 +274,22 @@ fn connection_form(app: &mut RustGisApp, ui: &mut egui::Ui) { ui.add_enabled(typed, egui::TextEdit::singleline(&mut st.secret).password(true).desired_width(300.0)); ui.end_row(); }); - ui.checkbox(&mut st.remember, tl!("Remember (without the secret)")); if ui.button(tl!("Connect")).clicked() { - let name = if st.name.trim().is_empty() { "default".to_string() } else { st.name.trim().to_string() }; - let mut p = json!({"name": name, "region": st.region.trim()}); - if !st.endpoint.trim().is_empty() { - p["endpoint"] = json!(st.endpoint.trim()); - } - match st.source.as_str() { - "profile" => p["profile"] = json!(if st.profile.trim().is_empty() { "default" } else { st.profile.trim() }), - "environment" => p["environment"] = json!(true), - "keys" if !st.key.trim().is_empty() || !st.secret.is_empty() => { - p["accessKeyId"] = json!(st.key.trim()); - p["secretAccessKey"] = json!(st.secret.trim()); - } - // Nothing typed, or none chosen: anonymous. - _ => {} - } - let remember = st.remember.then(|| Saved { - name: name.clone(), - endpoint: st.endpoint.trim().to_string(), - region: st.region.trim().to_string(), - source: st.source.clone(), - profile: st.profile.trim().to_string(), - }); - match app.run("s3.connect", p) { - Ok(_) => { - let st = &mut app.ui.s3; - // The secret stays with the session's connection, not in the form. - st.secret.clear(); - st.conn = name; - st.error = None; - st.buckets.clear(); - if let Some(r) = remember { - st.saved.retain(|c| c.name != r.name); - st.saved.insert(0, r); - store_saved(app); - } + connect(app); + } + let st = &app.ui.s3; + if let Some(def) = &st.connected + && def["name"].as_str() == Some(st.conn.as_str()) + && s3::connection(&st.conn).is_some() + && current.is_none() + { + if list.iter().any(|c| c.name.eq_ignore_ascii_case(&st.conn)) { + ui.label(egui::RichText::new(tl!("Rename it to save it: a connection has this name")).small().weak()); + } else if crate::connectionui::keep_button(ui) { + match crate::connectionui::keep(app, def.clone()) { + Ok(id) => app.ui.s3.connection = Some(id), + Err(e) => app.ui.s3.error = Some(e), } - Err(e) => app.ui.s3.error = Some(e), } } } @@ -412,3 +402,66 @@ fn size_text(n: u64) -> String { n => format!("{n} B"), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anonymous_connection_can_be_kept_and_picked_after_disconnect() { + let mut app = RustGisApp::with_sample(); + app.ui.s3.name = "keep-s3-test".into(); + app.ui.s3.endpoint = "https://s3.example.com".into(); + app.ui.s3.region = "eu-west-1".into(); + app.ui.s3.source = "none".into(); + connect(&mut app); + assert_eq!(app.ui.s3.error, None); + let def = app.ui.s3.connected.clone().unwrap(); + assert_eq!( + def, + json!({"kind":"s3","name":"keep-s3-test","address":"https://s3.example.com", + "options":{"region":"eu-west-1","credentials":"none"}}) + ); + assert!(app.session.connections.connections.is_empty(), "Connect alone must not save"); + let id = crate::connectionui::keep(&mut app, def).unwrap(); + let c = app.session.connections.find(&id).unwrap().clone(); + assert_eq!(c.options.get("credentials").map(String::as_str), Some("none")); + s3::disconnect("keep-s3-test"); + pick(&mut app, &c); + assert!(s3::connection("keep-s3-test").is_some()); + assert_eq!(app.ui.s3.conn, "keep-s3-test"); + assert_eq!(app.ui.s3.connection.as_deref(), Some(c.id.as_str())); + s3::disconnect("keep-s3-test"); + } + + #[test] + fn picking_typed_keys_clears_previous_secrets_and_waits_for_connect() { + let mut app = RustGisApp::with_sample(); + let v = app + .run( + "connection.new", + json!({"kind":"s3","name":"pick-typed-s3-test","address":"", + "options":{"credentials":"keys"}}), + ) + .unwrap(); + let c = app.session.connections.find(v["id"].as_str().unwrap()).unwrap().clone(); + app.ui.s3.key = "previous-key".into(); + app.ui.s3.secret = "previous-secret".into(); + app.ui.s3.connected = Some(json!({"name":"previous"})); + pick(&mut app, &c); + assert!(s3::connection(&c.name).is_none()); + assert!(app.ui.s3.key.is_empty()); + assert!(app.ui.s3.secret.is_empty()); + assert!(app.ui.s3.connected.is_none()); + assert_eq!(app.ui.s3.name, c.name); + assert_eq!(app.ui.s3.connection.as_deref(), Some(c.id.as_str())); + app.ui.s3.key = "test-access".into(); + app.ui.s3.secret = "test-secret".into(); + connect(&mut app); + assert_eq!(app.ui.s3.error, None); + assert!(app.ui.s3.secret.is_empty()); + assert_eq!(app.ui.s3.connected.as_ref().unwrap()["options"]["credentials"], "keys"); + assert_eq!(s3::connection(&c.name).unwrap().name, c.name); + s3::disconnect(&c.name); + } +} diff --git a/crates/ui-egui/src/sourcespane.rs b/crates/ui-egui/src/sourcespane.rs index 7ac0d7ae4..d2f0be893 100644 --- a/crates/ui-egui/src/sourcespane.rs +++ b/crates/ui-egui/src/sourcespane.rs @@ -37,7 +37,7 @@ fn snapshot(app: &mut RustGisApp) -> std::sync::Arc std::sync::Arc> { +pub(crate) fn connections_snapshot(app: &mut RustGisApp) -> std::sync::Arc> { let generation = app.session.connections.generation; match &app.ui.sources.connections { Some((g, list)) if *g == generation => list.clone(), diff --git a/crates/ui-egui/src/stacui.rs b/crates/ui-egui/src/stacui.rs index e4b73e0ca..beb5cd3fe 100644 --- a/crates/ui-egui/src/stacui.rs +++ b/crates/ui-egui/src/stacui.rs @@ -25,16 +25,13 @@ type Answer = Slot, String>>; /// What is being fetched. enum Asked { - Collections, + /// An API's collections, by the address as typed. + Collections(String), /// A search, by its address (which the answer's links are resolved against). Search(String), /// A token for an asset that needs signing: the token service's address, the asset's and the /// layer's name. - Token { - token_url: String, - href: String, - name: String, - }, + Token { token_url: String, href: String, name: String }, /// An item's or a collection's document (a `?stac=` link), by its address. Link(String), /// A static catalog's document, for its children, by its address. @@ -133,6 +130,10 @@ pub struct State { pub cube_asset: String, pub cube_range: String, pub api: String, + /// The saved connection this dialog was opened on, by id (see `connectionui::picked`). + pub connection: Option, + /// The API or catalog address whose collections or children last landed (what Save as Connection keeps). + pub listed: String, /// Collections the API offers: id and title. pub collections: Vec<(String, String)>, pub collection: String, @@ -180,6 +181,8 @@ impl Default for State { cube_asset: String::new(), cube_range: String::new(), api: stac::EARTH_SEARCH.to_string(), + connection: None, + listed: String::new(), collections: Vec::new(), collection: "sentinel-2-l2a".into(), from: String::new(), @@ -305,10 +308,11 @@ pub fn poll(app: &mut RustGisApp, ctx: &egui::Context) { st.error = None; let previous = st.info.take(); match asked { - Asked::Collections => match r.and_then(|b| stac::parse_collections(&b)) { + Asked::Collections(api) => match r.and_then(|b| stac::parse_collections(&b)) { Ok(c) => { st.info = Some(i18n::fmt("{n} collections", &[("n", &c.len().to_string())])); st.collections = c.into_iter().map(|c| (c.id, c.title)).collect(); + st.listed = api; } Err(e) => st.error = Some(e), }, @@ -342,6 +346,9 @@ pub fn poll(app: &mut RustGisApp, ctx: &egui::Context) { Ok(c) if st.walk.is_some() || (st.counted && !st.items.is_empty()) => { st.info = previous; st.children = c; + if url == st.api.trim() { + st.listed = url.clone(); + } } Ok(c) => { st.info = Some(if c.is_empty() { @@ -350,6 +357,9 @@ pub fn poll(app: &mut RustGisApp, ctx: &egui::Context) { i18n::fmt("{n} catalogs and collections", &[("n", &c.len().to_string())]) }); st.children = c; + if url == st.api.trim() { + st.listed = url.clone(); + } } Err(e) => { st.children.clear(); @@ -663,6 +673,15 @@ pub fn title(app: &RustGisApp) -> &'static str { shown_preset(&app.ui.stac).map(Preset::title).unwrap_or("STAC Search") } +fn switch_api(st: &mut State, url: &str) { + st.api = url.to_string(); + st.collections.clear(); + // Nothing of the last catalog's browsing is kept. + (st.node, st.node_title) = (String::new(), String::new()); + st.trail.clear(); + st.children.clear(); +} + /// The dialog. pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { let busy = app.ui.stac.waiting.is_some() || app.ui.stac.walk.is_some(); @@ -675,23 +694,31 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { if let Some(p) = shown_preset(&app.ui.stac) { ui.label(egui::RichText::new(tl!(p.hint())).weak()); } + let connections = crate::sourcespane::connections_snapshot(app); + let current = crate::connectionui::picked(&connections, app.ui.stac.connection.as_deref(), &app.ui.stac.api); egui::Grid::new("stac").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { let st = &mut app.ui.stac; ui.label(if is_static { tl!("STAC catalog") } else { tl!("STAC API") }); ui.horizontal(|ui| { let named = APIS.iter().find(|(_, u)| *u == st.api.trim_end_matches('/')).map(|(l, _)| tl!(*l).to_string()); egui::ComboBox::from_id_salt("stac_api") - .selected_text(named.unwrap_or_else(|| tl!("Other").to_string())) + .selected_text(current.map(|c| c.name.clone()).or(named).unwrap_or_else(|| tl!("Other").to_string())) .width((ui.available_width() * 0.4).clamp(90.0, 150.0)) .show_ui(ui, |ui| { for (label, url) in APIS { if ui.selectable_label(st.api == url, tl!(label)).clicked() && st.api != url { - st.api = url.to_string(); - st.collections.clear(); - // Nothing of the last catalog's browsing is kept. - (st.node, st.node_title) = (String::new(), String::new()); - st.trail.clear(); - st.children.clear(); + switch_api(st, url); + api_changed = true; + } + } + if connections.iter().any(|c| c.kind == "stac") { + ui.separator(); + } + for c in connections.iter().filter(|c| c.kind == "stac") { + let selected = current.is_some_and(|x| x.id == c.id); + if ui.selectable_label(selected, &c.name).on_hover_text(&c.address).clicked() && !selected { + switch_api(st, &c.address); + st.connection = Some(c.id.clone()); api_changed = true; } } @@ -784,11 +811,19 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { ui.checkbox(&mut st.use_view, tl!("Only where the map is")); ui.end_row(); }); + let st = &app.ui.stac; + let keepable = !st.listed.is_empty() + && st.listed == st.api.trim() + && crate::connectionui::picked(&connections, st.connection.as_deref(), &st.listed).is_none(); + let mut keep = false; ui.horizontal(|ui| { search = ui.add_enabled(!busy, egui::Button::new(tl!("Find Items"))).clicked(); if busy { ui.spinner(); } + if keepable { + keep = crate::connectionui::keep_button(ui); + } }); if api_changed && catalog::is_static(&app.ui.stac.api) && !busy { browse(app, None); @@ -800,13 +835,22 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { fetch_children(app); } else if list { match stac::api(Some(app.ui.stac.api.as_str())) { - Ok(api) => app.ui.stac.waiting = Some((Asked::Collections, fetch(&stac::collections_url(&api)))), + Ok(api) => { + let typed = app.ui.stac.api.trim().to_string(); + app.ui.stac.waiting = Some((Asked::Collections(typed), fetch(&stac::collections_url(&api)))); + } Err(e) => app.ui.stac.error = Some(e), } } if search { find_items(app); } + if keep { + match crate::connectionui::keep(app, json!({"kind": "stac", "address": app.ui.stac.listed.clone()})) { + Ok(id) => app.ui.stac.connection = Some(id), + Err(e) => app.ui.stac.error = Some(e), + } + } #[cfg(not(target_arch = "wasm32"))] cube_row(app, ui, busy); let t = crate::theme::Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index e488d14d4..3f2d1cea6 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -1565,6 +1565,177 @@ fn connections_section_and_dialog() { assert!(h.state().session.connections.find("Local").is_none()); } +#[test] +fn add_service_keeps_and_picks_connections() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let body = br#"Localimage/pngEPSG:3857aA"#; + for stream in listener.incoming().take(3) { + let mut stream = stream.unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut buf = [0u8; 2048]; + let _ = stream.read(&mut buf).unwrap(); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).unwrap(); + stream.write_all(body).unwrap(); + } + }); + let wait_for_listing = |h: &mut egui_kittest::Harness<'_, RustGisApp>| { + for _ in 0..100 { + h.step(); + if h.state().ui.service.as_ref().is_some_and(|c| c.listing.is_some() || c.error.is_some() || c.failure.is_some()) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let conn = h.state().ui.service.as_ref().unwrap(); + assert_eq!(conn.error, None); + assert!(conn.failure.is_none()); + assert_eq!(conn.listing.as_ref().unwrap()["layers"][0]["name"], "a"); + h.step(); + }; + let mut h = harness(); + let url = format!("http://{addr}/wms"); + let other = h.state_mut().run("connection.new", json!({"kind":"wms","address":url,"name":"Other user"})).unwrap(); + let other_id = other["id"].as_str().unwrap().to_string(); + h.state_mut().run("ui.addService", json!({"url":url,"type":"wms"})).unwrap(); + wait_for_listing(&mut h); + assert_eq!(h.state().session.connections.connections.len(), 1, "connecting never saves automatically"); + assert_eq!(h.state().dialogs.connection, None); + h.get_by_label("Save as Connection").click(); + h.step(); + let same_address: Vec<_> = h.state().session.connections.connections.iter().filter(|c| c.address == url).collect(); + assert_eq!(same_address.len(), 2, "an existing address is not a connection identity"); + let saved = same_address.into_iter().find(|c| c.id != other_id).unwrap(); + let saved_id = saved.id.clone(); + assert_ne!(saved.name, other["name"].as_str().unwrap(), "the new connection has a unique default name"); + assert_eq!(saved.kind, "wms"); + assert_eq!(h.state().dialogs.connection.as_deref(), Some(saved_id.as_str())); + h.step(); + assert!(h.query_by_label("Save as Connection").is_none()); + + h.state_mut().ui.service = None; + h.state_mut().run("ui.dialog", json!({"name":"addService"})).unwrap(); + h.step(); + h.step(); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Pick one…")).unwrap().click(); + h.step(); + h.get_by_label("Other user").click(); + h.step(); + assert_eq!(h.state().dialogs.connection.as_deref(), Some(other_id.as_str())); + assert_eq!(h.state().dialogs.text, url); + wait_for_listing(&mut h); + assert!(h.query_by_label("Save as Connection").is_none()); + + let edited = format!("{url}?x=1"); + h.state_mut().dialogs.text = edited.clone(); + h.state_mut().run("ui.addService", json!({"url":edited,"type":"wms"})).unwrap(); + wait_for_listing(&mut h); + assert_eq!(h.state().dialogs.connection.as_deref(), Some(other_id.as_str())); + h.get_by_label("Save as Connection"); + assert_eq!(h.state().session.connections.connections.len(), 2); + server.join().unwrap(); +} + +#[test] +fn add_database_picks_and_keeps_connections() { + let mut h = harness(); + let address = "postgres:host=db.example dbname=gis user=me"; + let picked = h.state_mut().run("connection.new", json!({"kind":"postgres","address":address,"name":"Picked DB"})).unwrap(); + let picked_id = picked["id"].as_str().unwrap().to_string(); + h.state_mut().run("ui.dialog", json!({"name":"addDatabase"})).unwrap(); + h.step(); + h.step(); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Pick one…")).unwrap().click(); + h.step(); + h.get_by_label("Picked DB").click(); + h.step(); + assert_eq!(h.state().dialogs.a, address); + assert_eq!(h.state().dialogs.connection.as_deref(), Some(picked_id.as_str())); + assert!(h.state().dialogs.b.is_empty(), "picking a connection never fills the password"); + // Simulate a successful List Tables without needing a database server. + h.state_mut().dialogs.listed = Some(address.into()); + h.step(); + assert!(h.query_by_label("Save as Connection").is_none()); + + // The same address entered by hand is still a new connection. + h.state_mut().dialogs.connection = None; + h.step(); + h.get_by_label("Save as Connection").click(); + h.step(); + let same_address: Vec<_> = h.state().session.connections.connections.iter().filter(|c| c.address == address).collect(); + assert_eq!(same_address.len(), 2); + let saved = same_address.into_iter().find(|c| c.id != picked_id).unwrap(); + let saved_id = saved.id.clone(); + assert_eq!(saved.kind, "postgres"); + assert_ne!(saved.name, "Picked DB"); + assert_eq!(h.state().dialogs.connection.as_deref(), Some(saved_id.as_str())); + h.step(); + assert!(h.query_by_label("Save as Connection").is_none()); + h.state_mut().dialogs.connection = None; + h.state_mut().dialogs.listed = None; + h.step(); + assert!(h.query_by_label("Save as Connection").is_none(), "an unlisted endpoint cannot be kept"); +} + +#[test] +fn stac_dialog_keeps_and_picks_connections() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let body = br#"{"collections":[{"id":"c1","title":"C"}]}"#; + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut buf = [0u8; 2048]; + let _ = stream.read(&mut buf).unwrap(); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()) + .unwrap(); + stream.write_all(body).unwrap(); + }); + let mut h = harness(); + let api = format!("http://{addr}/stac"); + h.state_mut().ui.stac.api = api.clone(); + h.state_mut().run("ui.dialog", json!({"name":"stac"})).unwrap(); + h.step(); + h.get_by_label("Collections").click(); + for _ in 0..100 { + h.step(); + if !h.state().ui.stac.collections.is_empty() || h.state().ui.stac.error.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(h.state().ui.stac.error, None); + assert_eq!(h.state().ui.stac.collections, vec![("c1".to_string(), "C".to_string())]); + assert_eq!(h.state().ui.stac.listed, api); + assert!(h.state().session.connections.connections.is_empty(), "listing never saves automatically"); + h.step(); + h.get_by_label("Save as Connection").click(); + h.step(); + assert_eq!(h.state().session.connections.connections.len(), 1); + let saved = &h.state().session.connections.connections[0]; + let saved_id = saved.id.clone(); + assert_eq!(saved.kind, "stac"); + assert_eq!(saved.address, api); + assert_eq!(h.state().ui.stac.connection.as_deref(), Some(saved_id.as_str())); + h.step(); + assert!(h.query_by_label("Save as Connection").is_none()); + + h.state_mut().run("connection.edit", json!({"id":saved_id,"name":"Picked STAC"})).unwrap(); + h.state_mut().ui.stac.api = "https://other.example/stac".into(); + h.step(); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Other")).unwrap().click(); + h.step(); + h.get_by_label("Picked STAC").click(); + h.step(); + assert_eq!(h.state().ui.stac.api, api); + assert!(h.state().ui.stac.collections.is_empty(), "the existing combo resets collections when picking a saved API"); + assert_eq!(h.state().ui.stac.connection.as_deref(), Some(saved_id.as_str())); + server.join().unwrap(); +} + #[test] fn connections_postgres_requires_an_address() { use egui::accesskit::Role; diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 30e51d982..90a26f899 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -316,8 +316,10 @@ rustgis-cli run --cmd 's3.connect={"name":"minio","endpoint":"https://minio.exam ``` Keys are kept until RustGIS closes: never in a project (which keeps the `s3://` address), a -command's result, a notice or the log. **Remember** keeps a connection's name, endpoint and region -in the app's settings, never its secret; storing secrets in the system keychain isn't supported. +command's result, a notice or the log. After **Connect**, **Save as Connection** keeps its name, +endpoint, region and credential source under **Data Sources › Connections**, never its keys. +Use **Saved connection** to pick it again; typed keys must be entered again after RustGIS closes. +Storing secrets in the system keychain isn't supported. Keys go only to the endpoint they're for: a signed request that is redirected isn't followed, and keys from a profile or the environment are used only with AWS or the profile's own `endpoint_url`. In the web app, the bucket's CORS rules must allow the page's origin (and, for a @@ -364,6 +366,20 @@ desktop and web; database tests run synchronously on desktop. Database rows rema but cannot be opened or tested in the web app. Earth Search and Planetary Computer remain available under **Library**. +**From Service…** (including **Catalogue search (CSW)**), **From Database…**, **STAC Search** +and **S3…** can use saved connections directly. Service, database and S3 dialogs offer a +**Saved connection** picker; STAC includes saved connections in its existing API/catalog list. +Picking one keeps its identity, not just its address: two connections can point to the same +server with different names or users. + +To keep an endpoint entered by hand, click **Save as Connection** after it connects: after +**List Tables** for a database, **Collections** or **Browse** for STAC, a catalogue search with +results, or **Connect** for a service or S3. Nothing is saved automatically. The new connection +is selected and the button disappears. An endpoint typed by hand can be saved even when another +connection has the same address; its default name is made unique. S3 uses the session name, so +choose a different name and connect again if that name is already saved. DuckDB files aren't +saved database connections. Within an ArcGIS directory, the button keeps the root directory. + Changing a form field clears pending or completed Test feedback; a late response from the previous inputs is ignored. PostgreSQL accepts a bare `host=… dbname=… user=…` connection string, but a blank address cannot be saved or tested. @@ -421,7 +437,8 @@ Renaming clears the runtime entries under both the old and new names, so the ren connection cannot inherit an unrelated login at its destination name. Web-service logins are not supported. Plugins may list connections but cannot manage, log in to, test or browse them. The in-app assistant asks for approval each time for those operations; -external assistants using MCP cannot run them. +external assistants using MCP cannot run them. Even `probe: true` needs approval: connection +operations cannot be approved for the whole conversation. Database passwords are shared by public DSN for the session, including existing layers and other named connections using that DSN. Editing or removing a named connection does not revoke that shared login; changing its address does not transfer the old password to the new address. @@ -429,8 +446,8 @@ that shared login; changing its address does not transfer the old password to th Desktop and web apps save connections with their preferences (`connections` in desktop `prefs.json`, `rustgis.connections` in web localStorage). The first load imports saved services, databases other than DuckDB files, STAC APIs and remembered S3 connections once, without -changing the old lists. Legacy saved endpoint lists are no longer shown in the pane; remembered -S3 connections remain available in the S3 dialog. Testing S3 can register or replace the +changing the old lists. Legacy saved endpoint lists are no longer shown in the pane or the +S3 dialog; use the imported named connections instead. Testing S3 can register or replace the session endpoint and credentials under the form's connection name, including a same-name session created in the S3 dialog. Cancel does not disconnect that login or restore the previous one. CLI and Python connections last only for the run. From fc897ef36365dee29971a86bdd5ffd683bd6be2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Thu, 22 Oct 2026 16:51:47 -0500 Subject: [PATCH 043/297] feat(ui): offer tools and their parameter keys in a gp.run line (#712) * feat(ui): offer tools and their parameter keys in a gp.run line * test(ui): check the Script hints with no list open and count each tool once * refactor(ui): read a gp.run line's tool with the same scan as its keys * fix(ui): keep the Script popups clear of the line next to the cursor's --- crates/ui-egui/src/scriptcomplete.rs | 513 ++++++++++++++++++++++++--- docs/user-guide/analysis.md | 5 +- 2 files changed, 462 insertions(+), 56 deletions(-) diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 156199912..56106f62c 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -1,8 +1,9 @@ //! Autocomplete for the Script pane (#662): a floating list of suggestions just under the cursor, //! as in a code editor. ↑/↓ move the highlight, Enter or Tab accept it, Escape closes the list, or //! a click accepts an item. What to offer at a cursor (`offer_at`) is worked out apart from the -//! box and tested on its own: command ids (a line's first word), and a command's parameter keys in -//! its `{…}` (`key_at`). Typing aids beside it: a command's parameters added on accept +//! box and tested on its own: command ids (a line's first word), a command's parameter keys in +//! its `{…}` (`key_at`), and in a `gp.run` line the tool ids for its `"tool"` (`value_at`) and, +//! in its `"params": {…}`, the keys of the tool it runs (`tool_of`). Typing aids beside it: a command's parameters added on accept //! (`template`), and braces that close themselves (`brace`). use egui::text::{CCursor, CCursorRange}; @@ -18,13 +19,23 @@ const MIN_TYPED: usize = 2; const MIN_WIDTH: f32 = 220.0; /// What to offer at a cursor: the word being typed (char indexes, `end` at the cursor) and what -/// can replace it: command ids, or the keys of command `keys_of`'s parameters. +/// can replace it: command ids (`of` None), or what `of` says. #[derive(Clone, Debug, PartialEq)] pub struct Offer { pub start: usize, pub end: usize, pub items: Vec, - pub keys_of: Option, + pub of: Option, +} + +/// What an offer holds besides command ids: a command's parameter keys (by its id), a +/// geoprocessing tool's (by its id) in a `gp.run` line's `"params"`, or the tool ids for a +/// `gp.run` line's `"tool"`. +#[derive(Clone, Debug, PartialEq)] +pub enum Of { + CommandKeys(String), + ToolKeys(String), + ToolIds, } /// The ids that complete the command id being typed at char `at` of `script`: the cursor at the @@ -93,9 +104,11 @@ pub fn hint_at(script: &str, at: usize) -> Option { } /// Where a popup of `size` (last frame's) goes by `cursor`: under it if it fits, else above if it -/// fits, else on the side with more room; kept on screen sideways. Its corner, and the height its -/// content may take (it scrolls beyond: a short window, a long help). +/// fits, else on the side with more room, a line and a half clear of the cursor's line (the line +/// next to it stays in view whole, with room around it); kept on screen sideways. Its corner, and the height its content may take (it +/// scrolls beyond: a short window, a long help). fn place(cursor: egui::Rect, size: egui::Vec2, screen: egui::Rect, min: f32) -> (egui::Pos2, f32) { + let cursor = cursor.expand2(egui::vec2(0.0, cursor.height() * 1.5)); let (below, above) = (screen.bottom() - cursor.bottom(), cursor.top() - screen.top()); let under = size.y <= below || (size.y > above && below >= above); let room = (if under { below } else { above } - FRAME).max(min); @@ -134,50 +147,144 @@ fn complete(start: usize, end: usize, word: &str, ids: &[String]) -> Option Option<(Hint, usize, String)> { - let h = hint_at(script, at)?; - let text = script.get(byte_at(script, h.brace)?..byte_at(script, at)?)?; - // What follows the cursor ends the word. - let next = script.get(byte_at(script, at)?..)?.chars().next(); - if next.is_some_and(|c| !c.is_whitespace() && !matches!(c, '"' | ',' | '}')) { - return None; - } +/// Where a line's object leaves off at its end (`text`, from its `{`): where a quoted key being +/// typed starts (after its quote, a char index of `text`; None when the end isn't in one), the +/// `{` of the top-level `"params"` object the end is in (None outside it), where a top-level +/// quoted value being typed starts, with its key, and the top-level quoted values read whole, +/// each with its key, in order. +struct Scan { + start: Option, + params: Option, + value: Option<(usize, String)>, + values: Vec<(String, String)>, +} + +/// Reads `text`, a line's object from its `{`, to its end: a key starts an entry right after a +/// `{` or a `,`, at the top level or at the top of its `"params": {…}`; not in a value, nor in +/// any other nested object. A top-level value follows its key and colon. +fn scan(text: &str) -> Scan { + let chars: Vec = text.chars().collect(); let (mut depth, mut quoted, mut escaped, mut entry, mut start) = (0usize, false, false, false, None); - for (i, c) in text.chars().enumerate() { + // The top-level key just read, the one whose colon was just read (its value comes next), + // where the key being read starts, the `{` of the `"params"` object the text is in, and the + // top-level value being read. + let (mut named, mut valued, mut from, mut params, mut value) = (None::, None::, None, None, None); + let mut values = Vec::new(); + for (i, &c) in chars.iter().enumerate() { let was_escaped = escaped; escaped = c == '\\' && !was_escaped; if quoted { if c == '"' && !was_escaped { + if let Some((v, key)) = value.take() { + values.push((key, chars.get(v..i).unwrap_or_default().iter().collect())); + } (quoted, start) = (false, None); + named = from.take().and_then(|f| chars.get(f..i)).map(|k| k.iter().collect()); } continue; } + let top = depth == 1 || (depth == 2 && params.is_some()); match c { - '"' => (quoted, start, entry) = (true, (depth == 1 && entry).then_some(i + 1), false), + '"' => { + (quoted, start) = (true, (top && entry).then_some(i + 1)); + from = (depth == 1 && entry).then_some(i + 1); + value = valued.take().filter(|_| depth == 1).map(|k| (i + 1, k)); + entry = false; + } '{' | '[' | '(' => { depth += 1; - entry = c == '{' && depth == 1; + if c == '{' && depth == 2 && valued.as_deref() == Some("params") { + params = Some(i); + } + entry = c == '{' && (depth == 1 || params.is_some_and(|p| p == i)); + } + '}' | ']' | ')' => { + depth = depth.saturating_sub(1); + if depth < 2 { + params = None; + } + entry = false; } - '}' | ']' | ')' => (depth, entry) = (depth.saturating_sub(1), false), - ',' => entry = depth == 1, + ',' => entry = top, + ':' => (valued, entry) = (named.take().filter(|_| depth == 1), false), c if c.is_whitespace() => {} _ => entry = false, } + // A key's name lasts up to its colon, and from there up to its value. + if !c.is_whitespace() { + if c != '"' { + named = None; + } + if c != ':' { + valued = None; + } + } } - let start = start?; - let word: String = text.chars().skip(start).collect(); - if word.chars().any(|c| c.is_whitespace() || c == '\\') { + Scan { start, params, value, values } +} + +/// The line's object from its `{` up to the cursor at char `at` of `script`, read (`scan`), with +/// the command's line (`hint_at`); None when what follows the cursor doesn't end a word. +fn scan_at(script: &str, at: usize) -> Option<(Hint, Scan, &str)> { + let h = hint_at(script, at)?; + let text = script.get(byte_at(script, h.brace)?..byte_at(script, at)?)?; + let next = script.get(byte_at(script, at)?..)?.chars().next(); + if next.is_some_and(|c| !c.is_whitespace() && !matches!(c, '"' | ',' | '}')) { return None; } + let scan = scan(text); + Some((h, scan, text)) +} + +/// The word of `text` from char `start` to its end, when it's one: no space, no escape. +fn word_from(text: &str, start: usize) -> Option { + let word: String = text.chars().skip(start).collect(); + (!word.chars().any(|c| c.is_whitespace() || c == '\\')).then_some(word) +} + +/// The key being typed at char `at` of `script`: the cursor in a quoted word that starts an entry +/// of its line's object, right after its `{` or a `,` (at the top level, or at the top of its +/// `"params": {…}`: not a value, not another nested object's key). The command's line +/// (`hint_at`), where the word starts (after its quote, a char index), the word, and the `{` of +/// the `"params"` object it's in (a char index; None at the top level). +pub fn key_at(script: &str, at: usize) -> Option<(Hint, usize, String, Option)> { + let (h, Scan { start, params, .. }, text) = scan_at(script, at)?; + let start = start?; + let word = word_from(text, start)?; + let (start, params) = (h.brace + start, params.map(|p| h.brace + p)); + Some((h, start, word, params)) +} + +/// The top-level value being typed at char `at` of `script`: the cursor in a quoted word right +/// after a key and its colon. The command's line, where the word starts (after its quote, a char +/// index), the word, and its key. +pub fn value_at(script: &str, at: usize) -> Option<(Hint, usize, String, String)> { + let (h, Scan { value, .. }, text) = scan_at(script, at)?; + let (start, key) = value?; + let word = word_from(text, start)?; let start = h.brace + start; - Some((h, start, word)) + Some((h, start, word, key)) +} + +/// Whether the cursor at char `at` of `script` is in the top-level `"params": {…}` of line `h`'s +/// object (not deeper in it). +fn in_params(script: &str, at: usize, h: &Hint) -> bool { + script.get(byte_at(script, h.brace).unwrap_or(0)..byte_at(script, at).unwrap_or(0)).is_some_and(|t| scan(t).params.is_some()) +} + +/// The top-level `"tool"` of a line's object (`line`, from its `{`): the tool a `gp.run` line runs, +/// before or after its `"params"`; the last one given, as Run reads it. None without one, or when +/// it isn't a plain string. +pub fn tool_of(line: &str) -> Option { + scan(line).values.into_iter().rev().find(|(key, _)| key == "tool").map(|(_, tool)| tool).filter(|t| !t.contains('\\')) +} + +/// Line `h`'s object, from its `{` to the line's end. +fn line_of<'a>(script: &'a str, h: &Hint) -> Option<&'a str> { + script.get(byte_at(script, h.brace)?..)?.split('\n').next() } /// The keys of the parameters `desc` describes, entry by entry, each with its alternatives @@ -200,33 +307,137 @@ fn entries(desc: &str) -> Vec<(Vec, String)> { /// without the word being typed, so that key, closed or not, doesn't count as given (a key already /// closed, `"lat|": 5`, can still be changed), while the same key given elsewhere does. fn complete_keys(id: &str, desc: &str, line: &str, start: usize, end: usize, word: &str) -> Option { + let entries = entries(desc).into_iter().map(|(keys, _)| keys).collect(); + offer_keys(Of::CommandKeys(id.to_string()), entries, line, start, end, word) +} + +/// The keys of `entries` (each entry's alternatives together) that complete `word`, as +/// `complete_keys` offers them; `line` is the object they go in, from its `{`. +fn offer_keys(of: Of, entries: Vec>, line: &str, start: usize, end: usize, word: &str) -> Option { let given = rustgis_engine::param_keys(line).unwrap_or_default(); - let items: Vec = entries(desc) + let items: Vec = entries .into_iter() - .filter(|(keys, _)| !keys.iter().any(|k| given.contains(&k.as_str()))) - .flat_map(|(keys, _)| keys) + .filter(|keys| !keys.iter().any(|k| given.contains(&k.as_str()))) + .flatten() .filter(|k| k.get(..word.len()).is_some_and(|p| p.eq_ignore_ascii_case(word))) .collect(); - (!items.is_empty()).then_some(Offer { start, end, items, keys_of: Some(id.to_string()) }) + (!items.is_empty()).then_some(Offer { start, end, items, of: Some(of) }) } -/// What to offer at char `at` of `script`: the command ids for a line's first word, or the keys -/// of the line's command for a key being typed in its `{…}`. +/// What to offer at char `at` of `script`: the command ids for a line's first word, the keys of +/// the line's command for a key being typed in its `{…}`; in a `gp.run` line, the tool ids for its +/// `"tool"`, and in its `"params"` the keys of the tool it runs. fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::Session) -> Option { if let Some((start, word)) = word_at(script, at) { return complete(start, at, &word, ids); } - let (h, start, word) = key_at(script, at)?; - // The line from its `{`, the word being typed taken out. - let before = script.get(byte_at(script, h.brace)?..byte_at(script, start)?)?; + if let Some((h, start, word, key)) = value_at(script, at) { + // Other values come later (#662's third step). + return if h.id == "gp.run" && key == "tool" { tool_ids(start, at, &word) } else { None }; + } + let (h, start, word, params) = key_at(script, at)?; let after = script.get(byte_at(script, at)?..)?.split('\n').next()?; - complete_keys(&h.id, ¶ms_of(session, &h.id)?, &format!("{before}{after}"), start, at, &word) + let Some(params) = params else { + // The line from its `{`, the word being typed taken out. + let before = script.get(byte_at(script, h.brace)?..byte_at(script, start)?)?; + return complete_keys(&h.id, ¶ms_of(session, &h.id)?, &format!("{before}{after}"), start, at, &word); + }; + if h.id != "gp.run" { + return None; + } + let tool = rustgis_engine::analysis::tool(&tool_of(line_of(script, &h)?)?)?; + // The `"params"` object from its `{` (it ends with its `}`: the rest of the line isn't read), + // the word being typed taken out. + let before = script.get(byte_at(script, params)?..byte_at(script, start)?)?; + let entries = tool.params.iter().map(|p| vec![p.name.to_string()]).collect(); + offer_keys(Of::ToolKeys(tool.id.to_string()), entries, &format!("{before}{after}"), start, at, &word) +} + +/// The ids of the geoprocessing tools that complete `word`, typed from char `start` to `end`, +/// case aside, in the order the tools are listed (a built-in tool first: an external one with +/// the same id isn't the one Run finds). +fn tool_ids(start: usize, end: usize, word: &str) -> Option { + let mut seen = std::collections::HashSet::new(); + let items: Vec = rustgis_engine::analysis::tools() + .map(|t| t.id) + .filter(|id| id.get(..word.len()).is_some_and(|p| p.eq_ignore_ascii_case(word)) && seen.insert(*id)) + .map(str::to_string) + .collect(); + (!items.is_empty()).then_some(Offer { start, end, items, of: Some(Of::ToolIds) }) } -/// The text describing key `key` of command `id`'s parameters (`"size"?: number`), for the help -/// beside a list of keys. -fn key_help(session: &rustgis_engine::Session, id: &str, key: &str) -> Option { - entries(¶ms_of(session, id)?).into_iter().find(|(keys, _)| keys.iter().any(|k| k == key)).map(|(_, text)| text) +/// What the help beside a list of keys says about key `key` of `of`'s parameters: the line +/// describing it (`"size"?: number`), and a longer help under it (a tool's; may be empty). +fn key_help(session: &rustgis_engine::Session, of: &Of, key: &str) -> Option<(String, String)> { + match of { + Of::CommandKeys(id) => entries(¶ms_of(session, id)?) + .into_iter() + .find(|(keys, _)| keys.iter().any(|k| k == key)) + .map(|(_, text)| (text, String::new())), + Of::ToolKeys(id) => { + let p = rustgis_engine::analysis::tool(id)?.params.iter().find(|p| p.name == key)?; + let help = if p.help.is_empty() { String::new() } else { tl!(p.help).to_string() }; + Some((param_line(p), help)) + } + Of::ToolIds => None, + } +} + +/// A tool's parameter as the help shows it, as a command's parameters are described: its key +/// (`?` when it may be left out), what it takes, its name and its default +/// (`"units"?: "Kilometers" | "Miles" — Units, default "Kilometers"`). +fn param_line(p: &rustgis_engine::analysis::ParamSpec) -> String { + use rustgis_engine::analysis::ParamKind as K; + // In logical order (`tl_raw!`): `fmt` puts the finished line in display form. + let choices; + let takes = match p.kind { + K::Layer => tl_raw!("layer"), + K::PolygonLayer => tl_raw!("polygon layer"), + K::PointLayer => tl_raw!("point layer"), + K::RasterLayer => tl_raw!("raster layer"), + K::VectorLayer => tl_raw!("vector layer"), + K::RasterLayers => tl_raw!("raster layers (names separated by ;)"), + K::VectorLayers => tl_raw!("vector layers (names separated by ;)"), + K::Field => tl_raw!("a field of the first layer"), + K::NumericField => tl_raw!("a numeric field of the first layer"), + K::CoordinateField => tl_raw!("a field of the first layer holding coordinates"), + K::Number => tl_raw!("number"), + K::Text => tl_raw!("text"), + K::Bool => tl_raw!("bool"), + K::Path => tl_raw!("file path"), + K::Choice(options) => { + choices = options.iter().map(|o| format!("\"{o}\"")).collect::>().join(" | "); + choices.as_str() + } + }; + let key = format!("\"{}\"{}", p.name, if p.required { "" } else { "?" }); + let args = [("key", key.as_str()), ("takes", takes), ("label", tl_raw!(p.label)), ("default", p.default)]; + if p.default.is_empty() { + crate::i18n::fmt("{key}: {takes} — {label}", &args) + } else { + crate::i18n::fmt("{key}: {takes} — {label}, default {default}", &args) + } +} + +/// The help for tool `t`, as `help` gives a command's: its signature, name, summary, parameters +/// one per line (`param_line`), and its toolbox. +fn tool_help(t: &rustgis_engine::analysis::ToolSpec) -> Help { + Help { + id: t.id.to_string(), + keys: signature(t.params.iter().map(|p| (p.name, !p.required))), + label: tl!(t.name).to_string(), + about: tl!(t.summary).to_string(), + params: t.params.iter().map(param_line).collect(), + location: crate::i18n::location(t.toolbox), + } +} + +/// The help by the cursor at char `at` of `script`, in line `h`'s braces: in a `gp.run` line's +/// `"params"`, the tool's it runs (when the line names one there is); elsewhere the command's. +fn help_at(session: &rustgis_engine::Session, script: &str, at: usize, h: &Hint) -> Option { + let tool = || rustgis_engine::analysis::tool(&tool_of(line_of(script, h)?)?); + let tool = if h.id == "gp.run" && in_params(script, at, h) { tool() } else { None }; + tool.map(tool_help).or_else(|| help(session, &h.id)) } /// `script_ids`, kept until the loaded plugins or the session's commands change (the built-in @@ -283,11 +494,17 @@ pub fn help(session: &rustgis_engine::Session, id: &str) -> Option { } }; let params = rustgis_engine::param_list(&desc).unwrap_or_default(); - let names: Vec = params.iter().map(|p| format!("{}{}", p.key, if p.optional { "?" } else { "" })).collect(); - let keys = if names.is_empty() { String::new() } else { format!(" {{{}}}", names.join(", ")) }; + let keys = signature(params.iter().map(|p| (p.key, p.optional))); Some(Help { id: id.to_string(), keys, label, about, params: params.iter().map(|p| p.text.to_string()).collect(), location }) } +/// The keys after an id in a help's signature, each with `?` when it may be left out +/// (` {layer?, size?}`); empty for none. +fn signature<'a>(keys: impl Iterator) -> String { + let names: Vec = keys.map(|(key, optional)| format!("{key}{}", if optional { "?" } else { "" })).collect(); + if names.is_empty() { String::new() } else { format!(" {{{}}}", names.join(", ")) } +} + /// The parameter description of command `id`: a built-in command's, or an enabled plugin's. fn params_of(session: &rustgis_engine::Session, id: &str) -> Option { match session.registry.get(id) { @@ -322,12 +539,19 @@ const HELP_MIN_WIDTH: f32 = 200.0; const GAP: f32 = 12.0; const FRAME: f32 = 24.0; -/// Draws a key's parameter `text` beside the list, coloured as the script is. -fn draw_key_help(ui: &mut Ui, text: &str, width: f32) { +/// Draws a key's parameter `text` beside the list, coloured as the script is, and its longer help +/// (`about`, may be empty) under it. +fn draw_key_help(ui: &mut Ui, text: &str, about: &str, width: f32) { let palette = crate::scriptcolor::Palette::of(&crate::theme::Tokens::get(ui.ctx())); let mut job = crate::scriptcolor::help_job(None, text, palette, &egui::TextStyle::Monospace.resolve(ui.style())); job.wrap.max_width = width; - ui.label(job); + ui.vertical(|ui| { + ui.set_max_width(width); + ui.label(job); + if !about.is_empty() { + ui.label(egui::RichText::new(about).small()); + } + }); } /// Draws `help` beside the list: the signature and parameters coloured as the script is, in its @@ -608,15 +832,21 @@ pub fn show( } } }); - // Worked out each frame the list shows, for one command: a registry lookup and a + // Worked out each frame the list shows, for one command or tool: a lookup and a // short description split. Kept, it would need the id and the language as its // key (the help is translated), for next to nothing saved. let shown = hovered.filter(|_| pointer_leads).unwrap_or(selected); - if let Some(id) = &offer.keys_of { + if offer.of == Some(Of::ToolIds) { + // A tool: its help, as a command's. + if let Some(t) = offer.items.get(shown).and_then(|id| rustgis_engine::analysis::tool(id)) { + ui.add_space(GAP); + draw_help(ui, &tool_help(t), help_width); + } + } else if let Some(of) = &offer.of { // A key: the line describing its parameter. - if let Some(text) = offer.items.get(shown).and_then(|key| key_help(session, id, key)) { + if let Some((text, about)) = offer.items.get(shown).and_then(|key| key_help(session, of, key)) { ui.add_space(GAP); - draw_key_help(ui, &text, help_width); + draw_key_help(ui, &text, &about, help_width); } } else if let Some(help) = offer.items.get(shown).and_then(|id| help(session, id)) { // A gap, not a separator: in a row, egui's separator takes all the height it @@ -653,7 +883,11 @@ pub fn hint(ui: &Ui, box_id: Id, out: &TextEditOutput, visible: egui::Rect, scri let found = match kept { Some(found) => found, None => { - let found = at.and_then(|at| hint_at(script, at)).and_then(|h| Some((h.clone(), help(session, &h.id)?))); + let found = at.and_then(|at| { + let h = hint_at(script, at)?; + let help = help_at(session, script, at, &h)?; + Some((h, help)) + }); list.hint.worked = Some(Worked { at, text, with, value: found.clone() }); found } @@ -695,7 +929,10 @@ pub fn hint(ui: &Ui, box_id: Id, out: &TextEditOutput, visible: egui::Rect, scri pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &str, session: &rustgis_engine::Session) { let chars: Vec = script.chars().collect(); let (Some(before), Some(mut after)) = (chars.get(..offer.start), chars.get(offer.end..)) else { return }; - let (tail, at) = if offer.keys_of.is_some() { + let (tail, at) = if offer.of == Some(Of::ToolIds) { + // A tool id: closed, the cursor after its quote (one already there is kept). + if after.first() == Some(&'"') { (String::new(), 1) } else { ("\"".to_string(), 1) } + } else if offer.of.is_some() { // A key: closed, its colon and a space after it, ready for the value; a closing quote // already there is taken in, and a key that has its colon keeps it. let quoted = after.first() == Some(&'"'); @@ -1383,7 +1620,7 @@ mod tests { /// after its `{` or a `,`, at the top level; not in a value, a nested object or a comment. #[test] fn key_at_finds_a_key_being_typed() { - let at = |s: &str| super::key_at(s, s.chars().count()).map(|(h, start, word)| (h.id, start, word)); + let at = |s: &str| super::key_at(s, s.chars().count()).map(|(h, start, word, _)| (h.id, start, word)); assert_eq!(at(r#"labels.style {""#), Some(("labels.style".into(), 15, String::new()))); assert_eq!(at(r#"labels.style {"layer": "Cities", "si"#).map(|k| k.2), Some("si".into()), "after a comma"); assert_eq!(at(r#"x.y {"w": "a\"b", ""#).map(|k| k.2), Some(String::new()), "an escaped quote in a value"); @@ -1411,7 +1648,7 @@ mod tests { assert!(keys(r#"{""#, "zz").is_empty()); assert_eq!(keys(r#"{"lat": 5}"#, ""), ["lon", "x", "scale"], "a key given"); let o = super::complete_keys("map.goToXY", desc, r#"{""#, 0, 0, "").unwrap(); - assert_eq!(o.keys_of.as_deref(), Some("map.goToXY")); + assert_eq!(o.of, Some(super::Of::CommandKeys("map.goToXY".into()))); } /// The key under the cursor, closed or not, doesn't count as given; the same key given @@ -1522,4 +1759,170 @@ mod tests { type_in(&mut h, r#""size": 12, "bold": true"#); assert!((h.get_by_label(&sig).rect().left() - first).abs() < 0.5, "it didn't move with the cursor"); } + + /// In a line's `"params": {…}`, a key starts an entry at its top, as at the line's top level; + /// not in another nested object, deeper in `params`, or once `params` is closed. + #[test] + fn key_at_finds_a_key_in_params() { + let at = |s: &str| super::key_at(s, s.chars().count()).map(|(_, start, word, params)| (start, word, params)); + let s = r#"gp.run {"tool": "buffer", "params": {"di"#; + assert_eq!(at(s), Some((38, "di".into(), Some(36)))); + assert_eq!(at(r#"gp.run {"params" : {"input": "Cities", ""#).map(|k| k.2), Some(Some(19)), "after a comma, spaced colon"); + assert_eq!(at(r#"gp.run {"tool": "buffer", "#), None, "no quote"); + assert_eq!(at(r#"gp.run {"tool": "buffer", ""#).map(|k| k.2), Some(None), "the top level"); + assert_eq!(at(r#"gp.run {"params": {"a": {""#), None, "deeper in params"); + assert_eq!(at(r#"gp.run {"params": [{""#), None, "an array"); + assert_eq!(at(r#"gp.run {"other": {""#), None, "another object"); + assert_eq!(at(r#"gp.run {"params": {"a": "b"#), None, "a value"); + assert_eq!(at(r#"gp.run {"params": {}, ""#).map(|k| k.2), Some(None), "params closed"); + assert_eq!(at(r#"gp.run {"x": "params", "y": {""#), None, "\"params\" as a value"); + } + + /// The tool a line runs: its top-level `"tool"`, wherever it's given, the last one as Run + /// reads it; not a nested one, a value named "tool", or an escaped name. + #[test] + fn tool_of_reads_the_lines_tool() { + use super::tool_of; + assert_eq!(tool_of(r#"{"tool": "buffer", "params": {}}"#).as_deref(), Some("buffer")); + assert_eq!(tool_of(r#"{"params": {"input": "a"}, "tool" : "clip"}"#).as_deref(), Some("clip"), "after params"); + assert_eq!(tool_of(r#"{"tool": "a", "tool": "b"}"#).as_deref(), Some("b"), "the last one"); + assert_eq!(tool_of(r#"{"params": {"tool": "buffer"}}"#), None, "nested"); + assert_eq!(tool_of(r#"{"x": "tool", "y": "buffer"}"#), None, "a value"); + assert_eq!(tool_of(r#"{"tool": 5}"#), None, "not a string"); + assert_eq!(tool_of(r#"{"tool": "a\"b"}"#), None, "escaped"); + assert_eq!(tool_of(r#"{"tool": "buf"#), None, "being typed"); + assert_eq!(tool_of(r#"{"tool""#), None); + assert_eq!(tool_of(""), None); + } + + /// In a `gp.run` line's `"params"`, the keys of the tool it runs (by id or name, case aside, + /// as Run finds it), less those that object has; none without a known tool, nor for another + /// command's `"params"`. + #[test] + fn a_tools_keys_are_offered_in_params() { + let app = crate::RustGisApp::with_sample(); + let keys = |s: &str| super::offer_at(s, s.chars().count(), &[], &app.session).map(|o| o.items).unwrap_or_default(); + assert_eq!(keys(r#"gp.run {"tool": "buffer", "params": {""#), ["input", "distance", "units", "dissolve", "output"]); + assert_eq!(keys(r#"gp.run {"params": {"input": "Cities", "d"#), Vec::::new(), "no tool yet"); + assert_eq!(keys(r#"gp.run {"tool": "Buffer", "params": {"input": "Cities", "d"#), ["distance", "dissolve"], "by name"); + assert_eq!(keys(r#"gp.run {"tool": "nope", "params": {""#), Vec::::new(), "an unknown tool"); + assert_eq!(keys(r#"task.addStep {"command": "x", "params": {""#), Vec::::new(), "another command"); + assert!(keys(r#"gp.run {"tool": "buffer", "params": {"output": "a", "o"#).is_empty(), "given in params"); + assert_eq!(keys(r#"gp.run {"output": 1, "tool": "buffer", "params": {"o"#), ["output"], "given at the top level"); + let s = r#"gp.run {"params": {""#; + let o = super::offer_at(&format!(r#"{s}"}}, "tool": "buffer"}}"#), s.chars().count(), &[], &app.session).unwrap(); + assert_eq!(o.items.len(), 5, "the tool after params"); + assert_eq!(o.of, Some(super::Of::ToolKeys("buffer".into()))); + let s = r#"gp.run {"tool": "buffer", "params": {"distance": 5, "distance"}}"#; + assert!(keys(&s[..s.len() - 2]).is_empty(), "the same key given before in params"); + } + + /// A tool's parameter reads as a command's is described: its key, what it takes, its name, and + /// its default when it has one. + #[test] + fn a_tools_parameter_reads_as_described() { + let buffer = rustgis_engine::analysis::tool("buffer").unwrap(); + let line = |name: &str| super::param_line(buffer.params.iter().find(|p| p.name == name).unwrap()); + assert_eq!(line("input"), r#""input": layer — Input Features"#); + assert_eq!(line("distance"), r#""distance": number — Distance, default 100"#); + assert_eq!(line("dissolve"), r#""dissolve"?: "None" | "All" — Dissolve, default "None""#); + let help = super::tool_help(buffer); + assert_eq!(help.keys, " {input, distance, units?, dissolve?, output?}"); + assert_eq!((help.id.as_str(), help.label.as_str(), help.params.len()), ("buffer", "Buffer", 5)); + } + + /// In the pane: in a `gp.run` line's `"params"`, a quote offers the tool's keys with the + /// highlighted one's parameter beside, accepting closes it with its colon, and the help by + /// the cursor is the tool's there, `gp.run`'s elsewhere in the line. + #[test] + fn a_tools_key_is_offered_and_accepted() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + type_in(&mut h, r#"gp.run {"tool": "buffer", "#); + let tool = super::tool_help(rustgis_engine::analysis::tool("buffer").unwrap()); + let sig = format!("{}{}", tool.id, tool.keys); + let run = super::help(&h.state().session, "gp.run").unwrap(); + h.get_by_label(&format!("{}{}", run.id, run.keys)); + assert!(h.query_by_label(&sig).is_none(), "gp.run's help at the top level"); + type_in(&mut h, r#""params": {"#); + h.get_by_label(&sig); + type_in(&mut h, r#""di"#); + h.get_by_role_and_label(egui::accesskit::Role::Button, "dissolve"); + h.get_by_label(r#""distance": number — Distance, default 100"#); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, r#"gp.run {"tool": "buffer", "params": {"distance": "#); + } + + /// A top-level value is being typed: the cursor in a quoted word right after a key and its + /// colon; not a key, a nested value, an unquoted one, or one already closed. + #[test] + fn value_at_finds_a_value_being_typed() { + let at = |s: &str| super::value_at(s, s.chars().count()).map(|(h, start, word, key)| (h.id, start, word, key)); + assert_eq!(at(r#"gp.run {"tool": "bu"#), Some(("gp.run".into(), 17, "bu".into(), "tool".into()))); + assert_eq!(at(r#"gp.run {"params": {}, "tool" : ""#).map(|v| (v.2, v.3)), Some((String::new(), "tool".into())), "after params"); + assert_eq!(at(r#"gp.run {"tool": "a", "x"#), None, "a key"); + assert_eq!(at(r#"gp.run {"params": {"tool": ""#), None, "nested"); + assert_eq!(at(r#"gp.run {"tool": 5"#), None, "not quoted"); + assert_eq!(at(r#"gp.run {"tool" ""#), None, "no colon"); + assert_eq!(at(r#"gp.run {"tool": "a" "#), None, "closed"); + let s = r#"gp.run {"tool": "bu"}"#; + assert_eq!(super::value_at(s, 19).map(|v| v.2), Some("bu".into()), "a closing quote after the cursor"); + assert!(super::value_at(s, 18).is_none(), "a letter after the cursor"); + } + + /// In a `gp.run` line's `"tool"`, the tool ids that start with what's typed, case aside; no + /// value is offered for another key or another command. + #[test] + fn tool_ids_are_offered_for_gp_runs_tool() { + let app = crate::RustGisApp::with_sample(); + let offer = |s: &str| super::offer_at(s, s.chars().count(), &[], &app.session); + let o = offer(r#"gp.run {"tool": "BUF"#).unwrap(); + assert_eq!((o.items.first().map(String::as_str), o.of), (Some("buffer"), Some(super::Of::ToolIds))); + assert!(o.items.iter().all(|id| id.to_lowercase().starts_with("buf"))); + let all = offer(r#"gp.run {"tool": ""#).map(|o| o.items).unwrap_or_default(); + let ids: std::collections::HashSet<&str> = rustgis_engine::analysis::tools().map(|t| t.id).collect(); + assert_eq!(all.len(), ids.len(), "every tool, once"); + assert!(offer(r#"gp.run {"tool": "zzz"#).is_none()); + assert!(offer(r#"labels.style {"layer": ""#).is_none(), "another key's value"); + assert!(offer(r#"task.addStep {"tool": ""#).is_none(), "another command"); + } + + /// In the pane: a quote after `"tool": ` in a `gp.run` line offers the tools with the + /// highlighted one's help beside; accepting closes the id (a quote already there is kept), + /// the cursor after it. + #[test] + fn a_tool_id_is_offered_and_accepted() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + type_in(&mut h, r#"gp.run {"tool": "buf"#); + let tool = super::tool_help(rustgis_engine::analysis::tool("buffer").unwrap()); + h.get_by_label(&format!("{}{}", tool.id, tool.keys)); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, r#"gp.run {"tool": "buffer""#); + let mut h = pane(); + type_in(&mut h, r#"gp.run {"tool": "buf"}"#); + for _ in 0..2 { + h.key_press(egui::Key::ArrowLeft); + h.run(); + } + h.key_press(egui::Key::Enter); + h.run(); + type_in(&mut h, ","); + assert_eq!(h.state().workflow.script, r#"gp.run {"tool": "buffer",}"#, "the cursor after the quote"); + } + + /// A popup leaves a line and a half clear of the cursor's line: under it when it fits, else + /// above it. + #[test] + fn a_popup_leaves_a_line_clear_of_the_cursor() { + let screen = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)); + let cursor = egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(2.0, 20.0)); + let (corner, _) = super::place(cursor, egui::vec2(200.0, 100.0), screen, 60.0); + assert_eq!(corner, egui::pos2(100.0, 150.0), "under, clear of the line below"); + let cursor = cursor.translate(egui::vec2(0.0, 400.0)); + let (corner, _) = super::place(cursor, egui::vec2(200.0, 100.0), screen, 60.0); + assert_eq!(corner.y + 100.0, 470.0, "above, clear of the line over it"); + } } diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 1fa2ec253..0fdaf7213 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -236,7 +236,10 @@ when every key may be left out; a command with no parameters gets none, and a li parameters keeps them. While you type a command's parameters (the cursor inside its `{…}`), the same help stays by the cursor; Escape closes it. A quote that starts a key in a command's braces opens the list of its keys not given yet, beside the highlighted key's description; accepting one closes it with its colon -(`"size": `). A `{` typed closes itself, the cursor inside, unless text follows it or it's in a +(`"size": `). In a `gp.run` line, a quote after `"tool": ` opens the list of the tools, beside the +highlighted tool's help; accepting one closes it. In its `"params": {…}`, the keys offered are those of the tool its +`"tool"` names (by id or name, before or after `params`), beside the highlighted key's kind, name, +default and help; there the help by the cursor is the tool's. A `{` typed closes itself, the cursor inside, unless text follows it or it's in a string or a comment; a `}` typed before a `}` steps over it, and Backspace in an empty `{}` deletes both. Pasting adds nothing. From fc2cf559b771500504cd4668d1a1b03d656ac49c Mon Sep 17 00:00:00 2001 From: Mavrick Fitzgerald Date: Thu, 22 Oct 2026 17:17:11 -0500 Subject: [PATCH 044/297] fix(data): honor Shapefile code-page metadata (#713) * fix(data): honor Shapefile code-page metadata * fix(data): tolerate unreadable and mixed-case CPG sidecars --- Cargo.lock | 10 + Cargo.toml | 3 +- THIRD-PARTY-LICENSES.txt | 2 +- apps/rustgis-python/THIRD-PARTY-LICENSES.txt | 2 +- crates/data/Cargo.toml | 1 + crates/data/src/dbf.rs | 145 ++++++++++ crates/data/src/io.rs | 32 ++- crates/data/src/lib.rs | 1 + crates/data/src/source/shp.rs | 35 ++- crates/data/tests/shapefile_encoding.rs | 285 +++++++++++++++++++ docs/user-guide/data-formats.md | 10 + 11 files changed, 502 insertions(+), 24 deletions(-) create mode 100644 crates/data/src/dbf.rs create mode 100644 crates/data/tests/shapefile_encoding.rs diff --git a/Cargo.lock b/Cargo.lock index 54776df3f..87b9d1cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1258,7 +1258,10 @@ checksum = "abde818b4b82df61e01348c2b4610c221bcecb97cb7f567da086edf295d8d126" dependencies = [ "bufrw", "byteorder", + "codepage", + "encoding_rs", "time", + "yore", ] [[package]] @@ -4508,6 +4511,7 @@ dependencies = [ "calamine", "crs-definitions", "csv", + "encoding_rs", "flatgeobuf", "geo", "geozero", @@ -7053,6 +7057,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "yore" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7947b754c504609dca276b5904e1049423769126de1dcd8259d96b43a5a764" + [[package]] name = "zbus" version = "5.19.0" diff --git a/Cargo.toml b/Cargo.toml index d6db9b636..ec2de3f1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,8 @@ web-time = "1" geo = "0.33" # A5, the equal-area pentagonal DGGS (opengeos/RustGIS#551) a5 = "1" -shapefile = "0.9" +shapefile = { version = "0.9", features = ["encoding_rs", "yore"] } +encoding_rs = "0.8" csv = "1" tiny-skia = "0.12" # GPU map backend: vertex data shared with wgpu in ui-egui (derive only; unsafe stays forbidden). diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index fc0ad2276..c4cfc72a9 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -8769,7 +8769,7 @@ SOFTWARE. ==================================================================================================== MIT License Used by: block2, bufrw, dispatch, dpi, i_key_sort, libm, objc-sys, objc2, objc2-app-kit, objc2-encode, -objc2-foundation, rsqlite-vfs +objc2-foundation, rsqlite-vfs, yore ==================================================================================================== MIT License diff --git a/apps/rustgis-python/THIRD-PARTY-LICENSES.txt b/apps/rustgis-python/THIRD-PARTY-LICENSES.txt index 86ef9af95..ef3070bc8 100644 --- a/apps/rustgis-python/THIRD-PARTY-LICENSES.txt +++ b/apps/rustgis-python/THIRD-PARTY-LICENSES.txt @@ -6913,7 +6913,7 @@ SOFTWARE. ==================================================================================================== MIT License -Used by: bufrw, i_key_sort, libm, rsqlite-vfs +Used by: bufrw, i_key_sort, libm, rsqlite-vfs, yore ==================================================================================================== MIT License diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index ddb1ec459..668920ebf 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -15,6 +15,7 @@ serde_json = { workspace = true } thiserror = { workspace = true } csv = { workspace = true } shapefile = { workspace = true } +encoding_rs = { workspace = true } # Zstandard, the usual compression of COGs, is C code that compiles to WebAssembly with clang # (on the CI runners), so the browser decodes it too. tiff = { workspace = true, features = ["zstd"] } diff --git a/crates/data/src/dbf.rs b/crates/data/src/dbf.rs new file mode 100644 index 000000000..4cad3f1c3 --- /dev/null +++ b/crates/data/src/dbf.rs @@ -0,0 +1,145 @@ +//! Shared Shapefile character encoding selection for whole-file and streamed reads. + +use encoding_rs as rs; +use shapefile::dbase::{CodePageMark, UnicodeLossy, encoding::DynEncoding}; + +const MAX_CPG_BYTES: usize = 1024; + +/// Keep the selection thread-safe; dbase's dynamic decoder is created per read because it +/// is `Send` but not `Sync`, whereas feature sources can be shared between readers. +pub(crate) enum DbfEncoding { + Standard(&'static rs::Encoding), + Oem(CodePageMark), + Utf8, +} + +impl DbfEncoding { + pub(crate) fn decoder(&self) -> DynEncoding { + match self { + Self::Standard(encoding) => shapefile::dbase::encoding::EncodingRs::from(*encoding).into(), + Self::Oem(mark) => mark.to_encoding().unwrap_or_else(|| UnicodeLossy.into()), + Self::Utf8 => UnicodeLossy.into(), + } + } + + fn from_number(number: u16) -> Option { + let encoding = match number { + 437 => return Some(Self::Oem(CodePageMark::CP437)), + 850 => return Some(Self::Oem(CodePageMark::CP850)), + 852 => return Some(Self::Oem(CodePageMark::CP852)), + 861 => return Some(Self::Oem(CodePageMark::CP861)), + 865 => return Some(Self::Oem(CodePageMark::CP865)), + 866 => rs::IBM866, + 874 => rs::WINDOWS_874, + 932 => rs::SHIFT_JIS, + 936 => rs::GBK, + 949 => rs::EUC_KR, + 950 => rs::BIG5, + 1250..=1258 => return Self::from_label(&format!("windows-{number}")), + 65001 => rs::UTF_8, + _ => return None, + }; + Some(Self::Standard(encoding)) + } + + fn from_label(label: &str) -> Option { + // dbase's name lookup with both decoder features enabled falls back to CP1252 + // for unknown labels. Resolve explicitly so unusable metadata is reported. + let encoding = rs::Encoding::for_label(label.as_bytes())?; + // UTF-16 and replacement encodings cannot represent DBF's ASCII padding and + // NUL-terminated field names. Treat these labels as unsupported metadata. + encoding.is_ascii_compatible().then_some(Self::Standard(encoding)) + } + + fn from_name(name: &str) -> Option { + let upper = name.to_ascii_uppercase(); + if let Some(suffix) = upper.strip_prefix("8859") { + return Self::from_label(&format!("iso-8859-{}", suffix.strip_prefix('-').unwrap_or(suffix))); + } + let prefixed = ["CP", "IBM", "OEM", "WINDOWS", "ANSI"].into_iter().find_map(|prefix| upper.strip_prefix(prefix)); + let number = match prefixed { + Some(suffix) => suffix.strip_prefix('-').unwrap_or(suffix).trim(), + None => &upper, + } + .parse::(); + match number { + Ok(number) => Self::from_number(number), + Err(_) => Self::from_label(&upper), + } + } + + fn from_mark(mark: CodePageMark) -> Option { + // Keep encoding_rs for its supported pages: dbase's yore adapter is strict for + // those encodings, which can discard records containing malformed text. + let encoding = match mark { + CodePageMark::CP437 | CodePageMark::CP850 | CodePageMark::CP852 | CodePageMark::CP861 | CodePageMark::CP865 => { + return Some(Self::Oem(mark)); + } + CodePageMark::CP1250 => rs::WINDOWS_1250, + CodePageMark::CP1251 => rs::WINDOWS_1251, + CodePageMark::CP1252 => rs::WINDOWS_1252, + CodePageMark::CP1253 => rs::WINDOWS_1253, + CodePageMark::CP1254 => rs::WINDOWS_1254, + CodePageMark::CP1255 => rs::WINDOWS_1255, + CodePageMark::CP1256 => rs::WINDOWS_1256, + CodePageMark::CP866 => rs::IBM866, + CodePageMark::CP874 => rs::WINDOWS_874, + CodePageMark::CP932 => rs::SHIFT_JIS, + CodePageMark::CP936 => rs::GBK, + CodePageMark::CP949 => rs::EUC_KR, + CodePageMark::CP950 => rs::BIG5, + CodePageMark::Utf8 => rs::UTF_8, + CodePageMark::Undefined => return Some(Self::Utf8), + _ => return None, + }; + Some(Self::Standard(encoding)) + } +} + +pub(crate) fn encoding(cpg: Option<&[u8]>, ldid: u8, notes: &mut Vec) -> DbfEncoding { + if let Some(bytes) = cpg { + let label = if bytes.len() <= MAX_CPG_BYTES { std::str::from_utf8(bytes).unwrap_or("") } else { "" }; + let label = label.trim().trim_start_matches('\u{feff}').trim(); + if let Some(encoding) = DbfEncoding::from_name(label) { + return encoding; + } + notes.push("Shapefile: unsupported or empty .cpg; using the .dbf code page, or UTF-8 if unavailable".into()); + } + // Esri's LDID table assigns 0x57 to ANSI 1252 (ArcPad, "Setting the code page + // for shapefiles"); dbase does not recognize that widely used marker. + let mark = if ldid == 0x57 { CodePageMark::CP1252 } else { CodePageMark::from(ldid) }; + if let Some(encoding) = DbfEncoding::from_mark(mark) { + return encoding; + } + notes.push(format!("Shapefile: unsupported .dbf code page 0x{ldid:02X}; using UTF-8")); + DbfEncoding::Utf8 +} + +/// A code-page label should be a few bytes. Bound reads even for a hostile sidecar. +pub(crate) fn read_cpg(path: &std::path::Path, notes: &mut Vec) -> Option> { + match try_read_cpg(path) { + Ok(bytes) => bytes, + Err(e) => { + notes.push(format!("Shapefile: could not read .cpg ({e}); using the .dbf code page, or UTF-8 if unavailable")); + None + } + } +} + +fn try_read_cpg(path: &std::path::Path) -> std::io::Result>> { + use std::io::Read; + // Check every ASCII case combination without scanning a potentially large directory. + // Prefer the conventional spellings if multiple sidecars exist. + for ext in ["cpg", "CPG", "Cpg", "cPg", "cpG", "CPg", "CpG", "cPG"] { + match std::fs::File::open(path.with_extension(ext)) { + Ok(file) => { + let mut bytes = Vec::new(); + file.take(MAX_CPG_BYTES as u64 + 1).read_to_end(&mut bytes)?; + return Ok(Some(bytes)); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + Ok(None) +} diff --git a/crates/data/src/io.rs b/crates/data/src/io.rs index fe0ebf4dc..1939598f5 100644 --- a/crates/data/src/io.rs +++ b/crates/data/src/io.rs @@ -1314,22 +1314,25 @@ fn read_shapefile(path: &Path, name: &str) -> Result<(Dataset, ReadReport)> { return Err(DataError::Format(format!("Shapefile: {} is missing", dbf.display()))); } let shx = path.with_extension("shx"); + let mut notes = Vec::new(); let parts = ShapefileParts { shp: std::fs::read(path)?, shx: if shx.is_file() { Some(std::fs::read(shx)?) } else { None }, dbf: std::fs::read(dbf)?, prj: crate::raster::read_prj(path), + cpg: crate::dbf::read_cpg(path, &mut notes), }; - read_shapefile_parts(parts, name) + let (ds, mut report) = read_shapefile_parts(parts, name)?; + report.messages.extend(notes); + Ok((ds, report)) } -/// The files of a Shapefile besides its `.shp` that [`gather_shapefiles`] zips with it. The `.cpg` -/// isn't read (the `.dbf`'s text is decoded without it); it's taken along so it isn't refused on its own. +/// The files of a Shapefile besides its `.shp` that [`gather_shapefiles`] zips with it. const SHAPEFILE_SIDECARS: [&str; 4] = ["dbf", "shx", "prj", "cpg"]; /// Gathers each Shapefile among files given together (several picked at once in the browser, or /// dropped at once) into a zip that reads as a zipped Shapefile: a `.shp` with the `.dbf` of the -/// same base name, ignoring case, and its `.shx`, `.prj` and `.cpg` when they are there (the `.cpg` only so it goes with the rest). The zip, +/// same base name, ignoring case, and its `.shx`, `.prj` and `.cpg` when they are there. The zip, /// named after the `.shp`, takes its place; its parts go. Other files, and a `.shp` without its /// `.dbf`, pass through as they came. A Shapefile whose parts are too large to read together (more /// than [`MAX_BYTES`], or the zip reader's limit) is left out, and said why in the messages @@ -1403,7 +1406,7 @@ pub fn read_zip(file_name: &str, bytes: &[u8]) -> Result Result { read.0.source = Some(format!("{file_name}/{n}")); out.push(read); @@ -1460,8 +1464,11 @@ pub fn read_zip(file_name: &str, bytes: &[u8]) -> Result skipped.push(format!("{n} ({e})")), } } - let mut others: Vec<(String, Vec)> = - entries.into_iter().flatten().filter(|(n, _)| !matches!(ext_of(n).as_str(), "shp" | "shx" | "dbf" | "prj" | "mid")).collect(); + let mut others: Vec<(String, Vec)> = entries + .into_iter() + .flatten() + .filter(|(n, _)| !matches!(ext_of(n).as_str(), "shp" | "shx" | "dbf" | "prj" | "cpg" | "mid")) + .collect(); others.sort_by(|a, b| a.0.cmp(&b.0)); for (n, b) in others { if out.len() >= MAX_ZIP_LAYERS { @@ -1495,6 +1502,7 @@ struct ShapefileParts { shx: Option>, dbf: Vec, prj: Option, + cpg: Option>, } fn read_shapefile_parts(parts: ShapefileParts, name: &str) -> Result<(Dataset, ReadReport)> { @@ -1502,7 +1510,12 @@ fn read_shapefile_parts(parts: ShapefileParts, name: &str) -> Result<(Dataset, R use shapefile::dbase::FieldValue; use std::io::Cursor; let fmt = |e: shapefile::Error| DataError::Format(format!("Shapefile: {e}")); - let dbf = shapefile::dbase::Reader::new(Cursor::new(parts.dbf)).map_err(|e| DataError::Format(format!("Shapefile: the .dbf: {e}")))?; + let mut encoding_notes = Vec::new(); + let encoding = crate::dbf::encoding(parts.cpg.as_deref(), parts.dbf.get(29).copied().unwrap_or(0), &mut encoding_notes); + let dbf = shapefile::dbase::ReaderBuilder::new() + .with_encoding(encoding.decoder()) + .build(Cursor::new(parts.dbf)) + .map_err(|e| DataError::Format(format!("Shapefile: the .dbf: {e}")))?; // Field order comes from the .dbf header (records are unordered maps). let order: Vec<(String, FieldType)> = dbf .fields() @@ -1528,6 +1541,7 @@ fn read_shapefile_parts(parts: ShapefileParts, name: &str) -> Result<(Dataset, R let fields: Vec = order.iter().map(|(n, t)| Field::new(n.clone(), *t)).collect(); let mut ds = Dataset::new(name, GeometryType::None, fields); let mut report = ReadReport::default(); + report.messages.extend(encoding_notes); let to_lonlat = shapefile_crs(parts.prj.as_deref(), &mut report).map(|(crs, t)| { ds.crs = Some(crs); t diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 6f06c19d0..c22a63dae 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -4,6 +4,7 @@ //! bad features are skipped and counted in [`ReadReport`]. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod dbf; pub mod dirs; pub mod dxf; pub mod filegdb; diff --git a/crates/data/src/source/shp.rs b/crates/data/src/source/shp.rs index d673fc638..38fc830df 100644 --- a/crates/data/src/source/shp.rs +++ b/crates/data/src/source/shp.rs @@ -1,4 +1,4 @@ -//! Shapefiles read in place: a large `.shp` with its `.dbf` and `.prj`, a view at a time. +//! Shapefiles read in place: a large `.shp` with its `.dbf`, `.prj` and `.cpg`, a view at a time. //! //! Opening builds or reuses a disk-backed R-tree and record directory. Queries use a bounded //! SQLite page cache, then read matching shapes and `.dbf` rows by their offsets. @@ -52,6 +52,7 @@ struct Table { row_len: usize, rows: u64, columns: Vec, + ldid: u8, } /// A Shapefile read in place. @@ -61,6 +62,7 @@ pub struct ShpSource { dbf: PathBuf, index: index::Index, table: Table, + encoding: crate::dbf::DbfEncoding, reproject: Reproject, } @@ -68,7 +70,7 @@ pub struct ShpSource { const BUFFER: usize = 1 << 20; impl ShpSource { - /// Opens `path` (the `.shp`) with the `.dbf` and `.prj` beside it. + /// Opens `path` (the `.shp`) with the `.dbf` and optional `.prj` and `.cpg` beside it. pub fn open_path(path: &Path, name: &str) -> Result { let dbf = path.with_extension("dbf"); if !dbf.is_file() { @@ -76,6 +78,8 @@ impl ShpSource { } let mut notes = Vec::new(); let table = read_table(&dbf)?; + let cpg = crate::dbf::read_cpg(path, &mut notes); + let encoding = crate::dbf::encoding(cpg.as_deref(), table.ldid, &mut notes); let shp_len = std::fs::metadata(path)?.len(); if shp_len > u64::from(u32::MAX) { return Err(bad("the .shp is larger than the format's 4 GB")); @@ -112,7 +116,12 @@ impl ShpSource { { notes.push("coordinates are not longitude/latitude; RustGIS reads them as WGS 84 and they may draw out of place".into()); } - let fields = table.columns.iter().zip(read_names(&dbf, table.columns.len())?).map(|(c, n)| Field::new(n, c.out)).collect(); + let fields = table + .columns + .iter() + .zip(read_names(&dbf, table.columns.len(), &encoding.decoder())?) + .map(|(c, n)| Field::new(n, c.out)) + .collect(); let info = SourceInfo { name: name.to_string(), format: "Shapefile", @@ -124,7 +133,7 @@ impl ShpSource { indexed: index.indexed(), notes, }; - Ok(ShpSource { info, shp: path.to_path_buf(), dbf, index, table, reproject }) + Ok(ShpSource { info, shp: path.to_path_buf(), dbf, index, table, encoding, reproject }) } } @@ -134,6 +143,7 @@ impl FeatureSource for ShpSource { } fn read(&self, req: &Request) -> Result { + let encoding = self.encoding.decoder(); let limit = clamp_limit(req.limit); let extent = req.extent.map(|e| self.reproject.extent_from_lonlat(&e)); if extent.is_some_and(|e| e.is_empty()) { @@ -176,7 +186,7 @@ impl FeatureSource for ShpSource { if row.first() == Some(&b'*') { return Ok(()); } - self.table.columns.iter().map(|c| value(c, row.get(1 + c.offset..1 + c.offset + c.len).unwrap_or(&[]))).collect() + self.table.columns.iter().map(|c| value(c, row.get(1 + c.offset..1 + c.offset + c.len).unwrap_or(&[]), &encoding)).collect() } else { vec![Value::Null; self.table.columns.len()] }; @@ -189,6 +199,7 @@ impl FeatureSource for ShpSource { true } fn visit_chunks(&self, extent: Option, bytes: usize, visit: &mut dyn FnMut(Vec) -> Result<()>) -> Result { + let encoding = self.encoding.decoder(); if bytes == 0 { return Err(bad("the render memory budget is zero")); } @@ -239,7 +250,7 @@ impl FeatureSource for ShpSource { if row.first() == Some(&b'*') { return Ok(()); } - self.table.columns.iter().map(|c| value(c, row.get(1 + c.offset..1 + c.offset + c.len).unwrap_or(&[]))).collect() + self.table.columns.iter().map(|c| value(c, row.get(1 + c.offset..1 + c.offset + c.len).unwrap_or(&[]), &encoding)).collect() } else { vec![Value::Null; self.table.columns.len()] }; @@ -424,11 +435,11 @@ fn read_table(path: &Path) -> Result
{ if offset + 1 > row_len { return Err(bad("the .dbf's fields are wider than its rows")); } - Ok(Table { header_len, row_len, rows, columns }) + Ok(Table { header_len, row_len, rows, columns, ldid: head.get(29).copied().unwrap_or(0) }) } /// The `.dbf`'s column names, in order (read again: the layout above keeps only offsets). -fn read_names(path: &Path, n: usize) -> Result> { +fn read_names(path: &Path, n: usize, encoding: &dyn shapefile::dbase::Encoding) -> Result> { let mut f = BufReader::new(File::open(path)?); f.seek(SeekFrom::Start(32))?; let mut out = Vec::with_capacity(n); @@ -437,7 +448,8 @@ fn read_names(path: &Path, n: usize) -> Result> { f.read_exact(&mut d)?; let name = d.get(..11).unwrap_or(&[]); let end = name.iter().position(|b| *b == 0).unwrap_or(name.len()); - out.push(String::from_utf8_lossy(name.get(..end).unwrap_or(&[])).trim().to_string()); + let name = name.get(..end).unwrap_or(&[]); + out.push(encoding.decode(name).unwrap_or_else(|_| String::from_utf8_lossy(name)).trim().to_string()); } Ok(out) } @@ -451,9 +463,8 @@ fn trimmed(b: &[u8]) -> &[u8] { } /// One `.dbf` value, as a Shapefile read whole gives it (blank and unreadable values are null). -fn value(c: &Column, b: &[u8]) -> Value { - let text = - || std::str::from_utf8(trimmed(b)).ok().map(str::to_string).unwrap_or_else(|| String::from_utf8_lossy(trimmed(b)).into_owned()); +fn value(c: &Column, b: &[u8], encoding: &dyn shapefile::dbase::Encoding) -> Value { + let text = || encoding.decode(trimmed(b)).unwrap_or_else(|_| String::from_utf8_lossy(trimmed(b))).into_owned(); let number = || { let t = trimmed(b); if t.is_empty() || t.iter().all(|c| *c == b'*') { diff --git a/crates/data/tests/shapefile_encoding.rs b/crates/data/tests/shapefile_encoding.rs new file mode 100644 index 000000000..1d859044f --- /dev/null +++ b/crates/data/tests/shapefile_encoding.rs @@ -0,0 +1,285 @@ +//! Shapefile text is decoded at the same public boundaries used by desktop and browser imports. + +use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value, io}; + +/// A single point with one 32-byte character field. The bytes are supplied directly rather +/// than encoded by the decoder under test; byte 29 is the DBF language-driver identifier. +fn parts(text: &[u8], ldid: u8, cpg: Option<&[u8]>) -> Vec<(String, Vec)> { + let mut ds = Dataset::new("places", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + ds.push(Some(Geometry::Point(rustgis_data::Coord::new(1.0, 2.0))), vec![Value::Text("x".repeat(32))]); + io::shapefile_parts(&ds) + .unwrap() + .0 + .into_iter() + .filter_map(|(ext, mut bytes)| { + if ext == "cpg" { + return cpg.map(|value| ("places.cpg".into(), value.to_vec())); + } + if ext == "dbf" { + bytes[29] = ldid; + let start = usize::from(u16::from_le_bytes([bytes[8], bytes[9]])) + 1; + bytes[start..start + 32].fill(b' '); + bytes[start..start + text.len()].copy_from_slice(text); + } + Some((format!("places.{ext}"), bytes)) + }) + .collect() +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn loose_shapefile_honors_cpg_before_dbf_code_page() { + let dir = tempfile::tempdir().unwrap(); + // LDID 0xF0 claims UTF-8; the sidecar correctly identifies Windows-1252. + for (name, bytes) in parts(b"Montr\xe9al \x96 Qu\xe9bec", 0xf0, Some(b"1252")) { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + let (ds, _) = io::read_path(&dir.path().join("places.shp")).unwrap(); + assert_eq!(ds.value(0, 0), &Value::Text("Montréal – Québec".into())); +} + +#[test] +fn zipped_shapefile_honors_its_own_cpg_in_a_folder() { + let entries: Vec<_> = parts(b"Montr\xe9al \x96 Qu\xe9bec", 0xf0, Some("\u{feff} Windows-1252\r\n".as_bytes())) + .into_iter() + .map(|(name, bytes)| (format!("data/{}", name.to_ascii_uppercase()), bytes)) + .chain([("elsewhere/PLACES.CPG".into(), b"UTF-8".to_vec())]) + .collect(); + let zip = rustgis_data::zip::write(&entries).unwrap(); + let layers = io::read_zip("upload.zip", &zip).unwrap(); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].0.value(0, 0), &Value::Text("Montréal – Québec".into())); + assert!(layers[0].1.messages.is_empty(), "{:?}", layers[0].1.messages); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn streamed_shapefile_decodes_cpg_in_sampled_and_complete_reads() { + use rustgis_data::source::{FeatureSource, Request, shp::ShpSource}; + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in parts(b"Montr\xe9al \x96 Qu\xe9bec", 0xf0, Some(b"1252")) { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + let src = ShpSource::open_path(&dir.path().join("places.shp"), "places").unwrap(); + let expected = vec![Value::Text("Montréal – Québec".into())]; + assert_eq!(src.read(&Request::all()).unwrap().features[0].attrs, expected); + let mut attrs = Vec::new(); + src.visit_chunks(None, 1024, &mut |features| { + attrs.extend(features.into_iter().map(|f| f.attrs)); + Ok(()) + }) + .unwrap(); + assert_eq!(attrs, vec![expected]); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn shapefile_uses_dbf_code_page_when_cpg_is_absent() { + use rustgis_data::source::{FeatureSource, Request, shp::ShpSource}; + // Esri documents both 0x03 and 0x57 as Windows-1252; C9 is Windows-1251. + for (ldid, text, expected) in [ + (0x03, b"Montr\xe9al \x96 Qu\xe9bec".as_slice(), "Montréal – Québec"), + (0x57, b"Montr\xe9al \x96 Qu\xe9bec".as_slice(), "Montréal – Québec"), + (0xc9, b"\xcc\xee\xf1\xea\xe2\xe0".as_slice(), "Москва"), + ] { + let files = parts(text, ldid, None); + let zip = rustgis_data::zip::write(&files).unwrap(); + assert_eq!(io::read_zip("places.zip", &zip).unwrap()[0].0.value(0, 0), &Value::Text(expected.into()), "LDID {ldid:02X}"); + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in files { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + let path = dir.path().join("places.shp"); + assert_eq!(io::read_path(&path).unwrap().0.value(0, 0), &Value::Text(expected.into()), "LDID {ldid:02X}"); + let src = ShpSource::open_path(&path, "places").unwrap(); + assert_eq!(src.read(&Request::all()).unwrap().features[0].attrs, [Value::Text(expected.into())], "LDID {ldid:02X}"); + } +} + +#[test] +fn unknown_dbf_code_page_reports_utf8_fallback() { + let files = parts("Montréal".as_bytes(), 0xff, None); + let layers = io::read_zip("places.zip", &rustgis_data::zip::write(&files).unwrap()).unwrap(); + assert_eq!(layers[0].0.value(0, 0), &Value::Text("Montréal".into())); + assert!(layers[0].1.messages.iter().any(|m| m.contains("0xFF") && m.contains("UTF-8")), "{:?}", layers[0].1.messages); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn unusable_cpg_reports_fallback_to_dbf_in_all_readers() { + use rustgis_data::source::{FeatureSource, Request, shp::ShpSource}; + let mut oversized = b"1252".to_vec(); + oversized.resize(2048, b' '); + for cpg in [Vec::new(), b"not-an-encoding".to_vec(), b"-437".to_vec(), b"UTF-16LE".to_vec(), vec![0xff, 0xfe], oversized] { + let files = parts(b"Montr\xe9al", 0x03, Some(&cpg)); + let layers = io::read_zip("places.zip", &rustgis_data::zip::write(&files).unwrap()).unwrap(); + assert_eq!(layers[0].0.value(0, 0), &Value::Text("Montréal".into())); + assert!(layers[0].1.messages.iter().any(|m| m.contains(".cpg")), "{:?}", layers[0].1.messages); + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in files { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + let path = dir.path().join("places.shp"); + let (ds, report) = io::read_path(&path).unwrap(); + assert_eq!(ds.value(0, 0), &Value::Text("Montréal".into())); + assert!(report.messages.iter().any(|m| m.contains(".cpg")), "{:?}", report.messages); + let src = ShpSource::open_path(&path, "places").unwrap(); + assert_eq!(src.read(&Request::all()).unwrap().features[0].attrs, [Value::Text("Montréal".into())]); + assert!(src.info().notes.iter().any(|m| m.contains(".cpg")), "{:?}", src.info().notes); + } +} + +#[test] +fn utf8_shapefile_text_survives_missing_metadata_and_utf8_cpg() { + for cpg in [None, Some(b"UTF-8".as_slice()), Some(b"65001".as_slice())] { + let files = parts("München – 東京".as_bytes(), if cpg.is_some() { 0x03 } else { 0 }, cpg); + let layers = io::read_zip("places.zip", &rustgis_data::zip::write(&files).unwrap()).unwrap(); + assert_eq!(layers[0].0.value(0, 0), &Value::Text("München – 東京".into())); + assert!(layers[0].1.messages.is_empty(), "{:?}", layers[0].1.messages); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn native_imports_discover_every_cpg_extension_case() { + use rustgis_data::source::{FeatureSource, Request, shp::ShpSource}; + for ext in ["cpg", "CPG", "Cpg", "cPg", "cpG", "CPg", "CpG", "cPG"] { + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in parts(b"Montr\xe9al", 0xf0, Some(b"1252")) { + let name = if name.ends_with(".cpg") { format!("places.{ext}") } else { name }; + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + let path = dir.path().join("places.shp"); + let (ds, report) = io::read_path(&path).unwrap(); + assert_eq!(ds.value(0, 0), &Value::Text("Montréal".into()), "extension {ext}"); + assert!(report.messages.is_empty(), "{:?}", report.messages); + let src = ShpSource::open_path(&path, "places").unwrap(); + assert_eq!(src.read(&Request::all()).unwrap().features[0].attrs, [Value::Text("Montréal".into())], "extension {ext}"); + assert!(src.info().notes.is_empty(), "{:?}", src.info().notes); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn unreadable_cpg_reports_fallback_without_preventing_native_imports() { + use rustgis_data::source::{FeatureSource, Request, shp::ShpSource}; + for (ldid, text, expected) in [(0x03, b"Montr\xe9al".as_slice(), "Montréal"), (0, "München".as_bytes(), "München")] { + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in parts(text, ldid, None) { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + // A directory at the sidecar path deterministically causes an I/O failure + // on both Unix and Windows, without depending on the test user's privileges. + std::fs::create_dir(dir.path().join("places.cpg")).unwrap(); + let path = dir.path().join("places.shp"); + let (ds, report) = io::read_path(&path).unwrap(); + assert_eq!(ds.value(0, 0), &Value::Text(expected.into())); + assert!(report.messages.iter().any(|m| m.contains(".cpg") && m.contains("read")), "{:?}", report.messages); + let src = ShpSource::open_path(&path, "places").unwrap(); + assert_eq!(src.read(&Request::all()).unwrap().features[0].attrs, [Value::Text(expected.into())]); + let mut attrs = Vec::new(); + src.visit_chunks(None, 1024, &mut |features| { + attrs.extend(features.into_iter().map(|f| f.attrs)); + Ok(()) + }) + .unwrap(); + assert_eq!(attrs, vec![vec![Value::Text(expected.into())]]); + assert!(src.info().notes.iter().any(|m| m.contains(".cpg") && m.contains("read")), "{:?}", src.info().notes); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn shapefile_decodes_legacy_field_names_in_all_readers() { + use rustgis_data::source::{FeatureSource, shp::ShpSource}; + let mut files = parts(b"Montr\xe9al", 0x03, None); + let dbf = &mut files.iter_mut().find(|(n, _)| n.ends_with(".dbf")).unwrap().1; + dbf[32..43].fill(0); + dbf[32..36].copy_from_slice(b"caf\xe9"); + let layers = io::read_zip("places.zip", &rustgis_data::zip::write(&files).unwrap()).unwrap(); + assert_eq!(layers[0].0.fields[0].name, "café"); + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in files { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + let path = dir.path().join("places.shp"); + assert_eq!(io::read_path(&path).unwrap().0.fields[0].name, "café"); + assert_eq!(ShpSource::open_path(&path, "places").unwrap().info().fields[0].name, "café"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dos_cpg_decodes_attributes_and_field_names_in_all_readers() { + use rustgis_data::source::{FeatureSource, Request, shp::ShpSource}; + // The same E0 byte is Greek alpha in CP437 and capital O acute in CP850. + for (cpg, expected) in [("437", "Café α"), ("CP437", "Café α"), ("850", "Café Ó"), ("IBM850", "Café Ó")] { + let mut files = parts(b"Caf\x82 \xe0", 0xf0, Some(cpg.as_bytes())); + let dbf = &mut files.iter_mut().find(|(n, _)| n.ends_with(".dbf")).unwrap().1; + dbf[32..43].fill(0); + dbf[32..36].copy_from_slice(b"caf\x82"); + let layers = io::read_zip("places.zip", &rustgis_data::zip::write(&files).unwrap()).unwrap(); + assert_eq!(layers[0].0.fields[0].name, "café", "CPG {cpg}"); + assert_eq!(layers[0].0.value(0, 0), &Value::Text(expected.into()), "CPG {cpg}"); + assert!(layers[0].1.messages.is_empty(), "{:?}", layers[0].1.messages); + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in files { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + let path = dir.path().join("places.shp"); + let (ds, report) = io::read_path(&path).unwrap(); + assert_eq!(ds.fields[0].name, "café"); + assert_eq!(ds.value(0, 0), &Value::Text(expected.into())); + assert!(report.messages.is_empty(), "{:?}", report.messages); + let src = ShpSource::open_path(&path, "places").unwrap(); + assert_eq!(src.info().fields[0].name, "café"); + assert_eq!(src.read(&Request::all()).unwrap().features[0].attrs, [Value::Text(expected.into())]); + let mut attrs = Vec::new(); + src.visit_chunks(None, 1024, &mut |features| { + attrs.extend(features.into_iter().map(|f| f.attrs)); + Ok(()) + }) + .unwrap(); + assert_eq!(attrs, vec![vec![Value::Text(expected.into())]]); + assert!(src.info().notes.is_empty(), "{:?}", src.info().notes); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn dos_dbf_markers_decode_text_without_cpg_in_all_readers() { + use rustgis_data::source::{FeatureSource, Request, shp::ShpSource}; + for (ldid, expected) in [(0x01, "Café α"), (0x02, "Café Ó"), (0x09, "Café α"), (0x0a, "Café Ó")] { + let files = parts(b"Caf\x82 \xe0", ldid, None); + let layers = io::read_zip("places.zip", &rustgis_data::zip::write(&files).unwrap()).unwrap(); + assert_eq!(layers[0].0.value(0, 0), &Value::Text(expected.into()), "LDID {ldid:02X}"); + assert!(layers[0].1.messages.is_empty(), "{:?}", layers[0].1.messages); + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in files { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + let path = dir.path().join("places.shp"); + let (ds, report) = io::read_path(&path).unwrap(); + assert_eq!(ds.value(0, 0), &Value::Text(expected.into())); + assert!(report.messages.is_empty(), "{:?}", report.messages); + let src = ShpSource::open_path(&path, "places").unwrap(); + assert_eq!(src.read(&Request::all()).unwrap().features[0].attrs, [Value::Text(expected.into())]); + assert!(src.info().notes.is_empty(), "{:?}", src.info().notes); + } +} + +#[test] +fn dos_support_preserves_other_encodings_and_lossy_utf8_fallback() { + for (cpg, ldid, text, expected) in [ + (Some(b"ISO-8859-2".as_slice()), 0xf0, b"\xa1".as_slice(), "Ą"), + (Some(b"CP932".as_slice()), 0xf0, b"\x93\x8c\x8b\x9e".as_slice(), "東京"), + (Some(b"UTF-8".as_slice()), 0xf0, b"Caf\xff".as_slice(), "Caf�"), + // With no encoding metadata, preserve the old UTF-8 fallback's treatment of + // field content: a leading U+FEFF is data, and invalid bytes are replaced. + (None, 0, b"\xef\xbb\xbfCaf\xff".as_slice(), "\u{feff}Caf�"), + ] { + let files = parts(text, ldid, cpg); + let layers = io::read_zip("places.zip", &rustgis_data::zip::write(&files).unwrap()).unwrap(); + assert_eq!(layers[0].0.value(0, 0), &Value::Text(expected.into())); + assert!(layers[0].1.messages.is_empty(), "{:?}", layers[0].1.messages); + } +} diff --git a/docs/user-guide/data-formats.md b/docs/user-guide/data-formats.md index a285daa80..077e13961 100644 --- a/docs/user-guide/data-formats.md +++ b/docs/user-guide/data-formats.md @@ -34,6 +34,16 @@ web app reads GeoPackages without SQLite, with a small SQLite file reader writte | Esri File Geodatabase | `.gdb` folder (desktop) | A file dialog can't pick a folder: in Add Data pick any `.gdbtable` file inside it; or double-click the `.gdb` in the Browser, or drop the folder on the window | Read without GDAL: every feature class becomes a layer and every table a standalone table, with their fields (dates as text) and coordinate system (by its code, EPSG or Esri; else its WKT). `"layer"` picks one, and `info.fileLayers` lists them. 3D multipatches (such as swissBUILDINGS3D's buildings) are drawn as their footprints, the union of their faces seen from above, with their lowest and highest Z in `z_min` and `z_max` and the difference in `height`, which an extrusion (Style › Extrusion) can stand them up by; one with no face seen from above (only walls) is drawn as the outline of its points. Curves are drawn as straight segments between their ends. Compressed tables, raster fields and some newer row indexes aren't read: with GDAL installed, such a geodatabase opens through GDAL instead (see below). Loaded whole, up to 2 GB a table. | | Standalone tables | CSV, or any layer without geometry | Add Data | Listed under the map in Layers; join or relate them to layers. | +Shapefile text uses the encoding in its `.cpg` sidecar first, then the language-driver code in +the `.dbf` header. Include the `.cpg` when picking or dropping loose files in the web app. +UTF-8 (including `65001`), DOS code pages such as CP437 and CP850 (numeric, `CP` or `IBM` +labels), Windows code pages such as `1252`, `CP1252` and `Windows-1252`, ISO-8859 encodings +and common East Asian encodings are supported. The Esri `0x57` DBF marker +is read as Windows-1252. An unreadable, empty, invalid, oversized or unsupported `.cpg` gives a notice and +falls back to the DBF code page; an unsupported DBF code page gives a notice and falls back to +UTF-8. Files without encoding metadata keep the UTF-8 default. Unsupported characters become +replacement characters. This applies to zipped files and large files read in place too. + Files with more than 100,000 features (GeoPackage, GeoParquet, FlatGeobuf, and Shapefile on the desktop) are read in place, a view at a time, rather than loaded whole: see [Large files](data.md#large-files). They draw, label, From 76d48b1cc1c9671e103487802253d94a91c6964a Mon Sep 17 00:00:00 2001 From: liyongyin Date: Fri, 23 Oct 2026 12:15:14 +0800 Subject: [PATCH 045/297] perf(render): reuse adjacent segment normals in line offsets (#716) * perf(render): reuse adjacent segment normals in line offsets * docs(render): clarify frozen normal comparison reference --------- Co-authored-by: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> --- crates/render/src/lib.rs | 89 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 84 insertions(+), 5 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 13390a8a5..8f98fde5e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -803,12 +803,14 @@ fn offset_polyline(pts: &[(f32, f32)], d: f32) -> Vec<(f32, f32)> { (-dy / l, dx / l) }; let mut out = Vec::with_capacity(p.len() + 4); - let (Some(&[a, b]), Some(&[y, z])) = (p.first_chunk::<2>(), p.last_chunk::<2>()) else { return out }; - let n = normal(a, b); + let (Some(&[a, b]), Some(&z)) = (p.first_chunk::<2>(), p.last()) else { return out }; + // Carry the previous segment normal forward; each segment is evaluated once. + let mut n = normal(a, b); out.push((a.0 + n.0 * d, a.1 + n.1 * d)); for w in p.windows(3) { - let [a, v, b] = [w.first(), w.get(1), w.get(2)].map(|x| x.copied().unwrap_or_default()); - let (na, nb) = (normal(a, v), normal(v, b)); + let [v, b] = [w.get(1), w.get(2)].map(|x| x.copied().unwrap_or_default()); + let (na, nb) = (n, normal(v, b)); + n = nb; let (sx, sy) = (na.0 + nb.0, na.1 + nb.1); let s2 = sx * sx + sy * sy; if s2 >= 0.25 { @@ -820,7 +822,6 @@ fn offset_polyline(pts: &[(f32, f32)], d: f32) -> Vec<(f32, f32)> { out.push((v.0 + nb.0 * d, v.1 + nb.1 * d)); } } - let n = normal(y, z); out.push((z.0 + n.0 * d, z.1 + n.1 * d)); out } @@ -5347,3 +5348,81 @@ mod stream_tests; #[cfg(test)] mod budget_tests; + +#[cfg(test)] +mod offset_normal_tests { + use super::offset_polyline; + + /// Reusing a normal must preserve the previous algorithm's exact output, including signed zero. + #[test] + fn reused_normals_match_reference_bits() { + let mut cases = vec![ + vec![], + vec![(1.0, 2.0)], + vec![(0.0, 0.0), (0.0, 0.0)], + vec![(-0.0, 0.0), (10.0, -0.0)], + vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 0.0)], + vec![(0.0, 0.0), (0.0, 0.0), (0.00001, 0.0), (3.0, 4.0)], + vec![(0.0, 0.0), (0.001, 0.0), (0.00101, 0.0), (3.0, 4.0)], + vec![(0.0, 0.0), (10.0, 0.0), (0.01, 0.01), (-5.0, 8.0)], + vec![(f32::NAN, 0.0), (0.0, 0.0), (3.0, 4.0), (f32::INFINITY, 0.0)], + vec![(40_500_000.0, 3_500_000.0), (40_500_020.0, 3_500_040.0)], + vec![(-f32::MAX, 0.0), (f32::MAX, 1.0), (0.0, -f32::MAX)], + ]; + for variant in 0..32 { + let path: Vec<_> = (0..80) + .map(|i| { + let x = (i as f32 * 0.37 + variant as f32).sin() * 100.0; + let y = (i as f32 * 0.13 - variant as f32).cos() * 100.0; + (x, y) + }) + .collect(); + cases.push(path.iter().rev().copied().collect()); + cases.push(path); + } + for (case, points) in cases.iter().enumerate() { + for d in [-12.0, -0.0, 0.0, 0.25, 9.0] { + let bits = |p: Vec<(f32, f32)>| p.into_iter().map(|(x, y)| (x.to_bits(), y.to_bits())).collect::>(); + assert_eq!(bits(offset_polyline(points, d)), bits(reference_offset_polyline(points, d)), "case={case} offset={d}"); + } + } + println!("normal cases: {} paths x 5 offsets", cases.len()); + } + + /// Frozen pre-optimization oracle; keep independent of the optimized implementation. + fn reference_offset_polyline(pts: &[(f32, f32)], d: f32) -> Vec<(f32, f32)> { + let mut p: Vec<(f32, f32)> = Vec::with_capacity(pts.len()); + for &q in pts.iter().filter(|q| q.0.is_finite() && q.1.is_finite()) { + if p.last().is_none_or(|l: &(f32, f32)| (l.0 - q.0).hypot(l.1 - q.1) > 1e-3) { + p.push(q); + } + } + // The unit normal to the right of a → b. + let normal = |a: (f32, f32), b: (f32, f32)| { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let l = dx.hypot(dy); + (-dy / l, dx / l) + }; + let mut out = Vec::with_capacity(p.len() + 4); + let (Some(&[a, b]), Some(&[y, z])) = (p.first_chunk::<2>(), p.last_chunk::<2>()) else { return out }; + let n = normal(a, b); + out.push((a.0 + n.0 * d, a.1 + n.1 * d)); + for w in p.windows(3) { + let [a, v, b] = [w.first(), w.get(1), w.get(2)].map(|x| x.copied().unwrap_or_default()); + let (na, nb) = (normal(a, v), normal(v, b)); + let (sx, sy) = (na.0 + nb.0, na.1 + nb.1); + let s2 = sx * sx + sy * sy; + if s2 >= 0.25 { + // The mitre: along the bisector, 2d / |na + nb|² times na + nb. + let k = 2.0 * d / s2; + out.push((v.0 + sx * k, v.1 + sy * k)); + } else { + out.push((v.0 + na.0 * d, v.1 + na.1 * d)); + out.push((v.0 + nb.0 * d, v.1 + nb.1 * d)); + } + } + let n = normal(y, z); + out.push((z.0 + n.0 * d, z.1 + n.1 * d)); + out + } +} From edcdd3e14c663a52e44e057cb75aa7ddbd8fe4c0 Mon Sep 17 00:00:00 2001 From: liyongyin Date: Fri, 23 Oct 2026 12:15:48 +0800 Subject: [PATCH 046/297] perf(data): evaluate polygon label grid with scanline masks (#717) * perf(data): evaluate polygon label grid with scanline masks * refactor(data): bind label scanline masks to a checked grid size --------- Co-authored-by: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> --- crates/data/src/geometry.rs | 237 +++++++++++++++++++++++++++++++++++- 1 file changed, 234 insertions(+), 3 deletions(-) diff --git a/crates/data/src/geometry.rs b/crates/data/src/geometry.rs index bf6695819..cd7488166 100644 --- a/crates/data/src/geometry.rs +++ b/crates/data/src/geometry.rs @@ -3,6 +3,11 @@ use rustgis_geom::{Coord, Extent}; use serde::{Deserialize, Serialize}; +/// Subdivisions of the label fallback grid; both endpoints are sampled. +const LABEL_GRID_STEPS: usize = 64; +// Prefix construction can shift by steps + 1, which must also fit in u128. +const _: () = assert!(LABEL_GRID_STEPS > 0 && LABEL_GRID_STEPS < (u128::BITS - 1) as usize); + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum GeometryType { Point, @@ -151,15 +156,20 @@ impl Geometry { outer.iter().for_each(|c| e.expand(*c)); e }; - let steps = 64; + let steps = LABEL_GRID_STEPS; let mut best: Option<(f64, Coord)> = None; for i in 1..steps { let y = e.min_y + e.height() * i as f64 / steps as f64; + // Evaluate the same 65 grid points together. Each crossing toggles a + // prefix of x samples, replacing 65 repeated edge walks per scanline. + let mut inside = rings.first().map_or(0, |ring| label_grid_ring(ring, y, &e)); + for hole in rings.iter().skip(1) { + inside &= !label_grid_ring(hole, y, &e); + } let mut run_start: Option = None; for j in 0..=steps { let x = e.min_x + e.width() * j as f64 / steps as f64; - let inside = polygon_contains(rings, Coord::new(x, y)); - match (inside, run_start) { + match (inside & (1u128 << j) != 0, run_start) { (true, None) => run_start = Some(x), (false, Some(s)) => { let w = x - s; @@ -178,6 +188,34 @@ impl Geometry { } } +/// Exact even-odd results for the label algorithm's existing 0..=64 x samples. +/// Search uses the original floating-point expression and strict comparison, preserving boundaries. +fn label_grid_ring(ring: &[Coord], y: f64, extent: &Extent) -> u128 { + if ring.len() < 3 { + return 0; + } + let mut mask = 0u128; + let Some(mut previous) = ring.last() else { return 0 }; + for point in ring { + if (point.y > y) != (previous.y > y) { + let crossing = (previous.x - point.x) * (y - point.y) / (previous.y - point.y) + point.x; + let (mut lo, mut hi) = (0usize, LABEL_GRID_STEPS + 1); + while lo < hi { + let mid = lo + (hi - lo) / 2; + let x = extent.min_x + extent.width() * mid as f64 / LABEL_GRID_STEPS as f64; + if x < crossing { + lo = mid + 1; + } else { + hi = mid; + } + } + mask ^= (1u128 << lo) - 1; + } + previous = point; + } + mask +} + /// One vertex of a geometry: its part (a line's part, a polygon's polygon; 0 for points), its /// ring (a polygon's ring; 0 otherwise) and its place along the part or ring (a multipoint's /// points are one part). A closed ring's repeated last point is not a vertex of its own: it @@ -554,3 +592,196 @@ mod tests { assert!(Geometry::Line(vec![vec![]]).label_point().is_none()); } } + +#[cfg(test)] +mod label_scanline_tests { + use super::*; + + /// Compare every grid sample against the existing even-odd point-in-polygon predicate. + #[test] + fn label_scanlines_match_every_legacy_grid_test_including_holes_and_large_origins() { + for (offset, scale) in [(0.0, 1.0), (41_000_000.0, 1.0), (41_000_000.0, 1e-4), (-80.0, 10000.0)] { + for variant in 0..24 { + let mut outer = Vec::new(); + for i in 0..19 { + let a = i as f64 * std::f64::consts::TAU / 19.0; + let r = if (i + variant) % 3 == 0 { 0.23 } else { 1.0 }; + outer.push(Coord::new(offset + a.cos() * r * scale, offset + a.sin() * r * scale)); + } + if variant % 2 == 0 { + outer.push(outer[0]); + } + let hole = |x: f64| { + vec![ + Coord::new(offset + x * scale, offset - 0.2 * scale), + Coord::new(offset + (x + 0.3) * scale, offset - 0.2 * scale), + Coord::new(offset + (x + 0.3) * scale, offset + 0.3 * scale), + Coord::new(offset + x * scale, offset + 0.3 * scale), + ] + }; + let rings = vec![outer, hole(-0.2), hole(0.0)]; + let mut extent = Extent::EMPTY; + for p in &rings[0] { + extent.expand(*p); + } + for i in 1..64 { + let y = extent.min_y + extent.height() * i as f64 / 64.0; + let mut bits = label_grid_ring(&rings[0], y, &extent); + for ring in rings.iter().skip(1) { + bits &= !label_grid_ring(ring, y, &extent); + } + for j in 0..=64 { + let x = extent.min_x + extent.width() * j as f64 / 64.0; + assert_eq!( + bits & (1u128 << j) != 0, + polygon_contains(&rings, Coord::new(x, y)), + "origin={offset} scale={scale} variant={variant} y={i} x={j}" + ); + } + } + } + } + assert_eq!(label_grid_ring(&[], 0.0, &Extent::EMPTY), 0); + } + + /// Frozen 64-step oracle; keep its constants independent of the optimized implementation. + fn reference_label_point(geometry: &Geometry) -> Option { + match geometry { + Geometry::Point(c) => Some(*c), + Geometry::MultiPoint(v) => v.first().copied(), + Geometry::Line(parts) => { + let part = parts.iter().max_by_key(|p| p.len())?; + part.get(part.len() / 2).copied() + } + Geometry::Polygon(polys) => { + let rings = polys.iter().max_by(|a, b| { + let aa = a.first().map(|r| ring_area(r).abs()).unwrap_or(0.0); + let bb = b.first().map(|r| ring_area(r).abs()).unwrap_or(0.0); + aa.partial_cmp(&bb).unwrap_or(std::cmp::Ordering::Equal) + })?; + let outer = rings.first()?; + let c = ring_centroid(outer)?; + if polygon_contains(rings, c) { + return Some(c); + } + // Scan a horizontal line through the centroid for the widest inside span. + let e = { + let mut e = Extent::EMPTY; + outer.iter().for_each(|c| e.expand(*c)); + e + }; + let steps = 64; + let mut best: Option<(f64, Coord)> = None; + for i in 1..steps { + let y = e.min_y + e.height() * i as f64 / steps as f64; + let mut run_start: Option = None; + for j in 0..=steps { + let x = e.min_x + e.width() * j as f64 / steps as f64; + let inside = polygon_contains(rings, Coord::new(x, y)); + match (inside, run_start) { + (true, None) => run_start = Some(x), + (false, Some(s)) => { + let w = x - s; + if best.is_none_or(|(bw, _)| w > bw) { + best = Some((w, Coord::new((s + x) / 2.0, y))); + } + run_start = None; + } + _ => {} + } + } + } + best.map(|(_, c)| c).or(Some(c)) + } + } + } + + /// Compare the full public label result, not just the scanline's membership bits. + #[test] + fn label_points_match_reference_bits() { + let ring = |v: &[(f64, f64)]| v.iter().map(|&(x, y)| Coord::new(x, y)).collect::>(); + let square = ring(&[(-4.0, -4.0), (4.0, -4.0), (4.0, 4.0), (-4.0, 4.0)]); + let hole = ring(&[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]); + let concave = ring(&[(0.0, 0.0), (8.0, 0.0), (8.0, 8.0), (6.0, 8.0), (6.0, 2.0), (2.0, 2.0), (2.0, 8.0), (0.0, 8.0)]); + assert!(!polygon_contains(std::slice::from_ref(&concave), ring_centroid(&concave).unwrap())); + assert!(!polygon_contains(&[square.clone(), hole.clone()], ring_centroid(&square).unwrap())); + let mut cases = vec![ + Geometry::Point(Coord::new(-0.0, 0.0)), + Geometry::MultiPoint(vec![]), + Geometry::MultiPoint(vec![Coord::new(1.0, 2.0)]), + Geometry::Line(vec![]), + Geometry::Line(vec![vec![], ring(&[(0.0, 0.0), (1.0, 2.0), (3.0, 4.0)])]), + Geometry::Polygon(vec![]), + Geometry::Polygon(vec![vec![]]), + Geometry::Polygon(vec![vec![vec![]]]), + ]; + let shifted: Vec<_> = square.iter().map(|c| Coord::new(c.x + 20.0, c.y)).collect(); + cases.push(Geometry::Polygon(vec![vec![square.clone(), hole.clone()], vec![shifted.clone()]])); + cases.push(Geometry::Polygon(vec![vec![shifted], vec![square.clone(), hole.clone()]])); + let fixtures = vec![ + vec![square.clone()], + vec![concave], + vec![square.clone(), hole.clone()], + vec![square.clone(), hole.clone(), hole.clone()], + vec![square.clone(), square.clone()], // No inside samples: retain the centroid fallback. + vec![ring(&[(0.0, 0.0)])], + vec![ring(&[(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)])], + vec![ring(&[(-0.0, 0.0), (0.0, -0.0), (0.0, 0.0)])], + ]; + for (offset, scale) in [(0.0, 1.0), (41_000_000.0, 1.0), (41_000_000.0, 1e-4), (-80.0, 10000.0)] { + for fixture in &fixtures { + for reversed in [false, true] { + for closed in [false, true] { + let rings: Vec> = fixture + .iter() + .map(|r| { + let mut r: Vec<_> = r.iter().map(|c| Coord::new(offset + c.x * scale, offset + c.y * scale)).collect(); + if reversed { + r.reverse(); + } + if closed && let Some(&first) = r.first() { + r.push(first); + } + r + }) + .collect(); + cases.push(Geometry::Polygon(vec![rings.clone()])); + // Equal-area polygons retain the legacy selection order; a smaller polygon precedes them. + cases.push(Geometry::Polygon(vec![vec![hole.clone()], rings.clone(), rings])); + } + } + } + } + for variant in 0..24 { + let outer: Vec<_> = (0..19) + .map(|i| { + let a = i as f64 * std::f64::consts::TAU / 19.0; + let r = if (i + variant) % 3 == 0 { 0.23 } else { 1.0 }; + Coord::new(a.cos() * r, a.sin() * r) + }) + .collect(); + cases.push(Geometry::Polygon(vec![vec![outer, hole.iter().map(|c| Coord::new(c.x * 0.2, c.y * 0.2)).collect()]])); + } + let mut fast = 0; + let mut fallback = 0; + for (case, geometry) in cases.iter().enumerate() { + if let Geometry::Polygon(polys) = geometry + && let Some(rings) = polys.iter().max_by(|a, b| { + let area = |r: &[Vec]| r.first().map_or(0.0, |r| ring_area(r).abs()); + area(a).partial_cmp(&area(b)).unwrap_or(std::cmp::Ordering::Equal) + }) + && let Some(c) = rings.first().and_then(|r| ring_centroid(r)) + { + if polygon_contains(rings, c) { + fast += 1; + } else { + fallback += 1; + } + } + let bits = |c: Option| c.map(|c| (c.x.to_bits(), c.y.to_bits())); + assert_eq!(bits(geometry.label_point()), bits(reference_label_point(geometry)), "case={case}"); + } + assert!(fast > 0 && fallback > 0, "both centroid and scanline paths must execute"); + println!("label cases: {}, centroid={fast}, scanline={fallback}", cases.len()); + } +} From a6a4598ed202ab551a00dc961b873cd53e4dc24f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Thu, 22 Oct 2026 23:16:26 -0500 Subject: [PATCH 047/297] feat(ui): offer second-level keys and a task step's command in the Script pane (#720) * feat(ui): offer second-level keys and a task step's command in the Script pane * feat(ui): close brackets in the Script pane as braces are Also updates a key_at test to the second-level rule from the previous commit. * fix(ui): read a Script key's given keys up to the end of its object * refactor(ui): name once which value a Script line's params follow * fix(ui): sort the commands a task step's command offers * fix(ui): offer only the commands task.addStep takes for a step's command * docs(engine): keep read_text's doc comment above it --- crates/engine/src/cmd/items.rs | 8 +- crates/ui-egui/src/scriptcomplete.rs | 591 +++++++++++++++++++-------- docs/user-guide/analysis.md | 16 +- 3 files changed, 446 insertions(+), 169 deletions(-) diff --git a/crates/engine/src/cmd/items.rs b/crates/engine/src/cmd/items.rs index 51d1f20a9..5645a692c 100644 --- a/crates/engine/src/cmd/items.rs +++ b/crates/engine/src/cmd/items.rs @@ -235,7 +235,7 @@ pub fn commands() -> Vec { .params(r#"{"name"?: string, "description"?: string, "steps"?: [{"title", "instructions"?, "command": id, "params"?: {}}], "fromHistory"?: bool (one step per geoprocessing run), "imported"?: bool (from a file: its steps may not touch files)}"#), C::new("task.addStep", "Add Task Step", "Tasks", |s, p| { let step: TaskStep = serde_json::from_value(p.clone()).map_err(|e| format!("a step needs a title and a command: {e}"))?; - if s.registry.get(&step.command).is_none() || step.command.starts_with("task.") { + if !can_be_step(s, &step.command) { return Err(format!("'{}' can't be a task step", step.command)); } let t = task_mut(s, p)?; @@ -335,6 +335,12 @@ fn to_json(v: &T) -> Result, String> { serde_json::to_vec(v).map_err(|e| e.to_string()) } +/// Whether command `id` can be added as a task's step (`task.addStep`): a built-in command, and +/// not a task's own (a step doesn't run a task). +pub fn can_be_step(s: &Session, id: &str) -> bool { + s.registry.get(id).is_some() && !id.starts_with("task.") +} + /// The JSON of an item from `text`, or the file at `path`. pub(crate) fn read_text(p: &Value) -> Result { if let Some(d) = ps(p, "data") { diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 56106f62c..76a721c32 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -2,9 +2,10 @@ //! as in a code editor. ↑/↓ move the highlight, Enter or Tab accept it, Escape closes the list, or //! a click accepts an item. What to offer at a cursor (`offer_at`) is worked out apart from the //! box and tested on its own: command ids (a line's first word), a command's parameter keys in -//! its `{…}` (`key_at`), and in a `gp.run` line the tool ids for its `"tool"` (`value_at`) and, -//! in its `"params": {…}`, the keys of the tool it runs (`tool_of`). Typing aids beside it: a command's parameters added on accept -//! (`template`), and braces that close themselves (`brace`). +//! its `{…}` and in its second-level objects (`key_at`), and the ids some values take: tools for +//! a `gp.run` line's `"tool"`, commands for a `task.addStep` line's `"command"` (`value_at`), +//! whose own keys then go in its `"params": {…}`. Typing aids beside it: a command's parameters +//! added on accept (`template`), and braces and brackets that close themselves (`brace`). use egui::text::{CCursor, CCursorRange}; use egui::widgets::text_edit::{TextEditOutput, TextEditState}; @@ -28,14 +29,18 @@ pub struct Offer { pub of: Option, } -/// What an offer holds besides command ids: a command's parameter keys (by its id), a -/// geoprocessing tool's (by its id) in a `gp.run` line's `"params"`, or the tool ids for a -/// `gp.run` line's `"tool"`. +/// What an offer holds besides the command ids that start a line: a command's parameter keys (by +/// its id), the keys of the second-level object of a command's entry `key`, a geoprocessing +/// tool's parameter keys (by its id) in a `gp.run` line's `"params"`, or the ids a value takes: +/// the tools for a `gp.run` line's `"tool"`, the commands for a `task.addStep` line's +/// `"command"`. #[derive(Clone, Debug, PartialEq)] pub enum Of { CommandKeys(String), + SubKeys { id: String, key: String }, ToolKeys(String), ToolIds, + CommandIds, } /// The ids that complete the command id being typed at char `at` of `script`: the cursor at the @@ -105,8 +110,8 @@ pub fn hint_at(script: &str, at: usize) -> Option { /// Where a popup of `size` (last frame's) goes by `cursor`: under it if it fits, else above if it /// fits, else on the side with more room, a line and a half clear of the cursor's line (the line -/// next to it stays in view whole, with room around it); kept on screen sideways. Its corner, and the height its content may take (it -/// scrolls beyond: a short window, a long help). +/// next to it stays in view whole, with room around it); kept on screen sideways. Its corner, +/// and the height its content may take (it scrolls beyond: a short window, a long help). fn place(cursor: egui::Rect, size: egui::Vec2, screen: egui::Rect, min: f32) -> (egui::Pos2, f32) { let cursor = cursor.expand2(egui::vec2(0.0, cursor.height() * 1.5)); let (below, above) = (screen.bottom() - cursor.bottom(), cursor.top() - screen.top()); @@ -140,38 +145,48 @@ fn popup(ui: &Ui, id: Id, corner: egui::Pos2, room: f32, content: impl FnOnce(&m /// The ids in `ids` (the commands a script can run, `script_ids`) that complete `word`, typed /// from char `start` to `end`, case aside; the id typed whole isn't offered again. fn complete(start: usize, end: usize, word: &str, ids: &[String]) -> Option { - let mut items: Vec = ids - .iter() - .filter(|id| id.get(..word.len()).is_some_and(|p| p.eq_ignore_ascii_case(word)) && !id.eq_ignore_ascii_case(word)) - .cloned() - .collect(); + let mut items: Vec = ids.iter().filter(|id| starts_with_ci(id, word) && !id.eq_ignore_ascii_case(word)).cloned().collect(); items.sort(); items.truncate(MAX_ITEMS); (!items.is_empty()).then_some(Offer { start, end, items, of: None }) } +/// Whether `text` starts with `word`, case aside (ASCII: ids and keys). +fn starts_with_ci(text: &str, word: &str) -> bool { + text.get(..word.len()).is_some_and(|p| p.eq_ignore_ascii_case(word)) +} + /// Where a line's object leaves off at its end (`text`, from its `{`): where a quoted key being /// typed starts (after its quote, a char index of `text`; None when the end isn't in one), the -/// `{` of the top-level `"params"` object the end is in (None outside it), where a top-level -/// quoted value being typed starts, with its key, and the top-level quoted values read whole, -/// each with its key, in order. +/// second-level object the end is in (`nested`), where a top-level quoted value being typed +/// starts, with its key, and the top-level quoted values read whole, each with its key, in order. struct Scan { start: Option, - params: Option, + nested: Option, value: Option<(usize, String)>, values: Vec<(String, String)>, } +/// A second-level object: the value of a top-level key (`"add": {…}`), or one of the objects in +/// its array (`"filters": [{…}, {…}]`). Its `{` (a char index) and that key. +#[derive(Clone, Debug, PartialEq)] +pub struct Nested { + pub brace: usize, + pub key: String, +} + /// Reads `text`, a line's object from its `{`, to its end: a key starts an entry right after a -/// `{` or a `,`, at the top level or at the top of its `"params": {…}`; not in a value, nor in -/// any other nested object. A top-level value follows its key and colon. +/// `{` or a `,`, at the top level or at the top of a second-level object; not in a value, nor +/// deeper. A top-level value follows its key and colon. fn scan(text: &str) -> Scan { let chars: Vec = text.chars().collect(); let (mut depth, mut quoted, mut escaped, mut entry, mut start) = (0usize, false, false, false, None); // The top-level key just read, the one whose colon was just read (its value comes next), - // where the key being read starts, the `{` of the `"params"` object the text is in, and the - // top-level value being read. - let (mut named, mut valued, mut from, mut params, mut value) = (None::, None::, None, None, None); + // where the key being read starts, and the top-level value being read. + let (mut named, mut valued, mut from, mut value) = (None::, None::, None, None); + // The key whose array the text is in (an array of objects), and the second-level object the + // text is in, with its depth: anywhere in it, deeper levels included. + let (mut array, mut nested) = (None::, None::<(Nested, usize)>); let mut values = Vec::new(); for (i, &c) in chars.iter().enumerate() { let was_escaped = escaped; @@ -186,7 +201,7 @@ fn scan(text: &str) -> Scan { } continue; } - let top = depth == 1 || (depth == 2 && params.is_some()); + let top = depth == 1 || nested.as_ref().is_some_and(|(_, d)| *d == depth); match c { '"' => { (quoted, start) = (true, (top && entry).then_some(i + 1)); @@ -196,15 +211,27 @@ fn scan(text: &str) -> Scan { } '{' | '[' | '(' => { depth += 1; - if c == '{' && depth == 2 && valued.as_deref() == Some("params") { - params = Some(i); + // A top-level key's object or array, or an object right in that array. + let key = match (c, depth) { + ('{', 2) => valued.clone(), + ('{', 3) => array.clone(), + _ => None, + }; + if c == '[' && depth == 2 { + array = valued.clone(); + } + if let Some(key) = key { + nested = Some((Nested { brace: i, key }, depth)); } - entry = c == '{' && (depth == 1 || params.is_some_and(|p| p == i)); + entry = c == '{' && (depth == 1 || nested.as_ref().is_some_and(|(n, _)| n.brace == i)); } '}' | ']' | ')' => { depth = depth.saturating_sub(1); + if nested.as_ref().is_some_and(|(_, d)| depth < *d) { + nested = None; + } if depth < 2 { - params = None; + array = None; } entry = false; } @@ -223,7 +250,7 @@ fn scan(text: &str) -> Scan { } } } - Scan { start, params, value, values } + Scan { start, nested: nested.map(|(n, _)| n), value, values } } /// The line's object from its `{` up to the cursor at char `at` of `script`, read (`scan`), with @@ -245,41 +272,59 @@ fn word_from(text: &str, start: usize) -> Option { (!word.chars().any(|c| c.is_whitespace() || c == '\\')).then_some(word) } +/// A key being typed (`key_at`): the command's line, where the word starts (after its quote, a +/// char index of the script), the word, and the second-level object it's in (its `{` a char +/// index of the script; None at the top level). +#[derive(Clone, Debug, PartialEq)] +pub struct KeyAt { + pub hint: Hint, + pub start: usize, + pub word: String, + pub nested: Option, +} + /// The key being typed at char `at` of `script`: the cursor in a quoted word that starts an entry -/// of its line's object, right after its `{` or a `,` (at the top level, or at the top of its -/// `"params": {…}`: not a value, not another nested object's key). The command's line -/// (`hint_at`), where the word starts (after its quote, a char index), the word, and the `{` of -/// the `"params"` object it's in (a char index; None at the top level). -pub fn key_at(script: &str, at: usize) -> Option<(Hint, usize, String, Option)> { - let (h, Scan { start, params, .. }, text) = scan_at(script, at)?; +/// of its line's object, right after its `{` or a `,`, at the top level or at the top of a +/// second-level object (not a value, not deeper). +pub fn key_at(script: &str, at: usize) -> Option { + let (hint, Scan { start, nested, .. }, text) = scan_at(script, at)?; let start = start?; let word = word_from(text, start)?; - let (start, params) = (h.brace + start, params.map(|p| h.brace + p)); - Some((h, start, word, params)) + let nested = nested.map(|n| Nested { brace: hint.brace + n.brace, key: n.key }); + Some(KeyAt { start: hint.brace + start, word, nested, hint }) +} + +/// A top-level value being typed (`value_at`): the command's line, where the word starts (after +/// its quote, a char index of the script), the word, and its key. +#[derive(Clone, Debug, PartialEq)] +pub struct ValueAt { + pub hint: Hint, + pub start: usize, + pub word: String, + pub key: String, } /// The top-level value being typed at char `at` of `script`: the cursor in a quoted word right -/// after a key and its colon. The command's line, where the word starts (after its quote, a char -/// index), the word, and its key. -pub fn value_at(script: &str, at: usize) -> Option<(Hint, usize, String, String)> { - let (h, Scan { value, .. }, text) = scan_at(script, at)?; +/// after a key and its colon. +pub fn value_at(script: &str, at: usize) -> Option { + let (hint, Scan { value, .. }, text) = scan_at(script, at)?; let (start, key) = value?; let word = word_from(text, start)?; - let start = h.brace + start; - Some((h, start, word, key)) + Some(ValueAt { start: hint.brace + start, word, key, hint }) } -/// Whether the cursor at char `at` of `script` is in the top-level `"params": {…}` of line `h`'s -/// object (not deeper in it). -fn in_params(script: &str, at: usize, h: &Hint) -> bool { - script.get(byte_at(script, h.brace).unwrap_or(0)..byte_at(script, at).unwrap_or(0)).is_some_and(|t| scan(t).params.is_some()) +/// The top-level key of the second-level object of line `h`'s object that the cursor at char +/// `at` of `script` is in, anywhere in it (deeper levels included); None outside one. +fn nested_at(script: &str, at: usize, h: &Hint) -> Option { + scan(script.get(byte_at(script, h.brace)?..byte_at(script, at)?)?).nested.map(|n| n.key) } -/// The top-level `"tool"` of a line's object (`line`, from its `{`): the tool a `gp.run` line runs, -/// before or after its `"params"`; the last one given, as Run reads it. None without one, or when -/// it isn't a plain string. -pub fn tool_of(line: &str) -> Option { - scan(line).values.into_iter().rev().find(|(key, _)| key == "tool").map(|(_, tool)| tool).filter(|t| !t.contains('\\')) +/// The top-level `key` of a line's object (`line`, from its `{`), as a plain string: the tool a +/// `gp.run` line runs, or the command a `task.addStep` line adds, before or after its +/// `"params"`; the last one given, as Run reads it. None without one, or when it isn't a plain +/// string. +pub fn value_of(line: &str, key: &str) -> Option { + scan(line).values.into_iter().rev().find(|(k, _)| k == key).map(|(_, v)| v).filter(|v| !v.contains('\\')) } /// Line `h`'s object, from its `{` to the line's end. @@ -319,67 +364,145 @@ fn offer_keys(of: Of, entries: Vec>, line: &str, start: usize, end: .into_iter() .filter(|keys| !keys.iter().any(|k| given.contains(&k.as_str()))) .flatten() - .filter(|k| k.get(..word.len()).is_some_and(|p| p.eq_ignore_ascii_case(word))) + .filter(|k| starts_with_ci(k, word)) .collect(); (!items.is_empty()).then_some(Offer { start, end, items, of: Some(of) }) } +/// What a command's naming key gives (`named`): a geoprocessing tool, or a command. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Named { + Tool, + Command, +} + +/// The top-level key whose value names what command `id`'s `"params": {…}` hold the parameters +/// of (`gp.run`'s `"tool"`, `task.addStep`'s `"command"`), and what it names; None for a command +/// without one. +fn named(id: &str) -> Option<(&'static str, Named)> { + match id { + "gp.run" => Some(("tool", Named::Tool)), + "task.addStep" => Some(("command", Named::Command)), + _ => None, + } +} + +/// What line `h`'s `"params"` hold the parameters of: the tool or command its naming key gives +/// (`named`), as written there. +fn named_in(script: &str, h: &Hint) -> Option<(Named, String)> { + let (key, what) = named(&h.id)?; + Some((what, value_of(line_of(script, h)?, key)?)) +} + /// What to offer at char `at` of `script`: the command ids for a line's first word, the keys of -/// the line's command for a key being typed in its `{…}`; in a `gp.run` line, the tool ids for its -/// `"tool"`, and in its `"params"` the keys of the tool it runs. +/// the line's command for a key being typed in its `{…}`, and in its second-level objects the +/// keys their entries describe; in a `gp.run` line the tool ids for its `"tool"` and the tool's +/// keys in its `"params"`, in a `task.addStep` line the command ids for its `"command"` and that +/// command's keys in its `"params"`. fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::Session) -> Option { if let Some((start, word)) = word_at(script, at) { return complete(start, at, &word, ids); } - if let Some((h, start, word, key)) = value_at(script, at) { + if let Some(ValueAt { hint, start, word, key }) = value_at(script, at) { // Other values come later (#662's third step). - return if h.id == "gp.run" && key == "tool" { tool_ids(start, at, &word) } else { None }; - } - let (h, start, word, params) = key_at(script, at)?; - let after = script.get(byte_at(script, at)?..)?.split('\n').next()?; - let Some(params) = params else { - // The line from its `{`, the word being typed taken out. - let before = script.get(byte_at(script, h.brace)?..byte_at(script, start)?)?; - return complete_keys(&h.id, ¶ms_of(session, &h.id)?, &format!("{before}{after}"), start, at, &word); + let (_, what) = named(&hint.id).filter(|(names, _)| key == *names)?; + return match what { + Named::Tool => value_ids(start, at, &word, rustgis_engine::analysis::tools().map(|t| t.id), Of::ToolIds), + // Those `task.addStep` takes, by its own rule (not a script's: it adds the step, it + // doesn't run it). + Named::Command => { + let steps = + session.registry.commands.iter().map(|c| c.id).filter(|id| rustgis_engine::cmd::items::can_be_step(session, id)); + value_ids(start, at, &word, steps, Of::CommandIds) + } + }; + } + let KeyAt { hint: h, start, word, nested } = key_at(script, at)?; + let after = object_rest(script.get(byte_at(script, at)?..)?.split('\n').next()?); + // The object the key goes in, from its `{` to its `}`, the word being typed taken out. + let brace = nested.as_ref().map_or(h.brace, |n| n.brace); + let object = format!("{}{after}", script.get(byte_at(script, brace)?..byte_at(script, start)?)?); + let Some(nested) = nested else { + return complete_keys(&h.id, ¶ms_of(session, &h.id)?, &object, start, at, &word); }; - if h.id != "gp.run" { - return None; + if nested.key == "params" && named(&h.id).is_some() { + return match named_in(script, &h)? { + (Named::Tool, name) => { + let tool = rustgis_engine::analysis::tool(&name)?; + let entries = tool.params.iter().map(|p| vec![p.name.to_string()]).collect(); + offer_keys(Of::ToolKeys(tool.id.to_string()), entries, &object, start, at, &word) + } + (Named::Command, name) => complete_keys(&name, ¶ms_of(session, &name)?, &object, start, at, &word), + }; } - let tool = rustgis_engine::analysis::tool(&tool_of(line_of(script, &h)?)?)?; - // The `"params"` object from its `{` (it ends with its `}`: the rest of the line isn't read), - // the word being typed taken out. - let before = script.get(byte_at(script, params)?..byte_at(script, start)?)?; - let entries = tool.params.iter().map(|p| vec![p.name.to_string()]).collect(); - offer_keys(Of::ToolKeys(tool.id.to_string()), entries, &format!("{before}{after}"), start, at, &word) + let entries = entries(&sub_desc(¶ms_of(session, &h.id)?, &nested.key)?).into_iter().map(|(keys, _)| keys).collect(); + offer_keys(Of::SubKeys { id: h.id, key: nested.key }, entries, &object, start, at, &word) } -/// The ids of the geoprocessing tools that complete `word`, typed from char `start` to `end`, -/// case aside, in the order the tools are listed (a built-in tool first: an external one with -/// the same id isn't the one Run finds). -fn tool_ids(start: usize, end: usize, word: &str) -> Option { +/// What follows a cursor in a key (`after`, to the line's end) up to the `}` that closes the key's +/// object, or all of it when nothing closes it: an object after it on the line (the next one in +/// its array, a later key's) isn't read as holding its keys. +fn object_rest(after: &str) -> &str { + // In the key's quotes, then one level in. + let (mut depth, mut quoted, mut escaped) = (1usize, true, false); + for (i, c) in after.char_indices() { + let was_escaped = escaped; + escaped = c == '\\' && !was_escaped; + if quoted { + quoted = !(c == '"' && !was_escaped); + continue; + } + match c { + '"' => quoted = true, + '{' | '[' | '(' => depth += 1, + '}' | ']' | ')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return after.get(..i + c.len_utf8()).unwrap_or(after); + } + } + _ => {} + } + } + after +} + +/// The description of the second-level object entry `key` of parameter description `desc` takes: +/// its `{…}`, or the `{…}` in its `[…]` (`"filters": [{"name", "where"}]`). None for an entry +/// that doesn't describe one. +fn sub_desc(desc: &str, key: &str) -> Option { + let (_, text) = entries(desc).into_iter().find(|(keys, _)| keys.iter().any(|k| k == key))?; + let value = text.split_once(':')?.1.trim_start(); + let value = value.strip_prefix('[').map_or(value, str::trim_start); + value.starts_with('{').then(|| value.to_string()) +} + +/// The ids of `ids` that complete `word`, typed from char `start` to `end`, case aside, each +/// once: the tools or commands a value takes (`of`), offered from its quote, so not cut to +/// `MAX_ITEMS` (the list scrolls). Commands sorted, as a line's first word offers them; tools +/// in their order, the built-in ones first (an external one with the same id isn't the one Run +/// finds). +fn value_ids<'a>(start: usize, end: usize, word: &str, ids: impl Iterator, of: Of) -> Option { let mut seen = std::collections::HashSet::new(); - let items: Vec = rustgis_engine::analysis::tools() - .map(|t| t.id) - .filter(|id| id.get(..word.len()).is_some_and(|p| p.eq_ignore_ascii_case(word)) && seen.insert(*id)) - .map(str::to_string) - .collect(); - (!items.is_empty()).then_some(Offer { start, end, items, of: Some(Of::ToolIds) }) + let mut items: Vec = ids.filter(|id| starts_with_ci(id, word) && seen.insert(*id)).map(str::to_string).collect(); + if of == Of::CommandIds { + items.sort(); + } + (!items.is_empty()).then_some(Offer { start, end, items, of: Some(of) }) } /// What the help beside a list of keys says about key `key` of `of`'s parameters: the line -/// describing it (`"size"?: number`), and a longer help under it (a tool's; may be empty). -fn key_help(session: &rustgis_engine::Session, of: &Of, key: &str) -> Option<(String, String)> { +/// describing it (`"size"?: number`), and a longer help under it (a tool's, when it has one). +fn key_help(session: &rustgis_engine::Session, of: &Of, key: &str) -> Option<(String, Option)> { + let described = |desc: &str| entries(desc).into_iter().find(|(keys, _)| keys.iter().any(|k| k == key)).map(|(_, text)| (text, None)); match of { - Of::CommandKeys(id) => entries(¶ms_of(session, id)?) - .into_iter() - .find(|(keys, _)| keys.iter().any(|k| k == key)) - .map(|(_, text)| (text, String::new())), + Of::CommandKeys(id) => described(¶ms_of(session, id)?), + Of::SubKeys { id, key: entry } => described(&sub_desc(¶ms_of(session, id)?, entry)?), Of::ToolKeys(id) => { let p = rustgis_engine::analysis::tool(id)?.params.iter().find(|p| p.name == key)?; - let help = if p.help.is_empty() { String::new() } else { tl!(p.help).to_string() }; - Some((param_line(p), help)) + Some((param_line(p), (!p.help.is_empty()).then(|| tl!(p.help).to_string()))) } - Of::ToolIds => None, + Of::ToolIds | Of::CommandIds => None, } } @@ -433,11 +556,14 @@ fn tool_help(t: &rustgis_engine::analysis::ToolSpec) -> Help { } /// The help by the cursor at char `at` of `script`, in line `h`'s braces: in a `gp.run` line's -/// `"params"`, the tool's it runs (when the line names one there is); elsewhere the command's. +/// `"params"`, the tool's it runs, in a `task.addStep` line's, the command's it adds (when the +/// line names one there is); elsewhere the line's command's. fn help_at(session: &rustgis_engine::Session, script: &str, at: usize, h: &Hint) -> Option { - let tool = || rustgis_engine::analysis::tool(&tool_of(line_of(script, h)?)?); - let tool = if h.id == "gp.run" && in_params(script, at, h) { tool() } else { None }; - tool.map(tool_help).or_else(|| help(session, &h.id)) + let inner = || match named_in(script, h).filter(|_| nested_at(script, at, h).as_deref() == Some("params"))? { + (Named::Tool, name) => rustgis_engine::analysis::tool(&name).map(tool_help), + (Named::Command, name) => help(session, &name), + }; + inner().or_else(|| help(session, &h.id)) } /// `script_ids`, kept until the loaded plugins or the session's commands change (the built-in @@ -541,14 +667,14 @@ const FRAME: f32 = 24.0; /// Draws a key's parameter `text` beside the list, coloured as the script is, and its longer help /// (`about`, may be empty) under it. -fn draw_key_help(ui: &mut Ui, text: &str, about: &str, width: f32) { +fn draw_key_help(ui: &mut Ui, text: &str, about: Option<&str>, width: f32) { let palette = crate::scriptcolor::Palette::of(&crate::theme::Tokens::get(ui.ctx())); let mut job = crate::scriptcolor::help_job(None, text, palette, &egui::TextStyle::Monospace.resolve(ui.style())); job.wrap.max_width = width; ui.vertical(|ui| { ui.set_max_width(width); ui.label(job); - if !about.is_empty() { + if let Some(about) = about { ui.label(egui::RichText::new(about).small()); } }); @@ -836,23 +962,31 @@ pub fn show( // short description split. Kept, it would need the id and the language as its // key (the help is translated), for next to nothing saved. let shown = hovered.filter(|_| pointer_leads).unwrap_or(selected); - if offer.of == Some(Of::ToolIds) { + let item = offer.items.get(shown); + // A gap before it, not a separator: in a row, egui's separator takes all the height + // it can, and would stretch the list down to the bottom of the screen. + match &offer.of { // A tool: its help, as a command's. - if let Some(t) = offer.items.get(shown).and_then(|id| rustgis_engine::analysis::tool(id)) { - ui.add_space(GAP); - draw_help(ui, &tool_help(t), help_width); + Some(Of::ToolIds) => { + if let Some(t) = item.and_then(|id| rustgis_engine::analysis::tool(id)) { + ui.add_space(GAP); + draw_help(ui, &tool_help(t), help_width); + } + } + // A command, starting a line or for a value. + None | Some(Of::CommandIds) => { + if let Some(help) = item.and_then(|id| help(session, id)) { + ui.add_space(GAP); + draw_help(ui, &help, help_width); + } } - } else if let Some(of) = &offer.of { // A key: the line describing its parameter. - if let Some((text, about)) = offer.items.get(shown).and_then(|key| key_help(session, of, key)) { - ui.add_space(GAP); - draw_key_help(ui, &text, &about, help_width); + Some(of) => { + if let Some((text, about)) = item.and_then(|key| key_help(session, of, key)) { + ui.add_space(GAP); + draw_key_help(ui, &text, about.as_deref(), help_width); + } } - } else if let Some(help) = offer.items.get(shown).and_then(|id| help(session, id)) { - // A gap, not a separator: in a row, egui's separator takes all the height it - // can, and would stretch the list down to the bottom of the screen. - ui.add_space(GAP); - draw_help(ui, &help, help_width); } }); }); @@ -929,23 +1063,25 @@ pub fn hint(ui: &Ui, box_id: Id, out: &TextEditOutput, visible: egui::Rect, scri pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &str, session: &rustgis_engine::Session) { let chars: Vec = script.chars().collect(); let (Some(before), Some(mut after)) = (chars.get(..offer.start), chars.get(offer.end..)) else { return }; - let (tail, at) = if offer.of == Some(Of::ToolIds) { - // A tool id: closed, the cursor after its quote (one already there is kept). - if after.first() == Some(&'"') { (String::new(), 1) } else { ("\"".to_string(), 1) } - } else if offer.of.is_some() { + let (tail, at) = match &offer.of { + // An id for a value: closed, the cursor after its quote (one already there is kept). + Some(Of::ToolIds | Of::CommandIds) => (if after.first() == Some(&'"') { String::new() } else { "\"".to_string() }, 1), // A key: closed, its colon and a space after it, ready for the value; a closing quote // already there is taken in, and a key that has its colon keeps it. - let quoted = after.first() == Some(&'"'); - let colon = quoted && after.get(1..).is_some_and(|a| a.iter().find(|c| !c.is_whitespace()) == Some(&':')); - if quoted && !colon { - after = after.get(1..).unwrap_or_default(); + Some(_) => { + let quoted = after.first() == Some(&'"'); + let colon = quoted && after.get(1..).is_some_and(|a| a.iter().find(|c| !c.is_whitespace()) == Some(&':')); + if quoted && !colon { + after = after.get(1..).unwrap_or_default(); + } + // With its colon, the cursor goes after the quote already there. + if colon { (String::new(), 1) } else { ("\": ".to_string(), 3) } } - // With its colon, the cursor goes after the quote already there. - if colon { (String::new(), 1) } else { ("\": ".to_string(), 3) } - } else if after.iter().take_while(|c| **c != '\n').all(|c| c.is_whitespace()) { - params_of(session, item).map(|d| template(&d)).unwrap_or_default() - } else { - Default::default() + // A command starting a line. + None if after.iter().take_while(|c| **c != '\n').all(|c| c.is_whitespace()) => { + params_of(session, item).map(|d| template(&d)).unwrap_or_default() + } + None => Default::default(), }; let mut new: String = before.iter().collect(); new.push_str(item); @@ -1002,8 +1138,8 @@ fn in_string(line: &str) -> bool { quoted } -/// What this frame's input did to the box, as `brace` needs it: a brace typed, Backspace, or -/// anything else (an undo or redo, a paste, a cut), which it leaves as it is. +/// What this frame's input did to the box, as `brace` needs it: a brace or bracket typed, +/// Backspace, or anything else (an undo or redo, a paste, a cut), which it leaves as it is. #[derive(Clone, Copy, Debug, PartialEq)] enum Keyed { Brace, @@ -1011,13 +1147,22 @@ enum Keyed { Other, } +/// The closing character of an opening brace or bracket. +fn closer(open: char) -> Option { + match open { + '{' => Some('}'), + '[' => Some(']'), + _ => None, + } +} + /// The script once the box's edit is followed through, the edit read from `script` as the box -/// left it, what was there `before`, and the cursor `now` (a char index): a `{` typed closes -/// itself (`{|}`) when nothing, a space or a closing `}`, `]` or `,` follows it; a `}` typed right -/// before a `}` steps over it rather than add one; Backspace in an empty `{|}` deletes both. Not -/// in a string or a comment line, and only for the keys that make those edits (`keyed`): an undo -/// can bring a `{` back as typing one would. The cursor stays where the box put it in each case. -/// None to leave the edit as it is. +/// left it, what was there `before`, and the cursor `now` (a char index): a `{` or `[` typed +/// closes itself (`{|}`, `[|]`) when nothing, a space or a closing `}`, `]` or `,` follows it; a +/// `}` or `]` typed right before the same one steps over it rather than add one; Backspace in an +/// empty `{|}` or `[|]` deletes both. Not in a string or a comment line, and only for the keys that +/// make those edits (`keyed`): an undo can bring a `{` back as typing one would. The cursor stays +/// where the box put it in each case. None to leave the edit as it is. fn brace(script: &str, before: Before, now: usize, keyed: Keyed) -> Option { let Before { at, prev, len } = before; // Outside a comment line and a string, up to char `i`. @@ -1027,32 +1172,32 @@ fn brace(script: &str, before: Before, now: usize, keyed: Keyed) -> Option { - Some(format!("{b}{{}}{}", rest.get(1..)?)) + (Some(open @ ('{' | '[')), next) if one_typed && next.is_none_or(|c| c.is_whitespace() || matches!(c, '}' | ']' | ',')) => { + Some(format!("{b}{open}{}{}", closer(open)?, rest.get(1..)?)) } - // The `}` typed goes: the cursor, after it, is after the one that was there. - (Some('}'), Some('}')) if one_typed => Some(format!("{b}{}", rest.get(1..)?)), - // The `}` that followed the deleted `{`, now at the cursor. - (_, Some('}')) if one_deleted => Some(format!("{b}{}", rest.get(1..)?)), + // The `}` or `]` typed goes: the cursor, after it, is after the one that was there. + (Some(close @ ('}' | ']')), Some(next)) if one_typed && close == next => Some(format!("{b}{}", rest.get(1..)?)), + // The closer that followed the deleted opener, now at the cursor. + (_, Some(next)) if one_deleted && prev.and_then(closer) == Some(next) => Some(format!("{b}{}", rest.get(1..)?)), _ => None, } } /// After the box: its edit followed through as `brace` says, from what was there `before` it /// took the keys. Read from what the box did rather than from the keys, so it holds whatever -/// keyboard types the brace (Shift, AltGr, Option). The cursor stays where the box put it, and the +/// keyboard types the brace or bracket (Shift, AltGr, Option). The cursor stays where the box put it, and the /// box's undo takes the brace back with what was typed with it. pub fn braces(ui: &Ui, before: Option, out: &TextEditOutput, script: &mut String) { let (Some(before), Some(now)) = (before, out.cursor_range.filter(|r| r.is_empty()).map(|r| r.primary.index.0)) else { return }; if !out.response.response.changed() { return; } - // One edit, made by one of those keys: the brace typed alone (Shift, AltGr or Option as it - // takes), or Backspace alone. + // One edit, made by one of those keys: the brace or bracket typed alone (Shift, AltGr or + // Option as it takes), or Backspace alone. let keyed = ui.input(|i| { let edits: Vec<&egui::Event> = i .events @@ -1068,7 +1213,7 @@ pub fn braces(ui: &Ui, before: Option, out: &TextEditOutput, script: &mu }) .collect(); match edits.as_slice() { - [egui::Event::Text(t)] if t == "{" || t == "}" => Keyed::Brace, + [egui::Event::Text(t)] if matches!(t.as_str(), "{" | "}" | "[" | "]") => Keyed::Brace, [egui::Event::Key { key: Key::Backspace, modifiers, .. }] if modifiers.is_none() => Keyed::Backspace, _ => Keyed::Other, } @@ -1575,6 +1720,24 @@ mod tests { assert_eq!(super::brace("x }", before, 2, super::Keyed::Other), None, "a cut, an undo"); } + /// `[` closes itself as `{` does; `]` steps over a `]`, not a `}` (nor `}` a `]`); Backspace + /// in an empty `[]` deletes both, not in `[}`. + #[test] + fn a_bracket_works_as_a_brace() { + assert_eq!(typed("x ", 2, "[").as_deref(), Some("x []")); + assert_eq!(typed(r#"x {"a": }"#, 8, "[").as_deref(), Some(r#"x {"a": []}"#), "before a closing brace"); + assert_eq!(typed("x []", 3, "{").as_deref(), Some("x [{}]"), "a brace in a bracket"); + assert_eq!(typed("x []", 3, "[").as_deref(), Some("x [[]]"), "before a closing bracket"); + assert_eq!(typed("x ab", 2, "["), None, "before other text"); + assert_eq!(typed(r#"x {"w": "a "#, 11, "["), None, "in a string"); + assert_eq!(typed("x [1]", 4, "]").as_deref(), Some("x [1]"), "stepped over"); + assert_eq!(typed("x [1}", 4, "]"), None, "not over a brace"); + assert_eq!(typed("x {1]", 4, "}"), None, "a brace not over a bracket"); + assert_eq!(backspace("x []", 3).as_deref(), Some("x ")); + assert_eq!(backspace("x [}", 3), None, "not a pair"); + assert_eq!(backspace("x {]", 3), None, "not a pair either"); + } + /// `}` typed right before a `}` steps over it; Backspace in an empty `{}` deletes both. #[test] fn a_closing_brace_steps_over_and_backspace_deletes_both() { @@ -1614,24 +1777,34 @@ mod tests { h.event(egui::Event::Paste("labels.style {".into())); h.run(); assert_eq!(h.state().workflow.script, "labels.style {", "a paste is left as it is"); + let mut h = pane(); + type_in(&mut h, r#"layer.displayFilters {"filters": "#); + type_in(&mut h, "["); + type_in(&mut h, "{"); + assert_eq!(h.state().workflow.script, r#"layer.displayFilters {"filters": [{}]"#, "a bracket closes itself too"); + type_in(&mut h, "}"); + type_in(&mut h, "]"); + type_in(&mut h, "!"); + assert_eq!(h.state().workflow.script, r#"layer.displayFilters {"filters": [{}]!"#, "and is stepped over"); } /// A key is being typed in a quoted word that starts an entry of the line's object: right - /// after its `{` or a `,`, at the top level; not in a value, a nested object or a comment. + /// after its `{` or a `,`, at the top level (second-level objects: `key_at_finds_a_key_in_a_ + /// second_level_object`); not in a value, deeper, or in a comment. #[test] fn key_at_finds_a_key_being_typed() { - let at = |s: &str| super::key_at(s, s.chars().count()).map(|(h, start, word, _)| (h.id, start, word)); + let at = |s: &str| super::key_at(s, s.chars().count()).map(|k| (k.hint.id, k.start, k.word)); assert_eq!(at(r#"labels.style {""#), Some(("labels.style".into(), 15, String::new()))); assert_eq!(at(r#"labels.style {"layer": "Cities", "si"#).map(|k| k.2), Some("si".into()), "after a comma"); assert_eq!(at(r#"x.y {"w": "a\"b", ""#).map(|k| k.2), Some(String::new()), "an escaped quote in a value"); assert_eq!(at("# ñ\r\nlabels.style {\"la").map(|k| (k.1, k.2)), Some((20, "la".into())), "chars, CRLF"); assert_eq!(at(r#"labels.style {"where": "po"#), None, "a value"); - assert_eq!(at(r#"x.y {"a": {""#), None, "a nested object"); + assert_eq!(at(r#"x.y {"a": {"b": {""#), None, "deeper than a second-level object"); assert_eq!(at(r#"x.y {"a": "#), None, "no quote"); assert_eq!(at(r#"x.y {"a" "#), None, "past the key"); assert_eq!(at(r#"# x.y {""#), None, "a comment"); let s = r#"labels.style {"layer"}"#; - assert_eq!(super::key_at(s, 20).map(|k| k.2), Some("layer".into()), "a closing quote after the cursor"); + assert_eq!(super::key_at(s, 20).map(|k| k.word), Some("layer".into()), "a closing quote after the cursor"); assert!(super::key_at(s, 18).is_none(), "a letter after the cursor"); } @@ -1760,29 +1933,35 @@ mod tests { assert!((h.get_by_label(&sig).rect().left() - first).abs() < 0.5, "it didn't move with the cursor"); } - /// In a line's `"params": {…}`, a key starts an entry at its top, as at the line's top level; - /// not in another nested object, deeper in `params`, or once `params` is closed. + /// In a second-level object (a top-level key's `{…}`, or an object in its `[…]`), a key starts + /// an entry at its top, as at the line's top level; not deeper, in a value, or once the object + /// is closed. #[test] - fn key_at_finds_a_key_in_params() { - let at = |s: &str| super::key_at(s, s.chars().count()).map(|(_, start, word, params)| (start, word, params)); + fn key_at_finds_a_key_in_a_second_level_object() { + let at = |s: &str| super::key_at(s, s.chars().count()).map(|k| (k.start, k.word, k.nested.map(|n| (n.brace, n.key)))); let s = r#"gp.run {"tool": "buffer", "params": {"di"#; - assert_eq!(at(s), Some((38, "di".into(), Some(36)))); - assert_eq!(at(r#"gp.run {"params" : {"input": "Cities", ""#).map(|k| k.2), Some(Some(19)), "after a comma, spaced colon"); + assert_eq!(at(s), Some((38, "di".into(), Some((36, "params".into()))))); + let nested = |s: &str| at(s).and_then(|k| k.2); + assert_eq!(nested(r#"gp.run {"params" : {"input": "Cities", ""#), Some((19, "params".into())), "after a comma, spaced colon"); + assert_eq!(nested(r#"topology.rules {"add": {""#).map(|n| n.1), Some("add".into()), "any key's object"); + let s = r#"layer.displayFilters {"filters": [{"name": "a"}, {""#; + assert_eq!(nested(s), Some((49, "filters".into())), "the array's second object"); assert_eq!(at(r#"gp.run {"tool": "buffer", "#), None, "no quote"); assert_eq!(at(r#"gp.run {"tool": "buffer", ""#).map(|k| k.2), Some(None), "the top level"); - assert_eq!(at(r#"gp.run {"params": {"a": {""#), None, "deeper in params"); - assert_eq!(at(r#"gp.run {"params": [{""#), None, "an array"); - assert_eq!(at(r#"gp.run {"other": {""#), None, "another object"); + assert_eq!(at(r#"gp.run {"params": {"a": {""#), None, "deeper"); + assert_eq!(at(r#"x.y {"a": [[{""#), None, "an array's array"); + assert_eq!(at(r#"x.y {"a": [{"b": [{""#), None, "an array in the array's object"); assert_eq!(at(r#"gp.run {"params": {"a": "b"#), None, "a value"); - assert_eq!(at(r#"gp.run {"params": {}, ""#).map(|k| k.2), Some(None), "params closed"); - assert_eq!(at(r#"gp.run {"x": "params", "y": {""#), None, "\"params\" as a value"); + assert_eq!(at(r#"gp.run {"params": {}, ""#).map(|k| k.2), Some(None), "closed"); + assert_eq!(at(r#"x.y {"filters": [{}], ""#).map(|k| k.2), Some(None), "the array closed"); + assert_eq!(at(r#"gp.run {"x": "params", "y" {""#), None, "no colon"); } - /// The tool a line runs: its top-level `"tool"`, wherever it's given, the last one as Run + /// The tool a line runs (its top-level `"tool"`), wherever it's given, the last one as Run /// reads it; not a nested one, a value named "tool", or an escaped name. #[test] - fn tool_of_reads_the_lines_tool() { - use super::tool_of; + fn value_of_reads_the_lines_tool() { + let tool_of = |line: &str| super::value_of(line, "tool"); assert_eq!(tool_of(r#"{"tool": "buffer", "params": {}}"#).as_deref(), Some("buffer")); assert_eq!(tool_of(r#"{"params": {"input": "a"}, "tool" : "clip"}"#).as_deref(), Some("clip"), "after params"); assert_eq!(tool_of(r#"{"tool": "a", "tool": "b"}"#).as_deref(), Some("b"), "the last one"); @@ -1815,6 +1994,13 @@ mod tests { assert_eq!(o.of, Some(super::Of::ToolKeys("buffer".into()))); let s = r#"gp.run {"tool": "buffer", "params": {"distance": 5, "distance"}}"#; assert!(keys(&s[..s.len() - 2]).is_empty(), "the same key given before in params"); + let s = r#"gp.run {"params": {""#; + let o = super::offer_at(&format!(r#"{s}"}}, "output": 1, "tool": "buffer"}}"#), s.chars().count(), &[], &app.session).unwrap(); + assert!(o.items.iter().any(|k| k == "output"), "a top-level key after params isn't given in it"); + let o = super::offer_at(&format!(r#"{s}"}}, "x": {{"output": 1}}, "tool": "buffer"}}"#), s.chars().count(), &[], &app.session); + assert!(o.is_some_and(|o| o.items.iter().any(|k| k == "output")), "nor a key of a later object"); + let o = super::offer_at(&format!("{s}\n\"tool\": \"buffer\"}}}}"), s.chars().count(), &[], &app.session); + assert!(o.is_none(), "a tool on another line: a command is one line"); } /// A tool's parameter reads as a command's is described: its key, what it takes, its name, and @@ -1858,7 +2044,7 @@ mod tests { /// colon; not a key, a nested value, an unquoted one, or one already closed. #[test] fn value_at_finds_a_value_being_typed() { - let at = |s: &str| super::value_at(s, s.chars().count()).map(|(h, start, word, key)| (h.id, start, word, key)); + let at = |s: &str| super::value_at(s, s.chars().count()).map(|v| (v.hint.id, v.start, v.word, v.key)); assert_eq!(at(r#"gp.run {"tool": "bu"#), Some(("gp.run".into(), 17, "bu".into(), "tool".into()))); assert_eq!(at(r#"gp.run {"params": {}, "tool" : ""#).map(|v| (v.2, v.3)), Some((String::new(), "tool".into())), "after params"); assert_eq!(at(r#"gp.run {"tool": "a", "x"#), None, "a key"); @@ -1867,7 +2053,7 @@ mod tests { assert_eq!(at(r#"gp.run {"tool" ""#), None, "no colon"); assert_eq!(at(r#"gp.run {"tool": "a" "#), None, "closed"); let s = r#"gp.run {"tool": "bu"}"#; - assert_eq!(super::value_at(s, 19).map(|v| v.2), Some("bu".into()), "a closing quote after the cursor"); + assert_eq!(super::value_at(s, 19).map(|v| v.word), Some("bu".into()), "a closing quote after the cursor"); assert!(super::value_at(s, 18).is_none(), "a letter after the cursor"); } @@ -1925,4 +2111,85 @@ mod tests { let (corner, _) = super::place(cursor, egui::vec2(200.0, 100.0), screen, 60.0); assert_eq!(corner.y + 100.0, 470.0, "above, clear of the line over it"); } + + /// In a second-level object, the keys its entry describes (`"add": {…}`, each object of + /// `"filters": [{…}]`), less those that object has, with the key's line beside; none for an + /// entry that describes none. + #[test] + fn a_second_level_objects_keys_are_offered() { + let app = crate::RustGisApp::with_sample(); + let offer = |s: &str| super::offer_at(s, s.chars().count(), &[], &app.session); + let keys = |s: &str| offer(s).map(|o| o.items).unwrap_or_default(); + assert_eq!(keys(r#"topology.rules {"add": {""#), ["rule", "layer", "by"]); + assert_eq!(keys(r#"topology.rules {"add": {"rule": "x", ""#), ["layer", "by"]); + assert_eq!(keys(r#"layer.displayFilters {"filters": [{"name": "a", ""#), ["minScale", "maxScale", "where"]); + let s = r#"layer.displayFilters {"filters": [{"name": "a"}, {""#; + assert_eq!(keys(s), ["name", "minScale", "maxScale", "where"], "another object: nothing given in it"); + let s = r#"layer.displayFilters {"filters": [{"na"#; + let later = super::offer_at(&format!(r#"{s}"}}, {{"name": "b"}}]}}"#), s.chars().count(), &[], &app.session); + assert_eq!(later.map(|o| o.items), Some(vec!["name".to_string()]), "a later object of the array isn't this one"); + assert!(keys(r#"topology.rules {"remove": {""#).is_empty(), "an entry that describes none"); + assert!(keys(r#"topology.rules {"nope": {""#).is_empty(), "no such entry"); + let of = offer(r#"topology.rules {"add": {""#).and_then(|o| o.of).unwrap(); + assert_eq!(of, super::Of::SubKeys { id: "topology.rules".into(), key: "add".into() }); + let (line, about) = super::key_help(&app.session, &of, "rule").unwrap(); + assert!(line.starts_with(r#""rule": "mustNotOverlap""#), "{line}"); + assert_eq!(about, None); + } + + /// In a `task.addStep` line, `"command": "` offers the commands a script can run, its + /// `"params"` that command's keys (the command before or after them), and the help by the + /// cursor there is that command's. + #[test] + fn a_steps_command_and_its_keys_are_offered() { + let app = crate::RustGisApp::with_sample(); + let ids = super::script_ids(&app.session); + let offer = |s: &str| super::offer_at(s, s.chars().count(), &ids, &app.session); + let o = offer(r#"task.addStep {"command": "LABELS.ST"#).unwrap(); + assert_eq!((o.items, o.of), (vec!["labels.style".to_string()], Some(super::Of::CommandIds))); + assert!(offer(r#"task.addStep {"command": "ui."#).is_none(), "no interface command"); + assert!(offer(r#"task.addStep {"command": "task."#).is_none(), "no task command: a step doesn't run a task"); + let o = offer(r#"task.addStep {"command": "script."#).map(|o| o.items).unwrap_or_default(); + assert!(o.iter().any(|id| id == "script.run"), "what task.addStep takes, not what a script runs: {o:?}"); + let all = offer(r#"task.addStep {"command": ""#).map(|o| o.items).unwrap_or_default(); + assert!(all.len() > super::MAX_ITEMS && all.is_sorted(), "every command, sorted as a line's first word offers them"); + assert!(offer(r#"task.addStep {"title": "labels.st"#).is_none(), "another key's value"); + let keys = |s: &str| offer(s).map(|o| o.items).unwrap_or_default(); + assert_eq!(keys(r#"task.addStep {"command": "labels.style", "params": {"si"#), ["size"]); + let s = r#"task.addStep {"params": {"si"#; + let after = super::offer_at(&format!(r#"{s}"}}, "command": "labels.style"}}"#), s.chars().count(), &ids, &app.session); + assert_eq!(after.map(|o| o.items), Some(vec!["size".to_string()]), "the command after params"); + assert!(keys(r#"task.addStep {"params": {""#).is_empty(), "no command"); + assert!(keys(r#"task.addStep {"command": "nope", "params": {""#).is_empty(), "an unknown command"); + let help = |s: &str| { + super::hint_at(s, s.chars().count()).and_then(|h| super::help_at(&app.session, s, s.chars().count(), &h)).map(|h| h.id) + }; + assert_eq!(help(r#"task.addStep {"command": "labels.style", "params": {"#).as_deref(), Some("labels.style")); + assert_eq!(help(r#"task.addStep {"command": "labels.style", "#).as_deref(), Some("task.addStep"), "outside params"); + } + + /// In the pane: a step's command is offered and closed on accept, then its keys in `params`. + #[test] + fn a_steps_command_then_its_key_is_accepted() { + let mut h = pane(); + type_in(&mut h, r#"task.addStep {"command": "labels.st"#); + h.key_press(egui::Key::Enter); + h.run(); + type_in(&mut h, r#", "params": {"si"#); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, r#"task.addStep {"command": "labels.style", "params": {"size": "#); + } + + /// What follows a key's cursor, up to the end of its object: past the key's closing quote, + /// nested braces and strings with braces in them; all of it when nothing closes it. + #[test] + fn object_rest_stops_at_the_keys_object() { + use super::object_rest; + assert_eq!(object_rest(r#"me": "a"}, {"name": "b"}]}"#), r#"me": "a"}"#); + assert_eq!(object_rest(r#"": {"b": [1]}, "c": "}"}, "d": 1}"#), r#"": {"b": [1]}, "c": "}"}"#, "nested, and a brace in a string"); + assert_eq!(object_rest(r#"a\"b": 1}"#), r#"a\"b": 1}"#, "an escaped quote in the key"); + assert_eq!(object_rest(r#"": 1, "b""#), r#"": 1, "b""#, "not closed"); + assert_eq!(object_rest(""), ""); + } } diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 0fdaf7213..c3ea817f5 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -236,12 +236,16 @@ when every key may be left out; a command with no parameters gets none, and a li parameters keeps them. While you type a command's parameters (the cursor inside its `{…}`), the same help stays by the cursor; Escape closes it. A quote that starts a key in a command's braces opens the list of its keys not given yet, beside the highlighted key's description; accepting one closes it with its colon -(`"size": `). In a `gp.run` line, a quote after `"tool": ` opens the list of the tools, beside the -highlighted tool's help; accepting one closes it. In its `"params": {…}`, the keys offered are those of the tool its -`"tool"` names (by id or name, before or after `params`), beside the highlighted key's kind, name, -default and help; there the help by the cursor is the tool's. A `{` typed closes itself, the cursor inside, unless text follows it or it's in a -string or a comment; a `}` typed before a `}` steps over it, and Backspace in an empty `{}` deletes -both. Pasting adds nothing. +(`"size": `). The same goes in a key's own object, or in each object of its list, for the keys the +command's description gives it (`topology.rules {"add": {"rule": …}}`). In a `gp.run` line, a +quote after `"tool": ` opens the list of the tools, beside the highlighted tool's help; accepting +one closes it. In its `"params": {…}`, the keys offered are those of the tool its `"tool"` names +(by id or name, before or after `params`), beside the highlighted key's kind, name, default and +help; there the help by the cursor is the tool's. A `task.addStep` line works the same way: its +`"command": "` offers the commands, and its `"params"` that command's keys and help. A `{` or `[` +typed closes itself, the cursor inside, unless text follows it or it's in a string or a comment; +a `}` or `]` typed before the same one steps over it, and Backspace in an empty `{}` or `[]` +deletes both. Pasting adds nothing. ## ModelBuilder From 6f89228f4f2c8e3920b4a505ce41338fcfee7ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ninh=20H=E1=BA=A3i=20=C4=90=C4=83ng?= Date: Fri, 23 Oct 2026 11:17:07 +0700 Subject: [PATCH 048/297] fix(engine): keep a polygon's outline when its fill color changes (#721) * fix(engine): keep a polygon's outline when its fill color changes layer.color went through Symbol::with_fill, which resets a polygon's stroke to fill.shade(0.6), so changing Color in the Symbol editor overwrote the Outline color the user had chosen. layer.color now only changes the fill; the outline is set by layer.outline. * test(engine): pin layer.color's fill and default outline; document it Address review: assert the new fill RGB, cover the never-customised outline, and say in layer.color's params that a polygon's outline is left to layer.outline. --- crates/engine/src/cmd/layer.rs | 10 +++++++--- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 9d7246ab2..87a74d2b9 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -117,8 +117,12 @@ pub fn commands() -> Vec { let g = l.geometry_type(); let fill = |sym: &rustgis_map::Symbol| { let mut out = sym.with_fill(c, g); - if explicit_alpha && g == GeometryType::Polygon { - out.fill = c; + if g == GeometryType::Polygon { + // The outline is its own setting (`layer.outline`); only the fill changes. + out.stroke = sym.stroke; + if explicit_alpha { + out.fill = c; + } } out }; @@ -130,7 +134,7 @@ pub fn commands() -> Vec { }) }) .when(need_feature_layer) - .params(r##"{"layer"?, "color": "#RRGGBB"|"#RRGGBBAA"}"##), + .params(r##"{"layer"?, "color": "#RRGGBB"|"#RRGGBBAA" (the fill; a polygon's outline is left as it is, set it with layer.outline)}"##), C::new("layer.outline", "Outline", "Style › Drawing", |s, p| { let c = s.get_color(p, "color")?; let w = f(p, "width").map(|w| w.clamp(0.0, 20.0) as f32); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9bfe9a1e1..2c79b66cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -11843,6 +11843,23 @@ fn layer_color_honours_an_explicit_fill_alpha() { assert_eq!(alpha(&s), 150); } +#[test] +fn layer_color_leaves_the_outline_alone() { + let mut s = world(); + let symbol = |s: &Session| s.map().layer_by_name("Countries").unwrap().renderer.base_symbol(); + let rgb = |c: rustgis_map::Rgba| (c.r(), c.g(), c.b()); + // An outline that was never set stays as it was too. + let default_stroke = symbol(&s).stroke; + s.run("layer.color", &json!({"layer": "Countries", "color": "#336699"})).unwrap(); + assert_eq!(symbol(&s).stroke, default_stroke, "a default outline is not re-derived from the new fill"); + // A chosen outline survives a fill change. + s.run("layer.outline", &json!({"layer": "Countries", "color": "#00FF00"})).unwrap(); + let before = symbol(&s).stroke; + s.run("layer.color", &json!({"layer": "Countries", "color": "#996633"})).unwrap(); + assert_eq!(symbol(&s).stroke, before, "changing the fill must not change a chosen outline"); + assert_eq!(rgb(symbol(&s).fill), (0x99, 0x66, 0x33), "changing the fill must set the requested RGB"); +} + #[test] fn buffer_point_adds_only_the_buffer() { let mut s = world(); From c8be2f0d47fe11f39840b75ae17897f9f0c6d5dc Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:14:12 +0200 Subject: [PATCH 049/297] =?UTF-8?q?feat(ui):=20Home=20=E2=80=BA=20Add=20Da?= =?UTF-8?q?ta=20=E2=80=BA=20Connections=E2=80=A6=20and=20New=20Connection?= =?UTF-8?q?=20menu=20(#699)=20(#714)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): Home > Add Data > Connections… and New Connection menu (#699) * test(ui): skip database kinds on wasm in the New Connection test --- crates/ui-egui/src/chrome.rs | 32 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/ribbon.rs | 12 ++++++++++++ docs/user-guide/workspace.md | 6 ++++++ 4 files changed, 52 insertions(+) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2a9937468..7ee32236d 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -623,6 +623,7 @@ pub(crate) const UI_COMMANDS: &[(&str, &str, &str)] = &[ ("ui.addUrl", "Add Data From URL…", "Home › Add"), ("ui.addZarr", "Add Zarr Variable…", "Home › Add"), ("ui.addService", "Add From Service…", "Home › Add"), + ("ui.openSources", "Connections…", "Home › Add Data"), ("ui.searchOpenData", "Search Open Data…", "Home › Web Data"), ("ui.stac.planet", "Planet Open Data…", "Home › Web Data"), ("ui.stac.portolan", "Portolan…", "Home › Web Data"), @@ -1263,4 +1264,35 @@ mod tests { assert!(l.height.is_finite() && (34.0..=80.0).contains(&l.height), "{l:?}"); } } + + #[test] + fn connections_entry_opens_sources_without_ever_closing_it() { + let mut app = RustGisApp::with_sample(); + app.run("ui.pane.sources", serde_json::Value::Null).unwrap(); + app.run("ui.pane.sources", serde_json::Value::Null).unwrap(); + assert_eq!(app.ui.left, None, "the View entry still toggles"); + app.run("ui.openSources", serde_json::Value::Null).unwrap(); + assert_eq!(app.ui.left, Some(crate::LeftPane::Sources), "opens from closed"); + app.run("ui.openSources", serde_json::Value::Null).unwrap(); + assert_eq!(app.ui.left, Some(crate::LeftPane::Sources), "idempotent when already open"); + app.ui.left = Some(crate::LeftPane::Catalog); + app.run("ui.openSources", serde_json::Value::Null).unwrap(); + assert_eq!(app.ui.left, Some(crate::LeftPane::Sources), "replaces another left pane"); + app.run("ui.pane.sources", serde_json::Value::Null).unwrap(); + assert_eq!(app.ui.left, None, "toggle closes it again"); + } + + #[test] + fn every_new_connection_entry_opens_its_form() { + for (kind, _) in crate::connectionui::KINDS { + // The menu leaves database kinds out on the web, where `open_new` refuses them. + if cfg!(target_arch = "wasm32") && rustgis_engine::connections::DATABASE_KINDS.contains(&kind) { + continue; + } + let mut app = RustGisApp::with_sample(); + app.run("ui.connection", serde_json::json!({"kind": kind})).unwrap(); + assert_eq!(app.dialogs.open, Some("newConnection"), "{kind}"); + assert_eq!(app.ui.connection.kind, kind); + } + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 100a0abbd..4c61d737b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1639,6 +1639,8 @@ impl RustGisApp { "ui.pane.contents" => self.ui.left = if self.ui.left == Some(LeftPane::Contents) { None } else { Some(LeftPane::Contents) }, "ui.pane.catalog" => self.ui.left = if self.ui.left == Some(LeftPane::Catalog) { None } else { Some(LeftPane::Catalog) }, "ui.pane.sources" => self.ui.left = if self.ui.left == Some(LeftPane::Sources) { None } else { Some(LeftPane::Sources) }, + // Unlike `ui.pane.sources` this never closes the pane: a menu entry that opens it. + "ui.openSources" => self.ui.left = Some(LeftPane::Sources), "ui.pane.geoprocessing" => toggle_right(&mut self.ui, RightPane::Geoprocessing), "ui.pane.symbology" => { // Showing its tab already open needs no layer. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index aae95f093..5f0f96ed3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -443,6 +443,18 @@ fn home_tab(app: &mut RustGisApp, ui: &mut Ui) { #[cfg(not(target_arch = "wasm32"))] menu_item(ui, app, "", "Database…", "ui.dialog", json!({"name": "addDatabase"})); menu_item(ui, app, "", "S3…", "ui.dialog", json!({"name": "s3"})); + // Saved connections live in the Data Sources pane, not here: one entry opens the pane + // (never closes it), and New Connection starts the form for a kind. + menu_item(ui, app, "", "Connections…", "ui.openSources", Value::Null); + submenu_row(ui, "", tl!("New Connection"), |ui| { + for (kind, label) in crate::connectionui::KINDS { + // Database drivers aren't built for the browser. + if cfg!(target_arch = "wasm32") && rustgis_engine::connections::DATABASE_KINDS.contains(&kind) { + continue; + } + menu_item(ui, app, "", label, "ui.connection", json!({"kind": kind})); + } + }); menu_item(ui, app, "graphics_layer", "Graphics Layer", "graphics.newLayer", Value::Null); ui.separator(); sample_data_gallery(ui, app); diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index 603335b2e..d54d6d435 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -8,6 +8,12 @@ Tools for working across maps and views, and for setting up the app the way you offline areas as cards (double-click to open); **Item Pop-ups** add hover details. - **Pane Sets** save the panes you have open and bring them back. The **Workflow Manager** tracks jobs (new, in progress, on hold, done) with the area each covers. +- **Connections** (Home › Add Data › **Connections…**) opens the Data Sources pane on its saved + connections; it only ever opens the pane, so it is safe to choose when the pane is already open + (**View › Windows › Data Sources** still shows or hides it). **Add Data › New Connection** lists + each kind of connection (PostgreSQL/PostGIS, MySQL/MariaDB and SQL Server are left out in the web + app) and opens its form. Saved connections are listed in the pane, not in the menu. Command + Search finds **Connections…** by name. - **Tasks** (Analyze › Tasks, or View › Tasks) steps through a task's instructions: read the current step and do it yourself, with **Show Me** to find where, or click **Run** to have it done. - **Show Me** points at the step's command: From 7e32baa0991af86edd3fd5c5b51929627bd095dc Mon Sep 17 00:00:00 2001 From: Mavrick Fitzgerald Date: Fri, 23 Oct 2026 06:16:49 -0500 Subject: [PATCH 050/297] Add imperial unit support across analysis, editing and measurement (#719) * feat: add imperial units across analysis, editing and measurement * fix: address imperial unit review findings * fix: harden imperial unit preferences and readouts * fix: select default editing units consistently * Honor imperial display units across profiles and distance controls --- crates/analysis/src/tools.rs | 36 +- crates/engine/src/cmd/design.rs | 34 +- crates/engine/src/cmd/gp.rs | 2 +- crates/engine/src/geometry_calc.rs | 17 +- crates/engine/tests/geometry_calculator.rs | 28 ++ crates/engine/tests/measurement_units.rs | 119 +++++++ crates/geom/src/lib.rs | 23 ++ crates/ui-egui/src/chrome.rs | 1 + crates/ui-egui/src/editpanes.rs | 41 ++- crates/ui-egui/src/labelpane.rs | 4 +- crates/ui-egui/src/lib.rs | 29 +- crates/ui-egui/src/mapview.rs | 18 +- crates/ui-egui/src/measure_units.rs | 68 ++++ crates/ui-egui/src/ribbon.rs | 17 +- crates/ui-egui/src/widgets.rs | 1 + crates/ui-egui/src/workspacepanes.rs | 18 +- crates/ui-egui/tests/measurement_units.rs | 379 +++++++++++++++++++++ docs/user-guide/analysis.md | 2 + docs/user-guide/editing.md | 22 +- docs/user-guide/maps.md | 11 + 20 files changed, 820 insertions(+), 50 deletions(-) create mode 100644 crates/engine/tests/measurement_units.rs create mode 100644 crates/ui-egui/src/measure_units.rs create mode 100644 crates/ui-egui/tests/measurement_units.rs diff --git a/crates/analysis/src/tools.rs b/crates/analysis/src/tools.rs index 1ffc9ec2f..109aaf622 100644 --- a/crates/analysis/src/tools.rs +++ b/crates/analysis/src/tools.rs @@ -10,7 +10,7 @@ use crate::convert::{from_line, from_multipolygon, from_point, to_geo, to_multil use crate::geodesic::geodesic_buffer; use crate::{Inputs, Output, OutputLayer, ParamKind as K, ParamSpec as P, ToolResult, ToolSpec}; -const UNITS: &[&str] = &["Kilometers", "Meters", "Miles"]; +const UNITS: &[&str] = &["Kilometers", "Meters", "Miles", "Feet"]; const KRIGING_MODELS: &[&str] = &["Spherical", "Exponential", "Gaussian"]; const MATCH_OPTIONS: &[&str] = &["Intersects", "Contains", "Within"]; const JOIN_OPERATIONS: &[&str] = &["Join one to one", "Join one to many"]; @@ -220,7 +220,8 @@ pub static TOOLS: &[ToolSpec] = &[ params: &[ P { name: "input", label: "Input Features", kind: K::Layer, required: true, default: "", help: "" }, P { name: "select", label: "Selecting Features", kind: K::Layer, required: true, default: "", help: "" }, - P { name: "distance", label: "Search Distance (km)", kind: K::Number, required: false, default: "0", help: "" }, + P { name: "distance", label: "Search Distance", kind: K::Number, required: false, default: "0", help: "" }, + P { name: "units", label: "Units", kind: K::Choice(UNITS), required: false, default: "\"Kilometers\"", help: "" }, P { name: "invert", label: "Invert", kind: K::Choice(&["No", "Yes"]), required: false, default: "\"No\"", help: "" }, ], run: select_by_location, @@ -507,12 +508,10 @@ fn feature_lat(g: &Geometry) -> f64 { /// A distance in `units` (one of [`UNITS`], as `resolve_params` spells it; empty for the default, /// kilometres) in metres. Any other unit is an error, never kilometres (#406). fn to_metres(d: f64, units: &str) -> Result { - match units { - "Meters" => Ok(d), - "Miles" => Ok(d * 1609.344), - "Kilometers" | "" => Ok(d * 1000.0), - other => Err(format!("Units must be one of {} (not '{other}')", UNITS.join(", "))), - } + let unit = if units.is_empty() { "Kilometers" } else { units }; + rustgis_geom::distance_unit_metres(&unit.to_ascii_lowercase()) + .map(|factor| d * factor) + .ok_or_else(|| format!("Units must be one of {} (not '{units}')", UNITS.join(", "))) } fn result(ds: Dataset, msg: String) -> Result { @@ -820,7 +819,7 @@ fn simplify(inp: &dyn Inputs, p: &Json) -> Result { fn select_by_location(inp: &dyn Inputs, p: &Json) -> Result { let ds = input(inp, p, "input")?; let sel_ds = input(inp, p, "select")?; - let dist = num(p, "distance").unwrap_or(0.0).max(0.0) * 1000.0; + let dist = to_metres(num(p, "distance").unwrap_or(0.0).max(0.0), s(p, "units"))?; if dist > 5_000_000.0 { return Err("Distance is too large (limit 5000 km)".into()); } @@ -1497,6 +1496,23 @@ mod tests { } } + #[test] + fn feet_buffer_has_the_requested_ground_radius() { + let mut pts = Dataset::new("p", GeometryType::Point, vec![]); + let center = Coord::new(-105.9378, 35.687); + pts.push(Some(Geometry::Point(center)), vec![]); + let m = Map(HashMap::from([("p".to_string(), pts)])); + let r = crate::run("buffer", &m, &serde_json::json!({"input": "p", "distance": 500, "units": "feet"})).unwrap(); + assert_eq!(r.messages, vec!["Buffer: 1 features at 500 Feet"]); + let ds = layer(r); + let Geometry::Polygon(polys) = ds.features[0].geometry.as_ref().unwrap() else { panic!("expected buffer polygon") }; + // 500 international feet is exactly 152.4 metres, at every vertex. + for vertex in polys.iter().flatten().flatten() { + let radius = rustgis_geom::haversine_m(center, *vertex); + assert!((radius - 152.4).abs() < 0.05, "radius: {radius}"); + } + } + /// Calculate Geometry stores full-precision values (#513): rounding to 0.01 km² gave every /// polygon under half a hectare an area of 0, and lines under 5 m a length of 0. #[test] @@ -1527,7 +1543,7 @@ mod tests { let m = Map(HashMap::from([("p".to_string(), pts)])); let buffer = |units: serde_json::Value| crate::run("buffer", &m, &serde_json::json!({"input": "p", "distance": 500, "units": units})); - for bad in ["meter", "Meter", "feet", "m", "km"] { + for bad in ["meter", "Meter", "foot", "m", "km"] { let e = buffer(serde_json::json!(bad)).err().unwrap_or_default(); assert!(e.contains("Kilometers, Meters, Miles") && e.contains(bad), "{bad}: {e}"); } diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index b8426fe50..f5f9c7ef7 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -297,17 +297,18 @@ pub fn commands() -> Vec { C::new("edit.snap", "Snap Point", "Edit › Snapping", snap).view().params(r#"{"lon", "lat"}"#), // Modify features. C::new("edit.move", "Move", "Edit › Features › Modify", |s, p| { - let (mut dx, mut dy) = (f(p, "dx").unwrap_or(0.0), f(p, "dy").unwrap_or(0.0)); + let unit = distance_unit_metres(p)?; + let (mut dx, mut dy) = (f(p, "dx").unwrap_or(0.0) * unit, f(p, "dy").unwrap_or(0.0) * unit); if let Some((k, rot)) = s.map().editing.settings.ground_to_grid { (dx, dy) = ground_to_grid(dx, dy, k, rot); } if !(dx.is_finite() && dy.is_finite()) || dx.abs() > 4e7 || dy.abs() > 4e7 { - return Err("dx and dy are metres east and north".into()); + return Err("dx and dy must be finite distances in the command's units (limit: 40,000 km per axis after ground-to-grid)".into()); } modify(s, |g| Some(g.map_coords(&|c: Coord| shift(c, dx, dy)))) }) .when(need_editable_selection) - .params(r#"{"dx": metres east, "dy": metres north}"#), + .params(r#"{"dx": distance east, "dy": distance north, "units"?: "Meters" (default)|"Feet"|"Kilometers"|"Miles"}"#), C::new("edit.rotate", "Rotate", "Edit › Features › Modify", |s, p| { let deg = f(p, "angle").ok_or("angle is required")?; modify(s, |g| { @@ -361,7 +362,7 @@ pub fn commands() -> Vec { C::new("edit.explode", "Explode", "Edit › Features › Modify", explode).when(need_editable_selection), C::new("edit.split", "Split", "Edit › Features › Modify", split).when(need_editable_selection).params(r#"{"lon", "lat"}"#), C::new("edit.traverse", "Traverse", "Edit › Features › Modify", traverse).when(need_editable).params( - r#"{"layer"?, "start": [lon, lat], "legs": [{"distance": metres, "bearing": degrees from north}]} (closed in a polygon layer, open in a line layer)"#, + r#"{"layer"?, "start": [lon, lat], "units"?: "Meters" (default)|"Feet"|"Kilometers"|"Miles", "legs": [{"distance": number, "bearing": degrees from north}]} (closed in a polygon layer, open in a line layer)"#, ), C::new("edit.tools", "Editor Tools", "Edit › Tools", |s, _| { let tools: Vec = s.registry.commands.iter().filter(|c| c.location.starts_with("Edit › Features")).map(|c| json!({"id": c.id, "label": c.label, "params": c.params})).collect(); @@ -373,7 +374,13 @@ pub fn commands() -> Vec { let mode = ps(p, "mode").ok_or("mode is required")?; let e = match mode { "none" => Elevation::None, - "constant" => Elevation::Constant(f(p, "z").ok_or("z is required for a constant elevation")?.clamp(-12_000.0, 100_000.0)), + "constant" => { + let z = f(p, "z").ok_or("z is required for a constant elevation")? * distance_unit_metres(p)?; + if !z.is_finite() { + return Err("elevation is too large".into()); + } + Elevation::Constant(z.clamp(-12_000.0, 100_000.0)) + }, "surface" => { let n = ps(p, "surface").ok_or("surface (a raster layer) is required")?; let l = s.map().layer_by_name(n).filter(|l| l.is_raster()).ok_or_else(|| format!("no raster layer '{n}'"))?; @@ -384,7 +391,7 @@ pub fn commands() -> Vec { s.map_mut().editing.settings.elevation = e; Ok(Json::Null) }) - .params(r#"{"mode": "none"|"constant"|"surface", "z"?: metres, "surface"?: raster layer}"#), + .params(r#"{"mode": "none"|"constant"|"surface", "z"?: number, "units"?: "Meters" (default)|"Feet"|"Kilometers"|"Miles", "surface"?: raster layer}"#), C::new("edit.groundToGrid", "Ground To Grid", "Edit › Corrections", |s, p| { let on = b(p, "on").unwrap_or(true); let k = f(p, "scale").unwrap_or(1.0); @@ -404,6 +411,17 @@ pub fn commands() -> Vec { ] } +/// Distance inputs use the Buffer tool's unit names; existing editing scripts default to metres. +fn distance_unit_metres(p: &Json) -> Result { + let units = match p.get("units") { + None | Some(Json::Null) => "Meters", + Some(Json::String(v)) => v.trim(), + _ => return Err("units must be Meters, Feet, Kilometers or Miles".into()), + }; + let unit = if units.is_empty() { "Meters" } else { units }; + rustgis_geom::distance_unit_metres(&unit.to_ascii_lowercase()).ok_or_else(|| "units must be Meters, Feet, Kilometers or Miles".into()) +} + /// A point moved by metres east and north (Web Mercator stretched to the point's latitude). fn shift(c: Coord, dx: f64, dy: f64) -> Coord { let k = 1.0 / c.y.to_radians().cos().max(0.01); @@ -658,14 +676,16 @@ fn traverse(s: &mut Session, p: &Json) -> CmdResult { if legs.is_empty() || legs.len() > 10_000 { return Err("give 1 to 10,000 legs".into()); } + let unit = distance_unit_metres(p)?; let g2g = s.map().editing.settings.ground_to_grid; let mut pts = vec![start]; for leg in legs { let d = leg .get("distance") .and_then(Json::as_f64) + .map(|d| d * unit) .filter(|d| d.is_finite() && *d >= 0.0 && *d < 2e7) - .ok_or("each leg has a distance in metres")?; + .ok_or("each leg has a finite, non-negative distance in the command's units (limit: less than 20,000 km)")?; let brg = leg.get("bearing").and_then(Json::as_f64).filter(|b| b.is_finite()).ok_or("each leg has a bearing in degrees")?; let (mut dx, mut dy) = (d * brg.to_radians().sin(), d * brg.to_radians().cos()); if let Some((k, rot)) = g2g { diff --git a/crates/engine/src/cmd/gp.rs b/crates/engine/src/cmd/gp.rs index 9fcaf7075..4276822c3 100644 --- a/crates/engine/src/cmd/gp.rs +++ b/crates/engine/src/cmd/gp.rs @@ -16,7 +16,7 @@ pub fn commands() -> Vec { r#"{"target": layer, "join": layer, "match"?: "Intersects" (default) | "Contains" | "Within", "operation"?: "Join one to one" (default) | "Join one to many", "keepAll"?: "Keep all" (default) | "Keep only matching", "prefix"?: "join_", "output"?: text}"#, ), C::new("gp.bufferPoint", "Buffer A Point", "Analyze › Geoprocessing", buffer_point).params( - r#"{"lon": number, "lat": number, "distance": number, "units"?: "Kilometers" (default) | "Meters" | "Miles", "name"?: output}"#, + r#"{"lon": number, "lat": number, "distance": number, "units"?: "Kilometers" (default) | "Meters" | "Miles" | "Feet", "name"?: output}"#, ), C::new("gp.tools", "Tools", "Analyze › Geoprocessing", |_, _| { Ok(Value::Array( diff --git a/crates/engine/src/geometry_calc.rs b/crates/engine/src/geometry_calc.rs index 6a4b1245b..73df963f9 100644 --- a/crates/engine/src/geometry_calc.rs +++ b/crates/engine/src/geometry_calc.rs @@ -18,7 +18,14 @@ pub fn properties(kind: GeometryType) -> &'static [(&'static str, &'static str)] /// The units `property` can be calculated in, as `(key, label)` pairs. pub fn units(property: &str) -> &'static [(&'static str, &'static str)] { match property { - "area" => &[("m2", "Square metres"), ("km2", "Square kilometres"), ("ha", "Hectares"), ("ac", "Acres")], + "area" => &[ + ("m2", "Square metres"), + ("km2", "Square kilometres"), + ("ha", "Hectares"), + ("ac", "Acres"), + ("ft2", "Square feet"), + ("mi2", "Square miles"), + ], "length" | "perimeter" => &[("m", "Metres"), ("km", "Kilometres"), ("ft", "Feet"), ("mi", "Miles")], "x" | "y" => &[("deg", "Degrees (WGS84)"), ("projected", "Projected CRS units")], _ => &[], @@ -99,11 +106,13 @@ pub fn run(s: &mut Session, p: &Json) -> CmdResult { } let divisor = match unit { "km" => 1e3, - "ft" => 0.3048, - "mi" => 1609.344, + "ft" => rustgis_geom::METRES_PER_FOOT, + "mi" => rustgis_geom::METRES_PER_MILE, "km2" => 1e6, + "ft2" => rustgis_geom::SQUARE_METRES_PER_SQUARE_FOOT, + "mi2" => rustgis_geom::SQUARE_METRES_PER_SQUARE_MILE, "ha" => 1e4, - "ac" => 4046.8564224, + "ac" => rustgis_geom::SQUARE_METRES_PER_ACRE, _ => 1.0, }; let mut values = Vec::with_capacity(rows.len()); diff --git a/crates/engine/tests/geometry_calculator.rs b/crates/engine/tests/geometry_calculator.rs index fb2c5cb24..c957ea391 100644 --- a/crates/engine/tests/geometry_calculator.rs +++ b/crates/engine/tests/geometry_calculator.rs @@ -162,3 +162,31 @@ fn polygons_across_the_antimeridian_are_measured() { assert!(s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap() > 0.0); } } + +#[test] +fn area_in_square_feet_is_43560_per_acre() { + let ring = vec![Coord::new(0.0, 0.0), Coord::new(0.001, 0.0), Coord::new(0.001, 0.001), Coord::new(0.0, 0.001), Coord::new(0.0, 0.0)]; + let (mut s, id) = session(GeometryType::Polygon, vec![Some(Geometry::Polygon(vec![vec![ring]]))]); + let mut p = json!({"layer": id, "field": "result", "property": "area", "unit": "ac", "scope": "all", "overwrite": true}); + s.run("edit.calculateGeometry", &p).unwrap(); + let acres = s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap(); + p["unit"] = json!("ft2"); + s.run("edit.calculateGeometry", &p).unwrap(); + let feet = s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap(); + assert!((feet / acres - 43_560.0).abs() < 1e-8); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.map().layer(id).unwrap().dataset.value(0, 0).as_f64(), Some(acres)); +} + +#[test] +fn area_in_square_miles_is_one_per_640_acres() { + let ring = vec![Coord::new(0.0, 0.0), Coord::new(1.0, 0.0), Coord::new(1.0, 1.0), Coord::new(0.0, 1.0), Coord::new(0.0, 0.0)]; + let (mut s, id) = session(GeometryType::Polygon, vec![Some(Geometry::Polygon(vec![vec![ring]]))]); + let mut p = json!({"layer": id, "field": "result", "property": "area", "unit": "ac", "scope": "all", "overwrite": true}); + s.run("edit.calculateGeometry", &p).unwrap(); + let acres = s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap(); + p["unit"] = json!("mi2"); + s.run("edit.calculateGeometry", &p).unwrap(); + let miles = s.map().layer(id).unwrap().dataset.value(0, 0).as_f64().unwrap(); + assert!((acres / miles - 640.0).abs() < 1e-10); +} diff --git a/crates/engine/tests/measurement_units.rs b/crates/engine/tests/measurement_units.rs new file mode 100644 index 000000000..70f48f38a --- /dev/null +++ b/crates/engine/tests/measurement_units.rs @@ -0,0 +1,119 @@ +//! Units are interpreted at the command boundary; stored geometry stays in lon/lat. +use rustgis_engine::{ + Project, Session, + data::Geometry, + geom::{Coord, lonlat_to_merc}, +}; +use serde_json::json; + +fn paths() -> Session { + let mut s = Session::new(Project::default()); + s.run("layer.template", &json!({"template": "lines", "name": "Paths"})).unwrap(); + s.run("edit.addFeature", &json!({"geometry": {"type": "LineString", "coordinates": [[0, 0], [0, 0.001]]}})).unwrap(); + s.run("select.rows", &json!({"layer": "Paths", "rows": [0]})).unwrap(); + s +} + +fn first(s: &Session) -> Coord { + let Geometry::Line(parts) = s.map().layer_by_name("Paths").unwrap().dataset.features[0].geometry.as_ref().unwrap() else { + panic!("line") + }; + parts[0][0] +} + +#[test] +fn move_feet_converts_before_ground_to_grid_and_undo_restores_geometry() { + let mut s = paths(); + s.run("edit.groundToGrid", &json!({"scale": 1.5, "rotation": 0})).unwrap(); + s.run("edit.move", &json!({"dx": 500, "dy": -100, "units": "Feet"})).unwrap(); + let p = lonlat_to_merc(first(&s)); + assert!((p.x - 228.6).abs() < 1e-6 && (p.y + 45.72).abs() < 1e-6, "{p:?}"); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(first(&s), Coord::new(0.0, 0.0)); + // Existing scripts keep metres when no unit is supplied. + s.run("edit.groundToGrid", &json!({"on": false})).unwrap(); + s.run("edit.move", &json!({"dx": 500})).unwrap(); + assert!((lonlat_to_merc(first(&s)).x - 500.0).abs() < 1e-6); +} + +#[test] +fn traverse_feet_and_metres_draw_the_same_line() { + let mut feet = paths(); + let mut metres = paths(); + feet.run("edit.groundToGrid", &json!({"scale": 1.5, "rotation": 20})).unwrap(); + metres.run("edit.groundToGrid", &json!({"scale": 1.5, "rotation": 20})).unwrap(); + feet.run( + "edit.traverse", + &json!({"start": [-84, 36], "units": "feet", "legs": [{"distance": 500, "bearing": 90}, {"distance": 300, "bearing": 0}]}), + ) + .unwrap(); + metres + .run("edit.traverse", &json!({"start": [-84, 36], "legs": [{"distance": 152.4, "bearing": 90}, {"distance": 91.44, "bearing": 0}]})) + .unwrap(); + let geometry = |s: &Session| s.map().layer_by_name("Paths").unwrap().dataset.features.last().unwrap().geometry.clone(); + assert_eq!(geometry(&feet), geometry(&metres)); + feet.run("edit.undo", &json!({})).unwrap(); + assert_eq!(feet.map().layer_by_name("Paths").unwrap().dataset.features.len(), 1); +} + +#[test] +fn constant_elevation_feet_are_stored_in_metres() { + let mut s = paths(); + s.run("edit.elevation", &json!({"mode": "constant", "z": 1000, "units": "Feet"})).unwrap(); + assert_eq!(s.map().editing.settings.elevation, rustgis_engine::map::design::Elevation::Constant(304.8)); + s.run("edit.elevation", &json!({"mode": "constant", "z": 1000})).unwrap(); + assert_eq!(s.map().editing.settings.elevation, rustgis_engine::map::design::Elevation::Constant(1000.0)); +} + +#[test] +fn select_by_location_feet_selects_only_features_within_the_radius() { + use rustgis_engine::data::{Dataset, GeometryType}; + let mut s = paths(); + let mut points = Dataset::new("Parcels", GeometryType::Point, vec![]); + // About 111 m and 222 m from the origin: only the first is within 500 ft. + points.push(Some(Geometry::Point(Coord::new(0.001, 0.0))), vec![]); + points.push(Some(Geometry::Point(Coord::new(0.002, 0.0))), vec![]); + let id = s.map_mut().add_dataset(points); + let mut center = Dataset::new("Site", GeometryType::Point, vec![]); + center.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + s.map_mut().add_dataset(center); + let mut p = json!({"tool": "selectByLocation", "params": {"input": "Parcels", "select": "Site", "distance": 500, "units": "Feet"}}); + s.run("gp.run", &p).unwrap(); + assert_eq!(s.map().layer(id).unwrap().selection, vec![0]); + p["params"]["distance"] = json!(0.1524); + p["params"].as_object_mut().unwrap().remove("units"); + s.run("gp.run", &p).unwrap(); + assert_eq!(s.map().layer(id).unwrap().selection, vec![0], "omitted units still mean kilometres"); +} + +#[test] +fn invalid_editing_units_and_scaled_overflow_leave_the_project_unchanged() { + for (command, params, overflow) in [ + ("edit.move", json!({"dx": 1, "dy": 1}), json!({"dx": f64::MAX, "units": "Miles"})), + ( + "edit.traverse", + json!({"start": [0, 0], "legs": [{"distance": 1, "bearing": 90}]}), + json!({"start": [0, 0], "legs": [{"distance": f64::MAX, "bearing": 90}], "units": "Miles"}), + ), + ("edit.elevation", json!({"mode": "constant", "z": 1}), json!({"mode": "constant", "z": f64::MAX, "units": "Miles"})), + ] { + let mut s = paths(); + let before = serde_json::to_value(&s.project).unwrap(); + let undo = s.undo_label(); + let unchanged = |s: &Session| { + assert_eq!(serde_json::to_value(&s.project).unwrap(), before, "{command} changed the project after an error"); + assert_eq!(s.undo_label(), undo, "{command} changed undo history after an error"); + }; + for units in [json!(7), json!(true), json!(["Feet"]), json!({"name": "Feet"}), json!("parsecs")] { + let mut p = params.clone(); + p["units"] = units; + let error = s.run(command, &p).unwrap_err(); + assert!(error.contains("units"), "{command}: {error}"); + unchanged(&s); + } + let error = s.run(command, &overflow).unwrap_err(); + assert!(error.contains("large") || error.contains("distance"), "{command}: {error}"); + unchanged(&s); + assert!(s.run("info.project", &json!({})).is_ok(), "{command} left an unusable session"); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 2849c866b..f2a05175f 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -22,6 +22,29 @@ pub const MAX_LAT: f64 = 85.051_128_78; /// Screen pixel size assumed when converting resolution to a map scale (0.28 mm, OGC). pub const PIXEL_M: f64 = 0.000_28; +/// Metres per international foot (not a US survey foot). +pub const METRES_PER_FOOT: f64 = 0.3048; +/// Metres per international mile. +pub const METRES_PER_MILE: f64 = 1609.344; +/// Square metres per international square foot. +pub const SQUARE_METRES_PER_SQUARE_FOOT: f64 = 0.09290304; +/// Square metres per international square mile. +pub const SQUARE_METRES_PER_SQUARE_MILE: f64 = 2_589_988.110336; +/// Square metres per international acre. +pub const SQUARE_METRES_PER_ACRE: f64 = 4046.8564224; + +/// Metres per unit for a lowercase distance unit name. Callers choose defaults and normalize input. +/// Feet are international feet, not US survey feet. +pub fn distance_unit_metres(unit: &str) -> Option { + match unit { + "meters" => Some(1.0), + "feet" => Some(METRES_PER_FOOT), + "kilometers" => Some(1000.0), + "miles" => Some(METRES_PER_MILE), + _ => None, + } +} + /// A 2-D coordinate. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Coord { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 7ee32236d..6eb7506c8 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -642,6 +642,7 @@ pub(crate) const UI_COMMANDS: &[(&str, &str, &str)] = &[ ("ui.pane.bookmarks", "Bookmarks Pane", "Home › Navigate › Bookmarks"), ("ui.theme", "Toggle Dark Mode", "View"), ("ui.map.gpu", "Toggle GPU Rendering", "View"), + ("ui.measurementUnits", "Toggle Imperial Measurements", "Home › Inspect › Measure"), ("ui.exportMap", "Export Map…", "Publish › Output"), ("ui.checkUpdate", "Check for Updates…", "Help › Help"), ]; diff --git a/crates/ui-egui/src/editpanes.rs b/crates/ui-egui/src/editpanes.rs index 0b2fb14b6..fa5b9dd8a 100644 --- a/crates/ui-egui/src/editpanes.rs +++ b/crates/ui-egui/src/editpanes.rs @@ -9,11 +9,16 @@ use serde_json::{Value, json}; use crate::RustGisApp; use crate::theme::{Tokens, semibold}; +pub(crate) const DISTANCE_UNITS: &[&str] = &["Meters", "Feet", "Kilometers", "Miles"]; +pub(crate) const DISTANCE_UNITS_PREF: &str = "editing_distance_units"; + /// Text typed into the panes' forms. #[derive(Default)] pub struct Forms { pub dx: String, pub dy: String, + /// Remembered units for Move and constant elevation. + pub distance_units: String, pub angle: String, pub factor: String, pub template: String, @@ -111,6 +116,24 @@ pub fn editing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }); + let mut units_changed = false; + ui.horizontal(|ui| { + let unit_label = ui.label(tl!("Distance units")); + let _ = egui::ComboBox::from_id_salt(DISTANCE_UNITS_PREF) + .selected_text(tl!(&app.forms.distance_units)) + .show_ui(ui, |ui| { + for &unit in DISTANCE_UNITS { + units_changed |= ui.selectable_value(&mut app.forms.distance_units, unit.to_string(), tl!(unit)).changed(); + } + }) + .response + .labelled_by(unit_label.id); + }); + if units_changed && let Some(save) = &app.services.save_pref { + save(DISTANCE_UNITS_PREF, &app.forms.distance_units); + } + let units = app.forms.distance_units.clone(); + heading(ui, "Modify Selected Features"); // Texts alone (#516): of these tools only Move applies to them (the others turn, scale or cut // shapes, and a text is a point); they are formatted in the Format pane. @@ -120,15 +143,15 @@ pub fn editing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }; let not_for_texts = tl!("Not for texts: a text is edited in the Format pane."); egui::Grid::new("modify").num_columns(3).spacing(vec2(6.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Move (m)")); + ui.label(tl!("Move")); ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut app.forms.dx).hint_text("east").desired_width(52.0)); - ui.add(egui::TextEdit::singleline(&mut app.forms.dy).hint_text("north").desired_width(52.0)); + ui.add(egui::TextEdit::singleline(&mut app.forms.dx).hint_text(tl!("east")).desired_width(52.0)); + ui.add(egui::TextEdit::singleline(&mut app.forms.dy).hint_text(tl!("north")).desired_width(52.0)); }); if ui.button(tl!("Move")).clicked() { let (tx, ty) = (app.forms.dx.clone(), app.forms.dy.clone()); if let (Some(dx), Some(dy)) = (number(app, &tx, "East"), number(app, &ty, "North")) { - run(app, "edit.move", json!({"dx": dx, "dy": dy})); + run(app, "edit.move", json!({"dx": dx, "dy": dy, "units": units})); } } ui.end_row(); @@ -240,13 +263,15 @@ pub fn editing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if ui.radio(mode == "none", tl!("None")).clicked() { run(app, "edit.elevation", json!({"mode": "none"})); } - if ui.radio(mode == "constant", tl!("Constant")).clicked() { + let constant = ui.radio(mode == "constant", tl!("Constant")).clicked(); + ui.add(egui::TextEdit::singleline(&mut app.forms.z).hint_text(tl!("z")).desired_width(60.0)); + let apply = ui.button(tl!("Apply elevation")).clicked(); + if constant || apply { let t = app.forms.z.clone(); if let Some(z) = number(app, &t, "Elevation") { - run(app, "edit.elevation", json!({"mode": "constant", "z": z})); + run(app, "edit.elevation", json!({"mode": "constant", "z": z, "units": units})); } } - ui.add(egui::TextEdit::singleline(&mut app.forms.z).hint_text("z (m)").desired_width(60.0)); for r in &rasters { let on = matches!(&settings.elevation, Elevation::Surface(n) if n == r); if ui.radio(on, crate::i18n::fmt("Surface: {layer}", &[("layer", r)])).clicked() { @@ -255,6 +280,8 @@ pub fn editing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }); + ui.weak(tl!("The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units.")); + heading(ui, "Ground To Grid"); ui.horizontal(|ui| { let on = settings.ground_to_grid.is_some(); diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 89e742601..1735ee942 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -240,7 +240,9 @@ fn visibility(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { for (label, cur, key) in [("Closest", l.labels.default.closest, "closest"), ("Farthest", l.labels.default.farthest, "farthest")] { ui.label(tl!(label)); - let text = |d: Option| d.map(rustgis_engine::geom::format_distance).unwrap_or_else(|| tl!("").to_string()); + let imperial = app.ui.measurement_imperial; + let text = + |d: Option| d.map(|d| crate::measure_units::distance(d, imperial)).unwrap_or_else(|| tl!("").to_string()); egui::ComboBox::from_id_salt(("label_pane_distance", key)).selected_text(text(cur)).width(140.0).show_ui(ui, |ui| { for d in std::iter::once(None).chain(DISTANCES.iter().map(|d| Some(*d))) { if ui.selectable_label(d == cur, text(d)).clicked() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c61d737b..6bbef2e47 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,6 +41,7 @@ mod localagents; mod mapgpu; mod mapmenu; mod mapview; +mod measure_units; mod modelbuilder; mod navui; mod nldiui; @@ -441,6 +442,8 @@ pub struct UiState { /// `panes::web_data_name`). pub web_data: &'static str, pub dark: bool, + /// Live measurement and map scale-bar units, chosen in Home › Measure. + pub measurement_imperial: bool, /// The theme follows the system's light or dark setting until the user picks one. pub dark_follows_system: bool, /// Interface language preference: a language code or `auto`. @@ -628,6 +631,7 @@ impl Default for UiState { right_open: Vec::new(), web_data: "opendata", dark: false, + measurement_imperial: false, dark_follows_system: true, language: i18n::AUTO.into(), worldview: i18n::AUTO.into(), @@ -833,7 +837,7 @@ impl RustGisApp { table: table::TableState::default(), layout: layoutview::LayoutView::default(), graphic_format: graphicformat::GraphicFormat::default(), - forms: editpanes::Forms::default(), + forms: editpanes::Forms { distance_units: "Meters".into(), ..editpanes::Forms::default() }, workflow: analysispane::Workflow::default(), mb: modelbuilder::ModelBuilder::default(), ws: workspacepanes::WorkspaceState::default(), @@ -906,6 +910,17 @@ impl RustGisApp { self.session.raster_defaults = d.sanitized(); } self.ui.raster_defaults_saved = self.session.raster_defaults.clone(); + self.forms.distance_units = self + .services + .load_pref + .as_ref() + .and_then(|load| load(editpanes::DISTANCE_UNITS_PREF)) + .and_then(|value| { + editpanes::DISTANCE_UNITS.iter().find(|unit| unit.eq_ignore_ascii_case(value.trim())).map(|unit| (*unit).to_owned()) + }) + .unwrap_or_else(|| "Meters".into()); + self.ui.measurement_imperial = + self.services.load_pref.as_ref().and_then(|load| load("measurement_units")).as_deref() == Some("imperial"); // The GPU map backend: RUSTGIS_GPU forces it on, else the saved preference. self.map.gpu_enabled = std::env::var_os("RUSTGIS_GPU").is_some() || self.services.load_pref.as_ref().and_then(|load| load("ui.map.gpu")).as_deref() == Some("1"); @@ -1819,6 +1834,18 @@ impl RustGisApp { self.ui.dark = p.get("dark").and_then(Value::as_bool).unwrap_or(!self.ui.dark); self.ui.dark_follows_system = false; } + "ui.measurementUnits" => { + let imperial = match p.get("units") { + None => !self.ui.measurement_imperial, + Some(Value::String(v)) if v.eq_ignore_ascii_case("metric") => false, + Some(Value::String(v)) if v.eq_ignore_ascii_case("imperial") => true, + _ => return Err(tl!("Measurement units must be metric or imperial").into()), + }; + self.ui.measurement_imperial = imperial; + if let Some(save) = &self.services.save_pref { + save("measurement_units", if imperial { "imperial" } else { "metric" }); + } + } "ui.map.gpu" => { self.map.gpu_enabled = p.get("on").and_then(Value::as_bool).unwrap_or(!self.map.gpu_enabled); // `"remember": false` turns it on or off for this visit only (a `?gpu` link). diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 4e305a8e6..b375a3a8f 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -2099,8 +2099,8 @@ fn overlays(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t: crate::i18n::fmt( "Total {total} · Segment {segment}", &[ - ("total", &rustgis_engine::geom::format_distance(geodesic_length_m(&pts))), - ("segment", &rustgis_engine::geom::format_distance(seg)), + ("total", &crate::measure_units::distance(geodesic_length_m(&pts), app.ui.measurement_imperial)), + ("segment", &crate::measure_units::distance(seg, app.ui.measurement_imperial)), ], ) } else { @@ -2111,8 +2111,8 @@ fn overlays(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t: crate::i18n::fmt( "Area {area} · Perimeter {perimeter}", &[ - ("area", &rustgis_engine::geom::format_area(geodesic_ring_area_m2(&ring))), - ("perimeter", &rustgis_engine::geom::format_distance(geodesic_length_m(&ring))), + ("area", &crate::measure_units::area(geodesic_ring_area_m2(&ring), app.ui.measurement_imperial)), + ("perimeter", &crate::measure_units::distance(geodesic_length_m(&ring), app.ui.measurement_imperial)), ], ) }; @@ -2206,7 +2206,7 @@ fn overlays(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t: fn scale_bar(app: &RustGisApp, p: &egui::Painter, rect: Rect, t: &Tokens) { let m_per_pt = app.session.map().ground_res(); - let d = rustgis_engine::geom::nice_distance(m_per_pt * 130.0); + let d = crate::measure_units::scale_distance(m_per_pt * 130.0, app.ui.measurement_imperial); if d <= 0.0 { return; } @@ -2221,7 +2221,13 @@ fn scale_bar(app: &RustGisApp, p: &egui::Painter, rect: Rect, t: &Tokens) { } p.rect_stroke(Rect::from_min_size(pos2(x0, y0), vec2(len, 5.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Middle); p.text(pos2(x0, y0 - 3.0), Align2::LEFT_BOTTOM, "0", regular(10.5), t.text); - p.text(pos2(x0 + len, y0 - 3.0), Align2::RIGHT_BOTTOM, rustgis_engine::geom::format_distance(d), regular(10.5), t.text); + p.text( + pos2(x0 + len, y0 - 3.0), + Align2::RIGHT_BOTTOM, + crate::measure_units::distance(d, app.ui.measurement_imperial), + regular(10.5), + t.text, + ); } fn north_arrow_rect(rect: Rect) -> Rect { diff --git a/crates/ui-egui/src/measure_units.rs b/crates/ui-egui/src/measure_units.rs new file mode 100644 index 000000000..92504281e --- /dev/null +++ b/crates/ui-egui/src/measure_units.rs @@ -0,0 +1,68 @@ +//! Display units for measurements and distance controls. Inputs stay in metres or square metres. +use rustgis_engine::geom; + +/// Keep nonzero measurements visible when rounding to whole units would erase them. +fn whole_or_small(value: f64) -> String { + let magnitude = value.abs(); + if magnitude > 0.0 && magnitude <= 0.5 { + if magnitude < 0.0001 { + format!("{value:.1e}") + } else { + // Two significant figures, with at most five decimal places in this range. + let precision = (1.0 - magnitude.log10().floor()) as usize; + format!("{value:.precision$}") + } + } else { + format!("{value:.0}") + } +} + +/// Format a distance supplied in metres, using feet/miles or the metric formatter. +/// Positive sub-foot values that would round to zero retain significant digits. +pub fn distance(m: f64, imperial: bool) -> String { + if !imperial { + return geom::format_distance(m); + } + let ft = m / geom::METRES_PER_FOOT; + if ft.round() < 5280.0 { + crate::i18n::fmt("{distance} ft", &[("distance", &whole_or_small(ft))]) + } else { + crate::i18n::fmt("{distance} mi", &[("distance", &format!("{:.1}", m / geom::METRES_PER_MILE))]) + } +} + +/// Format an elevation supplied in metres; imperial vertical measurements always use feet. +pub fn elevation(m: f64, imperial: bool) -> String { + if imperial { + crate::i18n::fmt("{distance} ft", &[("distance", &whole_or_small(m / geom::METRES_PER_FOOT))]) + } else { + crate::i18n::fmt("{distance} m", &[("distance", &rustgis_engine::map::symbology::fmt_num(m))]) + } +} + +/// Format square metres as square feet, acres or square miles, or use metric units. +/// Positive sub-square-foot values that would round to zero retain significant digits. +pub fn area(m2: f64, imperial: bool) -> String { + if !imperial { + return geom::format_area(m2); + } + if m2 < geom::SQUARE_METRES_PER_ACRE { + crate::i18n::fmt("{area} ft²", &[("area", &whole_or_small(m2 / geom::SQUARE_METRES_PER_SQUARE_FOOT))]) + } else if m2 < geom::SQUARE_METRES_PER_SQUARE_MILE { + crate::i18n::fmt("{area} acres", &[("area", &format!("{:.1}", m2 / geom::SQUARE_METRES_PER_ACRE))]) + } else { + crate::i18n::fmt("{area} mi²", &[("area", &format!("{:.1}", m2 / geom::SQUARE_METRES_PER_SQUARE_MILE))]) + } +} + +/// Choose a readable scale-bar length in the display units and return its length in metres. +pub fn scale_distance(max_m: f64, imperial: bool) -> f64 { + let unit = if !imperial { + 1.0 + } else if max_m < geom::METRES_PER_MILE { + geom::METRES_PER_FOOT + } else { + geom::METRES_PER_MILE + }; + geom::nice_distance(max_m / unit) * unit +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 5f0f96ed3..9f59714c6 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -503,6 +503,14 @@ fn home_tab(app: &mut RustGisApp, ui: &mut Ui) { app.map.cancel_sketch(); ui.close(); } + ui.separator(); + ui.label(tl!("Measurement units")); + for (imperial, units, label) in [(false, "metric", "Metric"), (true, "imperial", "Imperial")] { + if ui.radio(app.ui.measurement_imperial == imperial, tl!(label)).clicked() { + let _ = app.run("ui.measurementUnits", json!({"units": units})); + ui.close(); + } + } }); col(ui, |ui| { small(ui, app, "locate", "Locate", "Find a place by coordinates.", "ui.dialog", json!({"name": "goToXY"}), false); @@ -1860,7 +1868,10 @@ fn view_tab(app: &mut RustGisApp, ui: &mut Ui) { ui.close(); } for km in [100.0, 500.0, 1000.0, 3000.0] { - if ui.radio(clip == Some(km * 1000.0), rustgis_engine::geom::format_distance(km * 1000.0)).clicked() { + if ui + .radio(clip == Some(km * 1000.0), crate::measure_units::distance(km * 1000.0, app.ui.measurement_imperial)) + .clicked() + { let _ = app.run("map.clipping", json!({"mode": "distance", "distance": km * 1000.0})); ui.close(); } @@ -4162,7 +4173,9 @@ fn label_groups(app: &mut RustGisApp, ui: &mut Ui) { ] { small_menu(ui, app, "eye", label, desc, |ui, app| { for d in [None, Some(1_000.0), Some(10_000.0), Some(100_000.0), Some(1_000_000.0), Some(10_000_000.0)] { - let text = d.map(rustgis_engine::geom::format_distance).unwrap_or_else(|| tl!("").to_string()); + let text = d + .map(|d| crate::measure_units::distance(d, app.ui.measurement_imperial)) + .unwrap_or_else(|| tl!("").to_string()); if ui.radio(cur == d, text).clicked() { let _ = app.run("labels.distanceRange", json!({key: d})); ui.close(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f27b133b..ac9a25f37 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -236,6 +236,7 @@ fn small_menu_with( let t = Tokens::get(ui.ctx()); let tw = text_w(ui, label, regular(11.5)) + 20.0; let (r, resp) = ui.allocate_exact_size(vec2(26.0 + tw, 22.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), if label.is_empty() { desc } else { label })); bg(ui, r, &resp, false, &t); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(18.0, 18.0)), icon, t.icon, t.accent); ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.5), t.text); diff --git a/crates/ui-egui/src/workspacepanes.rs b/crates/ui-egui/src/workspacepanes.rs index 7f2a424bf..4b784a1bb 100644 --- a/crates/ui-egui/src/workspacepanes.rs +++ b/crates/ui-egui/src/workspacepanes.rs @@ -428,13 +428,15 @@ pub fn profile(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { return; } let (lo, hi) = (p["min"].as_f64().unwrap_or(0.0), p["max"].as_f64().unwrap_or(1.0).max(p["min"].as_f64().unwrap_or(0.0) + 1.0)); - let len = p["length"].as_f64().unwrap_or(1.0).max(1.0); + let len = p["length"].as_f64().unwrap_or(1.0); + // A degenerate profile still needs a plotting span; its reported length stays unchanged. + let plot_len = if len > 0.0 { len } else { 1.0 }; let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 150.0), Sense::hover()); let painter = ui.painter_at(rect); painter.rect_filled(rect, 4.0, t.canvas); let to = |(d, z): (f64, f64)| { pos2( - rect.min.x + 6.0 + (d / len) as f32 * (rect.width() - 12.0), + rect.min.x + 6.0 + (d / plot_len) as f32 * (rect.width() - 12.0), rect.max.y - 16.0 - ((z - lo) / (hi - lo)) as f32 * (rect.height() - 30.0), ) }; @@ -449,21 +451,21 @@ pub fn profile(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { stroke: egui::epaint::PathStroke::NONE, })); painter.add(egui::Shape::line(line, egui::Stroke::new(2.0, t.accent))); - let fmt = |v: f64| rustgis_engine::map::symbology::fmt_num(v); - painter.text(rect.left_top() + vec2(6.0, 4.0), egui::Align2::LEFT_TOP, format!("{} m", fmt(hi)), regular(10.0), t.text_dim); - painter.text(rect.left_bottom() + vec2(6.0, -4.0), egui::Align2::LEFT_BOTTOM, format!("{} m", fmt(lo)), regular(10.0), t.text_dim); + let fmt = |v: f64| crate::measure_units::elevation(v, app.ui.measurement_imperial); + painter.text(rect.left_top() + vec2(6.0, 4.0), egui::Align2::LEFT_TOP, fmt(hi), regular(10.0), t.text_dim); + painter.text(rect.left_bottom() + vec2(6.0, -4.0), egui::Align2::LEFT_BOTTOM, fmt(lo), regular(10.0), t.text_dim); painter.text( rect.right_bottom() + vec2(-6.0, -4.0), egui::Align2::RIGHT_BOTTOM, - rustgis_engine::geom::format_distance(len), + crate::measure_units::distance(len, app.ui.measurement_imperial), regular(10.0), t.text_dim, ); ui.label( egui::RichText::new(crate::i18n::fmt( - "Length {length} · gain {gain} m · loss {loss} m", + "Length {length} · gain {gain} · loss {loss}", &[ - ("length", &rustgis_engine::geom::format_distance(len)), + ("length", &crate::measure_units::distance(len, app.ui.measurement_imperial)), ("gain", &fmt(p["gain"].as_f64().unwrap_or(0.0))), ("loss", &fmt(p["loss"].as_f64().unwrap_or(0.0))), ], diff --git a/crates/ui-egui/tests/measurement_units.rs b/crates/ui-egui/tests/measurement_units.rs new file mode 100644 index 000000000..f86d21efc --- /dev/null +++ b/crates/ui-egui/tests/measurement_units.rs @@ -0,0 +1,379 @@ +//! Headless interaction tests for the units users choose and the results they see. +use egui_kittest::kittest::{NodeT, Queryable}; +use rustgis_engine::{Project, Session, data::Geometry, geom::lonlat_to_merc}; +use rustgis_ui_egui::{RustGisApp, Services}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, RustGisApp> { + harness_with_services(Services::default()) +} + +fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, RustGisApp> { + let mut app = RustGisApp::new(Session::new(Project::default())).with_services(services); + app.run("layer.template", json!({"template": "lines", "name": "Paths"})).unwrap(); + app.run("edit.addFeature", json!({"geometry": {"type": "LineString", "coordinates": [[0, 0], [0, 0.001]]}})).unwrap(); + app.run("select.rows", json!({"layer": "Paths", "rows": [0]})).unwrap(); + egui_kittest::Harness::builder().with_size(egui::vec2(1800.0, 1000.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ) +} + +fn first_x_metres(h: &egui_kittest::Harness<'_, RustGisApp>) -> f64 { + let Geometry::Line(parts) = h.state().session.map().layer_by_name("Paths").unwrap().dataset.features[0].geometry.as_ref().unwrap() + else { + panic!("line") + }; + lonlat_to_merc(parts[0][0]).x +} + +fn choose_editing_units(h: &mut egui_kittest::Harness<'_, RustGisApp>, unit: &str) { + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Distance units").click(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, unit).click(); + h.step(); +} + +#[test] +fn editing_pane_moves_in_feet() { + let mut h = harness(); + h.state_mut().run("ui.pane.editing", json!({})).unwrap(); + h.state_mut().forms.dx = "500".into(); + h.state_mut().forms.dy = "0".into(); + for _ in 0..3 { + h.step(); + } + choose_editing_units(&mut h, "Feet"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Move").click(); + h.step(); + let Geometry::Line(parts) = h.state().session.map().layer_by_name("Paths").unwrap().dataset.features[0].geometry.as_ref().unwrap() + else { + panic!("line") + }; + assert!((lonlat_to_merc(parts[0][0]).x - 152.4).abs() < 1e-6); +} + +#[test] +fn editing_pane_sets_constant_elevation_in_feet() { + let mut h = harness(); + h.state_mut().run("ui.pane.editing", json!({})).unwrap(); + h.state_mut().forms.z = "1000".into(); + for _ in 0..3 { + h.step(); + } + choose_editing_units(&mut h, "Feet"); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Constant").click(); + h.step(); + assert_eq!(h.state().session.map().editing.settings.elevation, rustgis_engine::map::design::Elevation::Constant(304.8)); +} + +#[test] +fn editing_units_are_remembered_and_applied_explicitly() { + use std::sync::{Arc, Mutex}; + let saved = Arc::new(Mutex::new(Vec::new())); + let writes = saved.clone(); + let mut h = harness_with_services(Services { + save_pref: Some(Box::new(move |key, value| writes.lock().unwrap().push((key.to_owned(), value.to_owned())))), + ..Services::default() + }); + h.state_mut().run("edit.elevation", json!({"mode": "constant", "z": 1000})).unwrap(); + h.state_mut().forms.z = "1000".into(); + h.state_mut().run("ui.pane.editing", json!({})).unwrap(); + for _ in 0..3 { + h.step(); + } + choose_editing_units(&mut h, "Feet"); + assert!(saved.lock().unwrap().contains(&("editing_distance_units".into(), "Feet".into())), "editing units were not saved"); + assert_eq!(h.state().session.map().editing.settings.elevation, rustgis_engine::map::design::Elevation::Constant(1000.0)); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Apply elevation").click(); + h.step(); + assert_eq!(h.state().session.map().editing.settings.elevation, rustgis_engine::map::design::Elevation::Constant(304.8)); + drop(h); + + let reads = saved.clone(); + let mut restored = harness_with_services(Services { + load_pref: Some(Box::new(move |key| reads.lock().unwrap().iter().rev().find(|(k, _)| k == key).map(|(_, v)| v.clone()))), + ..Services::default() + }); + restored.state_mut().forms.dx = "500".into(); + restored.state_mut().forms.dy = "0".into(); + restored.state_mut().run("ui.pane.editing", json!({})).unwrap(); + for _ in 0..3 { + restored.step(); + } + restored.get_by_role_and_label(egui::accesskit::Role::Button, "Move").click(); + restored.step(); + assert!((first_x_metres(&restored) - 152.4).abs() < 1e-6); + + // Unset and obsolete preferences retain the existing metre default. + for value in [None, Some("unsupported")] { + let mut h = harness_with_services(Services { + load_pref: Some(Box::new(move |key| if key == "editing_distance_units" { value.map(str::to_owned) } else { None })), + ..Services::default() + }); + h.state_mut().forms.dx = "500".into(); + h.state_mut().forms.dy = "0".into(); + h.state_mut().run("ui.pane.editing", json!({})).unwrap(); + for _ in 0..3 { + h.step(); + } + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Distance units").click(); + h.step(); + let metres = h.get_by_role_and_label(egui::accesskit::Role::Button, "Meters"); + assert_eq!(metres.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "default metres option is not selected"); + metres.click(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Move").click(); + h.step(); + assert!((first_x_metres(&h) - 500.0).abs() < 1e-6); + } +} + +fn click_map(h: &mut egui_kittest::Harness<'_, RustGisApp>, at: egui::Pos2) { + h.event(egui::Event::PointerMoved(at)); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + for _ in 0..3 { + h.step(); + } +} + +fn painted_text(h: &egui_kittest::Harness<'_, RustGisApp>) -> Vec { + fn collect(shape: &egui::Shape, text: &mut Vec) { + match shape { + egui::Shape::Text(t) => text.push(t.galley.text().to_string()), + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, text); + } + } + _ => {} + } + } + let mut text = Vec::new(); + for shape in &h.output().shapes { + collect(&shape.shape, &mut text); + } + text +} + +fn profile_harness(length: f64) -> egui_kittest::Harness<'static, RustGisApp> { + let mut h = harness(); + let grid = "ncols 2\nnrows 2\nxllcorner 0\nyllcorner 0\ncellsize 1\n30 30\n30 30\n"; + let raster = rustgis_engine::data::raster::ascii::read(grid, "Terrain", None).unwrap(); + h.state_mut() + .session + .map_mut() + .add_raster("Terrain", rustgis_engine::map::RasterLayer::from_bytes(raster, "terrain.asc", grid.as_bytes().to_vec())); + h.state_mut().ws.profile = Some(json!({ + "length": length, "min": 30, "max": 40, "gain": 10, "loss": 0, + "samples": [{"distance": 0, "elevation": 30}, {"distance": length, "elevation": 40}] + })); + h.state_mut().run("ui.pane.profile", json!({})).unwrap(); + for _ in 0..3 { + h.step(); + } + h +} + +#[test] +fn measurement_preference_updates_an_existing_profile_length() { + let mut h = profile_harness(1000.0); + assert!(painted_text(&h).iter().any(|s| s.starts_with("Length 1.0 km"))); + h.state_mut().run("ui.measurementUnits", json!({"units": "imperial"})).unwrap(); + h.step(); + let text = painted_text(&h); + assert!(text.iter().any(|s| s.starts_with("Length 3281 ft")), "{text:?}"); + assert!(text.iter().any(|s| s == "3281 ft"), "profile chart still uses metric: {text:?}"); + h.state_mut().run("ui.measurementUnits", json!({"units": "metric"})).unwrap(); + h.step(); + assert!(painted_text(&h).iter().any(|s| s.starts_with("Length 1.0 km"))); +} + +#[test] +fn half_foot_profile_elevations_and_gain_remain_nonzero() { + let mut h = profile_harness(1000.0); + // A rise from half a foot below sea level to sea level is exactly half a foot. + h.state_mut().ws.profile = Some(json!({ + "length": 1000, "min": -0.1524, "max": 0, "gain": 0.1524, "loss": 0, + "samples": [{"distance": 0, "elevation": -0.1524}, {"distance": 1000, "elevation": 0}] + })); + h.state_mut().run("ui.measurementUnits", json!({"units": "imperial"})).unwrap(); + h.step(); + let text = painted_text(&h); + assert!(text.iter().any(|s| s.contains("gain 0.50 ft") && s.contains("loss 0 ft")), "{text:?}"); + assert!(text.iter().any(|s| s == "-0.50 ft"), "negative elevation lost its precision: {text:?}"); +} + +#[test] +fn short_profile_reports_its_measured_length() { + let mut h = profile_harness(0.1524); + h.state_mut().run("ui.measurementUnits", json!({"units": "imperial"})).unwrap(); + h.step(); + let text = painted_text(&h); + assert!(text.iter().any(|s| s.starts_with("Length 0.50 ft")), "{text:?}"); + assert!(text.iter().any(|s| s == "0.50 ft"), "chart distance differs from the measured length: {text:?}"); +} + +#[test] +fn distance_controls_follow_display_units_and_keep_metre_command_values() { + let mut h = harness(); + h.set_size(egui::vec2(3200.0, 1800.0)); + h.state_mut().run("labels.distanceRange", json!({"closest": 1000, "farthest": 100000})).unwrap(); + h.state_mut().run("ui.pane.labeling", json!({"layer": "Paths"})).unwrap(); + for _ in 0..3 { + h.step(); + } + assert!(painted_text(&h).iter().any(|s| s == "1.0 km")); + h.state_mut().run("ui.measurementUnits", json!({"units": "imperial"})).unwrap(); + h.step(); + let text = painted_text(&h); + assert!(text.iter().any(|s| s == "3281 ft") && text.iter().any(|s| s == "62.1 mi"), "{text:?}"); + + h.state_mut().run("ui.pane.labeling", json!({})).unwrap(); + h.state_mut().run("ui.tab", json!({"tab": "Style"})).unwrap(); + for _ in 0..3 { + h.step(); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "Closest").click(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "6.2 mi").click(); + h.step(); + assert_eq!(h.state().session.map().layer_by_name("Paths").unwrap().labels.default.closest, Some(10000.0)); + + h.state_mut().run("ui.tab", json!({"tab": "View"})).unwrap(); + for _ in 0..3 { + h.step(); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "View Clipping").click(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "62.1 mi").click(); + h.step(); + assert_eq!(h.state().session.map().view.clip_distance, Some(100000.0)); + + h.state_mut().run("ui.measurementUnits", json!({"units": "metric"})).unwrap(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "View Clipping").click(); + h.step(); + assert!(h.query_by_role_and_label(egui::accesskit::Role::RadioButton, "100 km").is_some()); + assert_eq!(h.state().session.map().view.clip_distance, Some(100000.0)); +} + +#[test] +fn measure_menu_switches_live_distance_and_area_readouts_to_imperial() { + let mut h = harness(); + h.state_mut().run("map.goToXY", json!({"lon": 0, "lat": 0, "scale": 1000})).unwrap(); + h.state_mut().run("tool.set", json!({"tool": "measure"})).unwrap(); + for _ in 0..3 { + h.step(); + } + let center = h.state().map.view_rect.unwrap().0.center(); + click_map(&mut h, center - egui::vec2(50.0, 0.0)); + click_map(&mut h, center + egui::vec2(50.0, 0.0)); + assert!(painted_text(&h).iter().any(|s| s.starts_with("Total ") && s.contains(" m"))); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Measure").click(); + for _ in 0..3 { + h.step(); + } + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Imperial").click(); + h.step(); + // At 1:1000, 100 screen points span about 28 m, or 92 ft at the equator. + let text = painted_text(&h); + assert!(text.iter().any(|s| s.starts_with("Total 92 ft")), "{text:?}"); + assert!(text.iter().any(|s| s == "100 ft"), "scale bar: {text:?}"); + h.state_mut().run("tool.set", json!({"tool": "measureArea"})).unwrap(); + h.state_mut().map.cancel_sketch(); + h.step(); + click_map(&mut h, center - egui::vec2(50.0, 50.0)); + click_map(&mut h, center + egui::vec2(50.0, -50.0)); + click_map(&mut h, center + egui::vec2(50.0, 50.0)); + let text = painted_text(&h); + assert!(text.iter().any(|s| s.starts_with("Area ") && s.contains("ft²") && s.contains("Perimeter") && s.ends_with("ft")), "{text:?}"); + for (scale, unit) in [(10_000, "acres"), (1_000_000, "mi²")] { + h.state_mut().map.cancel_sketch(); + h.state_mut().run("map.goToXY", json!({"lon": 0, "lat": 0, "scale": scale})).unwrap(); + for _ in 0..3 { + h.step(); + } + click_map(&mut h, center - egui::vec2(50.0, 50.0)); + click_map(&mut h, center + egui::vec2(50.0, -50.0)); + click_map(&mut h, center + egui::vec2(50.0, 50.0)); + let text = painted_text(&h); + assert!(text.iter().any(|s| s.starts_with("Area ") && s.contains(unit)), "{unit}: {text:?}"); + } +} + +#[test] +fn imperial_measurements_preserve_small_positive_distances_and_areas() { + let mut h = harness(); + h.state_mut().run("map.goToXY", json!({"lon": 0, "lat": 80, "scale": 1})).unwrap(); + h.state_mut().run("ui.measurementUnits", json!({"units": "imperial"})).unwrap(); + for _ in 0..3 { + h.step(); + } + let center = h.state().map.view_rect.unwrap().0.center(); + // At this latitude and the closest zoom, five screen points span about 0.13 m. + // A very thin polygon exercises tiny areas while keeping its corners far enough apart + // to be separate clicks, rather than duplicate-click gestures. + for (line_end, area_second, area_third) in [ + (egui::vec2(5.0, 0.0), egui::vec2(5.0, 0.0), egui::vec2(5.0, 5.0)), + (egui::vec2(0.05, 0.0), egui::vec2(20.0, 0.0), egui::vec2(-20.0, 0.0001)), + ] { + for (tool, prefix, unit) in [("measure", "Total ", "ft"), ("measureArea", "Area ", "ft²")] { + h.state_mut().run("tool.set", json!({"tool": tool})).unwrap(); + h.state_mut().map.cancel_sketch(); + h.step(); + click_map(&mut h, center); + // Separate nearby clicks in time so they are not interpreted as a double-click. + for _ in 0..32 { + h.step(); + } + click_map(&mut h, center + if tool == "measure" { line_end } else { area_second }); + if tool == "measureArea" { + for _ in 0..32 { + h.step(); + } + click_map(&mut h, center + area_third); + } + let text = painted_text(&h); + let readout = text.iter().find(|s| s.starts_with(prefix)).unwrap_or_else(|| panic!("{text:?}")); + let mut words = readout.split_whitespace(); + let value: f64 = words.nth(1).unwrap().parse().unwrap(); + assert_eq!(words.next(), Some(unit), "{readout}"); + assert!(value > 0.0, "positive measurement rounded to zero: {readout}"); + } + } +} + +#[test] +fn measurement_unit_choice_is_saved_restored_and_available_to_scripts() { + use std::sync::{Arc, Mutex}; + let saved = Arc::new(Mutex::new(Vec::new())); + let writes = saved.clone(); + let mut h = harness(); + h.state_mut().services.save_pref = Some(Box::new(move |k, v| writes.lock().unwrap().push((k.to_string(), v.to_string())))); + for _ in 0..3 { + h.step(); + } + h.get_by_role_and_label(egui::accesskit::Role::Button, "Measure").click(); + for _ in 0..3 { + h.step(); + } + h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Imperial").click(); + h.step(); + assert!(saved.lock().unwrap().contains(&("measurement_units".into(), "imperial".into()))); + let mut restored = RustGisApp::new(Session::new(Project::default())).with_services(Services { + load_pref: Some(Box::new(|k| (k == "measurement_units").then(|| "imperial".into()))), + ..Services::default() + }); + assert!(restored.ui.measurement_imperial); + restored.run("ui.measurementUnits", json!({"units": "metric"})).unwrap(); + assert!(!restored.ui.measurement_imperial); + assert!(restored.run("ui.measurementUnits", json!({"units": "unknown"})).is_err()); + assert!(!restored.ui.measurement_imperial); +} diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index c3ea817f5..9d8a39886 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -49,6 +49,8 @@ result is added to the map, and the run is listed under **Analyze › History**. line or a script (`gp.run`), a built-in tool's parameter with a list of choices, such as Buffer's units, takes one of them in any letter case (`"meters"` for `Meters`); any other value is an error that names the choices (Buffer's units no longer take the abbreviations `m`, `mi` or `metres`). +Buffer and Select Layer By Location accept **Kilometers**, **Meters**, **Miles** and **Feet**. Feet are international feet +(exactly 0.3048 metres): a 500-foot notification radius is 152.4 metres on the ground. The History pane lists each run with when it ran (in UTC). Its **⋯** menu, or a right-click on the tool's name, copies a run as code, with the parameters it ran with: **Copy as Python** (a diff --git a/docs/user-guide/editing.md b/docs/user-guide/editing.md index 9338a40ff..b31a468f6 100644 --- a/docs/user-guide/editing.md +++ b/docs/user-guide/editing.md @@ -8,7 +8,8 @@ the selected features. Edit attributes in the attribute table (++ctrl+t++): doub to change it. **Data › Data Design** (and **Data › Fields**) adds and deletes fields and calculates a field from an expression; **Calculate Geometry** (in the attribute table's toolbar) fills a new or existing numeric field with each feature's area, perimeter, length or X/Y coordinate, in the units -you choose, for all features or the selected ones. Every edit is one undo step. +you choose, for all features or the selected ones. Length and perimeter include feet and miles; +area includes square feet (`ft2`), acres (`ac`) and square miles (`mi2`). Every edit is one undo step. ## Modifying features @@ -18,9 +19,13 @@ you choose, for all features or the selected ones. Every edit is one undo step. The Edit tab's **Edit** button turns editing on or off for the map; with it off, the map is -read-only. The Editing pane (Settings, Templates, Status, Modify) holds the rest: +read-only. The Editing pane (Settings, Templates, Status, Modify) holds the rest. Its **Distance units** +selector sets the units used for Move and constant elevation. The choice is remembered independently +of the live map's measurement units. The entered numbers are interpreted in the selected units when +you click **Move**, **Constant** or **Apply elevation**. The current elevation stays in effect while +you edit the value or choose units; click **Apply elevation** to apply the new inputs. -- **Modify**: move the selected features by metres east and north, rotate them, scale them, +- **Modify**: move the selected features by distances east and north, rotate them, scale them, merge them (polygons are unioned), explode multipart features, split a line where you click, and flip line direction. A single point turns and scales on itself, so Rotate and Scale leave points (text graphics included) as they are: with only points selected they change nothing, @@ -49,6 +54,17 @@ read-only. The Editing pane (Settings, Templates, Status, Modify) holds the rest have dangles, must be covered by); Validate lists the errors, and clicking one zooms to it. - **Status**: features added, changed and deleted per layer since the last save. +### Distance units in commands + +`edit.move`, `edit.traverse` and constant `edit.elevation` accept `"units": "Feet"`, +`"Meters"`, `"Kilometers"` or `"Miles"` (case insensitive). Omitting `units` keeps the +existing metre default. All traverse legs use the command's units. Conversion to metres +happens before ground-to-grid corrections and distance limits; stored elevation stays in +metres. Feet mean international feet (0.3048 m), independently of the source CRS's units. + +For example, `edit.move={"dx":500,"dy":0,"units":"Feet"}` moves selected features +152.4 metres east. + ## Data design The Data tab's Data Design pane keeps attributes valid, on every edit (creating, pasting, diff --git a/docs/user-guide/maps.md b/docs/user-guide/maps.md index e84bd17b7..3057ee952 100644 --- a/docs/user-guide/maps.md +++ b/docs/user-guide/maps.md @@ -15,6 +15,17 @@ projection; places that projection can't show are skipped. Type a scale in the s to it. More navigation tools are under [View effects](#view-effects) below. +**Measurement units.** In **Home › Inspect › Measure**, choose **Metric** or **Imperial**. +The choice is remembered and updates existing distance and area measurements, the live +map scale bar, elevation profiles, and distance labels and presets for label visibility +and view clipping. Imperial readouts use feet or miles for length, and square feet, acres +or square miles for area, according to size. Profile elevations and gain/loss use feet. +Feet are international feet (0.3048 m). +Scripts can set `ui.measurementUnits={"units":"imperial"}` or `{"units":"metric"}`; +calling it without `units` toggles the choice. Distance presets still send metre values +to commands. Editing tools, geoprocessing tools, directions and layout scale bars have +their own explicit unit controls. + **Zoom To Layer** in a layer's right-click menu shows that layer's extent instead of the whole map. It works for feature and raster data, PMTiles archives, cloud-optimised GeoTIFFs, and service tile layers whose capabilities advertise geographic bounds, including WMS. WMS 1.3 geographic From 60a11275fb0bfb931ffaeee7b0a7534b226cb8e9 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:19:23 +0200 Subject: [PATCH 051/297] feat(connections): reopen database layers on connection login and keep layer tokens out of saved projects (#723) * feat(connections): reopen database layers on connection login and keep layer tokens out of saved projects * fix(connections): address login classification and layer export review --- crates/data/src/lib.rs | 3 + crates/data/src/source/mysql.rs | 13 ++ crates/data/src/source/postgis.rs | 27 ++- crates/data/src/source/sqlserver.rs | 1 + crates/engine/src/cmd/connections.rs | 100 ++++++++++- crates/engine/src/cmd/items.rs | 16 +- crates/engine/src/cmd/project.rs | 15 +- crates/engine/src/connections.rs | 43 +++++ crates/engine/src/project.rs | 122 +++++++++++++- crates/engine/src/tests.rs | 242 +++++++++++++++++++++++++++ crates/map/src/lib.rs | 37 ++++ crates/map/src/source.rs | 32 ++-- crates/ui-egui/src/connectiontree.rs | 7 +- crates/ui-egui/src/connectionui.rs | 7 + crates/ui-egui/src/lib.rs | 17 +- crates/ui-egui/src/panes.rs | 13 ++ crates/ui-egui/src/sourcespane.rs | 5 + docs/user-guide/data.md | 23 ++- 18 files changed, 668 insertions(+), 55 deletions(-) diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index c22a63dae..390ded4df 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -46,6 +46,9 @@ pub enum DataError { Io(#[from] std::io::Error), #[error("{0}")] Format(String), + /// The server refused the login: no password, or a wrong one. + #[error("{0}")] + Login(String), #[error("unsupported file type: {0}")] Unsupported(String), /// A Zarr slice none of whose chunks is there (the array named): a step never written, diff --git a/crates/data/src/source/mysql.rs b/crates/data/src/source/mysql.rs index 1c69892be..128cb4492 100644 --- a/crates/data/src/source/mysql.rs +++ b/crates/data/src/source/mysql.rs @@ -215,6 +215,7 @@ pub struct Conn { fn err(e: mysql::Error) -> DataError { // The server's own message (a missing table, a refused password) rather than its code. match e { + mysql::Error::MySqlError(m) if matches!(m.code, 1045 | 1698) => DataError::Login(m.message), mysql::Error::MySqlError(m) => DataError::Format(m.message), e => DataError::Format(e.to_string()), } @@ -1026,6 +1027,18 @@ impl FeatureSource for MysqlSource { mod tests { use super::*; + #[test] + fn refused_login_is_not_a_missing_table() { + for code in [1045, 1698, 1146] { + let e = err(mysql::Error::MySqlError(mysql::MySqlError { state: "28000".into(), message: "Access denied".into(), code })); + if code == 1146 { + assert!(matches!(e, DataError::Format(_))); + } else { + assert!(matches!(e, DataError::Login(_))); + } + } + } + #[test] fn addresses_take_client_defaults_and_modes() { let c = config("mysql://me:pw@db.example:3307/gis?ssl-mode=VERIFY_IDENTITY").unwrap_or_else(|e| panic!("{e}")); diff --git a/crates/data/src/source/postgis.rs b/crates/data/src/source/postgis.rs index c298102e5..3e4da55ea 100644 --- a/crates/data/src/source/postgis.rs +++ b/crates/data/src/source/postgis.rs @@ -116,12 +116,18 @@ fn ident(s: &str) -> String { format!("\"{}\"", s.replace('"', "\"\"")) } +fn refused_login(e: &postgres::Error) -> bool { + // 28000 also covers access-policy and role failures that a password cannot fix. + matches!(e.code(), Some(&postgres::error::SqlState::INVALID_PASSWORD)) + // tokio-postgres exposes no typed discriminator for this client-side config error. + // The no-password handshake in the engine regression guards its exact source message. + || std::error::Error::source(e).is_some_and(|s| s.to_string() == "password missing") +} + fn err(e: postgres::Error) -> DataError { // The server's own message (a missing table, a failed password) rather than "db error". - match e.as_db_error() { - Some(db) => DataError::Format(db.message().to_string()), - None => DataError::Format(e.to_string()), - } + let message = e.as_db_error().map(|db| db.message().to_string()).unwrap_or_else(|| e.to_string()); + if refused_login(&e) { DataError::Login(message) } else { DataError::Format(message) } } // --------------------------------------------------------------------------------------------- @@ -196,11 +202,14 @@ fn open_client(config: &postgres::Config, tls: &Tls) -> Result Tls::Disable => config.connect(postgres::NoTls), Tls::Unverified | Tls::Verified(_) => config.connect(tokio_postgres_rustls::MakeRustlsConnect::new(rustls_config(tls)?)), } - .map_err(|e| match e.as_db_error() { - Some(db) => DataError::Format(db.message().to_string()), - // The cause ("password authentication failed", "connection refused"), not just "error - // connecting to server". - None => DataError::Format(std::error::Error::source(&e).map(|s| format!("{e}: {s}")).unwrap_or_else(|| e.to_string())), + .map_err(|e| { + let message = match e.as_db_error() { + Some(db) => db.message().to_string(), + // The cause ("password authentication failed", "connection refused"), not just + // "error connecting to server". + None => std::error::Error::source(&e).map(|s| format!("{e}: {s}")).unwrap_or_else(|| e.to_string()), + }; + if refused_login(&e) { DataError::Login(message) } else { DataError::Format(message) } })?; client .batch_execute(&format!( diff --git a/crates/data/src/source/sqlserver.rs b/crates/data/src/source/sqlserver.rs index 10436cc0b..5fa4bc3fb 100644 --- a/crates/data/src/source/sqlserver.rs +++ b/crates/data/src/source/sqlserver.rs @@ -151,6 +151,7 @@ pub struct Conn { fn err(e: tiberius::error::Error) -> DataError { match e { + tiberius::error::Error::Server(t) if t.code() == 18456 => DataError::Login(t.message().to_string()), tiberius::error::Error::Server(t) => DataError::Format(t.message().to_string()), e => DataError::Format(e.to_string()), } diff --git a/crates/engine/src/cmd/connections.rs b/crates/engine/src/cmd/connections.rs index b4f2e3c5a..904d1da86 100644 --- a/crates/engine/src/cmd/connections.rs +++ b/crates/engine/src/cmd/connections.rs @@ -27,7 +27,7 @@ pub fn commands() -> Vec { C::new("connection.duplicate", "Duplicate Connection", "Home › Add › Data Sources", duplicate).view().params(r#"{"id": "a connection's id or name", "name"?: text}"#), C::new("connection.remove", "Remove Connection", "Home › Add › Data Sources", remove).view().params(r#"{"id": "a connection's id or name"}"#), C::new("connection.login", "Log In to Connection", "Home › Add › Data Sources", login_command).view().params( - r#"{"id": "a connection's id or name", "password"?: "a database connection's, kept for this session only", "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "an S3 connection's keys (credentials: keys), kept for this session only"}"#, + r#"{"id": "a connection's id or name", "password"?: "a database connection's, kept for this session only; the project's layers that read its database and couldn't be read open again", "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "an S3 connection's keys (credentials: keys), kept for this session only"}"#, ), C::new("connection.test", "Test Connection", "Home › Add › Data Sources", test).view().params( r#"{"id"?: "a connection's id or name", "kind"?: "postgres"|"mysql"|"sqlserver"|"service"|"wms"|"wmts"|"wfs"|"wcs"|"ogcapi"|"arcgis"|"csw"|"stac"|"s3", "name"?: text, "group"?: text, "address"?: text, "options"?: "an unsaved connection, as connection.new (when no id)", "password"?: text, "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "as connection.login (logs in first)", "probe"?: "true: only the address the test reads, {fetch} (null for databases, which their driver tests)", "answer"?: "non-database kinds only: what that address returned, when the app fetched it (no network request then); databases ignore answer and test through their driver"}"#, @@ -83,6 +83,34 @@ fn options(p: &Value) -> Result, String> { .collect() } +/// Saved database connections, in list order, with how many project layers wait for their login. +pub(crate) fn waiting(s: &Session) -> Vec<(Connection, usize)> { + #[cfg(not(target_arch = "wasm32"))] + { + let mut counts = BTreeMap::::new(); + for layer in s.project.maps.iter().flat_map(|m| &m.layers) { + let Some(src) = layer.source.as_ref().filter(|src| src.login) else { continue }; + let Some((kind, database)) = crate::connections::layer_database(src) else { continue }; + if let Some(c) = s.connections.for_database(kind, database) { + *counts.entry(c.id.clone()).or_default() += 1; + } + } + s.connections.connections.iter().filter_map(|c| counts.get(&c.id).map(|&n| (c.clone(), n))).collect() + } + #[cfg(target_arch = "wasm32")] + { + let _ = s; + Vec::new() + } +} + +/// Says, once per connection, which layers wait for its login (a project just opened). +pub(crate) fn note_waiting(s: &mut Session) { + for (c, n) in waiting(s) { + s.notify(format!("{n} layer(s) read the database of the connection '{}' and need its password: right-click the connection in Data Sources › Connections, or one of the layers, and choose Log In…", c.name), false); + } +} + fn list(s: &mut Session, p: &Value) -> CmdResult { let kind = ps(p, "kind"); if let Some(k) = kind @@ -91,9 +119,16 @@ fn list(s: &mut Session, p: &Value) -> CmdResult { return Err(format!("unknown kind '{k}' ({})", KINDS.join(", "))); } let group = ps(p, "group"); + let waiting: BTreeMap<_, _> = waiting(s).into_iter().map(|(c, n)| (c.id, n)).collect(); Ok(json!({"connections": s.connections.connections.iter() .filter(|c| kind.is_none_or(|k| c.kind == k) && group.is_none_or(|g| c.group.chars().flat_map(char::to_lowercase).eq(g.chars().flat_map(char::to_lowercase)))) - .map(shown).collect::>() })) + .map(|c| { + let mut out = shown(c); + if let Some(&n) = waiting.get(&c.id) { + out["waiting"] = json!(n); + } + out + }).collect::>() })) } fn new(s: &mut Session, p: &Value) -> CmdResult { @@ -164,7 +199,53 @@ fn remove(s: &mut Session, p: &Value) -> CmdResult { Ok(json!({"removed": c.id})) } -fn login(s: &mut Session, c: &Connection, p: &Value) -> Result<(), String> { +#[derive(Default)] +struct Reopened { + opened: usize, + failed: usize, +} + +#[cfg(not(target_arch = "wasm32"))] +fn reopen(s: &mut Session, c: &Connection, password: &str) -> Reopened { + let Some(key) = crate::connections::database_key(&c.address) else { return Reopened::default() }; + let mut result = Reopened::default(); + let mut first_error = None; + for layer in s.project.maps.iter_mut().flat_map(|m| m.layers.iter_mut()) { + let Some(src) = layer.source.as_mut().filter(|src| src.login) else { continue }; + let Some((kind, database)) = crate::connections::layer_database(src) else { continue }; + if kind != key.0 || crate::connections::database_key(&crate::sources::redact(database)).as_ref() != Some(&key) { + continue; + } + if let Some(password_key) = crate::connections::database_key(database) { + rustgis_data::source::duckdb::remember_password(&password_key.1, password); + } + src.error = None; + match src.open() { + Ok(()) => result.opened += 1, + Err(e) => { + result.failed += 1; + first_error.get_or_insert(e); + } + } + } + if result.opened > 0 { + s.notify(format!("{} layer(s) from the connection '{}' opened again", result.opened, c.name), false); + } + if result.failed > 0 { + s.notify( + format!( + "{} layer(s) from the connection '{}' still can't be read: {}", + result.failed, + c.name, + first_error.as_deref().unwrap_or("") + ), + true, + ); + } + result +} + +fn login(s: &mut Session, c: &Connection, p: &Value) -> Result { if DATABASE_KINDS.contains(&c.kind.as_str()) { #[cfg(target_arch = "wasm32")] { @@ -174,7 +255,7 @@ fn login(s: &mut Session, c: &Connection, p: &Value) -> Result<(), String> { { let pw = ps(p, "password").filter(|t| !t.is_empty()).ok_or("password is required")?; super::layer::database_param(&json!({"database": c.address, "password": pw})); - return Ok(()); + return Ok(reopen(s, c, pw)); } } if c.kind != "s3" { @@ -205,13 +286,18 @@ fn login(s: &mut Session, c: &Connection, p: &Value) -> Result<(), String> { _ => q["anonymous"] = json!(true), } super::s3::connect(s, &q)?; - Ok(()) + Ok(Reopened::default()) } fn login_command(s: &mut Session, p: &Value) -> CmdResult { let c = found(s, p)?.clone(); - login(s, &c, p)?; - Ok(shown(&c)) + let result = login(s, &c, p)?; + let mut out = shown(&c); + if result.opened + result.failed > 0 { + out["reopened"] = json!(result.opened); + out["failed"] = json!(result.failed); + } + Ok(out) } fn tested(s: &Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/items.rs b/crates/engine/src/cmd/items.rs index 5645a692c..957abb1e1 100644 --- a/crates/engine/src/cmd/items.rs +++ b/crates/engine/src/cmd/items.rs @@ -173,7 +173,7 @@ pub fn commands() -> Vec { .params(r#"{"path": "*.rgistemplate", "name"?: template name}"#), C::new("project.fromTemplate", "Open Custom Template", "Home › Project", |s, p| { let text = read_text(p)?; - let mut proj = Project::from_json(&text).map_err(|e| e.replace("RustGIS project", "RustGIS template"))?; + let mut proj = Project::from_import_json(&text).map_err(|e| e.replace("RustGIS project", "RustGIS template"))?; proj.name = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Untitled").chars().take(200).collect(); super::project::reset_to(s, proj); s.path = None; @@ -417,7 +417,7 @@ fn import_maps(s: &mut Session, p: &Value) -> CmdResult { let text = read_text(p)?; let maps: Vec = match serde_json::from_str::(&text) { Ok(m) => vec![m], - Err(_) => Project::from_json(&text).map_err(|_| "not a RustGIS map file, project or package".to_string())?.maps, + Err(_) => Project::from_import_json(&text).map_err(|_| "not a RustGIS map file, project or package".to_string())?.maps, }; let mut names = Vec::new(); for mut m in maps.into_iter().take(100) { @@ -458,7 +458,7 @@ fn import_styles(s: &mut Session, p: &Value) -> CmdResult { } else if let Ok(m) = serde_json::from_str::(&text) { m.layers } else { - Project::from_json(&text) + Project::from_import_json(&text) .map_err(|_| "not a RustGIS layer file, map file or project".to_string())? .maps .into_iter() @@ -512,6 +512,7 @@ fn package_project(s: &Session) -> Result, String> { for m in &mut p.maps { *m = embedded_map(m, &mut budget); } + crate::project::strip_addresses(p.addresses_mut()); let text = serde_json::to_string(&p).map_err(|e| e.to_string())?; let readme = format!("{} — a RustGIS project package.\nOpen it with RustGIS (Project › Open), or unzip it for {}.rgis.\n", p.name, p.name); @@ -545,6 +546,7 @@ fn package_map(s: &Session, mobile: bool, tol: f64) -> Result, String> { l.touch_geometry(); } } + crate::project::strip_addresses(m.addresses_mut()); let text = serde_json::to_string(&m).map_err(|e| e.to_string())?; let kind = if mobile { "mobile map package" } else { "map package" }; let readme = format!("{} — a RustGIS {kind}.\nAdd it to a project with Data › Import.\n", m.name); @@ -938,12 +940,16 @@ pub fn item_bytes(s: &mut Session, kind: &str, p: &Value) -> Result<(String, Vec Some(n) => s.project.maps.iter().find(|m| m.name.eq_ignore_ascii_case(n)).ok_or_else(|| format!("no map '{n}'"))?, None => s.map(), }; - (map.name.clone(), to_json(&embedded_map(map, &mut MAX_EMBED.clone()))?) + let mut map = embedded_map(map, &mut MAX_EMBED.clone()); + crate::project::strip_addresses(map.addresses_mut()); + (map.name.clone(), to_json(&map)?) } "layer" => { let id = s.layer_id(p)?; let l = s.map().layer(id).ok_or("no such layer")?.clone(); - (l.name.clone(), to_json(&embed_layer(l, &mut MAX_EMBED.clone()))?) + let mut layer = embed_layer(l, &mut MAX_EMBED.clone()); + crate::project::strip_addresses(layer.addresses_mut().collect()); + (layer.name.clone(), to_json(&layer)?) } "styles" => { if s.project.styles.is_empty() { diff --git a/crates/engine/src/cmd/project.rs b/crates/engine/src/cmd/project.rs index 2ce5de18e..7af12e005 100644 --- a/crates/engine/src/cmd/project.rs +++ b/crates/engine/src/cmd/project.rs @@ -14,7 +14,7 @@ pub fn commands() -> Vec { C::new("project.new", "New Project", "Project", new).key("Mod+N").view().params(r#"{"name"?: string}"#), C::new("project.sample", "World Atlas Sample", "Project", sample).view(), C::new("project.open", "Open Project", "Project", open).key("Mod+O").view().params(r#"{"path": string}"#), - C::new("project.openText", "Open Project (from text)", "Project", open_text).view().params(r#"{"text": project JSON, "fileName"?: string}"#), + C::new("project.openText", "Open Project (from text)", "Project", open_text).view().params(r#"{"text": project JSON, "fileName"?: string, "package"?: "true for package contents: never restore saved layer secrets"}"#), C::new("project.save", "Save Project", "Project", save).key("Mod+S").view().params(r#"{"path"?: string}"#), C::new("project.saveAs", "Save Project As", "Project", save_as).key("Mod+Shift+S").view().params(r#"{"path": string}"#), C::new("project.rename", "Rename Project", "Project", rename).params(r#"{"name": string}"#), @@ -84,7 +84,7 @@ fn open(s: &mut Session, p: &Value) -> CmdResult { if bytes.len() as u64 > MAX_PACKAGE { return Err("the package is too large".into()); } - Project::from_json(&super::items::package_text(&bytes)?)? + Project::from_import_json(&super::items::package_text(&bytes)?)? } else { Project::load(&path)? }; @@ -95,12 +95,13 @@ fn open(s: &mut Session, p: &Value) -> CmdResult { } s.path = if is_package { None } else { Some(path) }; s.notify(format!("Opened {}", s.project.name), false); + super::connections::note_waiting(s); Ok(Value::Null) } fn open_text(s: &mut Session, p: &Value) -> CmdResult { let text = super::ps(p, "text").ok_or("text is required")?; - let proj = Project::from_json(text)?; + let proj = if super::b(p, "package").unwrap_or(false) { Project::from_import_json(text)? } else { Project::from_json(text)? }; reset(s, proj); // The browser opens files by name and contents: the name still names the project. if let Some(file) = super::ps(p, "fileName") { @@ -108,17 +109,21 @@ fn open_text(s: &mut Session, p: &Value) -> CmdResult { } s.path = None; s.notify(format!("Opened {}", s.project.name), false); + super::connections::note_waiting(s); Ok(Value::Null) } fn write(s: &mut Session, path: PathBuf) -> CmdResult { let path = if path.extension().is_none() { path.with_extension(crate::project::EXTENSION) } else { path }; - s.project.save(&path)?; + let stripped = s.project.save(&path)?; s.dirty = false; s.edit_counts.clear(); s.notify(format!("Saved {}", path.display()), false); + if stripped > 0 { + s.notify(format!("{stripped} layer address(es) were saved without their keys, tokens or passwords: after RustGIS closes, add those layers again to give them"), false); + } s.path = Some(path.clone()); - Ok(json!({"path": path.display().to_string()})) + Ok(json!({"path": path.display().to_string(), "stripped": stripped})) } /// Names the project after its file, as Save As does, so a renamed or copied file opens under diff --git a/crates/engine/src/connections.rs b/crates/engine/src/connections.rs index c29e1c0c1..4791fdb6c 100644 --- a/crates/engine/src/connections.rs +++ b/crates/engine/src/connections.rs @@ -53,6 +53,37 @@ pub fn database_kind(address: &str) -> Option<&'static str> { } } +/// The database kind and session-password key, as the driver receives the password-free address. +/// None for other addresses, and always in the browser. +pub fn database_key(address: &str) -> Option<(&'static str, String)> { + #[cfg(not(target_arch = "wasm32"))] + { + use rustgis_data::source::duckdb::Database; + match Database::parse(address) { + Database::Postgres(d) => Some(("postgres", d)), + Database::Mysql(d) => Some(("mysql", d)), + Database::Sqlserver(d) => Some(("sqlserver", d)), + Database::File(_) => None, + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = address; + None + } +} + +/// The kind and database address a streamed layer reads. +pub fn layer_database(src: &rustgis_map::source::SourceLayer) -> Option<(&'static str, &str)> { + if let Some(origin) = &src.postgis { + Some(("postgres", origin.database.as_str())) + } else if let Some(origin) = &src.mysql { + Some(("mysql", origin.database.as_str())) + } else { + src.sqlserver.as_ref().map(|origin| ("sqlserver", origin.database.as_str())) + } +} + fn same_name(a: &str, b: &str) -> bool { a.chars().flat_map(char::to_lowercase).eq(b.chars().flat_map(char::to_lowercase)) } @@ -126,6 +157,18 @@ pub fn clean(mut c: Connection) -> Result<(Connection, bool), String> { } impl Connections { + /// The first saved connection, in list order, of a database's kind whose address names it. + pub fn for_database(&self, kind: &str, database: &str) -> Option<&Connection> { + let key = database_key(&sources::redact(database)).filter(|k| k.0 == kind)?; + self.connections.iter().find(|c| c.kind == kind && database_key(&c.address).as_ref() == Some(&key)) + } + + /// The saved connection a layer that reads a database comes from. + pub fn for_layer(&self, l: &rustgis_map::Layer) -> Option<&Connection> { + let (kind, database) = layer_database(l.source.as_ref()?)?; + self.for_database(kind, database) + } + pub fn from_json(text: &str) -> Self { let v: Value = serde_json::from_str(text).unwrap_or(Value::Null); let mut s = Self { version: 1, next: v.get("next").and_then(Value::as_u64).unwrap_or(1).max(1), ..Self::default() }; diff --git a/crates/engine/src/project.rs b/crates/engine/src/project.rs index b2d14dacf..b37e08bd2 100644 --- a/crates/engine/src/project.rs +++ b/crates/engine/src/project.rs @@ -1,6 +1,9 @@ //! The project: maps, standalone tables and geoprocessing history, saved as JSON (`.rgis`). +use std::collections::hash_map::RandomState; +use std::hash::BuildHasher; use std::path::Path; +use std::sync::{Mutex, MutexGuard, OnceLock}; use rustgis_data::{Dataset, Feature, Field, GeometryType}; use rustgis_map::Map; @@ -38,6 +41,48 @@ pub const FORMAT: u32 = 1; /// Largest project file read (512 MiB). const MAX_BYTES: u64 = 512 << 20; +/// The address a saved file keeps. Only web addresses can contain layer credentials. +fn saved_address(address: &str) -> Option { + let inner = address.strip_prefix(crate::render::rustgis_tiles::s3::ANONYMOUS).unwrap_or(address); + let (protocol, _) = inner.split_once("://")?; + if !["http", "https", "s3"].iter().any(|s| protocol.eq_ignore_ascii_case(s)) { + return None; + } + let (scheme, mut rest) = address.split_once("://")?; + let end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + if let Some(at) = rest.get(..end).and_then(|authority| authority.rfind('@')) { + rest = rest.get(at + 1..)?; + } + let rest = rest.split('#').next().unwrap_or(rest); + let mut out = format!("{scheme}://{rest}"); + rustgis_data::source::credentials::redact_query(&mut out); + (out != address).then_some(out) +} + +/// Strips layer credentials, keeping originals in the visitor's order for the caller to restore. +/// Packaging calls this without registering the resulting text as a file this process wrote. +pub(crate) fn strip_addresses(addresses: Vec<&mut String>) -> Vec> { + addresses.into_iter().map(|a| saved_address(a).map(|redacted| std::mem::replace(a, redacted))).collect() +} + +struct Written { + len: usize, + hash: u64, + was: Vec>, +} + +const MAX_WRITTEN: usize = 32; + +fn kept() -> MutexGuard<'static, Vec> { + static KEPT: OnceLock>> = OnceLock::new(); + KEPT.get_or_init(Default::default).lock().unwrap_or_else(|e| e.into_inner()) +} + +fn identity(text: &str) -> (usize, u64) { + static KEY: OnceLock = OnceLock::new(); + (text.len(), KEY.get_or_init(RandomState::new).hash_one(text.as_bytes())) +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Project { #[serde(default = "format")] @@ -354,6 +399,23 @@ impl Project { } } + /// Every map and offline snapshot's data addresses, followed by standalone tables' sources. + pub fn addresses_mut(&mut self) -> Vec<&mut String> { + let mut addresses = Vec::new(); + for map in &mut self.maps { + addresses.extend(map.addresses_mut()); + } + for area in &mut self.offline_areas { + addresses.extend(area.data.addresses_mut()); + } + for table in &mut self.tables { + if let Some(source) = &mut table.dataset.source { + addresses.push(source); + } + } + addresses + } + /// The standalone table of an id. pub fn table(&self, id: u32) -> Option<&Table> { self.tables.iter().find(|t| t.id == id) @@ -548,13 +610,64 @@ impl Project { &mut self.maps[i] } - pub fn save(&self, path: &Path) -> Result<(), String> { - let text = serde_json::to_string(self).map_err(|e| e.to_string())?; - rustgis_data::io::write_atomic(path, text.as_bytes()).map_err(|e| format!("could not save {}: {e}", path.display())) + /// The file keeps no passwords, keys, tokens or signatures in layer addresses. The open project + /// keeps them. This process remembers the 32 most recent distinct saved snapshots with stripped + /// addresses: opening identical plain project text restores them until that snapshot is evicted + /// or RustGIS closes. Packages and imports never restore them. + pub fn saved_text(&mut self) -> Result<(String, usize), String> { + let was = strip_addresses(self.addresses_mut()); + let n = was.iter().flatten().count(); + let text = serde_json::to_string(self).map_err(|e| e.to_string()); + // Serialization can fail: put the live addresses back before handling its result. + for (address, original) in self.addresses_mut().into_iter().zip(&was) { + if let Some(original) = original { + *address = original.clone(); + } + } + if let Ok(text) = &text + && n > 0 + { + let (len, hash) = identity(text); + let mut kept = kept(); + kept.retain(|entry| (entry.len, entry.hash) != (len, hash)); + if kept.len() >= MAX_WRITTEN { + kept.remove(0); + } + kept.push(Written { len, hash, was }); + } + text.map(|text| (text, n)) + } + + fn restore_addresses(&mut self, text: &str) { + let id = identity(text); + let kept = kept(); + let Some(entry) = kept.iter().find(|entry| (entry.len, entry.hash) == id) else { return }; + for (address, original) in self.addresses_mut().into_iter().zip(&entry.was) { + if let Some(full) = original + && saved_address(full).as_deref() == Some(address.as_str()) + { + *address = full.clone(); + } + } + } + + pub fn save(&mut self, path: &Path) -> Result { + let (text, stripped) = self.saved_text()?; + rustgis_data::io::write_atomic(path, text.as_bytes()).map_err(|e| format!("could not save {}: {e}", path.display()))?; + Ok(stripped) } /// Reads a project from its JSON text (a browser upload). pub fn from_json(text: &str) -> Result { + Self::read_json(text, true) + } + + /// Reads imported JSON (including package contents) without restoring a saved file's secrets. + pub(crate) fn from_import_json(text: &str) -> Result { + Self::read_json(text, false) + } + + fn read_json(text: &str, restore: bool) -> Result { if text.len() as u64 > MAX_BYTES { return Err("the project file is too large".into()); } @@ -562,6 +675,9 @@ impl Project { if p.format > FORMAT { return Err(format!("this project was saved by a newer RustGIS (format {})", p.format)); } + if restore { + p.restore_addresses(text); + } p.normalize(); Ok(p) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2c79b66cd..fa0b9eefa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8810,6 +8810,248 @@ fn data_sources_recent_favourites_and_saved() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn layer_address_redaction_preserves_templates_and_local_paths() { + let cases = [ + ("https://{switch:a,b}.host/{z}/{x}/{y}@2x.png?key=secret&map=m#token", "https://{switch:a,b}.host/{z}/{x}/{y}@2x.png?map=m"), + ("HTTPS://u:p@host/data?token=secret&f=json", "HTTPS://host/data?f=json"), + ("s3://u:p@bucket/data?X-Amz-Signature=secret", "s3://bucket/data"), + ("/tmp/data?key=not-a-web-secret", "/tmp/data?key=not-a-web-secret"), + ("upload:map?token=local", "upload:map?token=local"), + ("postgres:host=localhost dbname=gis", "postgres:host=localhost dbname=gis"), + ]; + let mut addresses: Vec = cases.iter().map(|(a, _)| a.to_string()).collect(); + crate::project::strip_addresses(addresses.iter_mut().collect()); + for (address, (_, expected)) in addresses.iter().zip(cases) { + assert_eq!(address, expected); + } + let prefix = crate::render::rustgis_tiles::s3::ANONYMOUS; + let mut anonymous = format!("{prefix}https://bucket.example/a?token=secret"); + crate::project::strip_addresses(vec![&mut anonymous]); + assert_eq!(anonymous, format!("{prefix}https://bucket.example/a")); +} + +#[test] +fn saved_layer_files_keep_no_address_secrets() { + let mut s = world(); + let feature = s.map().layers[0].id; + let dataset_url = "https://user:dataset-password@gis.example/features?access_token=dataset-token&f=json"; + let source_url = "https://gis.example/roads.fgb?sig=source-signature&sv=1&collection=roads"; + { + let layer = s.map_mut().layer_mut(feature).unwrap(); + layer.dataset.source = Some(dataset_url.into()); + layer.source = Some(serde_json::from_value(json!({"path":source_url})).unwrap()); + } + let added = s.run("layer.addDataBytes", &json!({"fileName":"dem.tif","name":"Protected raster","data":dem_b64()})).unwrap(); + let raster = added["layer"].as_u64().unwrap() as u32; + let raster_url = "https://user:raster-password@gis.example/dem.tif?api_key=raster-key&band=1"; + let zarr_url = "https://gis.example/air.zarr?token=zarr-token&variable=air"; + { + let layer = s.map_mut().layer_mut(raster).unwrap().raster.as_mut().unwrap(); + layer.path = Some(raster_url.into()); + layer.time_zarr = Some(rustgis_map::raster::ZarrTime { store: zarr_url.into(), ..Default::default() }); + } + for id in [feature, raster] { + let (_, bytes) = crate::cmd::items::item_bytes(&mut s, "layer", &json!({"layer":id})).unwrap(); + let exported: Value = serde_json::from_slice(&bytes).unwrap(); + if id == feature { + assert_eq!(exported["dataset"]["source"], "https://gis.example/features?f=json"); + assert_eq!(exported["source"]["path"], "https://gis.example/roads.fgb?collection=roads"); + } else { + assert_eq!(exported["raster"]["path"], "https://gis.example/dem.tif?band=1"); + assert_eq!(exported["raster"]["time_zarr"]["store"], "https://gis.example/air.zarr?variable=air"); + } + } + let layer = s.map().layer(feature).unwrap(); + assert_eq!(layer.dataset.source.as_deref(), Some(dataset_url)); + assert_eq!(layer.source.as_ref().unwrap().path.as_deref(), Some(source_url)); + let layer = s.map().layer(raster).unwrap().raster.as_ref().unwrap(); + assert_eq!(layer.path.as_deref(), Some(raster_url)); + assert_eq!(layer.time_zarr.as_ref().unwrap().store, zarr_url); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn database_authorization_failures_do_not_wait_for_password() { + use std::io::{Read, Write}; + for (name, message) in [("HBA blocked", "no pg_hba.conf entry for host"), ("Missing role", "role \"missing\" does not exist")] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut length = [0; 4]; + stream.read_exact(&mut length).unwrap(); + let n = u32::from_be_bytes(length); + assert!((4..=65536).contains(&n)); + stream.read_exact(&mut vec![0; (n - 4) as usize]).unwrap(); + let body = format!("SFATAL\0VFATAL\0C28000\0M{message}\0\0"); + stream.write_all(b"E").unwrap(); + stream.write_all(&((body.len() + 4) as u32).to_be_bytes()).unwrap(); + stream.write_all(body.as_bytes()).unwrap(); + }); + let dsn = format!("postgres:host=127.0.0.1 port={port} dbname=review_auth user=missing sslmode=disable"); + let mut project = world().project; + project.map_mut().layers[0].source = + Some(serde_json::from_value(json!({"postgis":{"database":dsn,"table":"roads","family":"Lines"}})).unwrap()); + let mut s = Session::new(Project::default()); + s.run("connection.new", &json!({"kind":"postgres","name":name,"address":dsn})).unwrap(); + s.run("project.openText", &json!({"text":serde_json::to_string(&project).unwrap()})).unwrap(); + server.join().unwrap(); + let source = s.map().layers[0].source.as_ref().unwrap(); + assert!(source.error.as_deref().unwrap().contains(message)); + assert!(!source.login, "{message} cannot be fixed by a password"); + assert!(!s.notices.iter().any(|n| n.text.contains(name))); + assert!(s.run("connection.list", &Value::Null).unwrap()["connections"][0].get("waiting").is_none()); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn database_layers_wait_for_their_connection_login() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for mut stream in listener.incoming().take(16).flatten() { + stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); + let mut length = [0; 4]; + if stream.read_exact(&mut length).is_err() { + continue; + } + let n = u32::from_be_bytes(length); + if !(4..=65536).contains(&n) { + continue; + } + let mut startup = vec![0; (n - 4) as usize]; + if stream.read_exact(&mut startup).is_err() || stream.write_all(b"R\0\0\0\x08\0\0\0\x03").is_err() { + continue; + } + let mut kind = [0]; + if stream.read_exact(&mut kind).is_err() || kind[0] != b'p' || stream.read_exact(&mut length).is_err() { + continue; + } + let n = u32::from_be_bytes(length); + if !(4..=65536).contains(&n) { + continue; + } + let mut password = vec![0; (n - 4) as usize]; + if stream.read_exact(&mut password).is_err() { + continue; + } + let body = b"SFATAL\0VFATAL\0C28P01\0Mpassword authentication failed for user \"me\"\0\0"; + let _ = stream.write_all(b"E"); + let _ = stream.write_all(&((body.len() + 4) as u32).to_be_bytes()); + let _ = stream.write_all(body); + } + }); + let dsn = format!("postgres:host=127.0.0.1 port={port} dbname=waiting_login user=me sslmode=disable"); + let down = "postgres:host=127.0.0.1 port=1 dbname=waiting_down user=me sslmode=disable"; + let mut s = world(); + for (layer, database) in s.map_mut().layers.iter_mut().zip([dsn.as_str(), dsn.as_str(), down]) { + layer.source = Some(serde_json::from_value(json!({"postgis":{"database":database,"table":"roads","family":"Lines"}})).unwrap()); + } + let text = serde_json::to_string(&s.project).unwrap(); + let mut t = Session::new(Project::default()); + t.run("connection.new", &json!({"kind":"postgres","name":"Production GIS","address":dsn})).unwrap(); + t.run("connection.new", &json!({"kind":"postgres","name":"Down","address":down})).unwrap(); + t.run("project.openText", &json!({"text":text})).unwrap(); + for layer in t.map().layers.iter().take(2) { + // No password has been given: this exercises tokio-postgres's client-side config error, + // before the later login attempts exercise the server's 28P01 response. + assert!(layer.source.as_ref().unwrap().error.as_deref().unwrap().contains("password missing")); + assert!(layer.source.as_ref().unwrap().login); + assert_eq!(t.connections.for_layer(layer).unwrap().name, "Production GIS"); + } + assert!(t.map().layers.iter().skip(3).all(|l| t.connections.for_layer(l).is_none())); + let third = t.map().layers[2].source.as_ref().unwrap(); + assert!(third.error.is_some() && !third.login); + assert!(t.notices.iter().any(|n| n.text.contains("2 layer(s)") && n.text.contains("Production GIS"))); + assert!(!t.notices.iter().any(|n| n.text.contains("Down"))); + let listed = t.run("connection.list", &Value::Null).unwrap(); + assert_eq!(listed["connections"][0]["waiting"], 2); + assert!(listed["connections"][1].get("waiting").is_none()); + let result = t.run("connection.login", &json!({"id":"Production GIS","password":"wrong"})).unwrap(); + assert_eq!(result["reopened"], 0); + assert_eq!(result["failed"], 2); + assert!(t.map().layers.iter().take(2).all(|l| l.source.as_ref().unwrap().login)); + assert_eq!(t.run("connection.list", &Value::Null).unwrap()["connections"][0]["waiting"], 2); + // Duplicate saved connections use list order for reporting, but either can log in. + t.run("connection.new", &json!({"kind":"postgres","name":"Same database","address":dsn})).unwrap(); + assert_eq!(t.connections.for_layer(&t.map().layers[0]).unwrap().name, "Production GIS"); + let result = t.run("connection.login", &json!({"id":"Same database","password":"wrong"})).unwrap(); + assert_eq!(result["failed"], 2); +} + +#[test] +fn saved_projects_keep_no_layer_tokens() { + let mut s = world(); + let url = "https://gis.example/arcgis/rest/services/a/MapServer/tile/{z}/{y}/{x}?token=s3cret"; + s.run("map.addTiles", &json!({"url":url,"name":"Secured"})).unwrap(); + let layer = s.map().layers[0].id; + s.map_mut().layers[0].dataset.source = Some("https://u:pw@gis.example/f?f=json&access_token=tok2".into()); + s.map_mut().service_groups.push(rustgis_map::ServiceGroup { + id: 123, + name: "Protected service".into(), + url: "https://gis.example/wfs?key=k3y&map=m".into(), + kind: "wfs".into(), + expanded: true, + children: vec![rustgis_map::ServiceChild { + layer, + collection: "roads".into(), + source_url: "https://gis.example/roads?sig=x&sv=1".into(), + }], + }); + let path = std::env::temp_dir().join(format!("rustgis-token-audit-{}.rgis", std::process::id())); + let result = s.run("project.saveAs", &json!({"path":path})).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + for secret in ["s3cret", "pw@", "tok2", "k3y", "sig=x"] { + assert!(!text.contains(secret), "{secret} leaked into the file"); + } + assert!(text.contains("MapServer/tile/{z}/{y}/{x}") && text.contains("map=m")); + assert!(result["stripped"].as_u64().unwrap() >= 4); + assert!(s.notices.iter().any(|n| n.text.contains("saved without their keys"))); + assert!(s.map().tiles.iter().any(|t| t.url == url)); + for kind in ["map", "projectPackage", "mapPackage", "mobilePackage"] { + let (_, bytes) = crate::cmd::items::item_bytes(&mut s, kind, &Value::Null).unwrap(); + let packaged = + if kind == "map" { String::from_utf8(bytes.clone()).unwrap() } else { crate::cmd::items::package_text(&bytes).unwrap() }; + for secret in ["s3cret", "pw@", "tok2", "k3y", "sig=x"] { + assert!(!packaged.contains(secret), "{kind} leaked {secret}"); + } + if kind == "projectPackage" { + // Even a hostile archive carrying the exact registered JSON must not restore it. + let hostile = rustgis_data::zip::write(&[("exact.rgis".into(), text.as_bytes().to_vec())]).unwrap(); + let package_path = path.with_extension("rgispkg"); + std::fs::write(&package_path, hostile).unwrap(); + let mut opened = Session::new(Project::default()); + opened.run("project.open", &json!({"path":package_path})).unwrap(); + assert!(!serde_json::to_string(&opened.project).unwrap().contains("s3cret")); + opened.run("project.openText", &json!({"text":text,"package":true})).unwrap(); + assert!(!serde_json::to_string(&opened.project).unwrap().contains("s3cret")); + std::fs::remove_file(package_path).unwrap(); + } + } + let mut t = Session::new(Project::default()); + t.run("project.open", &json!({"path":path})).unwrap(); + assert!(t.map().tiles.iter().any(|t| t.url == url)); + assert!(t.map().layers[0].dataset.source.as_deref().unwrap().contains("tok2")); + let modified = text.replace("\"name\":\"Secured\"", "\"name\":\"Secures\""); + assert_ne!(text, modified); + t.run("project.openText", &json!({"text":modified})).unwrap(); + let live = serde_json::to_string(&t.project).unwrap(); + for secret in ["s3cret", "pw@", "tok2", "k3y", "sig=x"] { + assert!(!live.contains(secret), "{secret} restored into a modified hostile file"); + } + let public = url.split('?').next().unwrap(); + let mut hand = world(); + hand.run("map.addTiles", &json!({"url":public,"name":"Hand made"})).unwrap(); + t.run("project.openText", &json!({"text":serde_json::to_string(&hand.project).unwrap()})).unwrap(); + assert!(t.map().tiles.iter().any(|t| t.url == public)); + assert!(!serde_json::to_string(&t.project).unwrap().contains("s3cret")); + std::fs::remove_file(path).unwrap(); +} + #[test] fn connections_commands() { use crate::render::rustgis_tiles::s3; diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index a2dab5d0a..c28098d9b 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -188,6 +188,14 @@ fn yes() -> bool { } impl Layer { + /// The dataset, streamed, raster and time-following Zarr addresses used to read this layer + /// again, in that order. Local paths are included; database origins stay password-free. + pub fn addresses_mut(&mut self) -> impl Iterator { + self.dataset.source.iter_mut().chain(self.source.iter_mut().filter_map(|source| source.path.as_mut())).chain( + self.raster.iter_mut().flat_map(|raster| raster.path.iter_mut().chain(raster.time_zarr.iter_mut().map(|zarr| &mut zarr.store))), + ) + } + pub fn new(id: u32, dataset: Dataset) -> Self { let renderer = Renderer::Single(Symbol::default_for(dataset.geometry_type, id)); Layer { @@ -876,6 +884,35 @@ impl Map { } } + /// Every address the map keeps to read its data again (service groups and their layers' + /// sources, tile layers and their dated addresses, terrain tiles, layers' dataset, streamed and + /// raster sources, time-following Zarr stores). Local paths come too: callers judge each one. + pub fn addresses_mut(&mut self) -> Vec<&mut String> { + let mut addresses = Vec::new(); + for group in &mut self.service_groups { + addresses.push(&mut group.url); + for child in &mut group.children { + addresses.push(&mut child.source_url); + } + } + for tile in &mut self.tiles { + addresses.push(&mut tile.url); + if let Some(url) = &mut tile.time_url { + addresses.push(url); + } + for (_, url) in &mut tile.time_urls { + addresses.push(url); + } + } + if let Some(view::Terrain { source: view::TerrainSource::Tiles { url, .. }, .. }) = &mut self.view.terrain { + addresses.push(url); + } + for layer in &mut self.layers { + addresses.extend(layer.addresses_mut()); + } + addresses + } + /// A nonzero id no layer, service group or layer group has: past the highest in use, or (when ids reach /// `u32::MAX`, as a project file may have them) the lowest free one. fn next_id(&mut self) -> u32 { diff --git a/crates/map/src/source.rs b/crates/map/src/source.rs index 6921aaacc..5f455f230 100644 --- a/crates/map/src/source.rs +++ b/crates/map/src/source.rs @@ -79,6 +79,9 @@ pub struct SourceLayer { /// Why the source couldn't be opened or read. #[serde(skip)] pub error: Option, + /// The last open failed because the server refused the login (no password given this session, or a wrong one). + #[serde(skip)] + pub login: bool, /// The most features a view reads (0 = [`VIEW_LIMIT`]). #[serde(skip)] pub limit: usize, @@ -189,6 +192,7 @@ impl std::fmt::Debug for SourceLayer { .field("table", &self.table) .field("window", &self.window) .field("error", &self.error) + .field("login", &self.login) .finish_non_exhaustive() } } @@ -221,6 +225,17 @@ fn rerun(d: &DuckdbOrigin) -> rustgis_data::Result { .ok_or_else(|| rustgis_data::DataError::Format(format!("the query returns no {} any more", d.family.label()))) } +/// Adds the manual reconnect hint without losing a refused-password classification. +#[cfg(not(target_arch = "wasm32"))] +fn database_open_error(e: rustgis_data::DataError) -> rustgis_data::DataError { + match e { + rustgis_data::DataError::Login(message) => { + rustgis_data::DataError::Login(format!("{message} (connect again with Add Data › Database…, giving its password)")) + } + e => rustgis_data::DataError::Format(format!("{e} (connect again with Add Data › Database…, giving its password)")), + } +} + /// Connects to a PostGIS layer's table again (a project opening). Only a password given this /// session is sent: a project file names its server, which must never get the user's own /// credentials (`PGPASSWORD`, the password file) just for being opened. @@ -236,10 +251,7 @@ fn open_postgis(pg: &PostgisOrigin) -> rustgis_data::Result rustgis_data::Result> let Database::Mysql(address) = Database::parse(&my.database) else { return Err(rustgis_data::DataError::Format(format!("{}: not a MySQL or MariaDB connection", my.database))); }; - let again = |e: rustgis_data::DataError| { - rustgis_data::DataError::Format(format!("{e} (connect again with Add Data › Database…, giving its password)")) - }; - let conn = mysql::connect(&address, false).map_err(again)?; + let conn = mysql::connect(&address, false).map_err(database_open_error)?; let table = mysql::find_table(&conn, &my.table)?; Ok(Box::new(mysql::MysqlSource::open(conn, table, my.family, my.crs.as_deref())?)) } @@ -282,10 +291,7 @@ fn open_sqlserver(ms: &SqlServerOrigin) -> rustgis_data::Result { + self.login = matches!(e, rustgis_data::DataError::Login(_)); let e = e.to_string(); self.error = Some(e.clone()); Err(e) diff --git a/crates/ui-egui/src/connectiontree.rs b/crates/ui-egui/src/connectiontree.rs index d8d6f1051..9a44911a1 100644 --- a/crates/ui-egui/src/connectiontree.rs +++ b/crates/ui-egui/src/connectiontree.rs @@ -380,12 +380,7 @@ fn level(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connection, node: &s } let keys = c.kind == "s3" && c.options.get("credentials").is_some_and(|v| v == "keys"); if (DATABASE_KINDS.contains(&c.kind.as_str()) || keys) && ui.small_button(tl!("Log In…")).clicked() { - match app.run("ui.connection", json!({"id": c.id})) { - Ok(_) => { - app.dialogs.info = Some(tl!("Type the password or keys to log in for this session, then Save.").to_string()) - } - Err(e) => app.session.notify(e, true), - } + crate::connectionui::log_in(app, &c.id); } }); } diff --git a/crates/ui-egui/src/connectionui.rs b/crates/ui-egui/src/connectionui.rs index bd65b8e33..a1a2ec8f1 100644 --- a/crates/ui-egui/src/connectionui.rs +++ b/crates/ui-egui/src/connectionui.rs @@ -147,6 +147,13 @@ pub fn open_edit(app: &mut RustGisApp, id: &str) -> Result<(), String> { Ok(()) } +pub fn log_in(app: &mut RustGisApp, id: &str) { + match app.run("ui.connection", json!({"id": id})) { + Ok(_) => app.dialogs.info = Some(tl!("Type the password or keys to log in for this session, then Save.").to_string()), + Err(e) => app.session.notify(e, true), + } +} + fn definition(st: &State) -> Value { let address = st.address.trim(); let lower = address.to_ascii_lowercase(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6bbef2e47..153cbfb07 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1130,7 +1130,7 @@ impl RustGisApp { } if lower.ends_with(".rgispkg") { let text = rustgis_engine::cmd::items::package_text(bytes)?; - let r = self.run("project.openText", json!({"text": text})); + let r = self.run("project.openText", json!({"text": text, "package": true})); self.map.invalidate(); return r; } @@ -1206,8 +1206,8 @@ impl RustGisApp { // Not an undoable edit: saving names the project, as `project.saveAs` does, once the // file is written. let old = std::mem::replace(&mut self.session.project.name, stem.clone()); - let text = match serde_json::to_string(&self.session.project) { - Ok(text) => text, + let (text, stripped) = match self.session.project.saved_text() { + Ok(saved) => saved, Err(e) => { self.session.project.name = old; return Err(e.to_string()); @@ -1215,6 +1215,15 @@ impl RustGisApp { }; let file = format!("{stem}.rgis"); self.download(&file, text.into_bytes()); + if stripped > 0 { + self.session.notify( + crate::i18n::fmt( + "{n} layer address(es) were saved without their keys, tokens or passwords: after RustGIS closes, add those layers again to give them", + &[("n", &stripped.to_string())], + ), + false, + ); + } self.session.dirty = false; self.ui.web_file = Some(file); Ok(()) @@ -2953,7 +2962,7 @@ impl RustGisApp { } else { std::str::from_utf8(bytes).map_err(|_| "not a RustGIS project".to_string())?.to_string() }; - let mut p = json!({"text": text}); + let mut p = json!({"text": text, "package": package}); if let Some(f) = file.filter(|_| !package) { p["fileName"] = json!(f); } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index af0e52ec8..bc91294cf 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1641,6 +1641,19 @@ fn layer_menu(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, reorder: bool) { let _ = app.run("ui.refreshService", json!({"layer":l.id})); ui.close(); } + let login = l + .source + .as_ref() + .is_some_and(|s| s.login) + .then(|| app.session.connections.for_layer(l)) + .flatten() + .map(|c| (c.id.clone(), c.name.clone())); + if let Some((id, name)) = login + && ui.button(crate::i18n::fmt("Log In to {name}…", &[("name", &name)])).clicked() + { + crate::connectionui::log_in(app, &id); + ui.close(); + } let items: &[(&str, &str)] = if l.is_raster() { &[("Zoom To Layer", "layer.zoomTo"), ("Symbology", "ui.pane.symbology")] } else { diff --git a/crates/ui-egui/src/sourcespane.rs b/crates/ui-egui/src/sourcespane.rs index d2f0be893..bcf88484e 100644 --- a/crates/ui-egui/src/sourcespane.rs +++ b/crates/ui-egui/src/sourcespane.rs @@ -331,6 +331,11 @@ fn connection_header(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connecti } } if !unavailable { + let keys = c.kind == "s3" && c.options.get("credentials").is_some_and(|v| v == "keys"); + if (DATABASE_KINDS.contains(&c.kind.as_str()) || keys) && ui.button(tl!("Log In…")).clicked() { + crate::connectionui::log_in(app, &c.id); + ui.close(); + } if ui.button(tl!("Refresh")).clicked() { crate::connectiontree::refresh(app, Some(&c.id)); ui.close(); diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 90a26f899..bc33ee379 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -146,9 +146,12 @@ connection without it. Without one, `PGPASSWORD` or libpq's password file (`~/.p `%APPDATA%\postgresql\pgpass.conf`) serve when you add a table. A project keeps a streamed table's connection and reads it again when it opens, sending only a password given in this session: a project file could name any host, and must never get your own credentials. When the -server wants a password, connect again with **Add Data › Database…** (the layer reads again when -the project is next opened). A small table's features are kept in the project as they were when -added. +server wants a password and the layer's database matches a saved connection, the project says +which connection is waiting. Right-click that connection in **Data Sources › Connections**, or a +waiting layer, and choose **Log In…**. Saving the password reopens every layer from that database. +Without a matching connection, connect again with **Add Data › Database…** (the layer reads again +when the project is next opened). A small table's features are kept in the project as they were +when added. `layer.addSql` takes a connection as its `"database"` too, through DuckDB's `postgres` extension (installed the first time, like DuckDB Spatial), so a query names its tables plainly; the password @@ -428,7 +431,11 @@ Browsing never changes the project or the saved connections. Addresses are saved without passwords or tokens. `connection.login` remembers a database `password`, or S3 `accessKeyId`, `secretAccessKey` and optional `sessionToken`, for this session only, using the same credential stores as the existing add commands. With login parameters, -`connection.test` logs in first; a failed listing does not undo that session login. +`connection.test` logs in first; a failed listing does not undo that session login. A database +login also reopens project layers that read the same password-free address and were refused for a +missing or wrong password. `connection.list` gives such waiting layers as `waiting`, and +`connection.login` gives `reopened` and `failed` when it tries any of them. A server that is down +or a missing table is not reported as waiting for a login. S3 profiles and environment credentials remain subject to the existing endpoint restrictions; editing an S3 name, endpoint or options disconnects its session login. S3 shares the name-based session registry used by the S3 dialog and `s3.connect`, so editing or removing a saved @@ -708,6 +715,14 @@ routes, areas, observations…) to draw into. Add Folder connects a folder to th also lists the project's layouts, styles and tasks. A task's steps each run a command; New Task From History turns the geoprocessing history into a task you can replay. +Saved projects, map and layer files, and packages keep no password, key, token or signature in +layer web addresses. The open project keeps them for this session. RustGIS remembers the 32 most +recent distinct saved snapshots whose addresses needed stripping. Opening a plain `.rgis` file +whose bytes exactly match one of those snapshots restores its secrets; saving more distinct +snapshots evicts the oldest. Changed files, packages, imports and files saved by another process +never restore secrets. After a snapshot is evicted or RustGIS closes, add those layers again with +their credentials. + ### Complete streamed Shapefile rendering The CPU renderer traverses a streamed Shapefile in source order instead of painting only From 74229fe928389e7807bdb4d8b5bd4be1585be4a5 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Fri, 23 Oct 2026 13:21:14 +0200 Subject: [PATCH 052/297] feat(linux): draw an Adwaita title bar on Wayland (#725) * feat(linux): draw an Adwaita title bar on Wayland GNOME on Wayland leaves window decorations to the app. winit was built without its `wayland-csd-adwaita` feature, so it fell back to smithay-client-toolkit's plain grey frame. Turn the feature on for the desktop app on Linux and the BSDs: the title bar now looks like GNOME's, with minimise, maximise and close, following the light/dark theme. New crates: sctk-adwaita (MIT) and tiny-skia/tiny-skia-path 0.11 (BSD-3-Clause), which it needs. THIRD-PARTY-LICENSES.txt refreshed with `cargo xtask notices`. * docs(linux): note that the winit entry must match eframe's version --- Cargo.lock | 50 ++++++++++++++++++++++++++++++++++++---- THIRD-PARTY-LICENSES.txt | 2 +- apps/rustgis/Cargo.toml | 7 ++++++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87b9d1cbe..ed1f1b445 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4457,7 +4457,8 @@ dependencies = [ "rustgis-engine", "rustgis-ui-egui", "serde_json", - "tiny-skia", + "tiny-skia 0.12.0", + "winit", ] [[package]] @@ -4564,7 +4565,7 @@ dependencies = [ "serde", "serde_json", "sys-locale", - "tiny-skia", + "tiny-skia 0.12.0", "web-time", ] @@ -4651,7 +4652,7 @@ dependencies = [ "rustgis-tiles", "serde_json", "tempfile", - "tiny-skia", + "tiny-skia 0.12.0", "web-time", ] @@ -4670,7 +4671,7 @@ dependencies = [ "ruzstd", "serde_json", "sha2 0.11.1", - "tiny-skia", + "tiny-skia 0.12.0", "ureq", "web-time", ] @@ -4862,6 +4863,19 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia 0.11.4", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -5494,6 +5508,20 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path 0.11.4", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -5506,7 +5534,18 @@ dependencies = [ "cfg-if", "log", "png", - "tiny-skia-path", + "tiny-skia-path 0.12.0", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", ] [[package]] @@ -6880,6 +6919,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", + "sctk-adwaita", "smithay-client-toolkit 0.19.2", "smol_str", "tracing", diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index c4cfc72a9..ce2553cf2 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -9635,7 +9635,7 @@ SOFTWARE. ==================================================================================================== MIT License -Used by: rfd +Used by: rfd, sctk-adwaita ==================================================================================================== MIT License diff --git a/apps/rustgis/Cargo.toml b/apps/rustgis/Cargo.toml index 898b6b3c1..d6d0b3235 100644 --- a/apps/rustgis/Cargo.toml +++ b/apps/rustgis/Cargo.toml @@ -28,3 +28,10 @@ embed-resource = "3" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSUserDefaults"] } + +# Linux: GNOME on Wayland leaves the title bar to the app. winit draws an Adwaita one (title, +# minimise, maximise, close, following the light/dark theme) instead of its plain grey fallback. +# Only adds the feature to eframe's winit: keep the version in step with eframe's (`cargo tree -i +# winit` lists one winit). A different one would build a second winit and lose the title bar. +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))'.dependencies] +winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita"] } From 660721576edeef3834e060f2c539cd7e99168dbb Mon Sep 17 00:00:00 2001 From: liyongyin Date: Fri, 23 Oct 2026 19:22:17 +0800 Subject: [PATCH 053/297] perf(render): reuse path storage for stacked line strokes (#727) Co-authored-by: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> --- crates/render/src/lib.rs | 12 +++- crates/render/src/stream_tests.rs | 44 ++++++++++++ crates/render/src/stroke_paths.rs | 29 ++++++++ crates/render/src/stroke_paths/tests.rs | 93 +++++++++++++++++++++++++ 4 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 crates/render/src/stroke_paths.rs create mode 100644 crates/render/src/stroke_paths/tests.rs diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8f98fde5e..78b728930 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -17,6 +17,7 @@ mod picture; pub mod post; mod raster; pub mod report; +mod stroke_paths; pub mod terrain; mod text; pub mod tile_mesh; @@ -588,6 +589,10 @@ impl Ctx<'_> { self.ring_path(&mut pb, part, false); } let Some(path) = pb.finish() else { return }; + self.stacked_path(pm, &path, s, strokes); + } + + fn stacked_path(&self, pm: &mut Pixmap, path: &tiny_skia::Path, s: &Symbol, strokes: &[LineStroke]) { // Shifted once per offset: a casing and its core usually share one. let mut shifted: Vec<(f32, Option)> = Vec::new(); for st in strokes { @@ -596,10 +601,10 @@ impl Ctx<'_> { continue; } if st.offset != 0.0 && !shifted.iter().any(|(o, _)| *o == st.offset) { - shifted.push((st.offset, offset_path(&path, st.offset * self.ppp))); + shifted.push((st.offset, offset_path(path, st.offset * self.ppp))); } let drawn = match st.offset { - 0.0 => Some(&path), + 0.0 => Some(path), o => shifted.iter().find(|(x, _)| *x == o).and_then(|(_, p)| p.as_ref()), }; let Some(drawn) = drawn else { continue }; @@ -2783,6 +2788,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac effects::extrude(dest, &ctx, ds_ptr, &geoms, &rows, x, px_per_m, |r| layer.symbol(r)); } } else { + let mut stroke_paths = stroke_paths::Paths::default(); for (row, g) in drawn.iter().enumerate() { let Some(g) = g else { continue }; if rows.get(row) != Some(&true) || !visible(&extents.get(row).copied().unwrap_or_else(|| g.extent())) { @@ -2814,7 +2820,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } match g { Geometry::Line(parts) if !layer.line_strokes.is_empty() && !layer.graphics => { - ctx.stacked_line(dest, parts, &sym, &layer.line_strokes) + stroke_paths.draw(&ctx, dest, parts, &sym, &layer.line_strokes) } _ => ctx.feature(dest, g, &sym), } diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index f737b2026..bb34a7495 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -231,6 +231,50 @@ fn complete_chunks_use_the_entire_mask_source() { } } +#[test] +fn complete_chunks_preserve_reused_stroke_paths_with_overlap_and_alpha() { + let mut ds = Dataset::new("overlapping lines", GeometryType::Polyline, vec![Field::new("value", FieldType::Integer)]); + for i in 0..37 { + let y = (i % 5) as f64 * 0.006 - 0.012; + ds.push( + Some(Geometry::Line(vec![ + vec![Coord::new(-0.06, y), Coord::new(0.0, y + 0.02), Coord::new(0.06, y)], + vec![Coord::new(-0.04, -y), Coord::new(0.04, -y)], + ])), + vec![Value::Int(i)], + ); + } + let mut base = Map::new("stroke paths"); + base.basemap = rustgis_map::Basemap::Blank; + base.add_dataset(ds); + let layer = &mut base.layers[0]; + layer.definition_query = Some("value < 34".into()); + layer.selection = vec![0]; + layer.line_strokes = vec![ + LineStroke { offset: 3.0, width: Some(6.0), color: Some(Rgba::new(20, 40, 180, 120)), ..Default::default() }, + LineStroke { offset: 3.0, width: Some(2.0), color: Some(Rgba::new(220, 30, 40, 180)), ..Default::default() }, + LineStroke { offset: -2.0, width: Some(1.5), color: Some(Rgba::new(40, 180, 80, 200)), ..Default::default() }, + ]; + for transparency in [0.0, 0.35] { + base.layers[0].transparency = transparency; + for ppp in [1.0, 1.5, 2.0] { + let view = + View { width: 240.0, height: 180.0, pixels_per_point: ppp, camera: Camera::new(Coord::new(0.0, 0.0), 80.0), sky: false }; + let expected = render(&mut base.clone(), &view).unwrap(); + for size in [1, 3, 11, 100] { + let mut actual = base.clone(); + actual.layers[0] = streamed(&base.layers[0], size); + settle(&mut actual, &view); + let owner = actual.layers[0].dataset.features.clone(); + let got = render(&mut actual, &view).unwrap(); + assert!(got.pixmap.data() == expected.pixmap.data(), "size={size} ppp={ppp} transparency={transparency}"); + assert_eq!(actual.layers[0].dataset.features, owner); + assert_eq!(render_png(&mut actual, &view).unwrap(), render_png(&mut base.clone(), &view).unwrap()); + } + } + } +} + #[test] fn complete_chunks_preserve_label_classes_projection_rotation_and_png_export() { let mut ds = diff --git a/crates/render/src/stroke_paths.rs b/crates/render/src/stroke_paths.rs new file mode 100644 index 000000000..2a71ddade --- /dev/null +++ b/crates/render/src/stroke_paths.rs @@ -0,0 +1,29 @@ +//! Reuse a single feature path without changing feature or stroke compositing order. +use super::{Ctx, LineStroke, PathBuilder, Pixmap, Symbol}; +use rustgis_geom::Coord; +// Retain paths up to this point count per draw chunk; this is not a byte/capacity budget. +const CACHE_POINTS: usize = 16_384; + +#[derive(Default)] +pub(super) struct Paths { + cached: Option, +} +impl Paths { + pub fn draw(&mut self, ctx: &Ctx<'_>, pm: &mut Pixmap, parts: &[Vec], symbol: &Symbol, strokes: &[LineStroke]) { + let mut builder = PathBuilder::new(); + if let Some(path) = self.cached.take() { + builder = path.clear(); + } + for part in parts { + ctx.ring_path(&mut builder, part, false); + } + let Some(path) = builder.finish() else { return }; + ctx.stacked_path(pm, &path, symbol, strokes); + self.remember(path); + } + fn remember(&mut self, path: tiny_skia::Path) { + self.cached = if path.points().len() <= CACHE_POINTS { Some(path) } else { None }; + } +} +#[cfg(test)] +mod tests; diff --git a/crates/render/src/stroke_paths/tests.rs b/crates/render/src/stroke_paths/tests.rs new file mode 100644 index 000000000..4fdc57f4a --- /dev/null +++ b/crates/render/src/stroke_paths/tests.rs @@ -0,0 +1,93 @@ +use super::*; +use crate::{Extent, View}; +use rustgis_map::{Camera, Rgba}; + +#[test] +fn cached_pixels_equal_sequential_strokes_including_overlap_and_alpha() { + for ppp in [1.0f32, 1.5, 2.0] { + let view = View { + width: 600.0, + height: 400.0, + pixels_per_point: f64::from(ppp), + camera: Camera { center: Coord::new(0.0, 0.0), res: 1.0, bearing: 0.0, pitch: 0.0 }, + sky: false, + }; + let ctx = Ctx { view: &view, ppp, wireframe: false, cull: Extent::EMPTY, globe: None, sprites: Default::default() }; + let mut expected = Pixmap::new((600.0 * ppp) as u32, (400.0 * ppp) as u32).unwrap(); + let mut actual = expected.clone(); + let symbol = Symbol::default_for(rustgis_data::GeometryType::Polyline, 0); + for join in [rustgis_map::LineJoin::Round, rustgis_map::LineJoin::Miter, rustgis_map::LineJoin::Bevel] { + for cap in [rustgis_map::LineCap::Round, rustgis_map::LineCap::Square, rustgis_map::LineCap::Butt] { + expected.fill(tiny_skia::Color::WHITE); + actual.fill(tiny_skia::Color::WHITE); + let strokes = [ + LineStroke { offset: 2.0, width: Some(3.0), color: Some(Rgba::new(40, 70, 180, 150)), join, cap }, + LineStroke { offset: -2.0, width: Some(1.0), color: Some(Rgba::new(220, 30, 40, 200)), join, cap }, + LineStroke { offset: 2.0, width: Some(1.0), color: Some(Rgba::rgb(30, 40, 50)), join, cap }, + ]; + let mut batch = Paths::default(); + for i in 0..180 { + let x = f64::from(i % 8) * 70.1 - 245.0; + let y = f64::from(i / 8) * 60.3 - 165.0; + let parts = vec![vec![Coord::new(x, y), Coord::new(x + 7.5, y + 0.25), Coord::new(x + 5.0, y + 9.0)]]; + let mut styled = symbol; + if i % 11 == 0 { + styled.stroke_width = 5.0; + } + ctx.stacked_line(&mut expected, &parts, &styled, &strokes); + batch.draw(&ctx, &mut actual, &parts, &styled, &strokes); + // Repeated features verify unchanged overlap compositing order. + if i % 13 == 0 { + ctx.stacked_line(&mut expected, &parts, &styled, &strokes); + batch.draw(&ctx, &mut actual, &parts, &styled, &strokes); + } + } + let mismatch = actual.data().iter().zip(expected.data()).position(|(a, b)| a != b); + assert!(mismatch.is_none(), "byte={mismatch:?} ppp={ppp} join={join:?} cap={cap:?}"); + } + } + } +} + +#[test] +fn path_storage_is_reused_and_large_paths_are_not_retained() { + let view = View { + width: 600.0, + height: 400.0, + pixels_per_point: 1.0, + camera: Camera { center: Coord::new(0.0, 0.0), res: 1.0, bearing: 0.0, pitch: 0.0 }, + sky: false, + }; + let ctx = Ctx { view: &view, ppp: 1.0, wireframe: false, cull: Extent::EMPTY, globe: None, sprites: Default::default() }; + let mut image = Pixmap::new(600, 400).unwrap(); + let symbol = Symbol::default_for(rustgis_data::GeometryType::Polyline, 0); + let strokes = [LineStroke { offset: 0.5, width: Some(0.5), ..Default::default() }]; + let mut paths = Paths::default(); + let mut allocation = None; + for i in 0..200 { + let x = f64::from(i % 20) * 10.0; + paths.draw(&ctx, &mut image, &[vec![Coord::new(x, 0.0), Coord::new(x + 1.0, 1.0)]], &symbol, &strokes); + assert!(paths.cached.as_ref().is_some_and(|path| path.points().len() <= CACHE_POINTS)); + let current = paths.cached.as_ref().unwrap().points().as_ptr(); + if let Some(previous) = allocation { + assert_eq!(current, previous, "the next feature should reuse the point allocation"); + } + allocation = Some(current); + } + let mut large = PathBuilder::new(); + large.move_to(0.0, 0.0); + for i in 0..=CACHE_POINTS { + large.line_to(i as f32, 1.0); + } + paths.remember(large.finish().unwrap()); + assert!(paths.cached.is_none(), "oversized buffers must be released after use"); + // Empty and invalid paths must neither replay stale geometry nor poison later reuse. + for parts in [vec![], vec![vec![]], vec![vec![Coord::new(f64::NAN, 0.0)]]] { + let before = image.data().to_vec(); + paths.draw(&ctx, &mut image, &parts, &symbol, &strokes); + assert_eq!(image.data(), before); + assert!(paths.cached.is_none()); + } + paths.draw(&ctx, &mut image, &[vec![Coord::new(0.0, 0.0), Coord::new(1.0, 1.0)]], &symbol, &strokes); + assert!(paths.cached.is_some()); +} From 0fc00c9d937d9cf92a6564056964c1fcc5eaf4b2 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:35:45 +0200 Subject: [PATCH 054/297] feat(ui): name tree-added datasets in Recent after their connection (#724) A dataset added from the Connections tree is noted in Recent under the connection's name, keeping the source's kind, address and extra. Direct, selected and delayed (service tilesets, WCS, downloads, STAC signing) adds are covered; failed or dropped adds are not renamed. --- crates/ui-egui/src/connectiontree.rs | 411 +++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 46 ++- crates/ui-egui/src/serviceui.rs | 24 +- crates/ui-egui/tests/smoke.rs | 3 +- docs/user-guide/data.md | 10 +- 5 files changed, 457 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/connectiontree.rs b/crates/ui-egui/src/connectiontree.rs index 9a44911a1..36d8ded5b 100644 --- a/crates/ui-egui/src/connectiontree.rs +++ b/crates/ui-egui/src/connectiontree.rs @@ -26,8 +26,71 @@ pub struct State { /// The definition each connection's entries were made for: an edit makes them stale. seen: HashMap, pub picks: Vec, - /// Signed addresses of STAC assets waiting on a token: (token address, asset, name, answer). - tokens: Vec<(String, String, String, Slot, String>>)>, + /// STAC assets waiting on a token. + tokens: Vec, + /// The connection a `ui.addUrl` being run is for, handed to it out of band ([`run_add_url`]), + /// so it never shows in the command's parameters (recordings, the log). + origin: Option, +} + +/// A STAC asset waiting on its signing token: the token's address, the asset, the layer's name, +/// the connection it was added from (named in Recent once it is added) and the token on its way. +struct Signing { + token_url: String, + href: String, + name: String, + origin: Option, + slot: Slot, String>>, +} + +/// The key a `ui.addUrl` still waiting on a probe or a download carries the connection's name +/// under, in its parameters. Taken off before any command sees them. +pub(crate) const ORIGIN: &str = "_origin"; + +/// Runs `ui.addUrl` for a dataset of the connection named `origin` (none: for any other source): +/// whichever way the download ends, the add is noted in Recent under that name. +pub(crate) fn run_add_url(app: &mut RustGisApp, params: Value, origin: Option<&str>) -> Result { + app.ui.connection_tree.origin = origin.map(str::to_string); + let r = app.run("ui.addUrl", params); + app.ui.connection_tree.origin = None; + r +} + +/// The connection `ui.addUrl` was run for ([`run_add_url`]). +pub(crate) fn take_origin(app: &mut RustGisApp) -> Option { + app.ui.connection_tree.origin.take() +} + +/// The connection a pending `ui.addUrl`'s parameters carry ([`ORIGIN`]), taken off them. +pub(crate) fn take_origin_key(params: &mut Value) -> Option { + match params.as_object_mut()?.remove(ORIGIN)? { + Value::String(name) => Some(name), + _ => None, + } +} + +/// Runs a command that adds a dataset now, noting it in Recent under the connection's name. +pub(crate) fn run_named(app: &mut RustGisApp, id: &str, params: Value, origin: Option<&str>) -> Result { + let before = app.session.sources.generation; + let r = app.run(id, params); + if r.is_ok() { + name_recent(app, before, origin); + } + r +} + +/// Names the source the session noted since `before` (a [`Sources::generation`]) as Recent +/// shows it, after the connection it came from. The kind, address and `extra` are the ones noted +/// (cleaned and redacted), so reopening it targets the same dataset; a name is a snapshot, taken +/// when added. Nothing noted (a failed add, one still on its way) leaves Recent as it was. +pub(crate) fn name_recent(app: &mut RustGisApp, before: u64, origin: Option<&str>) { + let Some(origin) = origin else { return }; + if app.session.sources.generation == before { + return; + } + let Some(mut added) = app.session.sources.recent.first().cloned() else { return }; + added.name = origin.to_string(); + app.session.sources.note(added); } pub enum Entry { @@ -163,15 +226,16 @@ pub fn poll(app: &mut RustGisApp, ctx: &egui::Context) { app.ui.connection_tree.entries.insert(key, entry); } // STAC assets that needed a token: signed and added when it lands. - for (token_url, href, name, slot) in std::mem::take(&mut app.ui.connection_tree.tokens) { - let Some(r) = slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { - app.ui.connection_tree.tokens.push((token_url, href, name, slot)); + for signing in std::mem::take(&mut app.ui.connection_tree.tokens) { + let Some(r) = signing.slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { + app.ui.connection_tree.tokens.push(signing); continue; }; match r.and_then(|b| stac::parse_sas(&b)) { Ok(token) => { - stac::remember_token(&token_url, &token); - if let Err(e) = app.run("ui.addUrl", json!({"url": stac::sign(&href, &token), "name": name, "zoom": true})) { + stac::remember_token(&signing.token_url, &token); + let p = json!({"url": stac::sign(&signing.href, &token), "name": signing.name, "zoom": true}); + if let Err(e) = run_add_url(app, p, signing.origin.as_deref()) { app.session.notify(e, true); } app.map.invalidate(); @@ -186,21 +250,23 @@ pub fn poll(app: &mut RustGisApp, ctx: &egui::Context) { } /// Adds one dataset a listing offers. Each branch is one command, so each is one undo step. -pub fn add(app: &mut RustGisApp, child: &Value, source: Option<&ServiceSource>) -> Result<(), String> { +/// `origin` is the connection it is for: Recent then shows the dataset's source (as added, its +/// address and options unchanged) under that name, once it is added. +fn add_from(app: &mut RustGisApp, child: &Value, source: Option<&ServiceSource>, origin: Option<&str>) -> Result<(), String> { let add = child.get("add").filter(|a| a.is_object()).ok_or_else(|| tl!("Nothing to add here").to_string())?; let command = add["command"].as_str().unwrap_or(""); let params = add["params"].clone(); match (source, child.get("layer").filter(|l| l.is_object())) { (Some(source), Some(layer)) => { - serviceui::add_layer(app, source, layer, "", false)?; + serviceui::add_layer_from(app, source, layer, "", false, origin)?; } _ => match command { "layer.addUrl" => { - app.run("ui.addUrl", params)?; + run_add_url(app, params, origin)?; } - "stac.addAsset" => add_asset(app, child["href"].as_str().unwrap_or(""), params["name"].as_str().unwrap_or(""))?, + "stac.addAsset" => add_asset(app, child["href"].as_str().unwrap_or(""), params["name"].as_str().unwrap_or(""), origin)?, _ => { - app.run(command, params)?; + run_named(app, command, params, origin)?; } }, } @@ -210,17 +276,18 @@ pub fn add(app: &mut RustGisApp, child: &Value, source: Option<&ServiceSource>) /// A STAC asset by its address, signed first when it needs a token (one in hand is used; one to /// fetch is fetched off the frame and the asset added when it lands). -fn add_asset(app: &mut RustGisApp, href: &str, name: &str) -> Result<(), String> { +fn add_asset(app: &mut RustGisApp, href: &str, name: &str, origin: Option<&str>) -> Result<(), String> { if !stac::needs_signing(href) { - app.run("ui.addUrl", json!({"url": href, "name": name, "zoom": true}))?; + run_add_url(app, json!({"url": href, "name": name, "zoom": true}), origin)?; return Ok(()); } let token_url = stac::sas_token_url(href)?; if let Some(token) = stac::cached_token(&token_url) { - app.run("ui.addUrl", json!({"url": stac::sign(href, &token), "name": name, "zoom": true}))?; + run_add_url(app, json!({"url": stac::sign(href, &token), "name": name, "zoom": true}), origin)?; } else { let slot = crate::fetch_into(&token_url); - app.ui.connection_tree.tokens.push((token_url, href.to_string(), name.to_string(), slot)); + let (href, name, origin) = (href.to_string(), name.to_string(), origin.map(str::to_string)); + app.ui.connection_tree.tokens.push(Signing { token_url, href, name, origin, slot }); } Ok(()) } @@ -336,7 +403,9 @@ pub fn prune(app: &mut RustGisApp) { fn add_picks(app: &mut RustGisApp) { prune(app); for pick in std::mem::take(&mut app.ui.connection_tree.picks) { - if let Err(e) = add(app, &pick.child, pick.source.as_deref()) { + // The name as it is now, for the whole add (pruned above: the connection is there). + let origin = app.session.connections.find(&pick.conn).map(|c| c.name.clone()); + if let Err(e) = add_from(app, &pick.child, pick.source.as_deref(), origin.as_deref()) { app.session.notify(e, true); } } @@ -521,7 +590,7 @@ fn dataset(app: &mut RustGisApp, ui: &mut Ui, c: &Connection, listing: &Listing, let result = if with_options { add_with_options(app, c, child, listing.source.as_deref()) } else if add_now { - add(app, child, listing.source.as_deref()) + add_from(app, child, listing.source.as_deref(), Some(c.name.as_str())) } else { Ok(()) }; @@ -529,3 +598,309 @@ fn dataset(app: &mut RustGisApp, ui: &mut Ui, c: &Connection, listing: &Listing, app.session.notify(e, true); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use egui_kittest::kittest::Queryable; + use rustgis_engine::sources::{self, Source}; + use std::io::{Read, Write}; + + type Harness = egui_kittest::Harness<'static, RustGisApp>; + + fn harness() -> Harness { + egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).with_step_dt(0.05).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + RustGisApp::with_sample(), + ) + } + + /// Answers every request on a local port with `answer(request head)`: status and body. + fn serve(answer: impl Fn(&str) -> (u16, Vec) + Send + 'static) -> String { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut s) = stream else { continue }; + let mut buf = [0u8; 4096]; + let n = s.read(&mut buf).unwrap_or(0); + let (status, body) = answer(&String::from_utf8_lossy(&buf[..n])); + let _ = write!(s, "HTTP/1.1 {status} X\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + let _ = s.write_all(&body); + } + }); + base + } + + fn until(h: &mut Harness, done: impl Fn(&RustGisApp) -> bool) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !done(h.state()) { + assert!(std::time::Instant::now() < deadline, "timed out waiting"); + h.step(); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + /// Capabilities whose GetMap endpoint is loopback (port 9 refuses): no test reaches the network. + const WMS: &str = r#"Localimage/pngEPSG:3857aAbB"#; + + fn wms(url: &str) -> ServiceSource { + ServiceSource { url: url.into(), kind: "wms", text: WMS.into(), documents: Vec::new() } + } + + /// A listed WMS layer, as the tree adds it. + fn layer(name: &str) -> Value { + json!({"add": {"command": "service.add", "params": {}}, "layer": {"name": name, "title": name.to_uppercase()}}) + } + + /// The Recent entry a `service.add` of this layer gives, named `name`: what the session + /// notes (cleaned, redacted), with only the name replaced. + fn expected(url: &str, layer: &str, name: &str) -> Source { + let mut added = sources::added_by("service.add", &json!({"url": url, "layer": layer, "type": "wms"})).unwrap(); + added.name = name.into(); + sources::clean(added).unwrap() + } + + #[test] + fn a_tree_add_is_recent_under_the_connection_name_with_its_own_source() { + let mut h = harness(); + let app = h.state_mut(); + let url = "http://127.0.0.1:9/wms"; + let layers = app.session.map().tiles.len(); + add_from(app, &layer("a"), Some(&wms(url)), Some("Local")).unwrap(); + assert_eq!(app.session.map().tiles.len(), layers + 1); + assert_eq!(app.session.sources.recent.first(), Some(&expected(url, "a", "Local"))); + assert_eq!(app.session.sources.recent[0].extra, json!({"layer": "a", "type": "wms"})); + // The layer keeps the name the service gave it. + assert_ne!(app.session.map().tiles.last().map(|t| t.name.as_str()), Some("Local")); + + // A name is a snapshot: renaming the connection leaves it until something is added again. + let id = app.session.run("connection.new", &json!({"kind": "wms", "address": url, "name": "Local"})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + app.session.run("connection.edit", &json!({"id": id, "name": "Renamed"})).unwrap(); + assert_eq!(app.session.sources.recent[0].name, "Local"); + let name = app.session.connections.find(&id).unwrap().name.clone(); + add_from(app, &layer("b"), Some(&wms(url)), Some(&name)).unwrap(); + // One entry per kind and address: the re-add takes the current name and layer. + assert_eq!(app.session.sources.recent.iter().filter(|s| s.address == url).count(), 1); + assert_eq!(app.session.sources.recent.first(), Some(&expected(url, "b", "Renamed"))); + + // Another connection to the same address shares the entry. + add_from(app, &layer("a"), Some(&wms(url)), Some("Twin")).unwrap(); + assert_eq!(app.session.sources.recent.iter().filter(|s| s.address == url).count(), 1); + assert_eq!(app.session.sources.recent.first(), Some(&expected(url, "a", "Twin"))); + + // An address with a secret is redacted exactly as a plain add redacts it. + let secret = "http://127.0.0.1:9/wms?token=SECRET&access_token=SECRET"; + add_from(app, &layer("a"), Some(&wms(secret)), Some("Local")).unwrap(); + let noted = app.session.sources.recent.first().unwrap(); + assert!(!noted.address.contains("SECRET"), "{}", noted.address); + assert_eq!(noted.name, "Local"); + } + + #[test] + fn a_failed_add_is_not_renamed_or_recorded() { + let mut h = harness(); + let app = h.state_mut(); + let before = app.session.sources.recent.clone(); + assert!(add_from(app, &layer("nope"), Some(&wms("http://127.0.0.1:9/wms")), Some("Local")).is_err()); + let missing = json!({"add": {"command": "layer.addData", "params": {"path": "/no/such/file.geojson"}}}); + assert!(add_from(app, &missing, None, Some("Local")).is_err()); + assert_eq!(app.session.sources.recent, before); + } + + #[test] + fn selected_datasets_are_each_named_after_their_own_connection() { + let mut h = harness(); + let app = h.state_mut(); + let (one, two) = ("http://127.0.0.1:9/one", "http://127.0.0.1:9/two"); + let mut ids = Vec::new(); + for (name, url) in [("One", one), ("Two", two)] { + ids.push( + app.session.run("connection.new", &json!({"kind": "wms", "address": url, "name": name})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(), + ); + } + for (id, url) in ids.iter().zip([one, two]) { + let pick = Pick { conn: id.clone(), key: "a".into(), name: "A".into(), child: layer("a"), source: Some(Arc::new(wms(url))) }; + app.ui.connection_tree.picks.push(pick); + app.ui.connection_tree.seen.insert(id.clone(), app.session.connections.find(id).unwrap().clone()); + } + add_picks(app); + assert!(app.ui.connection_tree.picks.is_empty()); + assert_eq!(app.session.sources.recent.first(), Some(&expected(two, "a", "Two"))); + assert_eq!(app.session.sources.recent.get(1), Some(&expected(one, "a", "One"))); + } + + /// A download lands after the add returned: its entry is named for the connection it came + /// from, whatever happened to the connection meanwhile, and a download that fails has none. + #[test] + fn a_delayed_download_is_named_when_it_lands_and_a_failed_one_is_not() { + let mut h = harness(); + let base = serve(|head| { + if head.contains("/missing") { + (404, b"no".to_vec()) + } else { + (200, br#"{"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": [1, 2]}}"#.to_vec()) + } + }); + let (good, bad) = (format!("{base}/points.geojson"), format!("{base}/missing.geojson")); + let url_add = |address: &str| json!({"add": {"command": "layer.addUrl", "params": {"url": address, "name": "Points"}}}); + let app = h.state_mut(); + let id = app.session.run("connection.new", &json!({"kind": "wms", "address": "http://127.0.0.1:9/wms", "name": "Local"})).unwrap() + ["id"] + .as_str() + .unwrap() + .to_string(); + let before = app.session.sources.recent.clone(); + add_from(app, &url_add(&good), None, Some("Local")).unwrap(); + add_from(app, &url_add(&bad), None, Some("Local")).unwrap(); + assert_eq!(app.session.sources.recent, before, "nothing is recorded before the data lands"); + // The connection is renamed and the selection changes before it lands. + app.session.run("connection.edit", &json!({"id": id, "name": "Renamed"})).unwrap(); + app.ui.connection_tree.picks.clear(); + until(&mut h, |app| { + app.session.sources.recent.iter().any(|s| s.address == good) && app.ui.downloads.is_empty() && app.ui.cog_probes.is_empty() + }); + let recent = &h.state().session.sources.recent; + let landed = recent.iter().find(|s| s.address == good).unwrap(); + assert_eq!((landed.kind.as_str(), landed.name.as_str()), ("url", "Local")); + assert!(recent.iter().all(|s| s.address != bad), "a failed download isn't recorded"); + } + + #[test] + fn a_tileset_step_names_its_layer_and_a_refused_one_is_not_recorded() { + let mut h = harness(); + let api = "https://x.org/collections"; + let tiled = r#"{"collections": [{"id": "relief", "links": [{"rel": "tilesets-map", "href": "https://x.org/relief/map/tiles"}]}]}"#; + let step = |fallback: &str, origin: Option<&str>| { + let slot = Slot::default(); + *slot.lock().unwrap() = Some(Err("unreachable".to_string())); + let params = json!({"url": api, "layer": "relief (map tiles)", "text": tiled, "name": "Relief"}); + let resolve = + crate::TilesResolve { params, list: "l".into(), doc: "l".into(), read: 0, vector: false, fallback: fallback.into(), slot }; + crate::ServiceStep::Tiles { resolve, dialog: false, origin: origin.map(str::to_string) } + }; + let before = h.state().session.sources.recent.clone(); + h.state_mut().ui.service_steps.push(step("http://127.0.0.1/{z}/{x}/{y}", Some("Atlas"))); + h.step(); + assert_eq!(h.state().session.sources.recent, before, "a refused template adds nothing"); + h.state_mut().ui.service_steps.push(step("https://x.org/relief/map/tiles/smerc/{z}/{y}/{x}.png", Some("Atlas"))); + h.step(); + let noted = h.state().session.sources.recent.first().unwrap().clone(); + assert_eq!((noted.kind.as_str(), noted.address.as_str(), noted.name.as_str()), ("service", api, "Atlas")); + assert_eq!(noted.extra["layer"], "relief (map tiles)"); + // Added from a dialog, it keeps the name the address gives. + h.state_mut().ui.service_steps.push(step("https://x.org/relief/map/tiles/smerc/{z}/{y}/{x}.png", None)); + h.step(); + assert_eq!(h.state().session.sources.recent.first().unwrap().name, sources::default_name(api)); + } + + /// A WCS coverage through a local server: its description is read, then the GeoTIFF is + /// downloaded; Recent names the coverage's source after the connection once it lands. + #[test] + fn a_coverage_is_named_once_its_download_lands() { + use rustgis_engine::data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; + let r = Raster::new( + "soc", + 8, + 8, + vec![vec![3.0; 64]], + GeoTransform { origin_x: 5.0, origin_y: 50.0, pixel_width: 0.1, pixel_height: -0.1 }, + Crs::LonLat, + None, + ColorHint::Gray, + "Float32", + ) + .unwrap(); + let tiff = geotiff::write(&r, 0).unwrap(); + let caps = r#" + Soils + socSoil carbon + 5 49.25.8 50 + "#; + let describe = r#" + 49.2 550 5.8"#; + let base = serve(move |head| { + if head.contains("GetCoverage") { + (200, tiff.clone()) + } else if head.contains("DescribeCoverage") { + (200, describe.as_bytes().to_vec()) + } else { + (200, caps.as_bytes().to_vec()) + } + }); + let url = format!("{base}/wcs"); + let mut h = harness(); + let app = h.state_mut(); + app.run("map.zoomToExtent", json!({"extent": [5.0, 49.2, 5.8, 50.0]})).unwrap(); + let listed = app.session.run("service.layers", &json!({"url": url, "text": caps})).unwrap(); + let source = ServiceSource { url: url.clone(), kind: "wcs", text: caps.into(), documents: Vec::new() }; + let child = json!({"add": {"command": "service.add", "params": {}}, "layer": listed["layers"][0].clone()}); + let before = app.session.sources.recent.clone(); + add_from(app, &child, Some(&source), Some("Soils")).unwrap(); + assert_eq!(app.session.sources.recent, before, "nothing is recorded while the description is read"); + until(&mut h, |app| { + app.ui.service_steps.is_empty() + && app.ui.downloads.is_empty() + && app.ui.cog_probes.is_empty() + && app.session.sources.recent.first().is_some_and(|s| s.name == "Soils") + }); + let noted = h.state().session.sources.recent.first().unwrap(); + assert_eq!(noted.kind, "url"); + assert!(noted.address.starts_with(&url) && noted.address.contains("GetCoverage"), "{}", noted.address); + } + + /// The whole path on screen: open a connection in the Data Sources pane and double-click a + /// layer. Recent then lists it under the connection's name with the source as added, and + /// clicking that entry opens the service browser on the same address. + #[test] + fn recent_in_the_pane_shows_the_connection_and_reopens_its_service() { + let base = serve(|_| (200, WMS.as_bytes().to_vec())); + let url = format!("{base}/wms"); + let mut h = harness(); + let id = h.state_mut().run("connection.new", json!({"kind": "wms", "address": url, "name": "Local"})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + h.state_mut().run("ui.pane.sources", json!({})).unwrap(); + h.step(); + h.get_by_label("Local").click(); + until(&mut h, |app| matches!(app.ui.connection_tree.entries.get(&(id.clone(), String::new())), Some(Entry::Ready(_)))); + // (egui counts clicks in a row: let the connection's click age out of its window first.) + for _ in 0..14 { + h.step(); + } + let layers = h.state().session.map().tiles.len(); + // A double-click adds it: the first click selects, the second adds. + h.get_by_label("A").click(); + h.step(); + h.get_by_label("A").click(); + h.step(); + h.step(); + assert_eq!(h.state().session.map().tiles.len(), layers + 1); + let noted = h.state().session.sources.recent.first().unwrap().clone(); + assert_eq!((noted.kind.as_str(), noted.address.as_str(), noted.name.as_str()), ("service", url.as_str(), "Local")); + assert_eq!(noted.extra, json!({"layer": "a", "type": "wms"})); + + // A rename leaves the entry as it was. + h.state_mut().run("connection.edit", json!({"id": id, "name": "Renamed"})).unwrap(); + h.step(); + assert_eq!(h.state().session.sources.recent.first().map(|s| s.name.as_str()), Some("Local")); + + // Recent is listed above Connections: its row is the first labelled "Local". + h.state_mut().ui.service = None; + h.query_all_by_label("Local").next().unwrap().click(); + h.step(); + assert_eq!(h.state().ui.service.as_ref().map(|c| c.url.as_str()), Some(url.as_str())); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 153cbfb07..65ee072aa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -322,13 +322,14 @@ pub struct TilesResolve { } /// A service layer waiting on a document before it is added. `dialog` tells whether the add came -/// from the add-service dialog (its result shows there) or from the Connections tree (a notice). +/// from the add-service dialog (its result shows there) or from the Connections tree (a notice); +/// `origin` is the connection a tree add is for, named in Recent once the layer is added. pub enum ServiceStep { /// An OGC API layer whose advertised tile template is being read. - Tiles { resolve: TilesResolve, dialog: bool }, + Tiles { resolve: TilesResolve, dialog: bool, origin: Option }, /// A WCS coverage whose description is being read before it is asked for: `service.coverage`'s /// parameters and the description on its way. - Coverage { params: Value, slot: Slot, String>>, dialog: bool }, + Coverage { params: Value, slot: Slot, String>>, dialog: bool, origin: Option }, } /// Fetches an address into a slot, off the frame. @@ -2041,11 +2042,13 @@ impl RustGisApp { // Data by its web address: the dialog, then (with an address) the download, off the // frame; it's added when it lands. A PMTiles archive is a tile layer, never downloaded. "ui.addUrl" => { + // The connection a Connections tree add is for (named in Recent once it is added). + let origin = connectiontree::take_origin(self); let Some(url) = p.get("url").and_then(Value::as_str).map(str::trim).filter(|u| !u.is_empty()) else { return self.run("ui.dialog", json!({"name": "addUrl"})); }; if url.to_ascii_lowercase().split(['?', '#']).next().is_some_and(|u| u.ends_with(".pmtiles")) { - return self.run("layer.addUrl", p.clone()); + return connectiontree::run_named(self, "layer.addUrl", p.clone(), origin.as_deref()); } // A GeoTIFF, FlatGeobuf or GeoParquet: its header (or footer) first, off the // frame; one that can be read in place is, another is downloaded (see @@ -2073,7 +2076,14 @@ impl RustGisApp { ); } self.session.notify(i18n::fmt("Reading {url}…", &[("url", url)]), false); - self.ui.cog_probes.push((p.clone(), slot)); + let mut probe = p.clone(); + if let Some(o) = probe.as_object_mut() { + o.remove(connectiontree::ORIGIN); + if let Some(origin) = &origin { + o.insert(connectiontree::ORIGIN.into(), json!(origin)); + } + } + self.ui.cog_probes.push((probe, slot)); return Ok(Value::Null); } let url = url.to_string(); @@ -2097,6 +2107,9 @@ impl RustGisApp { q[k] = json!(true); } } + if let Some(origin) = &origin { + q[connectiontree::ORIGIN] = json!(origin); + } self.session.notify(i18n::fmt("Downloading {url}…", &[("url", &url)]), false); let id = self.session.download_started(); self.ui.downloads.push((id, url, q, slot)); @@ -2986,16 +2999,19 @@ impl RustGisApp { None => true, }); for (mut p, r) in probed { + let origin = connectiontree::take_origin_key(&mut p); let url = p.get("url").and_then(Value::as_str).unwrap_or("").to_string(); match r { Ok(true) => { + let before = self.session.sources.generation; if self.run("layer.addUrl", p).is_ok() { + connectiontree::name_recent(self, before, origin.as_deref()); self.map.invalidate(); } } Ok(false) => { p["whole"] = json!(true); - let _ = self.run("ui.addUrl", p); + let _ = connectiontree::run_add_url(self, p, origin.as_deref()); } Err(e) => self.session.notify(if e.starts_with(&url) { e } else { format!("{url}: {e}") }, true), } @@ -3009,6 +3025,7 @@ impl RustGisApp { None => true, }); for (id, url, mut p, r) in landed { + let origin = connectiontree::take_origin_key(&mut p); match r { Ok(bytes) => { if !self.session.download_landed(id, bytes) { @@ -3024,8 +3041,10 @@ impl RustGisApp { if zoom && self.ui.link_zoomed { p["zoom"] = json!(false); } + let before = self.session.sources.generation; match self.run("layer.addDownload", p) { Ok(r) => { + connectiontree::name_recent(self, before, origin.as_deref()); if r.get("zoomed").and_then(Value::as_bool) == Some(true) { self.ui.link_zoomed = true; } @@ -3061,9 +3080,9 @@ impl RustGisApp { use rustgis_engine::data::ogc::{TilesStep, resolve_tiles}; for step in std::mem::take(&mut self.ui.service_steps) { match step { - ServiceStep::Tiles { mut resolve, dialog } => { + ServiceStep::Tiles { mut resolve, dialog, origin } => { let Some(r) = resolve.slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { - self.ui.service_steps.push(ServiceStep::Tiles { resolve, dialog }); + self.ui.service_steps.push(ServiceStep::Tiles { resolve, dialog, origin }); continue; }; let next = r.ok().and_then(|body| resolve_tiles(&resolve.list, &resolve.doc, &body, resolve.vector)); @@ -3072,7 +3091,7 @@ impl RustGisApp { resolve.read += 1; resolve.doc = next.clone(); resolve.slot = fetch_into(&next); - self.ui.service_steps.push(ServiceStep::Tiles { resolve, dialog }); + self.ui.service_steps.push(ServiceStep::Tiles { resolve, dialog, origin }); continue; } Some(TilesStep::Template(t)) => t, @@ -3081,13 +3100,14 @@ impl RustGisApp { let mut p = resolve.params; p["tiles"] = json!(template); let title = p["name"].as_str().unwrap_or("").to_string(); - let r = self.run("service.add", p).map(|_| format!("{} {title}", tl!("Added"))); + let r = + connectiontree::run_named(self, "service.add", p, origin.as_deref()).map(|_| format!("{} {title}", tl!("Added"))); self.report_step(dialog, r); self.map.invalidate(); } - ServiceStep::Coverage { mut params, slot, dialog } => { + ServiceStep::Coverage { mut params, slot, dialog, origin } => { let Some(r) = slot.lock().unwrap_or_else(|e| e.into_inner()).take() else { - self.ui.service_steps.push(ServiceStep::Coverage { params, slot, dialog }); + self.ui.service_steps.push(ServiceStep::Coverage { params, slot, dialog, origin }); continue; }; let title = params["name"].as_str().unwrap_or("").to_string(); @@ -3096,7 +3116,7 @@ impl RustGisApp { // Not `self.run`: an error belongs where the step reports, not the notices. let c = self.session.run("service.coverage", ¶ms)?; let get = c.get("url").and_then(Value::as_str).unwrap_or("").to_string(); - self.run("ui.addUrl", json!({"url": get, "name": title, "coverage": true})) + connectiontree::run_add_url(self, json!({"url": get, "name": title, "coverage": true}), origin.as_deref()) }); self.report_step(dialog, r.map(|_| format!("{} {title}", tl!("Downloading…")))); } diff --git a/crates/ui-egui/src/serviceui.rs b/crates/ui-egui/src/serviceui.rs index 834c6e5a9..5ed443f5a 100644 --- a/crates/ui-egui/src/serviceui.rs +++ b/crates/ui-egui/src/serviceui.rs @@ -47,13 +47,29 @@ pub(crate) fn kind_of(t: &str) -> &'static str { /// background as From URL… does, map layers are added now, and an OGC API layer or a WCS coverage /// waits on a document (a step the app moves along). `dialog` tells where the result is reported. pub(crate) fn add_layer(app: &mut RustGisApp, src: &ServiceSource, l: &Value, time: &str, dialog: bool) -> Result<&'static str, String> { + add_layer_from(app, src, l, time, dialog, None) +} + +/// [`add_layer`] for a layer of the connection named `origin` (the Connections tree): once the +/// layer is added, now or when its document or download lands, Recent shows its source under that +/// name. An add that fails or is dropped is not renamed. +pub(crate) fn add_layer_from( + app: &mut RustGisApp, + src: &ServiceSource, + l: &Value, + time: &str, + dialog: bool, + origin: Option<&str>, +) -> Result<&'static str, String> { let name = l.get("name").and_then(Value::as_str).unwrap_or("").to_string(); let title = l.get("title").and_then(Value::as_str).unwrap_or(&name).to_string(); let offer = l.get("offer").cloned().unwrap_or_default(); if offer.get("type").and_then(Value::as_str) == Some("features") { let items = offer.get("url").and_then(Value::as_str).unwrap_or("").to_string(); - return app.run("ui.addUrl", json!({"url": items, "name": title, "service": true})).map(|_| tl!("Downloading…")); + return crate::connectiontree::run_add_url(app, json!({"url": items, "name": title, "service": true}), origin) + .map(|_| tl!("Downloading…")); } + let origin = origin.map(str::to_string); if offer.get("type").and_then(Value::as_str) == Some("coverage") { // A coverage: its description first, off the frame (a step asks for it). let describe = offer.get("describe").and_then(Value::as_str).unwrap_or("").to_string(); @@ -62,7 +78,7 @@ pub(crate) fn add_layer(app: &mut RustGisApp, src: &ServiceSource, l: &Value, ti p["type"] = json!(src.kind); } let slot = crate::fetch_into(&describe); - app.ui.service_steps.push(crate::ServiceStep::Coverage { params: p, slot, dialog }); + app.ui.service_steps.push(crate::ServiceStep::Coverage { params: p, slot, dialog, origin }); return Ok(tl!("Describing")); } let mut p = json!({"url": src.url, "layer": name, "text": src.text, "documents": src.documents, "source_url": l.get("source_url")}); @@ -81,10 +97,10 @@ pub(crate) fn add_layer(app: &mut RustGisApp, src: &ServiceSource, l: &Value, ti let vector = offer.get("type").and_then(Value::as_str) == Some("vectorTiles"); let slot = crate::fetch_into(list); let resolve = crate::TilesResolve { params: p, list: list.to_string(), doc: list.to_string(), read: 0, vector, fallback, slot }; - app.ui.service_steps.push(crate::ServiceStep::Tiles { resolve, dialog }); + app.ui.service_steps.push(crate::ServiceStep::Tiles { resolve, dialog, origin }); Ok(tl!("Reading the tileset of")) } - None => app.run("service.add", p).map(|_| tl!("Added")), + None => crate::connectiontree::run_named(app, "service.add", p, origin.as_deref()).map(|_| tl!("Added")), } } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 3f2d1cea6..01edf66ac 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -1941,7 +1941,8 @@ fn connections_tree_browses_and_adds() { assert!(h.state().ui.connection_tree.picks.is_empty()); h.state_mut().run("edit.undo", json!(null)).unwrap(); assert_eq!(h.state().session.map().tiles.len(), before); - h.get_by_label("Local").click_secondary(); + // Recent now lists the added layer under "Local" too, above the connection's own row. + h.query_all_by_label("Local").last().unwrap().click_secondary(); h.step(); h.get_by_label("Refresh").click(); h.step(); diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index bc33ee379..d4c07ef95 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -336,7 +336,15 @@ places data comes from: - **Favourites**: any source, right-clicked and added. - **Recent**: the last 20 sources added (files, web addresses, tile layers, services, database - tables, Zarr variables, STAC assets, sample data), newest first. + tables, Zarr variables, STAC assets, sample data), newest first. A dataset added from a + connection in the tree is listed under that connection's name rather than one made from its + address. Only the name changes: the entry keeps the kind, address and options of the dataset + as it was added, and the layer keeps its own name. Clicking it opens it as any Recent entry + of that kind opens (a service entry reopens the service's layer list). A source is one entry + per kind and address, so every dataset added through one service shares an entry, as do two + connections with one address. The name is the connection's when the dataset was added: + renaming a connection changes it only when something is added from it again. An add that + fails, or is cancelled before it lands, isn't listed. - **Folders**: the folders connected to the Browser (**Add Folder…**; desktop). - **Connections**: named services, databases, STAC APIs and S3 endpoints, organised by optional groups. **New Connection ▾** opens a kind-specific form. Click a connection to open it: its From a7ed09406361d2f78a475d7e726e2c3a7529f89c Mon Sep 17 00:00:00 2001 From: Andrew Gene Brown Date: Fri, 23 Oct 2026 07:10:49 -0700 Subject: [PATCH 055/297] fix(tests-gpu): run mapgpu GPU tests one at a time (#735) - so Mesa EGL never runs in parallel --- crates/ui-egui/src/mapgpu.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/ui-egui/src/mapgpu.rs b/crates/ui-egui/src/mapgpu.rs index 07b799f77..a02863740 100644 --- a/crates/ui-egui/src/mapgpu.rs +++ b/crates/ui-egui/src/mapgpu.rs @@ -1648,6 +1648,12 @@ mod tests { assert!(star.iter().any(|p| (p[1] + 1.2).abs() < 1e-6), "star top point"); } + /// GPU tests run one at a time because Mesa EGL can stall when entered concurrently. + fn one_at_a_time() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + /// A headless wgpu render state (the egui_kittest recipe). `None` on hosts without any /// adapter — CI without a GPU skips these tests. fn headless_render_state() -> Option { @@ -1697,6 +1703,7 @@ mod tests { #[test] fn gpu_renderer_constructs_and_uploads_headless() { + let _serial = one_at_a_time(); let Some(rs) = headless_render_state() else { return }; let r = GpuMapRenderer::new(&rs); // A tiny scene uploads: one triangle, one marker instance, one CPU image. @@ -1863,6 +1870,7 @@ mod tests { /// frame is warped from flat bands (resampled, so a thin line's colour spreads), its GPU one /// drawn in perspective directly. fn assert_parity_within(name: &str, build: impl Fn() -> Map, view: &View, backends: wgpu::Backends, outliers: usize) { + let _serial = one_at_a_time(); let mut gm = build(); let Some(gpu) = gpu_render_offscreen_on(&mut gm, view, backends) else { return }; // no adapter: skip let mut cm = build(); @@ -2314,6 +2322,7 @@ mod tests { /// A tile picture is uploaded once, and drawn again from its texture by the next scenes. #[test] fn tile_textures_are_kept_across_scenes() { + let _serial = one_at_a_time(); let Some(rs) = headless_render_state() else { return }; let r = GpuMapRenderer::new(&rs); let mut pm = rustgis_engine::render::Pixmap::new(4, 4).expect("pixmap"); @@ -2352,6 +2361,7 @@ mod tests { /// one the frame draws, and a picture the tile cache lets go frees its layer (#635). #[test] fn live_tile_pictures_are_pooled_paced_and_bounded() { + let _serial = one_at_a_time(); let Some(rs) = headless_render_state() else { return }; let picture = |c: u8| { let mut pm = rustgis_engine::render::Pixmap::new(256, 256).expect("pixmap"); @@ -2405,6 +2415,7 @@ mod tests { /// frame doesn't draw from, rather than drawing nothing (#635). #[test] fn live_tiles_of_another_size_take_back_budget() { + let _serial = one_at_a_time(); let Some(rs) = headless_render_state() else { return }; let picture = |side: u32, c: u8| { let mut pm = rustgis_engine::render::Pixmap::new(side, side).expect("pixmap"); From dca115fa2f67fafd6eb33d96562ac28ff827c95c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ninh=20H=E1=BA=A3i=20=C4=90=C4=83ng?= Date: Fri, 23 Oct 2026 21:23:27 +0700 Subject: [PATCH 056/297] feat(ui): pick several layers in the Layers pane (#728) * feat(ui): pick several layers in the Layers pane Ctrl/Cmd+click adds or drops a layer, Shift+click takes the rows from the last one clicked, in the order the pane lists them. The layers picked together are the layer selection, with the active layer among them; it belongs to the project's state, not the saved file, and a layer the name filter hides leaves it. layer.select takes a list and a mode (replace, toggle, extend). Remove, visibility, selectable, move and zoomTo take a list in `layer`, or the layer selection when it is left out: one undo step, and an unknown layer fails the whole command. Visibility toggles every layer to the opposite of the active layer's state. Several layers move to the top or the bottom, keeping their order. Right-clicking one of several picked layers opens a menu of what acts on all of them. Adds GLOSSARY.md for layer selection vs feature selection. * fix(ui): group several picked layers as one layer is grouped Move To Top/Bottom of several layers failed outright when one of them was in a group that wasn't already at that end: each layer was sent to index 0 (or the last), and a layer can't leave its group. A layer in a group now goes to the top or bottom of its group, the others to the map's, still one undo step. The menu of several layers had only "Move To New Group"; it now has the same Group submenu as one layer's (New Group, Remove From Group, Move To a group), with layer.group taking a list. A group made from either menu asks for its name, rather than leaving every one "New Group". * fix(ui): outline the active layer instead of bolding it With several layers picked, only the active one had a bold name, which read as the only one picked. Every picked row now looks alike (tinted, regular weight); the active layer, the one the Style and Data tabs act on, has a thin accent outline, as a file browser marks the item with focus. * fix(ui): name the several-layer menu items after what they change "Show Selected / Hide Selected" beside "Make Selectable / Make Not Selectable" used one word for two things: the picked layers, and whether the Select tool picks their features. The items are now Show Layers / Hide Layers and Allow / Block Feature Selection, with a hover text saying what the latter do. * fix(engine): keep the layer selection with its project and its active layer From review of #728: - A project put aside moved its layer selection out instead of copying it: the next project's layers reuse the same ids, so they came back picked. - A layer selection made around another active layer is cleared after the command that left it behind, so the old active layer coming back doesn't bring the old selection back. - layer.group with a one-layer list grouped that layer, not the active one. - A command's layer conditions read the first layer of a `layer` list, so a list works with no active layer (or a raster one). - The name filter keeps the active layer active when it shows it. - A double-click with Ctrl, Command or Shift held picks layers, not opens Symbology over one. - layer_menu has its doc comment back; layer.select says that toggling out the last layer leaves none active. * docs(engine): say what layer.move's "to" means for several layers The params said several layers go to the bottom when "to" is past the end; 2 of 4 layers at "to": 2 already do, the topmost landing at 2 as one layer would. The text now says so, and the test pins "to" 2 and 3. No behaviour changes. --- GLOSSARY.md | 17 +++ crates/engine/src/cmd/group.rs | 23 +++- crates/engine/src/cmd/layer.rs | 197 ++++++++++++++++++++++++++++----- crates/engine/src/lib.rs | 50 ++++++++- crates/engine/src/tests.rs | 197 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 11 ++ crates/ui-egui/src/panes.rs | 171 ++++++++++++++++++++++++++-- docs/user-guide/index.md | 9 ++ 8 files changed, 638 insertions(+), 37 deletions(-) create mode 100644 GLOSSARY.md diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 000000000..8f15d7ca5 --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,17 @@ +# Layers pane + +How a map's layers are picked and acted on in the Layers pane, and the words the pane, the commands and the docs share for it. + +## Language + +**Active layer**: +The one layer the commands that need a layer act on (Style and Data tabs, attribute table, Zoom To Layer). It is always part of the layer selection. +_Avoid_: Current layer, selected layer + +**Layer selection**: +The layers picked together in the Layers pane for an action on all of them (remove, show or hide, move, group). It is kept with the open project's state, not saved in the project file. +_Avoid_: Multi-select, layer set + +**Feature selection**: +The features picked in a layer, shown by its count badge and the Selection view. A bare "selection" always means this. +_Avoid_: Layer selection (a different thing: it picks layers, not features) diff --git a/crates/engine/src/cmd/group.rs b/crates/engine/src/cmd/group.rs index eeb15e4c5..5e0a52940 100644 --- a/crates/engine/src/cmd/group.rs +++ b/crates/engine/src/cmd/group.rs @@ -28,7 +28,7 @@ pub fn commands() -> Vec { C::new("group.remove", "Remove Group", "Layers", remove).params(r#"{"group": name|id} (and its layers)"#), C::new("layer.group", "Move To Group", "Layers", move_to_group) .when(super::need_layer) - .params(r#"{"layer"?: name|id (default: the active layer), "group": name|id|null (null takes it out of its group), "before"?: name|id (the layer it goes just above: one of the group's, or out of a group any other; without it, next to the group's layers, or where it is when taken out)}"#), + .params(r#"{"layer"?: name|id | [name|id] (default: the layer selection), "group": name|id|null (null takes it out of its group), "before"?: name|id (the layer it goes just above: one of the group's, or out of a group any other; without it, next to the group's layers, or where it is when taken out)}"#), ] } @@ -183,12 +183,31 @@ fn remove(s: &mut Session, p: &Value) -> CmdResult { } fn move_to_group(s: &mut Session, p: &Value) -> CmdResult { - let layer = s.layer_id(p)?; let group = match p.get("group") { None => return Err("give the group's name or id (\"group\"), or null to take the layer out of its group".into()), Some(Value::Null) => None, Some(_) => Some(group_id(s.map(), p)?), }; + // Several (a list, or the layer selection): each in or out of the group, one undo step. + let ids = super::layer::layer_ids(s, p)?; + if ids.len() > 1 { + if p.get("before").is_some_and(|v| !v.is_null()) { + return Err("\"before\" places one layer: give a single layer with it".into()); + } + if group.is_some() { + for id in &ids { + groupable(s, *id)?; + } + } + let m = s.map_mut(); + let mut ids = ids; + ids.sort_by_key(|id| m.layer_index(*id)); + for id in &ids { + m.set_group(*id, group); + } + return Ok(json!({"layers": ids, "group": group})); + } + let layer = ids.first().copied().ok_or("no such layer")?; if group.is_some() { groupable(s, layer)?; } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 87a74d2b9..720c1bc2a 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -43,12 +43,13 @@ pub fn commands() -> Vec { ), C::new("layer.addDataText", "Add Data (from text)", "Home › Add", add_data_text).params(r#"{"fileName": "name.geojson|.csv|.tsv", "text": string, "name"?: text, "x"?, "y"?, "wkt"?, "crs"?, "delimiter"? (as layer.addData, for delimited text)}"#), C::new("layer.addSample", "Add Sample Data", "Home › Add › Add Data", add_sample).params(r#"{"name": "countries"|"cities"|"rivers"|"lakes"}"#), - C::new("layer.remove", "Remove", "Layers", remove).key("Delete").when(need_layer).params(r#"{"layer"?: name|id}"#), - C::new("layer.select", "Select Layer", "Layers", |s, p| { - s.active_layer = Some(s.layer_id(p)?); - Ok(Value::Null) - }).params(r#"{"layer"?: name|id (default: the active layer)}"#) - .view(), + C::new("layer.remove", "Remove", "Layers", remove) + .key("Delete") + .when(need_layer) + .params(r#"{"layer"?: name|id | [name|id] (default: the layer selection)}"#), + C::new("layer.select", "Select Layer", "Layers", select) + .params(r#"{"layer"?: name|id | [name|id] (default: the active layer; the last of a list becomes the active layer), "mode"?: "replace" (only these; the default) | "toggle" (each in or out of the layer selection; the last one out leaves no active layer) | "extend" (added to it)}"#) + .view(), C::new("layer.rename", "Rename", "Layers", |s, p| { let n = s_req(p, "name")?.trim().chars().take(200).collect::(); if n.is_empty() { @@ -71,22 +72,35 @@ pub fn commands() -> Vec { }).params(r#"{"layer"?, "name": string}"#) .when(need_layer), C::new("layer.visibility", "Visibility", "Layers", |s, p| { - let on = b(p, "visible"); - with(s, p, |l| l.visible = on.unwrap_or(!l.visible)) - }).params(r#"{"layer"?, "visible"?: bool (toggles when omitted)}"#) + let ids = layer_ids(s, p)?; + // Toggled together: every layer takes the opposite of the active layer's state (or the + // first one's), not each its own, which would swap shown and hidden ones. + let on = b(p, "visible").unwrap_or_else(|| { + let lead = s.active_layer.filter(|a| ids.contains(a)).or(ids.first().copied()); + !lead.and_then(|id| s.map().layer(id)).is_some_and(|l| l.visible) + }); + for id in ids { + if let Some(l) = s.map_mut().layer_mut(id) { + l.visible = on; + } + } + Ok(Value::Null) + }).params(r#"{"layer"?: name|id | [name|id] (default: the layer selection), "visible"?: bool (toggles when omitted: all take the opposite of the active layer's state)}"#) .when(need_layer), C::new("layer.moveUp", "Move Up", "Layers", |s, p| move_layer(s, p, -1)).params(r#"{"layer"?: name|id (default: the active layer)}"#).when(need_layer), C::new("layer.moveDown", "Move Down", "Layers", |s, p| move_layer(s, p, 1)).params(r#"{"layer"?: name|id (default: the active layer)}"#).when(need_layer), - C::new("layer.move", "Move", "Layers", move_to).when(need_layer).params(r#"{"layer"?: name|id, "to": position (0 = top)}"#), + C::new("layer.move", "Move", "Layers", move_to) + .when(need_layer) + .params(r#"{"layer"?: name|id | [name|id] (default: the layer selection), "to": position (0 = top; past the end, the bottom). Several keep their order, the topmost landing at "to" as one layer would, and go only to the top or the bottom (one in a group, to its group's): with 4 layers and 2 of them, 2 or more is the bottom, 1 is refused}"#), C::new("layer.zoomTo", "Zoom To Layer", "Layers", |s, p| { - let id = s.layer_id(p)?; - let e = s.map().layer(id).map(|l| l.shown_extent()).unwrap_or_default(); + let ids = layer_ids(s, p)?; + let e = ids.iter().filter_map(|id| s.map().layer(*id)).fold(rustgis_geom::Extent::EMPTY, |e, l| e.union(&l.shown_extent())); if e.is_empty() { - return Err("the layer has no features".into()); + return Err(if ids.len() > 1 { "the layers have no features" } else { "the layer has no features" }.into()); } s.zoom_to(&e); Ok(Value::Null) - }).params(r#"{"layer"?: name|id (default: the active layer)}"#) + }).params(r#"{"layer"?: name|id | [name|id] (default: the layer selection; zooms to all of them)}"#) .view() .when(need_layer), C::new("layer.symbology", "Symbology", "Style › Drawing", symbology) @@ -189,9 +203,18 @@ pub fn commands() -> Vec { .when(need_layer) .params(r#"{"layer"?, "min"?: scale (out beyond), "max"?: scale (in beyond)}"#), C::new("layer.selectable", "Selectable", "Layers", |s, p| { - let on = b(p, "on"); - with(s, p, |l| l.selectable = on.unwrap_or(!l.selectable)) - }).params(r#"{"layer"?, "on"?: bool (toggles when omitted)}"#) + let ids = layer_ids(s, p)?; + let on = b(p, "on").unwrap_or_else(|| { + let lead = s.active_layer.filter(|a| ids.contains(a)).or(ids.first().copied()); + !lead.and_then(|id| s.map().layer(id)).is_some_and(|l| l.selectable) + }); + for id in ids { + if let Some(l) = s.map_mut().layer_mut(id).filter(|l| !l.is_raster()) { + l.selectable = on; + } + } + Ok(Value::Null) + }).params(r#"{"layer"?: name|id | [name|id] (default: the layer selection), "on"?: bool (toggles when omitted: all take the opposite of the active layer's state)}"#) .when(need_feature_layer), C::new("layer.expand", "Expand Legend", "Layers", |s, p| { let on = b(p, "on"); @@ -2346,15 +2369,91 @@ pub(crate) fn drop_layers(s: &mut Session, ids: &[u32]) { } fn remove(s: &mut Session, p: &Value) -> CmdResult { - let id = s.layer_id(p)?; + let ids = layer_ids(s, p)?; let m = s.map_mut(); - let i = m.layer_index(id).ok_or("no such layer")?; - let name = m.layers.remove(i).name; - m.set_group(id, None); - let next = m.layers.get(i.min(m.layers.len().saturating_sub(1))).map(|l| l.id); - s.active_layer = next; + // The layer that takes the place of the topmost one removed becomes the active layer. + let at = ids.iter().filter_map(|id| m.layer_index(*id)).min().ok_or("no such layer")?; + let mut names = Vec::new(); + for id in ids { + if let Some(i) = m.layer_index(id) { + names.push(m.layers.remove(i).name); + m.set_group(id, None); + } + } + let next = m.layers.get(at.min(m.layers.len().saturating_sub(1))).map(|l| l.id); + s.set_layer_selection(next.into_iter().collect(), next); // A legend that left it out forgets it. - legends_follow_layer(s, &name, None); + for name in names { + legends_follow_layer(s, &name, None); + } + Ok(Value::Null) +} + +/// The most layers one command takes in a list. +const MAX_LAYER_LIST: usize = 10_000; + +/// The layers a command acts on: `layer` as a name or id, or a list of them; else the layer +/// selection (the active layer alone when nothing else is picked). +pub(crate) fn layer_ids(s: &Session, p: &Value) -> Result, String> { + match p.get("layer") { + Some(Value::Array(list)) => { + if list.is_empty() { + return Err("name at least one layer (\"layer\")".into()); + } + if list.len() > MAX_LAYER_LIST { + return Err(format!("at most {MAX_LAYER_LIST} layers at a time")); + } + let mut ids = Vec::new(); + for v in list { + if !(v.is_number() || v.is_string()) { + return Err("each layer is a name or an id".into()); + } + let id = s.layer_id(&json!({"layer": v}))?; + if !ids.contains(&id) { + ids.push(id); + } + } + Ok(ids) + } + Some(_) => Ok(vec![s.layer_id(p)?]), + None => { + let ids = s.selected_layers(); + if ids.is_empty() { Err("select a layer in the Layers pane first".into()) } else { Ok(ids) } + } + } +} + +/// `layer.select`: the layers picked in the Layers pane, replaced, toggled or extended. +fn select(s: &mut Session, p: &Value) -> CmdResult { + let ids = match p.get("layer") { + Some(Value::Array(_)) => layer_ids(s, p)?, + _ => vec![s.layer_id(p)?], + }; + match ps(p, "mode").unwrap_or("replace") { + "replace" => s.set_layer_selection(ids, None), + "extend" => { + let mut picked = s.selected_layers(); + picked.extend(ids.iter().copied()); + s.set_layer_selection(picked, ids.last().copied()); + } + "toggle" => { + let mut picked = s.selected_layers(); + let mut active = s.active_layer; + for id in ids { + if let Some(at) = picked.iter().position(|x| *x == id) { + picked.remove(at); + if active == Some(id) { + active = picked.last().copied(); + } + } else { + picked.push(id); + active = Some(id); + } + } + s.set_layer_selection(picked, active); + } + other => return Err(format!("unknown mode '{other}' (replace, toggle or extend)")), + } Ok(Value::Null) } @@ -2373,8 +2472,56 @@ fn move_layer(s: &mut Session, p: &Value, d: i32) -> CmdResult { /// Moves a layer to a position in the drawing order: 0 is the top, anything past the end the /// bottom. One command (and one undo step) however far the layer travels. fn move_to(s: &mut Session, p: &Value) -> CmdResult { - let id = s.layer_id(p)?; + let ids = layer_ids(s, p)?; let to = uint(p, "to").ok_or("to is required: the position in the drawing order, 0 at the top")?; + if let [id] = ids[..] { + return move_one(s, id, to); + } + // Several: to the top or the bottom, keeping their order among themselves. A layer in a group + // stays in it, going to the top or the bottom of its group. + let m = s.map_mut(); + let len = m.layers.len(); + let to = usize::try_from(to).unwrap_or(usize::MAX); + let top = match to { + 0 => true, + t if t >= len.saturating_sub(ids.len()) => false, + _ => return Err("several layers move only to the top (to 0) or the bottom".into()), + }; + let mut ids = ids; + ids.sort_by_key(|id| m.layer_index(*id)); + if !top { + ids.reverse(); + } + // The layers out of groups first: moving those within a group shifts no other layer. + let (loose, grouped): (Vec, Vec) = ids.into_iter().partition(|id| m.group_of(*id).is_none()); + for (k, id) in loose.iter().enumerate() { + let t = if top { k } else { len.saturating_sub(1 + k) }; + move_to_index(m, *id, t)?; + } + let mut placed: Vec<(u32, usize)> = Vec::new(); + for id in grouped { + let Some(g) = m.group_of(id).map(|g| g.id) else { continue }; + let run: Vec = + m.layers.iter().enumerate().filter(|(_, l)| m.group_of(l.id).is_some_and(|x| x.id == g)).map(|(i, _)| i).collect(); + let (Some(&lo), Some(&hi)) = (run.first(), run.last()) else { continue }; + let k = placed.iter().filter(|(x, _)| *x == g).count(); + placed.push((g, k)); + let t = if top { lo + k } else { hi.saturating_sub(k) }; + move_to_index(m, id, t)?; + } + Ok(Value::Null) +} + +/// Moves a layer to index `t` of the drawing order, if it isn't there already. +fn move_to_index(m: &mut rustgis_map::Map, id: u32, t: usize) -> Result<(), String> { + let i = m.layer_index(id).ok_or("no such layer")?; + if i != t { + m.move_layer(i, t)?; + } + Ok(()) +} + +fn move_one(s: &mut Session, id: u32, to: u64) -> CmdResult { let m = s.map_mut(); let i = m.layer_index(id).ok_or("no such layer")?; let to = usize::try_from(to).unwrap_or(usize::MAX).min(m.layers.len().saturating_sub(1)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8144a3004..cf7b88ce4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -378,6 +378,8 @@ struct PutAside { project_starts: u64, history_from_file: usize, active_layer: Option, + layer_selection: Vec, + layer_selection_of: Option, table_layer: Option, table_standalone: Option, tool: Tool, @@ -427,6 +429,12 @@ pub struct Session { pub tool: Tool, /// The layer selected in the Layers pane (the one the Style and Data tabs act on). pub active_layer: Option, + /// The layers picked together in the Layers pane, the active layer among them. Read through + /// [`Session::selected_layers`]: it holds only while `layer_selection_of` is still the active + /// layer, so setting `active_layer` on its own (adding data, a removal) starts over with that + /// layer alone. + layer_selection: Vec, + layer_selection_of: Option, /// Map view size in logical points (set by the UI each frame). pub view_size: (f64, f64), /// A MapLibre style tile layer (by its address) whose own view the map goes to once it loads @@ -620,6 +628,8 @@ impl Session { opened: 0, tool: Tool::Explore, active_layer: None, + layer_selection: Vec::new(), + layer_selection_of: None, view_size: (1200.0, 720.0), style_view: None, notices: Vec::new(), @@ -792,8 +802,13 @@ impl Session { self.notify(format!("{id} ignores {} (it reads {reads})", said.join(", ")), false); } } - // Layer conditions apply to the layer the parameters name, falling back to the active one. - let named = params.get("layer").and_then(|_| self.layer_id(params).ok()); + // Layer conditions apply to the layer the parameters name (the first of a list), falling + // back to the active one. + let named = match params.get("layer") { + Some(serde_json::Value::Array(list)) => list.first().and_then(|v| self.layer_id(&serde_json::json!({"layer": v})).ok()), + Some(_) => self.layer_id(params).ok(), + None => None, + }; let active = self.active_layer; if named.is_some() { self.active_layer = named; @@ -981,6 +996,9 @@ impl Session { project_starts: self.project_starts, history_from_file: self.history_from_file, active_layer: self.active_layer, + // Moved, not copied: the next project's layers reuse the same ids. + layer_selection: take(&mut self.layer_selection), + layer_selection_of: self.layer_selection_of.take(), table_layer: self.table_layer, table_standalone: self.table_standalone, tool: self.tool, @@ -1023,6 +1041,8 @@ impl Session { project_starts, history_from_file, active_layer, + layer_selection, + layer_selection_of, table_layer, table_standalone, tool, @@ -1049,6 +1069,7 @@ impl Session { (self.dirty, self.project_starts, self.history_from_file) = (dirty, project_starts, history_from_file); (self.trusted_tasks, self.trusted_models, self.trusted_notebooks) = (trusted_tasks, trusted_models, trusted_notebooks); (self.active_layer, self.table_layer, self.table_standalone, self.tool) = (active_layer, table_layer, table_standalone, tool); + (self.layer_selection, self.layer_selection_of) = (layer_selection, layer_selection_of); (self.measurements, self.topology_errors, self.edit_counts) = (measurements, topology_errors, edit_counts); (self.path, self.model_run, self.style_view) = (path, model_run, style_view); (self.pending, self.plugin_runs, self.plugin_retries) = (pending, plugin_runs, plugin_retries); @@ -1263,6 +1284,25 @@ impl Session { !self.forward.is_empty() && self.map().projection.code() == self.history_projection } + /// The layers picked together in the Layers pane, top of the drawing order first: the layer + /// selection if it was made around the current active layer, else that layer alone (none + /// when there is no active layer). Layers the map no longer has are left out. + pub fn selected_layers(&self) -> Vec { + let map = self.map(); + let picked: &[u32] = if self.layer_selection_of == self.active_layer { &self.layer_selection } else { &[] }; + map.layers.iter().map(|l| l.id).filter(|id| picked.contains(id) || self.active_layer == Some(*id)).collect() + } + + /// Makes `ids` the layer selection with `active` the active layer (one of them, else none). + pub(crate) fn set_layer_selection(&mut self, mut ids: Vec, active: Option) { + let mut seen = std::collections::HashSet::new(); + ids.retain(|id| seen.insert(*id)); + let active = active.filter(|a| ids.contains(a)).or_else(|| ids.last().copied()); + self.active_layer = active; + self.layer_selection = ids; + self.layer_selection_of = active; + } + /// Keeps `active_layer` pointing at a layer of the active map. pub(crate) fn fix_active_layer(&mut self) { if self.active_layout.is_some_and(|i| i >= self.project.layouts.len()) { @@ -1283,6 +1323,12 @@ impl Session { { self.active_layer = map.layers.first().map(|l| l.id); } + // A layer selection made around another active layer is over, for good: the active layer + // coming back later doesn't bring it back. + if self.layer_selection_of != self.active_layer { + self.layer_selection.clear(); + self.layer_selection_of = self.active_layer; + } if let Some(id) = self.table_layer && map.layer(id).is_none() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fa0b9eefa..26669d593 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -13620,3 +13620,200 @@ fn scans_have_no_coordinates_to_give_away() { assert!(s.run("share.exportRaster", &json!({"path": out.display().to_string()})).is_ok()); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn layer_selection_replaces_toggles_and_extends() { + let mut s = world(); + let name = |s: &Session, id: u32| s.map().layer(id).map(|l| l.name.clone()).unwrap_or_default(); + let picked = |s: &Session| s.selected_layers().into_iter().map(|id| name(s, id)).collect::>(); + let id = |s: &Session, n: &str| s.map().layer_by_name(n).map(|l| l.id).unwrap(); + let depth = s.undo_depth(); + s.run("layer.select", &json!({"layer": "Cities"})).unwrap(); + assert_eq!(picked(&s), ["Cities"]); + // Toggled in, the layer becomes active; toggled out, another of the selection does. + s.run("layer.select", &json!({"layer": "Lakes", "mode": "toggle"})).unwrap(); + assert_eq!(picked(&s), ["Cities", "Lakes"]); + assert_eq!(s.active_layer, Some(id(&s, "Lakes"))); + s.run("layer.select", &json!({"layer": "Lakes", "mode": "toggle"})).unwrap(); + assert_eq!(picked(&s), ["Cities"]); + assert_eq!(s.active_layer, Some(id(&s, "Cities"))); + // Extended by a list; the last of it is active. A plain select starts over. + s.run("layer.select", &json!({"layer": ["Rivers", "Countries", "Rivers"], "mode": "extend"})).unwrap(); + assert_eq!(picked(&s), ["Cities", "Rivers", "Countries"]); + assert_eq!(s.active_layer, Some(id(&s, "Countries"))); + s.run("layer.select", &json!({"layer": "Rivers"})).unwrap(); + assert_eq!(picked(&s), ["Rivers"]); + // Choosing layers is not an undo step. A bad layer, mode or list changes nothing. + assert_eq!(s.undo_depth(), depth); + s.run("layer.select", &json!({"layer": ["Cities", "Lakes"]})).unwrap(); + for p in [ + json!({"layer": ["Cities", "Nope"]}), + json!({"layer": [], "mode": "toggle"}), + json!({"layer": [null]}), + json!({"layer": [[1]]}), + json!({"layer": "Cities", "mode": "sideways"}), + ] { + assert!(s.run("layer.select", &p).is_err(), "{p}"); + } + assert_eq!(picked(&s), ["Cities", "Lakes"]); + // Setting the active layer on its own (adding data, a removal) leaves the selection behind. + s.active_layer = Some(id(&s, "Rivers")); + assert_eq!(picked(&s), ["Rivers"]); +} + +#[test] +fn layer_commands_act_on_the_whole_layer_selection() { + let mut s = world(); + let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); + // Hidden and shown layers swap to one state: the opposite of the active layer's (the last + // one listed, Rivers, hidden: all are shown). + s.run("layer.visibility", &json!({"layer": "Rivers", "visible": false})).unwrap(); + s.run("layer.select", &json!({"layer": ["Countries", "Rivers"]})).unwrap(); + s.run("layer.visibility", &Value::Null).unwrap(); + let shown = |s: &Session, n: &str| s.map().layer_by_name(n).unwrap().visible; + assert!(shown(&s, "Rivers") && shown(&s, "Countries")); + s.run("layer.visibility", &json!({"visible": false})).unwrap(); + assert!(!shown(&s, "Rivers") && !shown(&s, "Countries") && shown(&s, "Cities")); + // To the top and to the bottom, keeping their order, each one undo step. + s.run("layer.move", &json!({"to": 0})).unwrap(); + assert_eq!(names(&s), ["Rivers", "Countries", "Cities", "Lakes"]); + s.run("layer.move", &json!({"to": 1e9})).unwrap(); + assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); + assert!(s.run("layer.move", &json!({"to": 1})).is_err(), "several layers can't go to the middle"); + // "to" is where the topmost of them lands, as for one layer: 2 of 4 at 2 is the bottom. + s.run("edit.undo", &Value::Null).unwrap(); + s.run("layer.move", &json!({"to": 2})).unwrap(); + assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); + s.run("edit.undo", &Value::Null).unwrap(); + s.run("layer.move", &json!({"to": 3})).unwrap(); + assert_eq!(names(&s), ["Cities", "Lakes", "Rivers", "Countries"]); + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(names(&s), ["Rivers", "Countries", "Cities", "Lakes"]); + // Selectable on all of them. + s.run("layer.selectable", &json!({"on": false})).unwrap(); + assert!(!s.map().layer_by_name("Rivers").unwrap().selectable && !s.map().layer_by_name("Countries").unwrap().selectable); + assert!(s.map().layer_by_name("Cities").unwrap().selectable); + // Zoom to both: a view command, no undo step. + let depth = s.undo_depth(); + s.run("layer.zoomTo", &Value::Null).unwrap(); + assert_eq!(s.undo_depth(), depth); + // Removing: one undo step; the layer that takes the topmost one's place is active alone. + s.run("layer.remove", &Value::Null).unwrap(); + assert_eq!(names(&s), ["Cities", "Lakes"]); + assert_eq!(s.undo_depth(), depth + 1); + assert_eq!(s.selected_layers(), vec![s.active_layer.unwrap()]); + assert_eq!(s.active_layer, s.map().layer_by_name("Cities").map(|l| l.id)); + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(names(&s), ["Rivers", "Countries", "Cities", "Lakes"]); + // A list with an unknown layer removes nothing. + assert!(s.run("layer.remove", &json!({"layer": ["Rivers", "Nope"]})).is_err()); + assert_eq!(names(&s).len(), 4); +} + +#[test] +fn layer_selection_survives_a_failed_project_open() { + let mut s = world(); + s.run("layer.select", &json!({"layer": ["Cities", "Lakes"]})).unwrap(); + let before = s.selected_layers(); + // Another project replaces this one and fails: the selection comes back with it. + let _ = s.run("project.openText", &json!({"text": "not a project"})); + assert_eq!(s.selected_layers(), before); +} + +#[test] +fn several_layers_move_to_the_top_with_one_in_a_group() { + let mut s = world(); + let names = |s: &Session| s.map().layers.iter().map(|l| l.name.clone()).collect::>(); + s.run("group.add", &json!({"layers": ["Lakes", "Countries"]})).unwrap(); + assert_eq!(names(&s), ["Cities", "Rivers", "Lakes", "Countries"]); + s.run("layer.select", &json!({"layer": ["Rivers", "Countries"]})).unwrap(); + // A layer out of a group goes to the map's top; one in a group, to its group's top. + let depth = s.undo_depth(); + s.run("layer.move", &json!({"to": 0})).unwrap(); + assert_eq!(names(&s), ["Rivers", "Cities", "Countries", "Lakes"]); + assert_eq!(s.undo_depth(), depth + 1); + s.run("layer.move", &json!({"to": 1e9})).unwrap(); + assert_eq!(names(&s), ["Cities", "Lakes", "Countries", "Rivers"]); + let group = |s: &Session, n: &str| s.map().layer_by_name(n).and_then(|l| s.map().group_of(l.id)).map(|g| g.name.clone()); + assert_eq!(group(&s, "Countries").as_deref(), Some("New Group")); +} + +#[test] +fn several_layers_go_into_and_out_of_a_group_in_one_step() { + let mut s = world(); + let group = |s: &Session, n: &str| s.map().layer_by_name(n).and_then(|l| s.map().group_of(l.id)).map(|g| g.name.clone()); + s.run("group.add", &json!({"name": "Water", "layers": ["Lakes"]})).unwrap(); + s.run("layer.select", &json!({"layer": ["Cities", "Rivers"]})).unwrap(); + let depth = s.undo_depth(); + s.run("layer.group", &json!({"group": "Water"})).unwrap(); + assert_eq!(group(&s, "Cities").as_deref(), Some("Water")); + assert_eq!(group(&s, "Rivers").as_deref(), Some("Water")); + assert_eq!(s.undo_depth(), depth + 1); + s.run("layer.group", &json!({"layer": ["Cities", "Rivers"], "group": null})).unwrap(); + assert_eq!(group(&s, "Cities"), None); + assert_eq!(group(&s, "Rivers"), None); + assert_eq!(group(&s, "Lakes").as_deref(), Some("Water")); + // "before" places one layer; an unknown one fails the whole list. + assert!(s.run("layer.group", &json!({"layer": ["Cities", "Rivers"], "group": "Water", "before": "Lakes"})).is_err()); + assert!(s.run("layer.group", &json!({"layer": ["Cities", "Nope"], "group": "Water"})).is_err()); + assert_eq!(group(&s, "Cities"), None); +} + +#[test] +fn another_project_starts_with_no_layer_selection() { + let mut s = world(); + let text = serde_json::to_string(&s.project).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.openText", &json!({"text": text})).unwrap(); + let opened_active = t.active_layer.and_then(|id| t.map().layer(id)).map(|l| l.name.clone()).unwrap(); + // Picked around the layer the next project starts active on, with the same ids there. + let other = if opened_active == "Lakes" { "Cities" } else { "Lakes" }; + s.run("layer.select", &json!({"layer": [other, opened_active.as_str()]})).unwrap(); + s.run("project.openText", &json!({"text": text})).unwrap(); + assert!(s.selected_layers().len() <= 1, "{:?}", s.selected_layers()); +} + +#[test] +fn a_layer_selection_left_behind_stays_gone() { + let mut s = world(); + let id = |s: &Session, n: &str| s.map().layer_by_name(n).map(|l| l.id).unwrap(); + s.run("layer.select", &json!({"layer": ["Cities", "Lakes"]})).unwrap(); + let lakes = id(&s, "Lakes"); + // Another layer becomes active on its own (as adding data does), then Lakes again. + s.active_layer = Some(id(&s, "Rivers")); + s.run("layer.visibility", &json!({"layer": "Rivers", "visible": true})).unwrap(); + s.active_layer = Some(lakes); + assert_eq!(s.selected_layers(), vec![lakes]); +} + +#[test] +fn a_one_layer_list_names_that_layer_not_the_active_one() { + let mut s = world(); + let group = |s: &Session, n: &str| s.map().layer_by_name(n).and_then(|l| s.map().group_of(l.id)).map(|g| g.name.clone()); + s.run("group.add", &json!({"name": "Water", "layers": ["Lakes"]})).unwrap(); + s.run("layer.select", &json!({"layer": "Rivers"})).unwrap(); + s.run("layer.group", &json!({"layer": ["Cities"], "group": "Water"})).unwrap(); + assert_eq!(group(&s, "Cities").as_deref(), Some("Water")); + assert_eq!(group(&s, "Rivers"), None); +} + +#[test] +fn a_layer_list_needs_no_active_layer() { + let mut s = world(); + s.active_layer = None; + s.run("layer.visibility", &json!({"layer": ["Cities", "Rivers"], "visible": false})).unwrap(); + s.run("layer.selectable", &json!({"layer": ["Cities"], "on": false})).unwrap(); + assert!(!s.map().layer_by_name("Rivers").unwrap().visible); + assert!(!s.map().layer_by_name("Cities").unwrap().selectable); +} + +#[test] +fn toggling_out_the_only_picked_layer_leaves_none_active() { + let mut s = world(); + s.run("layer.select", &json!({"layer": "Cities"})).unwrap(); + s.run("layer.select", &json!({"layer": "Cities", "mode": "toggle"})).unwrap(); + assert_eq!(s.active_layer, None); + assert!(s.selected_layers().is_empty()); + assert!(s.run("layer.remove", &Value::Null).is_err(), "nothing picked, nothing removed"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 65ee072aa..1fe308d02 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -495,6 +495,13 @@ pub struct UiState { /// The active layer the filter hid (so it stopped being active), selected again once the /// filter shows it, unless another layer was picked meanwhile. pub layer_filter_hid: Option, + /// The Layers pane's layer selection as the frame began (the rows tint from it), the layers its + /// rows list in the order drawn (what a Shift+click spans), the row a Shift+click spans from, + /// and the one it was made on, resolved once the rows are all drawn. + pub layer_picked: Vec, + pub layer_rows: Vec, + pub layer_anchor: Option, + pub layer_range: Option, /// How the Layers pane lists the layers (#301). Kept for the session, not saved. pub layers_view: LayersView, /// The command an item picked in the browser opens with (web). @@ -697,6 +704,10 @@ impl Default for UiState { layer_filter: String::new(), layer_filter_map: String::new(), layer_filter_hid: None, + layer_picked: Vec::new(), + layer_rows: Vec::new(), + layer_anchor: None, + layer_range: None, layers_view: LayersView::DrawingOrder, dynamic_text: None, scale_text: None, diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index bc91294cf..59bbb2c06 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -509,6 +509,18 @@ pub fn follow_layer_filter(app: &mut RustGisApp) { let q = filter_query(&app.ui.layer_filter); // Whether the filter shows layer `id` (None: the map has no such layer). let shown = |s: &Session, id: u32| s.map().layer(id).map(|l| filter_shows(&q, &l.name)); + // Layers picked together are the ones in sight too: a removal must not take one the filter hides. + let picked = app.session.selected_layers(); + if picked.len() > 1 { + let active = app.session.active_layer; + // The active layer last, so it stays active if the filter shows it. + let mut keep: Vec = + picked.iter().copied().filter(|id| shown(&app.session, *id) == Some(true) && Some(*id) != active).collect(); + keep.extend(active.filter(|id| shown(&app.session, *id) == Some(true))); + if keep.len() < picked.len() && !keep.is_empty() { + let _ = app.run("layer.select", json!({"layer": keep})); + } + } match (app.session.active_layer, app.ui.layer_filter_hid) { (Some(id), _) if shown(&app.session, id) == Some(false) => { // No command clears the active layer; it is view state, like a click on an empty row. @@ -554,6 +566,8 @@ fn contents(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.add_space(8.0); filter_box(app, ui); // (Which layer is active as the filter changes: `follow_layer_filter`, every frame.) + app.ui.layer_picked = app.session.selected_layers(); + app.ui.layer_rows.clear(); let q = filter_query(&app.ui.layer_filter); let filtering = !q.is_empty(); let shows = |name: &str| filter_shows(&q, name); @@ -667,6 +681,7 @@ fn contents(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { LayersView::DataSource => source_groups(app, ui, &layers, &tiles, scale, &shows, t), LayersView::Selection => selection_groups(app, ui, &layers, &shows, t), }; + resolve_layer_range(app); if app.ui.service_import.is_some() { ui.horizontal(|ui| { ui.spinner(); @@ -969,10 +984,52 @@ fn selection_groups(app: &mut RustGisApp, ui: &mut Ui, layers: &[Layer], shows: n } +/// The active layer's row (the one the Style and Data tabs act on) among those picked: a thin +/// outline, as a file browser marks the item with focus. +fn active_outline(ui: &Ui, r: Rect, active: bool, t: &Tokens) { + if active { + let stroke = Stroke::new(1.0, t.accent); + ui.painter().rect_stroke(r.shrink2(vec2(4.0, 1.0)), CornerRadius::same(5), stroke, egui::StrokeKind::Inside); + } +} + +/// A click on a layer's row: alone makes it the only layer picked, with Ctrl or Command it joins +/// or leaves the layer selection, with Shift the rows between the last one clicked and this one +/// are picked (once the pane has drawn them: `resolve_layer_range`). +fn click_layer(app: &mut RustGisApp, ui: &Ui, id: u32) { + let (shift, toggle) = ui.input(|i| (i.modifiers.shift, i.modifiers.command)); + if shift { + app.ui.layer_range = Some(id); + return; + } + let mode = if toggle { "toggle" } else { "replace" }; + let _ = app.run("layer.select", json!({"layer": id, "mode": mode})); + app.ui.layer_anchor = Some(id); +} + +/// Picks the rows from the anchor to the layer a Shift+click was made on, in the order the pane +/// lists them (collapsed groups' layers and filtered ones are not listed, so not picked); the +/// layer clicked ends up active. Without a listed anchor, only that layer. +fn resolve_layer_range(app: &mut RustGisApp) { + let Some(to) = app.ui.layer_range.take() else { return }; + let rows = &app.ui.layer_rows; + let from = app.ui.layer_anchor.or(app.session.active_layer); + let span = match (from.and_then(|f| rows.iter().position(|r| *r == f)), rows.iter().position(|r| *r == to)) { + (Some(a), Some(b)) if a <= b => rows[a..=b].to_vec(), + (Some(a), Some(b)) => rows[b..=a].iter().rev().copied().collect(), + _ => vec![to], + }; + let _ = app.run("layer.select", json!({"layer": span})); + if app.ui.layer_anchor.is_none() { + app.ui.layer_anchor = from; + } +} + /// A layer's row in the Selection view: a box for whether it can be selected /// (`layer.selectable`), its name, and how many of its features are selected with a ✕ that clears /// them (`select.rows`). A click makes it the active layer; right-click opens its menu. fn selection_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { + app.ui.layer_rows.push(l.id); let count = l.selection.len(); // The name starts at 48 and stops short of the count and its ✕. let counted = if count > 0 { 22.0 + 4.0 + crate::widgets::text_w(ui, &count.to_string(), regular(12.0)) + 8.0 } else { 0.0 }; @@ -980,11 +1037,12 @@ fn selection_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { let name = row_name(ui, l.name.clone(), regular(12.5), ui.available_width() - 48.0 - 8.0 - counted); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), name.row_h), Sense::click()); let cy = r.min.y + 13.0; - if app.session.active_layer == Some(l.id) { + if app.ui.layer_picked.contains(&l.id) { ui.painter().rect_filled(r.shrink2(vec2(4.0, 1.0)), CornerRadius::same(5), t.selection); } else if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(4.0, 1.0)), CornerRadius::same(5), t.hover); } + active_outline(ui, r, app.session.active_layer == Some(l.id), t); // Lined up with the other views' checkboxes and names. let cb = Rect::from_center_size(pos2(r.min.x + 33.0, cy), vec2(14.0, 14.0)); let cb_resp = ui.interact(hit(cb), egui::Id::new(("layer_selectable", l.id)), Sense::click()); @@ -1022,7 +1080,7 @@ fn selection_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { None => resp, }; if resp.clicked() { - let _ = app.run("layer.select", json!({"layer": l.id})); + click_layer(app, ui, l.id); } resp.context_menu(|ui| layer_menu(app, ui, l, false)); } @@ -1452,7 +1510,9 @@ impl RowName { /// A layer's row and, expanded, its legend; returns the rectangle of both. With `reorder` off /// (the list is filtered or children are sorted by name) the row can't be dragged. fn layer_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, scale: f64, reorder: bool, t: &Tokens) -> Rect { + app.ui.layer_rows.push(l.id); let active = app.session.active_layer == Some(l.id); + let picked = app.ui.layer_picked.contains(&l.id); // The name takes two lines when it must (the tooltip has it whole when cut), leaving room for // the selection badge. let badge = (!l.selection.is_empty()).then(|| { @@ -1461,7 +1521,9 @@ fn layer_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, scale: f64, reorder: (text, w) }); let shown = if l.definition_query.is_some() { format!("{} {}", l.name, tl!("(filtered)")) } else { l.name.clone() }; - let font = if active { semibold(12.5) } else { regular(12.5) }; + // Every picked row looks alike; the active one has an outline (`active_outline`), not a + // bolder name, which would read as the only one picked. + let font = regular(12.5); let room = ui.available_width() - NAME_X - badge.as_ref().map_or(8.0, |(_, w)| w + 18.0); let name = row_name(ui, shown.clone(), font, room); let sense = if reorder { Sense::click_and_drag() } else { Sense::click() }; @@ -1473,11 +1535,12 @@ fn layer_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, scale: f64, reorder: egui::DragAndDrop::set_payload(ui.ctx(), DraggedLayer(l.id)); } let dragging = egui::DragAndDrop::payload::(ui.ctx()).is_some_and(|d| d.0 == l.id); - if active { + if picked { ui.painter().rect_filled(r.shrink2(vec2(4.0, 1.0)), CornerRadius::same(5), t.selection); } else if resp.hovered() || dragging { ui.painter().rect_filled(r.shrink2(vec2(4.0, 1.0)), CornerRadius::same(5), t.hover); } + active_outline(ui, r, active, t); let mut x = r.min.x + 10.0; // Expand arrow. let raster_legend = l.raster.as_ref().map(|r| r.legend()); @@ -1543,9 +1606,10 @@ fn layer_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, scale: f64, reorder: } else if ar_resp.clicked() { let _ = app.run("layer.expand", json!({"layer": l.id})); } else if resp.clicked() { - let _ = app.run("layer.select", json!({"layer": l.id})); + click_layer(app, ui, l.id); } - if resp.double_clicked() { + // Not with Ctrl, Command or Shift held: that is picking layers, and would drop the others. + if resp.double_clicked() && !ui.input(|i| i.modifiers.command || i.modifiers.shift) { let _ = app.run("layer.select", json!({"layer": l.id})); app.ui.open_right(RightPane::Symbology); } @@ -1628,14 +1692,105 @@ fn layer_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, scale: f64, reorder: Rect::from_min_max(r.min, pos2(r.max.x, ui.min_rect().max.y.max(r.max.y))) } +/// Makes a group of layers from a menu, then asks for its name, as the default one says nothing. +fn new_group(app: &mut RustGisApp, params: Value) { + let Ok(made) = app.run("group.add", params) else { return }; + let Some(id) = made.get("group").and_then(Value::as_u64) else { return }; + let name = app.session.map().groups.iter().find(|g| u64::from(g.id) == id).map(|g| g.name.clone()).unwrap_or_default(); + let _ = app.run("ui.dialog", json!({"name": "renameGroup"})); + app.dialogs.layer = id.to_string(); + app.dialogs.text = name; +} + +/// The menu of several layers picked together: what acts on all of them; what needs one layer is +/// there, dimmed, so it is known why it can't be used. +fn layers_menu(app: &mut RustGisApp, ui: &mut Ui, picked: &[u32], reorder: bool) { + let in_service = picked.iter().any(|id| app.session.map().service_groups.iter().any(|g| g.children.iter().any(|c| c.layer == *id))); + let ids = json!(picked); + let act = |app: &mut RustGisApp, ui: &mut Ui, id: &str, extra: Value| { + let mut p = json!({"layer": ids}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) { + p.extend(extra.clone()); + } + let _ = app.run(id, p); + ui.close(); + }; + if ui.button(tl!("Zoom To Layers")).clicked() { + act(app, ui, "layer.zoomTo", Value::Null); + } + ui.separator(); + // "Layers", not "Selected": the selection a bare word means is of features, and the next + // two items are about that one. + if ui.button(tl!("Show Layers")).clicked() { + act(app, ui, "layer.visibility", json!({"visible": true})); + } + if ui.button(tl!("Hide Layers")).clicked() { + act(app, ui, "layer.visibility", json!({"visible": false})); + } + let tip = tl!("Whether the Select tool picks these layers' features on the map"); + if ui.button(tl!("Allow Feature Selection")).on_hover_text(tip).clicked() { + act(app, ui, "layer.selectable", json!({"on": true})); + } + if ui.button(tl!("Block Feature Selection")).on_hover_text(tip).clicked() { + act(app, ui, "layer.selectable", json!({"on": false})); + } + ui.separator(); + // Not while the list is filtered: the layers would move among hidden ones. + if ui.add_enabled(reorder, egui::Button::new(tl!("Move To Top"))).clicked() { + act(app, ui, "layer.move", json!({"to": 0})); + } + if ui.add_enabled(reorder, egui::Button::new(tl!("Move To Bottom"))).clicked() { + act(app, ui, "layer.move", json!({"to": 1_000_000_000u64})); + } + // As one layer's Group menu; a service's collections stay in their service group. + if in_service { + ui.add_enabled(false, egui::Button::new(tl!("Group"))) + .on_disabled_hover_text(tl!("A service's layers stay in their service group.")); + } else { + let groups: Vec<(u32, String)> = app.session.map().groups.iter().map(|g| (g.id, g.name.clone())).collect(); + let grouped = picked.iter().any(|id| app.session.map().group_of(*id).is_some()); + ui.menu_button(tl!("Group"), |ui| { + if ui.button(tl!("New Group")).clicked() { + new_group(app, json!({"layers": picked})); + ui.close(); + } + if grouped && ui.button(tl!("Remove From Group")).clicked() { + act(app, ui, "layer.group", json!({"group": null})); + } + if !groups.is_empty() { + ui.separator(); + for (id, name) in &groups { + if ui.button(crate::i18n::fmt("Move To {group}", &[("group", name)])).clicked() { + act(app, ui, "layer.group", json!({"group": id})); + } + } + } + }); + } + ui.separator(); + for label in ["Attribute Table", "Symbology"] { + ui.add_enabled(false, egui::Button::new(tl!(label))).on_disabled_hover_text(tl!("Pick a single layer.")); + } + ui.separator(); + if ui.button(tl!("Remove")).clicked() { + act(app, ui, "layer.remove", Value::Null); + } +} + /// A layer's right-click menu (its row in any Layers view). Move Up and Move Down only with /// `reorder` (the drawing order view, unfiltered). fn layer_menu(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, reorder: bool) { // Once, as the menu opens: a command each frame it stays open would draw the map again each // time (a command moves the session's revision). - if app.session.active_layer != Some(l.id) { + // A layer outside the layer selection becomes the only one picked; one inside keeps it. + if !app.session.selected_layers().contains(&l.id) { let _ = app.session.run("layer.select", &json!({"layer": l.id})); } + let picked = app.session.selected_layers(); + if picked.len() > 1 { + layers_menu(app, ui, &picked, reorder); + return; + } let service_child = app.session.map().service_groups.iter().any(|g| g.children.iter().any(|c| c.layer == l.id)); if service_child && ui.add_enabled(app.ui.service_import.is_none(), egui::Button::new(tl!("Refresh"))).clicked() { let _ = app.run("ui.refreshService", json!({"layer":l.id})); @@ -1721,7 +1876,7 @@ fn layer_menu(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, reorder: bool) { let current = app.session.map().group_of(l.id).map(|g| g.id); ui.menu_button(tl!("Group"), |ui| { if ui.button(tl!("New Group")).clicked() { - let _ = app.run("group.add", json!({"layers": [l.id]})); + new_group(app, json!({"layers": [l.id]})); ui.close(); } if current.is_some() && ui.button(tl!("Remove From Group")).clicked() { diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index c7e1146e7..e248f1e1c 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -38,6 +38,15 @@ This guide walks through RustGIS by task. Each page covers one part of the app: reorders layers. A layer that isn't selectable is skipped by the Select tool's clicks and boxes on the map; Select By Attributes, Select Layer By Location and the attribute table still select in it, since they name the layer. + **Pick several layers** with ++ctrl++ (++cmd++ on a Mac) and click to add or drop one, or + ++shift++ and click to take the rows from the last one clicked; the layer clicked last, + outlined, is the one the Style and Data tabs act on. Right-click one of them to zoom to them + all, show or hide them, allow or block selecting their features on the map, move them to the top + or bottom of the drawing order (one in a group, to the top or bottom of its group), put them in + a group or take them out of one, or remove them (++delete++ does too); a layer the filter hides + is left out. A new group made from the menu asks for its name. In scripts, `layer.select` takes + a list and a `mode` (`replace`, `toggle` or `extend`), and the commands that remove, show, move, + group or zoom to a layer take a list in `layer`, or the picked layers when it is left out. **Group layers** to keep related ones together, such as one dataset drawn as several layers with their own visibility ranges: right-click a layer and pick **Group › New Group**, or move it into a group that already exists. A group folds open and closed with its arrow; its box From eb81b6d950798443d22c9478d4aa2c4aa8e069c5 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Fri, 23 Oct 2026 16:25:27 +0200 Subject: [PATCH 057/297] feat(map): stacked line strokes per class (#729) * feat(map): stacked line strokes per class A line layer's stacked strokes were one symbol for the whole layer, so a motorway and a footpath on the same layer could only differ in colour and width. A Unique Values or Graduated Colors class can now stack strokes of its own: a casing and a core for main roads, a single thin line for paths. A class without its own takes the layer's strokes, so existing projects draw as before. - map: `class_strokes` (and Unique Values' `other_strokes`) beside the hand-set legend labels, read from project files bounded and clamped like the layer's strokes; kept when the same symbology is applied again. `Layer::strokes(row)` picks a feature's stack. - render: each feature draws its own stack; layers with any stacked strokes keep the CPU path, as before. - engine: `layer.lineStrokes` names a class with `value`, `index` or `other`, as `layer.classStyle` does. - ui: a "Strokes for" picker in the Strokes section; classes with strokes of their own are marked. Tested with swisstopo's SMV25 QGIS project (281 road classes with up to five strokes each): carried over, the roads and railways draw like QGIS's rendering of the same project. * chore(ui): open a combo box by what it shows in ui_shot scripts * perf(map): skip classing a feature for strokes when no class has its own * fix(ui): list a class value given twice once in the Strokes for picker * perf(map): class a feature once for its symbol and strokes --- crates/engine/src/cmd/embeddings.rs | 2 + crates/engine/src/cmd/layer.rs | 30 +++- crates/engine/src/tests.rs | 80 ++++++++++ crates/map/src/lib.rs | 74 ++++++++++ crates/map/src/symbology.rs | 219 ++++++++++++++++++++++++++-- crates/render/src/layout.rs | 3 + crates/render/src/lib.rs | 79 ++++++++-- crates/ui-egui/examples/ui_shot.rs | 12 +- crates/ui-egui/src/panes.rs | 135 +++++++++++++++-- crates/ui-egui/tests/smoke.rs | 35 +++++ docs/user-guide/cartography.md | 14 ++ 11 files changed, 634 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/cmd/embeddings.rs b/crates/engine/src/cmd/embeddings.rs index 95cdb4b75..3c8635ae4 100644 --- a/crates/engine/src/cmd/embeddings.rs +++ b/crates/engine/src/cmd/embeddings.rs @@ -231,6 +231,8 @@ pub(crate) fn style_points(s: &mut Session, id: u32) { ramp: Default::default(), class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 720c1bc2a..af7b54767 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -168,7 +168,7 @@ pub fn commands() -> Vec { C::new("layer.lineStrokes", "Line Strokes", "Style › Drawing", line_strokes) .when(need_lines) .params( - r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel"}] (drawn bottom first; [] goes back to the single stroke)}"##, + r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel"}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' )}"##, ), C::new("layer.marker", "Marker", "Style › Drawing", |s, p| { let shape = ps(p, "shape").map(|n| Marker::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(n)).ok_or("unknown marker shape")).transpose()?; @@ -324,8 +324,9 @@ fn need_lines(s: &Session) -> Option<&'static str> { } } -/// `layer.lineStrokes`: sets a line layer's stacked strokes. +/// `layer.lineStrokes`: sets a line layer's stacked strokes, or one class's own. fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { + let class = class_ref(p)?; let list = p.get("strokes").and_then(Value::as_array).ok_or("strokes is a list of strokes ([] for the single stroke)")?; if list.len() > MAX_STROKES { return Err(format!("at most {MAX_STROKES} strokes")); @@ -368,8 +369,12 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { if l.geometry_type() != GeometryType::Polyline || l.graphics { return Err("this works on line layers".into()); } - l.line_strokes = strokes; - Ok(json!({"strokes": l.line_strokes.len()})) + let n = strokes.len(); + match class { + Some(c) => l.renderer.set_class_strokes(&c, strokes)?, + None => l.line_strokes = strokes, + } + Ok(json!({"strokes": n})) } pub(crate) fn s_req<'a>(p: &'a Value, k: &str) -> Result<&'a str, String> { @@ -915,6 +920,8 @@ fn apply_styles(s: &mut Session, id: u32, styles: &[(String, rustgis_data::kml:: ramp: Default::default(), class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; } @@ -2595,14 +2602,16 @@ fn symbology(s: &mut Session, p: &Value) -> CmdResult { with(s, p, |l| l.renderer = r) } -/// `layer.classStyle`: one class's colour, outline colour and legend label (#314). -fn class_style(s: &mut Session, p: &Value) -> CmdResult { +/// The class `value`, `index` or `other: true` name in a command's parameters, or `None` when +/// none does. +fn class_ref(p: &Value) -> Result, String> { let other = b(p, "other").unwrap_or(false); let index = match p.get("index") { Some(_) => Some(uint(p, "index").ok_or("index must be a whole number from 0")?), None => None, }; - let class = match (p.get("value"), index, other) { + Ok(Some(match (p.get("value"), index, other) { + (None, None, false) => return Ok(None), (Some(v), None, false) => ClassRef::Value(match v { Value::String(t) => t.clone(), // As the data prints it (the classes' own keys): `1000.0` is the class "1000". @@ -2616,7 +2625,12 @@ fn class_style(s: &mut Session, p: &Value) -> CmdResult { (None, Some(i), false) => ClassRef::Index(usize::try_from(i).map_err(|_| "index is too large")?), (None, None, true) => ClassRef::Other, _ => return Err("name one class: value (Unique Values), index (Graduated Colors) or other: true".into()), - }; + })) +} + +/// `layer.classStyle`: one class's colour, outline colour and legend label (#314). +fn class_style(s: &mut Session, p: &Value) -> CmdResult { + let class = class_ref(p)?.ok_or("name one class: value (Unique Values), index (Graduated Colors) or other: true")?; let label = match p.get("label") { None => None, Some(Value::String(t)) => Some(t.clone()), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 26669d593..075911f2a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -391,6 +391,86 @@ fn line_strokes_stack_and_survive_save_and_undo() { assert_eq!(s.map().layer_by_name("Rivers").unwrap().line_strokes.len(), 1); } +#[test] +fn a_class_stacks_strokes_of_its_own() { + use rustgis_map::symbology::ClassRef; + use rustgis_map::{LineStroke, Rgba}; + let mut s = world(); + s.run("layer.symbology", &json!({"layer": "Rivers", "type": "unique", "field": "scalerank"})).unwrap(); + // The layer's one stroke, a casing and core for rivers of rank 1, a thin line for + // ; rank 2 keeps the layer's. + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": [{"offset": 3}]})).unwrap(); + let depth = s.undo_depth(); + let pair = json!([{"color": "#000000", "width": 6}, {"color": "#FF8C00", "width": 3}]); + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "value": "1", "strokes": pair})).unwrap(); + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "other": true, "strokes": [{"width": 0.5}]})).unwrap(); + assert_eq!(s.undo_depth(), depth + 2); + let casing = vec![ + LineStroke { color: Some(Rgba::rgb(0, 0, 0)), width: Some(6.0), ..Default::default() }, + LineStroke { color: Some(Rgba::rgb(0xFF, 0x8C, 0)), width: Some(3.0), ..Default::default() }, + ]; + let layer_own = vec![LineStroke { offset: 3.0, ..Default::default() }]; + let check = |s: &Session| { + let l = s.map().layer_by_name("Rivers").unwrap(); + let fi = l.dataset.field_index("scalerank").unwrap(); + assert!(l.has_strokes()); + for row in 0..l.dataset.features.len() { + let want = if l.dataset.value(row, fi).to_string() == "1" { &casing } else { &layer_own }; + assert_eq!(l.strokes(row), want.as_slice(), "row {row}"); + // Classed once, the same as asked apart. + assert_eq!(l.style(row), (l.symbol(row), want.as_slice()), "row {row}"); + } + assert_eq!(l.renderer.class_strokes(&ClassRef::Other).unwrap().len(), 1); + }; + check(&s); + // A number names the class as the data prints it. + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "value": 1, "strokes": pair})).unwrap(); + check(&s); + // Kept in the project file, and through applying the same symbology again. + let dir = std::env::temp_dir().join(format!("rustgis-class-strokes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("strokes.rgis"); + s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); + check(&t); + let png = dir.join("map.png"); + t.run("share.exportMap", &json!({"path": png.display().to_string(), "width": 320, "height": 200})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + s.run("layer.symbology", &json!({"layer": "Rivers", "type": "unique", "field": "scalerank", "ramp": "Ember"})).unwrap(); + check(&s); + // Junk, a class that isn't there, or a class on the wrong renderer: refused, nothing changes. + let depth = s.undo_depth(); + for bad in [ + json!({"layer": "Rivers", "value": "7", "strokes": pair}), + json!({"layer": "Rivers", "index": 0, "strokes": pair}), + json!({"layer": "Rivers", "value": "1", "index": 0, "strokes": pair}), + json!({"layer": "Rivers", "value": [1], "strokes": pair}), + json!({"layer": "Rivers", "value": "1", "strokes": vec![json!({}); 17]}), + json!({"layer": "Rivers", "value": "1", "strokes": [{"cap": "pointy"}]}), + json!({"layer": "Cities", "value": "1", "strokes": []}), + ] { + assert!(s.run("layer.lineStrokes", &bad).is_err(), "{bad}"); + } + assert_eq!(s.undo_depth(), depth); + // [] gives the class back to the layer's strokes; undo brings its own back. + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "value": "1", "strokes": []})).unwrap(); + assert!(s.map().layer_by_name("Rivers").unwrap().strokes(0).iter().all(|st| st.offset == 3.0)); + s.run("edit.undo", &Value::Null).unwrap(); + check(&s); + // Another field starts fresh. + s.run("layer.symbology", &json!({"layer": "Rivers", "type": "unique", "field": "name"})).unwrap(); + assert!(!s.map().layer_by_name("Rivers").unwrap().renderer.has_class_strokes()); + // Graduated Colors classes go by index; a single symbol has no classes. + s.run("layer.symbology", &json!({"layer": "Rivers", "type": "graduated", "field": "scalerank", "classes": 2})).unwrap(); + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "index": 0, "strokes": pair})).unwrap(); + assert_eq!(s.map().layer_by_name("Rivers").unwrap().renderer.class_strokes(&ClassRef::Index(0)).unwrap(), casing.as_slice()); + assert!(s.run("layer.lineStrokes", &json!({"layer": "Rivers", "index": 9, "strokes": pair})).is_err()); + assert!(s.run("layer.lineStrokes", &json!({"layer": "Rivers", "value": "1", "strokes": pair})).is_err()); + s.run("layer.symbology", &json!({"layer": "Rivers", "type": "single"})).unwrap(); + assert!(s.run("layer.lineStrokes", &json!({"layer": "Rivers", "index": 0, "strokes": pair})).is_err()); +} + #[test] fn project_save_and_open_roundtrip() { let mut s = world(); diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index c28098d9b..87106faab 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -405,6 +405,35 @@ impl Layer { if self.graphics { graphics::symbol(&self.dataset, row) } else { self.renderer.symbol_for(&self.dataset, row) } } + /// The stacked strokes a line feature row is drawn with: its class's own + /// ([`Renderer::strokes_for`]), else the layer's `line_strokes`; none (the single stroke) for + /// a graphics layer. + pub fn strokes(&self, row: usize) -> &[LineStroke] { + if self.graphics { + return &[]; + } + // Most layers have no class with strokes of its own: no need to class the feature. + if !self.renderer.has_class_strokes() { + return &self.line_strokes; + } + self.renderer.strokes_for(&self.dataset, row).unwrap_or(&self.line_strokes) + } + + /// A row's symbol and stacked strokes ([`Layer::symbol`], [`Layer::strokes`]), the feature + /// classed once for both. + pub fn style(&self, row: usize) -> (Symbol, &[LineStroke]) { + if self.graphics || !self.renderer.has_class_strokes() { + return (self.symbol(row), self.strokes(row)); + } + let (symbol, strokes) = self.renderer.style_for(&self.dataset, row); + (symbol, strokes.unwrap_or(&self.line_strokes)) + } + + /// Whether some feature may be drawn with stacked strokes ([`Layer::strokes`]). + pub fn has_strokes(&self) -> bool { + !self.graphics && (!self.line_strokes.is_empty() || self.renderer.has_class_strokes()) + } + pub fn is_raster(&self) -> bool { self.raster.is_some() } @@ -1580,6 +1609,49 @@ mod tests { ); } + #[test] + fn hostile_class_strokes_in_a_project_are_brought_into_range() { + // The same for a class's own strokes, Unique Values' (and ') and + // Graduated Colors'; a class listed with none is dropped. + let wild = vec![symbology::LineStroke { width: Some(f32::MAX), offset: 1e30, ..Default::default() }; 5000]; + let tame = |v: &[symbology::LineStroke]| { + v.len() == symbology::MAX_STROKES + && v.iter().all(|st| st.width == Some(symbology::MAX_STROKE_WIDTH) && st.offset == symbology::MAX_STROKE_OFFSET) + }; + let s = Symbol::default_for(GeometryType::Polyline, 0); + let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![])); + l.renderer = Renderer::Unique { + field: "k".into(), + classes: vec![("a".into(), s), ("b".into(), s)], + other: s, + ramp: Ramp::Ember, + class_labels: Default::default(), + other_label: None, + class_strokes: [("a".to_string(), wild.clone()), ("b".to_string(), Vec::new())].into_iter().collect(), + other_strokes: wild.clone(), + }; + let back: Layer = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); + let Renderer::Unique { class_strokes, other_strokes, .. } = &back.renderer else { panic!("unique renderer expected") }; + assert!(tame(&class_strokes["a"]) && !class_strokes.contains_key("b") && tame(other_strokes)); + l.renderer = Renderer::Graduated { + field: "k".into(), + breaks: vec![1.0], + symbols: vec![s], + ramp: Ramp::Ember, + method: ClassMethod::NaturalBreaks, + min: 0.0, + class_labels: Default::default(), + class_strokes: [(0, wild)].into_iter().collect(), + }; + let back: Layer = serde_json::from_str(&serde_json::to_string(&l).unwrap()).unwrap(); + let Renderer::Graduated { class_strokes, .. } = &back.renderer else { panic!("graduated renderer expected") }; + assert!(tame(&class_strokes[&0])); + // A project from before per-class strokes reads with none. + let old = r#"{"Unique":{"field":"k","classes":[],"other":{"fill":[0,0,0,255],"stroke":[0,0,0,255],"stroke_width":1,"size":0,"marker":"Circle"},"ramp":"Ember"}}"#; + let r: Renderer = serde_json::from_str(old).unwrap(); + assert!(!r.has_class_strokes()); + } + /// A layer of one long line along the equator with `n` vertices. fn long_line(n: usize) -> Layer { let mut d = Dataset::new("line", GeometryType::Polyline, vec![]); @@ -1786,6 +1858,8 @@ mod tests { ramp: Ramp::Ember, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; let labels = |l: &Layer, scale| l.shown_legend(scale).map(|v| v.into_iter().map(|e| e.label).collect::>()); assert_eq!(labels(l, 1e6), Some(vec!["1".to_string(), "2".to_string()]), "no 9, no "); diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 35fff1d92..bc8b319c7 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -215,6 +215,23 @@ pub fn de_strokes<'de, D: serde::Deserializer<'de>>(d: D) -> Result(d: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, + K: Deserialize<'de> + Ord, +{ + let m: BTreeMap> = BTreeMap::deserialize(d)?; + Ok(m.into_iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, mut v)| { + v.truncate(MAX_STROKES); + (k, v.into_iter().map(LineStroke::clamped).collect()) + }) + .collect()) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ClassMethod { #[default] @@ -259,6 +276,13 @@ pub enum Renderer { /// The legend label of `other`, when set by hand (otherwise ``). #[serde(default, skip_serializing_if = "Option::is_none")] other_label: Option, + /// A line layer's stacked strokes for one class, by class value: drawn for that class's + /// features instead of the layer's `line_strokes`. A class without its own takes the layer's. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "de_class_strokes")] + class_strokes: BTreeMap>, + /// The stacked strokes of `other`; empty takes the layer's. + #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "de_strokes")] + other_strokes: Vec, }, /// Upper bounds of each class (ascending) and one symbol per class. Graduated { @@ -272,6 +296,9 @@ pub enum Renderer { /// shows its range. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] class_labels: BTreeMap, + /// A line layer's stacked strokes for one class, by class index, as Unique Values'. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty", deserialize_with = "de_class_strokes")] + class_strokes: BTreeMap>, }, /// Marker size grows with the value. Proportional { @@ -317,6 +344,27 @@ pub enum ClassRef { Other, } +/// The class a feature falls in ([`Renderer::class_of`]): a place in the classes, or Unique +/// Values' ``. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Slot { + Class(usize), + Other, +} + +fn no_class(i: usize, n: usize) -> String { + format!("no class {i}: this renderer has {n} (0 to {})", n.saturating_sub(1)) +} + +/// Why a class reference doesn't fit the renderer, as [`Renderer::set_class_style`] says it. +fn class_mismatch(r: &Renderer) -> String { + match r { + Renderer::Unique { .. } => "Unique Values classes are named by their value, not by number".into(), + Renderer::Graduated { .. } => "Graduated Colors classes are numbered from 0, the lowest".into(), + _ => "only Unique Values and Graduated Colors have classes to style".into(), + } +} + /// What [`Renderer::set_class_style`] changes in a class; `None` leaves that part as it is. #[derive(Clone, Debug, Default, PartialEq)] pub struct ClassStyle { @@ -370,6 +418,8 @@ impl Renderer { ramp, class_labels: BTreeMap::new(), other_label: None, + class_strokes: BTreeMap::new(), + other_strokes: Vec::new(), }) } @@ -406,6 +456,7 @@ impl Renderer { method, min, class_labels: BTreeMap::new(), + class_strokes: BTreeMap::new(), }) } @@ -431,12 +482,13 @@ impl Renderer { }) } - /// Symbol for a feature row. - pub fn symbol_for(&self, ds: &Dataset, row: usize) -> Symbol { + /// The class a feature row falls in: a Unique Values class's place in `classes` (its value's + /// first class) or ``, a Graduated Colors class's index; `None` for the other + /// renderers, and for a Graduated Colors row without a number. + fn class_of(&self, ds: &Dataset, row: usize) -> Option { match self { - Renderer::Single(s) => *s, - Renderer::Unique { field, classes, other, .. } => { - let Some(fi) = ds.field_index(field) else { return *other }; + Renderer::Unique { field, classes, .. } => { + let Some(fi) = ds.field_index(field) else { return Some(Slot::Other) }; // Text compares as it is; other values as they print. let printed; let v = match ds.value(row, fi) { @@ -446,14 +498,121 @@ impl Renderer { printed.as_str() } }; - classes.iter().find(|(k, _)| k == v).map(|c| c.1).unwrap_or(*other) + Some(classes.iter().position(|(k, _)| k == v).map_or(Slot::Other, Slot::Class)) } - Renderer::Graduated { field, breaks, symbols, .. } => { + Renderer::Graduated { field, breaks, .. } => { + let v = ds.field_index(field).and_then(|fi| ds.value(row, fi).as_f64())?; + Some(Slot::Class(breaks.iter().position(|b| v <= *b).unwrap_or(breaks.len().saturating_sub(1)))) + } + Renderer::Single(_) | Renderer::Proportional { .. } => None, + } + } + + /// The stacked strokes a line feature row's class has of its own (`class_strokes`), or `None` + /// when its class has none (the layer's `line_strokes` apply). + pub fn strokes_for(&self, ds: &Dataset, row: usize) -> Option<&[LineStroke]> { + self.strokes_in(self.class_of(ds, row)) + } + + /// The symbol a feature row draws with ([`Renderer::symbol_for`]) and its class's own stacked + /// strokes ([`Renderer::strokes_for`]), the feature classed once for both. + pub fn style_for(&self, ds: &Dataset, row: usize) -> (Symbol, Option<&[LineStroke]>) { + let slot = self.class_of(ds, row); + (self.symbol_in(ds, row, slot), self.strokes_in(slot)) + } + + fn strokes_in(&self, slot: Option) -> Option<&[LineStroke]> { + let strokes = match (self, slot?) { + (Renderer::Unique { classes, class_strokes, .. }, Slot::Class(i)) => class_strokes.get(&classes.get(i)?.0)?, + (Renderer::Unique { other_strokes, .. }, Slot::Other) => other_strokes, + (Renderer::Graduated { class_strokes, .. }, Slot::Class(i)) => class_strokes.get(&i)?, + _ => return None, + }; + (!strokes.is_empty()).then_some(strokes.as_slice()) + } + + /// Whether any class has stacked strokes of its own. + pub fn has_class_strokes(&self) -> bool { + match self { + Renderer::Unique { class_strokes, other_strokes, .. } => !class_strokes.is_empty() || !other_strokes.is_empty(), + Renderer::Graduated { class_strokes, .. } => !class_strokes.is_empty(), + Renderer::Single(_) | Renderer::Proportional { .. } => false, + } + } + + /// One class's own stacked strokes (empty when it takes the layer's). + pub fn class_strokes(&self, class: &ClassRef) -> Result<&[LineStroke], String> { + let none: &[LineStroke] = &[]; + match (self, class) { + (Renderer::Unique { classes, class_strokes, .. }, ClassRef::Value(v)) => { + if !classes.iter().any(|(k, _)| k == v) { + return Err(format!("no class with the value '{v}'")); + } + Ok(class_strokes.get(v).map_or(none, Vec::as_slice)) + } + (Renderer::Unique { other_strokes, .. }, ClassRef::Other) => Ok(other_strokes), + (Renderer::Graduated { symbols, class_strokes, .. }, ClassRef::Index(i)) => { + if *i >= symbols.len() { + return Err(no_class(*i, symbols.len())); + } + Ok(class_strokes.get(i).map_or(none, Vec::as_slice)) + } + _ => Err(class_mismatch(self)), + } + } + + /// Sets one class's own stacked strokes, at most [`MAX_STROKES`], each brought into range; + /// none goes back to the layer's. + pub fn set_class_strokes(&mut self, class: &ClassRef, strokes: Vec) -> Result<(), String> { + if strokes.len() > MAX_STROKES { + return Err(format!("at most {MAX_STROKES} strokes")); + } + let strokes: Vec = strokes.into_iter().map(LineStroke::clamped).collect(); + match (&mut *self, class) { + (Renderer::Unique { classes, class_strokes, .. }, ClassRef::Value(v)) => { + if !classes.iter().any(|(k, _)| k == v) { + return Err(format!("no class with the value '{v}'")); + } + if strokes.is_empty() { + class_strokes.remove(v); + } else { + class_strokes.insert(v.clone(), strokes); + } + } + (Renderer::Unique { other_strokes, .. }, ClassRef::Other) => *other_strokes = strokes, + (Renderer::Graduated { symbols, class_strokes, .. }, ClassRef::Index(i)) => { + if *i >= symbols.len() { + return Err(no_class(*i, symbols.len())); + } + if strokes.is_empty() { + class_strokes.remove(i); + } else { + class_strokes.insert(*i, strokes); + } + } + _ => return Err(class_mismatch(self)), + } + Ok(()) + } + + /// Symbol for a feature row. + pub fn symbol_for(&self, ds: &Dataset, row: usize) -> Symbol { + self.symbol_in(ds, row, self.class_of(ds, row)) + } + + /// The symbol for a feature row in class `slot` ([`Renderer::class_of`]). + fn symbol_in(&self, ds: &Dataset, row: usize, slot: Option) -> Symbol { + match self { + Renderer::Single(s) => *s, + Renderer::Unique { classes, other, .. } => match slot { + Some(Slot::Class(i)) => classes.get(i).map_or(*other, |c| c.1), + _ => *other, + }, + Renderer::Graduated { symbols, .. } => { let fallback = symbols.first().copied().unwrap_or(Symbol::default_for(ds.geometry_type, 0)); - let Some(v) = ds.field_index(field).and_then(|fi| ds.value(row, fi).as_f64()) else { + let Some(Slot::Class(i)) = slot else { return Symbol { fill: Rgba::new(0xC8, 0xC8, 0xC8, 140), ..fallback }; }; - let i = breaks.iter().position(|b| v <= *b).unwrap_or(breaks.len().saturating_sub(1)); symbols.get(i).copied().unwrap_or(fallback) } Renderer::Proportional { field, base, min_size, max_size, min, max } => { @@ -673,15 +832,29 @@ impl Renderer { pub fn carry_edits_from(&mut self, old: &Renderer, g: GeometryType) { match (self, old) { ( - Renderer::Unique { field, classes, other, ramp, class_labels, other_label }, - Renderer::Unique { field: of, classes: oc, other: oo, ramp: or, class_labels: ol, other_label: ool }, + Renderer::Unique { field, classes, other, ramp, class_labels, other_label, class_strokes, other_strokes }, + Renderer::Unique { + field: of, + classes: oc, + other: oo, + ramp: or, + class_labels: ol, + other_label: ool, + class_strokes: ost, + other_strokes: oos, + }, ) if field == of => { let same_scheme = ramp == or; let n = oc.len(); + // Strokes are drawn by hand, as labels are: a class keeps them whatever the scheme. + other_strokes.clone_from(oos); for (k, s) in classes.iter_mut() { if let Some(t) = ol.get(k.as_str()) { class_labels.insert(k.clone(), t.clone()); } + if let Some(st) = ost.get(k.as_str()) { + class_strokes.insert(k.clone(), st.clone()); + } if same_scheme && let Some(j) = oc.iter().position(|(ok, _)| ok == k) && let Some((_, os)) = oc.get(j) @@ -695,14 +868,15 @@ impl Renderer { } } ( - Renderer::Graduated { field, breaks, symbols, ramp, min, class_labels, .. }, - Renderer::Graduated { field: of, breaks: ob, symbols: os, ramp: or, min: om, class_labels: ol, .. }, + Renderer::Graduated { field, breaks, symbols, ramp, min, class_labels, class_strokes, .. }, + Renderer::Graduated { field: of, breaks: ob, symbols: os, ramp: or, min: om, class_labels: ol, class_strokes: ost, .. }, ) if field == of => { // A Graduated Colors edit belongs to a range: it stays only while the ranges do. if breaks != ob || min != om { return; } class_labels.clone_from(ol); + class_strokes.clone_from(ost); if ramp == or && symbols.len() == os.len() { let n = os.len(); for (i, (s, o)) in symbols.iter_mut().zip(os).enumerate() { @@ -1328,6 +1502,8 @@ mod tests { ramp: Ramp::Ember, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; let labels = |rows: &[bool]| r.legend_for(&d, rows).into_iter().map(|e| e.label).collect::>(); assert_eq!(labels(&[]), ["a", "b", ""]); @@ -1345,6 +1521,8 @@ mod tests { ramp: Ramp::Ember, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; let shown = twice.legend_for(&d, &[]); assert_eq!(shown.iter().map(|e| e.label.as_str()).collect::>(), ["a", "b", ""]); @@ -1357,6 +1535,8 @@ mod tests { ramp: Ramp::Ember, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; assert_eq!(gone.legend_for(&d, &[]).len(), 1); // The fallback row is marked as such, whatever a class is called. @@ -1367,6 +1547,8 @@ mod tests { ramp: Ramp::Ember, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; let mut d2 = d.clone(); d2.push(None, vec![Value::Text(OTHER_VALUES.into())]); @@ -1396,6 +1578,8 @@ mod tests { ramp: Ramp::Ember, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; let labels = |r: &Renderer, rows: &[bool]| r.legend_for(&d, rows).into_iter().map(|e| e.label).collect::>(); assert_eq!(labels(&r, &[]), ["a"], "z falls in the invisible fallback"); @@ -1410,6 +1594,8 @@ mod tests { ramp: Ramp::Ember, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; assert_eq!(labels(&r2, &[]), ["a", OTHER_VALUES]); // A line's fill draws nothing: a fallback with only a fill is invisible on lines. @@ -1424,6 +1610,8 @@ mod tests { ramp: Ramp::Ember, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; assert!(r3.legend_for(&lines, &[]).is_empty()); assert_eq!(labels(&r3, &[]), ["a", OTHER_VALUES], "a polygon fill still shows"); @@ -1450,6 +1638,8 @@ mod tests { ramp: Ramp::Ember, class_labels: BTreeMap::new(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; let legend = renderer.legend(); assert_eq!( @@ -1538,6 +1728,8 @@ mod tests { ramp, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), } } @@ -1550,6 +1742,7 @@ mod tests { method: ClassMethod::Quantile, min: 0.0, class_labels: Default::default(), + class_strokes: Default::default(), } } diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 3b113df22..295b8a268 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -1360,6 +1360,8 @@ mod tests { ramp: rustgis_map::Ramp::Ember, class_labels: Default::default(), other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), }; } // How far down the legend draws, in pixels. @@ -1465,6 +1467,7 @@ mod tests { method: rustgis_map::ClassMethod::NaturalBreaks, min: 0.0, class_labels: Default::default(), + class_strokes: Default::default(), }; } map.layers.retain(|l| l.id == pts); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 78b728930..8b1f778eb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -581,7 +581,7 @@ impl Ctx<'_> { pm.stroke_path(&path, p, &st, Transform::identity(), None); } - /// A line drawn as a layer's stacked strokes (`Layer::line_strokes`), bottom first, each + /// A line drawn as its stacked strokes (`Layer::strokes`), bottom first, each /// shifted sideways by its offset. fn stacked_line(&self, pm: &mut Pixmap, parts: &[Vec], s: &Symbol, strokes: &[LineStroke]) { let mut pb = PathBuilder::new(); @@ -1766,7 +1766,7 @@ pub fn gpu_may_turn(map: &Map, view: &View) -> bool { && map.swipe.is_none_or(|(id, _)| id != l.id) && l.aggregation.is_none() && !l.graphics - && l.line_strokes.is_empty() + && !l.has_strokes() && (l.extrusion.is_none() || view.camera.pitch != 0.0) }) } @@ -2646,7 +2646,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } else { Default::default() }; - let mut highlights: Vec<(usize, Geometry, Symbol)> = Vec::new(); + let mut highlights: Vec<(usize, Geometry, Symbol, Vec)> = Vec::new(); // The GPU mesh vertices the layer has left: a streamed layer's batches share them, so // one too dense to mesh goes to the CPU however it is split. let mut mesh_room = mesh::MAX_LAYER_VERTICES; @@ -2731,7 +2731,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac && layer.aggregation.is_none() && layer.extrusion.is_none() && !layer.graphics - && layer.line_strokes.is_empty() + && !layer.has_strokes() && !upright.contains(&layer.id); // Lyon refuses degenerate geometry tiny-skia draws fine, and a layer too dense to mesh // (`MAX_LAYER_VERTICES`) is quicker splatted: collect_layer reports both, and the layer @@ -2806,22 +2806,21 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } continue; } - let sym = layer.symbol(row); + // Its symbol, and the layer's stacked strokes or the feature's class's own. + let (sym, strokes) = layer.style(row); // A feature under a pixel across: blend its coverage into the pixels it touches // (a dense layer seen whole is mostly these, and a path each is slow). // Not stacked strokes: a splat would draw them unshifted, in one colour. if globe.is_none() && !layer.graphics - && layer.line_strokes.is_empty() + && strokes.is_empty() && let Some(e) = extents.get(row) && ctx.splat(dest, e, g, &sym) { continue; } match g { - Geometry::Line(parts) if !layer.line_strokes.is_empty() && !layer.graphics => { - stroke_paths.draw(&ctx, dest, parts, &sym, &layer.line_strokes) - } + Geometry::Line(parts) if !strokes.is_empty() => stroke_paths.draw(&ctx, dest, parts, &sym, strokes), _ => ctx.feature(dest, g, &sym), } } @@ -2851,11 +2850,13 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // draw over it (the CPU path draws highlights over the layer). if complete_source { let rank = ds_ptr.features.get(*row).and_then(|f| selected_order.get(&f.id)).copied().unwrap_or(usize::MAX); - highlights.push((rank, (**g).clone(), layer.symbol(*row))); + let (symbol, strokes) = layer.style(*row); + highlights.push((rank, (**g).clone(), symbol, strokes.to_vec())); } else if gpu_done && mesh::collect_highlight(&mut cmds, g, &layer.symbol(*row), ctx.ppp, map_per_px).is_ok() { // highlight tessellated into the scene, right after its layer } else { - ctx.highlight(&mut pm, g, &layer.symbol(*row), if layer.graphics { &[] } else { &layer.line_strokes }); + let (symbol, strokes) = layer.style(*row); + ctx.highlight(&mut pm, g, &symbol, strokes); cpu_dirty = true; } } @@ -3013,9 +3014,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac composite(&mut pm, &target); } } - highlights.sort_by_key(|(rank, _, _)| *rank); - for (_, geometry, symbol) in highlights { - ctx.highlight(&mut pm, &geometry, &symbol, if layer.graphics { &[] } else { &layer.line_strokes }); + highlights.sort_by_key(|(rank, ..)| *rank); + for (_, geometry, symbol, strokes) in highlights { + ctx.highlight(&mut pm, &geometry, &symbol, &strokes); cpu_dirty = true; } } @@ -5290,6 +5291,56 @@ mod tests { assert_eq!(r.pixmap.pixel(100, 100).unwrap(), blank, "nothing on the centreline"); } + #[test] + fn a_class_draws_its_own_stacked_strokes() { + // Two lines of two Unique Values classes: "road" has a black casing and a red core of its + // own, "path" takes the layer's single green stroke. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("lines", GeometryType::Polyline, vec![Field::new("kind", FieldType::Text)]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, 0.3), Coord::new(1.0, 0.3)]])), vec![Value::Text("road".into())]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, -0.3), Coord::new(1.0, -0.3)]])), vec![Value::Text("path".into())]); + let id = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, 0.0)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 200.0, height: 200.0, pixels_per_point: 1.0, camera, sky: false }; + let colour = |r: &Rendered, y: u32| { + let p = r.pixmap.pixel(100, y).unwrap(); + (p.red(), p.green(), p.blue()) + }; + let (black, red, green) = (Rgba::rgb(0, 0, 0), Rgba::rgb(255, 0, 0), Rgba::rgb(0, 160, 0)); + let stroke = |c: Rgba, w: f32| rustgis_map::LineStroke { color: Some(c), width: Some(w), ..Default::default() }; + let l = m.layer_mut(id).unwrap(); + let s = Symbol::default_for(GeometryType::Polyline, 0); + l.renderer = rustgis_map::Renderer::Unique { + field: "kind".into(), + classes: vec![("road".into(), s), ("path".into(), s)], + other: s, + ramp: rustgis_map::Ramp::Ember, + class_labels: Default::default(), + other_label: None, + class_strokes: [("road".to_string(), vec![stroke(black, 9.0), stroke(red, 3.0)])].into_iter().collect(), + other_strokes: Vec::new(), + }; + l.line_strokes = vec![stroke(green, 3.0)]; + // 0.3° is about 22 px from the centre at this resolution. + let r = render(&mut m, &view).unwrap(); + let (road, path) = ((0..100).find(|y| colour(&r, *y) == (255, 0, 0)), (100..200).find(|y| colour(&r, *y) == (0, 160, 0))); + let (Some(road), Some(path)) = (road, path) else { panic!("the road's red core and the path's green stroke draw") }; + assert_eq!(colour(&r, road - 2), (0, 0, 0), "the road's casing shows around its core"); + assert!((path - 1..=path + 3).all(|y| colour(&r, y) != (0, 0, 0)), "the path has no casing"); + // Selected, the road keeps its own stack under the halo. + m.layer_mut(id).unwrap().selection = vec![0]; + let r = render(&mut m, &view).unwrap(); + assert_eq!(colour(&r, road + 1), (255, 0, 0)); + // Without the layer's strokes, the path draws its symbol's single line; the road keeps its own. + m.layer_mut(id).unwrap().line_strokes.clear(); + m.layer_mut(id).unwrap().selection.clear(); + let r = render(&mut m, &view).unwrap(); + assert_eq!(colour(&r, road + 1), (255, 0, 0)); + assert_eq!(colour(&r, road - 2), (0, 0, 0)); + assert!((100..200).all(|y| colour(&r, y) != (0, 160, 0)), "no green left"); + } + #[test] fn features_under_a_pixel_still_show() { // A dense grid of tiny squares seen whole: each is splatted rather than filled as a path, diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index aad261f75..3982de986 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -5,7 +5,8 @@ //! The script is JSON lines: `{"cmd": id, "params": {...}}` runs a command (ui.* included), //! `{"shot": "/abs/out.png"}` saves the window, `{"steps": n}` runs frames, `{"sleep": ms}` runs frames //! for that long, `{"click": label}` clicks the first widget whose label contains the text (with -//! `"exact": true`, whose whole label is the text), `{"type": text}` types into the focused box, +//! `"exact": true`, whose whole label is the text), `{"combo": text}` opens the combo box showing +//! that text, `{"type": text}` types into the focused box, //! `{"key": "Enter"}` presses a key (egui's key names), `{"dark": true}` switches theme, //! `{"drag": [x, y, dx, dy]}` presses the pointer at (x, y) and moves it by (dx, dy), holding //! it (shots then catch the map mid-drag; `{"release": true}` lets go; `"button": "secondary"`; `"ctrl": true` holds Ctrl @@ -154,6 +155,15 @@ fn main() { for _ in 0..3 { harness.step(); } + } else if let Some(shown) = l.get("combo").and_then(|v| v.as_str()) { + // A combo box has no label of its own: it is found by what it shows. + match harness.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some(shown)) { + Some(node) => node.click(), + None => eprintln!("no combo box showing '{shown}'"), + } + for _ in 0..3 { + harness.step(); + } } else if let Some(d) = l.get("drag").and_then(|v| v.as_array()) { let n: Vec = d.iter().map(|v| v.as_f64().unwrap_or(0.0) as f32).collect(); let [x, y, dx, dy] = n[..] else { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 59bbb2c06..afbd2a744 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -10,7 +10,7 @@ use rustgis_engine::data::GeometryType; use rustgis_engine::map::raster::{Normalizer, RasterLegend, RasterStyle}; use rustgis_engine::map::symbology::{ ClassRef, ClassStyle, LineCap, LineJoin, LineStroke, MAX_LABEL, MAX_STROKE_OFFSET, MAX_STROKE_WIDTH, MAX_STROKES, MAX_UNIQUE, - normalize_label, + OTHER_VALUES, normalize_label, }; use rustgis_engine::map::tiles::{TileKind, TileLayer}; use rustgis_engine::map::{ @@ -3785,15 +3785,62 @@ fn stroke_json(st: &LineStroke) -> Value { }) } -fn set_strokes(app: &mut RustGisApp, strokes: &[LineStroke]) { - let _ = app.run("layer.lineStrokes", json!({"strokes": strokes.iter().map(stroke_json).collect::>()})); +/// Sets the layer's stacked strokes, or `class`'s own. +fn set_strokes(app: &mut RustGisApp, class: Option<&ClassRef>, strokes: &[LineStroke]) { + let mut p = json!({"strokes": strokes.iter().map(stroke_json).collect::>()}); + match class { + Some(ClassRef::Value(v)) => p["value"] = json!(v), + Some(ClassRef::Index(i)) => p["index"] = json!(i), + Some(ClassRef::Other) => p["other"] = json!(true), + None => {} + } + let _ = app.run("layer.lineStrokes", p); +} + +/// The classes a line layer's strokes can be set for, with their legend labels and symbols. +fn stroke_classes(r: &Renderer) -> Vec<(ClassRef, String, Symbol)> { + match r { + Renderer::Unique { classes, other, class_labels, other_label, .. } => { + // A value listed twice is drawn with its first class, as the legend shows it. + let mut seen = std::collections::HashSet::new(); + classes + .iter() + .filter(|(k, _)| seen.insert(k.as_str())) + .map(|(k, s)| (ClassRef::Value(k.clone()), class_labels.get(k).cloned().unwrap_or_else(|| k.clone()), *s)) + .chain(std::iter::once((ClassRef::Other, other_label.clone().unwrap_or_else(|| tl!(OTHER_VALUES).to_string()), *other))) + .collect() + } + Renderer::Graduated { symbols, .. } => { + let legend = r.legend(); + symbols + .iter() + .enumerate() + .map(|(i, s)| (ClassRef::Index(i), legend.get(i).map(|e| e.label.clone()).unwrap_or_else(|| i.to_string()), *s)) + .collect() + } + Renderer::Single(_) | Renderer::Proportional { .. } => Vec::new(), + } } /// The Strokes section of a line layer's symbology: strokes stacked in one symbol, each with -/// its own colour, width, sideways offset, cap and join. Listed top first, as they are seen. +/// its own colour, width, sideways offset, cap and join. Listed top first, as they are seen. A +/// layer with classes can stack strokes for one class of its own, picked at the top. fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::map::Layer, t: &Tokens) { - let base = l.renderer.base_symbol(); - let strokes = l.line_strokes.clone(); + let classes = stroke_classes(&l.renderer); + let picked_id = egui::Id::new(("stroke_class", l.id)); + // The class being edited, while the renderer still has it; `None` is the layer's own strokes. + let picked: Option = + ui.data(|d| d.get_temp::>(picked_id)).flatten().filter(|c| classes.iter().any(|(k, ..)| k == c)); + let class = picked.as_ref(); + let base = class.and_then(|c| classes.iter().find(|(k, ..)| k == c)).map_or_else(|| l.renderer.base_symbol(), |(.., s)| *s); + let own = class.and_then(|c| l.renderer.class_strokes(c).ok()).map(<[LineStroke]>::to_vec); + // What the class draws: its own strokes, else the layer's. + let strokes = match &own { + Some(v) if !v.is_empty() => v.clone(), + Some(_) | None => l.line_strokes.clone(), + }; + // A class drawn with the layer's strokes edits them as a copy of its own. + let borrowed = own.as_ref().is_some_and(Vec::is_empty); egui::Frame::NONE.fill(t.pane_header).corner_radius(CornerRadius::same(8)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(ui.available_width()); ui.horizontal(|ui| { @@ -3804,10 +3851,32 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma let mut next = strokes.clone(); // The first stroke stands for the symbol's own; later ones copy the top one. next.push(strokes.last().copied().unwrap_or_default()); - set_strokes(app, &next); + set_strokes(app, class, &next); } }); }); + if !classes.is_empty() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Strokes for")).size(11.5).color(t.text_dim)); + let name = |c: Option<&ClassRef>| match c.and_then(|c| classes.iter().find(|(k, ..)| k == c)) { + Some((_, label, _)) => label.clone(), + None => tl!("All classes").to_string(), + }; + egui::ComboBox::from_id_salt(("stroke_class_pick", l.id)).selected_text(name(class)).show_ui(ui, |ui| { + if ui.selectable_label(class.is_none(), tl!("All classes")).on_hover_text(tl!("The layer's strokes")).clicked() { + ui.data_mut(|d| d.insert_temp::>(picked_id, None)); + } + for (k, label, _) in &classes { + // A class with strokes of its own is marked. + let has_own = l.renderer.class_strokes(k).is_ok_and(|s| !s.is_empty()); + let text = if has_own { format!("{label} •") } else { label.clone() }; + if ui.selectable_label(class == Some(k), text).clicked() { + ui.data_mut(|d| d.insert_temp(picked_id, Some(k.clone()))); + } + } + }); + }); + } // The composed symbol along a line drawn left to right: a positive offset shows below it. let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); ui.painter().rect_filled(r, CornerRadius::same(4), t.ribbon); @@ -3833,6 +3902,13 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma ui.painter().line_segment([pos2(ax - 10.0, ay), pos2(ax, ay)], Stroke::new(1.0, t.text_dim)); ui.painter().line_segment([pos2(ax - 3.0, ay - 3.0), pos2(ax, ay)], Stroke::new(1.0, t.text_dim)); ui.painter().line_segment([pos2(ax - 3.0, ay + 3.0), pos2(ax, ay)], Stroke::new(1.0, t.text_dim)); + if borrowed { + ui.label( + egui::RichText::new(tl!("This class is drawn with the layer's strokes. Changing them here gives it its own.")) + .size(11.5) + .color(t.text_dim), + ); + } if strokes.is_empty() { ui.label( egui::RichText::new(tl!("One stroke, from the symbol. Add strokes for casings, parallel lines and offsets.")) @@ -3853,17 +3929,17 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma if crate::widgets::icon_button(ui, "close", tl!("Remove")).clicked() { let mut next = strokes.clone(); next.remove(i); - set_strokes(app, &next); + set_strokes(app, class, &next); } if ui.add_enabled(i > 0, egui::Button::new("↓").small()).on_hover_text(tl!("Move down")).clicked() { let mut next = strokes.clone(); next.swap(i, i - 1); - set_strokes(app, &next); + set_strokes(app, class, &next); } if ui.add_enabled(i + 1 < n, egui::Button::new("↑").small()).on_hover_text(tl!("Move up")).clicked() { let mut next = strokes.clone(); next.swap(i, i + 1); - set_strokes(app, &next); + set_strokes(app, class, &next); } }); }); @@ -3872,7 +3948,7 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma if let Some(slot) = next.get_mut(i) { *slot = new; } - set_strokes(app, &next); + set_strokes(app, class, &next); }; egui::Grid::new(("stroke_grid", i)).num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { let (c, w) = st.resolve(&base); @@ -3933,8 +4009,17 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma }); } ui.add_space(4.0); - if ui.small_button(tl!("Use a Single Stroke")).on_hover_text(tl!("Remove the strokes and draw the symbol's line alone")).clicked() { - set_strokes(app, &[]); + if class.is_some() { + let back = ui.add_enabled(!borrowed, egui::Button::new(tl!("Use the Layer's Strokes")).small()); + if back.on_hover_text(tl!("Remove this class's own strokes and draw it with the layer's")).clicked() { + set_strokes(app, class, &[]); + } + } else if ui + .small_button(tl!("Use a Single Stroke")) + .on_hover_text(tl!("Remove the strokes and draw the symbol's line alone")) + .clicked() + { + set_strokes(app, None, &[]); } }); } @@ -4703,6 +4788,30 @@ mod tests { assert_eq!(f.values.get("output").map(String::as_str), Some("kept"), "not given"); } + /// The Strokes for picker lists a value listed twice once, for its first class (the one its + /// features draw with), then ``. + #[test] + fn stroke_classes_list_a_repeated_value_once() { + let (a, b) = ( + Symbol { stroke_width: 1.0, ..Symbol::default_for(GeometryType::Polyline, 0) }, + Symbol { stroke_width: 2.0, ..Symbol::default_for(GeometryType::Polyline, 0) }, + ); + let r = Renderer::Unique { + field: "kind".into(), + classes: vec![("road".into(), a), ("rail".into(), b), ("road".into(), b)], + other: b, + ramp: Ramp::default(), + class_labels: Default::default(), + other_label: None, + class_strokes: Default::default(), + other_strokes: Vec::new(), + }; + let listed = stroke_classes(&r); + let refs: Vec<&ClassRef> = listed.iter().map(|(c, ..)| c).collect(); + assert_eq!(refs, [&ClassRef::Value("road".into()), &ClassRef::Value("rail".into()), &ClassRef::Other]); + assert_eq!(listed.first().map(|(.., s)| s.stroke_width), Some(1.0)); + } + fn layer(app: &RustGisApp, name: &str) -> u32 { app.session.map().layer_by_name(name).map(|l| l.id).unwrap_or_default() } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 01edf66ac..c70163d4e 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -2861,3 +2861,38 @@ fn map_and_layout_tabs_rename_from_their_menus() { assert_eq!(p.maps[0].name, "Renamed Map"); assert_eq!(p.layouts.last().map(|l| l.name.as_str()), Some("Renamed Layout")); } + +#[test] +fn line_strokes_editor_edits_one_class() { + use rustgis_engine::map::symbology::ClassRef; + let mut h = harness(); + let app = h.state_mut(); + app.run("layer.select", json!({"layer": "Rivers"})).unwrap(); + app.run("layer.symbology", json!({"layer": "Rivers", "type": "unique", "field": "scalerank"})).unwrap(); + let pair = json!([{"color": "#000000", "width": 6}, {"color": "#FFFFFF", "width": 2}]); + app.run("layer.lineStrokes", json!({"layer": "Rivers", "value": "1", "strokes": pair})).unwrap(); + app.run("ui.pane.symbology", json!({})).unwrap(); + h.step(); + h.step(); + let class_strokes = |h: &egui_kittest::Harness<'_, RustGisApp>, c: &ClassRef| { + h.state().session.map().layer_by_name("Rivers").unwrap().renderer.class_strokes(c).unwrap().len() + }; + // The layer's strokes first; class 1 is marked as having its own. + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("All classes")).unwrap().click(); + h.step(); + h.get_by_label("1 •").click_accesskit(); + h.step(); + h.step(); + assert!(h.query_by_label("Stroke 2").is_some(), "class 1's two strokes are listed"); + // Back to the layer's strokes: the class borrows them, and Add Stroke gives it its own again. + h.get_by_label("Use the Layer's Strokes").click_accesskit(); + h.step(); + h.step(); + assert_eq!(class_strokes(&h, &ClassRef::Value("1".into())), 0); + assert!(h.query_by_label_contains("drawn with the layer's strokes").is_some()); + h.get_by_label("Add Stroke").click_accesskit(); + h.step(); + h.step(); + assert_eq!(class_strokes(&h, &ClassRef::Value("1".into())), 1); + assert!(h.state().session.map().layer_by_name("Rivers").unwrap().line_strokes.is_empty(), "the layer's own are untouched"); +} diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 9847c4a1d..56b71fc2e 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -91,6 +91,20 @@ layer.lineStrokes={"layer":"Rivers","strokes":[{"color":"#000000","width":11.5," A stroke without `color` or `width` takes the renderer's. +A Unique Values or Graduated Colors class can stack strokes of its own, so a motorway gets a +casing and a centre line while a path stays a single thin line on the same layer. Pick the class +under **Strokes for** at the top of the Strokes section (**All classes** edits the layer's +strokes); a class with strokes of its own is marked with •. A class without its own is drawn with +the layer's strokes, and changing them while it is picked gives it its own copy. **Use the +Layer's Strokes** gives the class back to the layer's. A class keeps its strokes when the same +symbology is applied again on the same field (a Graduated Colors class, while its range stays the +same). By command, name the class as `layer.classStyle` does, with `value`, `index` or +`"other": true`: + +```text +layer.lineStrokes={"layer":"Roads","value":"Motorway","strokes":[{"color":"#5A3A1E","width":7},{"color":"#F28C28","width":5},{"color":"#FFFFFF","width":0.6}]} +``` + ### Custom color ramps Besides the built-in color schemes, a project can hold your own color ramps: an organization's From 3a51ce31daf03dcbb372c7b42ff54f7adfd94dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Fri, 23 Oct 2026 09:27:40 -0500 Subject: [PATCH 058/297] feat(ui): pair quotes in the Script pane as braces and brackets (#731) * feat(ui): pair quotes in the Script pane as braces and brackets * docs(ui): name quotes in the Script pane's pairing comment * refactor(ui): name where a Script quote opens and closes --- crates/ui-egui/src/analysispane.rs | 3 +- crates/ui-egui/src/scriptcomplete.rs | 103 ++++++++++++++++++++++----- docs/user-guide/analysis.md | 6 +- 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 2171e87ad..e20102eed 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -1097,7 +1097,8 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { if let Some((offer, item)) = crate::scriptcomplete::keys(ui, box_id, &app.workflow.script) { crate::scriptcomplete::accept(ui, box_id, &mut app.workflow.script, &offer, &item, &app.session); } - // `{` closing itself (#662): what the box does with the keys, followed through after it. + // Braces, brackets and quotes closing themselves (#662): what the box does with the keys, + // followed through after it. let before = crate::scriptcomplete::before(ui, box_id, &app.workflow.script); let out = egui::ScrollArea::vertical().id_salt("script_box").max_height(room).auto_shrink([false, true]).show(ui, |ui| { egui::TextEdit::multiline(&mut app.workflow.script) diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 76a721c32..8f797336c 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -404,7 +404,7 @@ fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::S return complete(start, at, &word, ids); } if let Some(ValueAt { hint, start, word, key }) = value_at(script, at) { - // Other values come later (#662's third step). + // The values `named` lists; the project's layers and fields come later (#662's third step). let (_, what) = named(&hint.id).filter(|(names, _)| key == *names)?; return match what { Named::Tool => value_ids(start, at, &word, rustgis_engine::analysis::tools().map(|t| t.id), Of::ToolIds), @@ -1138,20 +1138,21 @@ fn in_string(line: &str) -> bool { quoted } -/// What this frame's input did to the box, as `brace` needs it: a brace or bracket typed, +/// What this frame's input did to the box, as `brace` needs it: a brace, bracket or quote typed, /// Backspace, or anything else (an undo or redo, a paste, a cut), which it leaves as it is. #[derive(Clone, Copy, Debug, PartialEq)] enum Keyed { - Brace, + Pair, Backspace, Other, } -/// The closing character of an opening brace or bracket. +/// The closing character of an opening brace, bracket or quote. fn closer(open: char) -> Option { match open { '{' => Some('}'), '[' => Some(']'), + '"' => Some('"'), _ => None, } } @@ -1159,10 +1160,12 @@ fn closer(open: char) -> Option { /// The script once the box's edit is followed through, the edit read from `script` as the box /// left it, what was there `before`, and the cursor `now` (a char index): a `{` or `[` typed /// closes itself (`{|}`, `[|]`) when nothing, a space or a closing `}`, `]` or `,` follows it; a -/// `}` or `]` typed right before the same one steps over it rather than add one; Backspace in an -/// empty `{|}` or `[|]` deletes both. Not in a string or a comment line, and only for the keys that -/// make those edits (`keyed`): an undo can bring a `{` back as typing one would. The cursor stays -/// where the box put it in each case. None to leave the edit as it is. +/// `"` too (`"|"`), when it also comes where a string can start (a line's start, a space, `{`, +/// `[`, `,` or `:`); a `}` or `]` typed right before the same one steps over it rather than add +/// one, and so does a `"` that ends a string; Backspace in an empty `{|}`, `[|]` or `"|"` deletes +/// both. Not in a string or a comment line, and only for the keys that make those edits +/// (`keyed`): an undo can bring a `{` back as typing one would. The cursor stays where the box put +/// it in each case. None to leave the edit as it is. fn brace(script: &str, before: Before, now: usize, keyed: Keyed) -> Option { let Before { at, prev, len } = before; // Outside a comment line and a string, up to char `i`. @@ -1171,16 +1174,24 @@ fn brace(script: &str, before: Before, now: usize, keyed: Keyed) -> Option { + (Some(open @ ('{' | '[')), next) if typed_one && plain(at) && next.is_none_or(closes) => { Some(format!("{b}{open}{}{}", closer(open)?, rest.get(1..)?)) } + (Some('"'), next) if typed_one && plain(at) && next.is_none_or(closes) && prev.is_none_or(opens) => { + Some(format!("{b}\"\"{}", rest.get(1..)?)) + } // The `}` or `]` typed goes: the cursor, after it, is after the one that was there. - (Some(close @ ('}' | ']')), Some(next)) if one_typed && close == next => Some(format!("{b}{}", rest.get(1..)?)), + (Some(close @ ('}' | ']')), Some(next)) if typed_one && plain(at) && close == next => Some(format!("{b}{}", rest.get(1..)?)), + // So does a `"` that ends a string (in one before it, out of it after): not an escaped one. + (Some('"'), Some('"')) if typed_one && !plain(at) && plain(now) => Some(format!("{b}{}", rest.get(1..)?)), // The closer that followed the deleted opener, now at the cursor. (_, Some(next)) if one_deleted && prev.and_then(closer) == Some(next) => Some(format!("{b}{}", rest.get(1..)?)), _ => None, @@ -1189,15 +1200,15 @@ fn brace(script: &str, before: Before, now: usize, keyed: Keyed) -> Option, out: &TextEditOutput, script: &mut String) { let (Some(before), Some(now)) = (before, out.cursor_range.filter(|r| r.is_empty()).map(|r| r.primary.index.0)) else { return }; if !out.response.response.changed() { return; } - // One edit, made by one of those keys: the brace or bracket typed alone (Shift, AltGr or - // Option as it takes), or Backspace alone. + // One edit, made by one of those keys: the brace, bracket or quote typed alone (Shift, AltGr + // or Option as it takes), or Backspace alone. let keyed = ui.input(|i| { let edits: Vec<&egui::Event> = i .events @@ -1213,7 +1224,7 @@ pub fn braces(ui: &Ui, before: Option, out: &TextEditOutput, script: &mu }) .collect(); match edits.as_slice() { - [egui::Event::Text(t)] if matches!(t.as_str(), "{" | "}" | "[" | "]") => Keyed::Brace, + [egui::Event::Text(t)] if matches!(t.as_str(), "{" | "}" | "[" | "]" | "\"") => Keyed::Pair, [egui::Event::Key { key: Key::Backspace, modifiers, .. }] if modifiers.is_none() => Keyed::Backspace, _ => Keyed::Other, } @@ -1686,7 +1697,7 @@ mod tests { let b = super::byte_at(old, at).unwrap(); let script = format!("{}{ins}{}", &old[..b], &old[b..]); let before = super::Before { at, prev: at.checked_sub(1).and_then(|i| super::char_at(old, i)), len: old.len() }; - super::brace(&script, before, at + ins.chars().count(), super::Keyed::Brace) + super::brace(&script, before, at + ins.chars().count(), super::Keyed::Pair) } /// What `brace` makes of Backspace at char `at` of `old`. @@ -1738,6 +1749,33 @@ mod tests { assert_eq!(backspace("x {]", 3), None, "not a pair either"); } + /// `"` closes itself as `{` does, where a string can start: not after a word, a quote or a + /// closing brace; one typed before the quote that ends its string steps over it, not an + /// escaped one; Backspace in an empty `""` deletes both, not after a string's closing quote. + #[test] + fn a_quote_works_as_a_brace() { + assert_eq!(typed("x {}", 3, "\"").as_deref(), Some(r#"x {""}"#), "before a closing brace"); + assert_eq!(typed(r#"x {"a": }"#, 8, "\"").as_deref(), Some(r#"x {"a": ""}"#), "a value"); + assert_eq!(typed(r#"x {"a":}"#, 7, "\"").as_deref(), Some(r#"x {"a":""}"#), "right after a colon"); + assert_eq!(typed("x [1, ]", 6, "\"").as_deref(), Some(r#"x [1, ""]"#), "before a closing bracket"); + assert_eq!(typed("", 0, "\"").as_deref(), Some(r#""""#), "the script's start"); + assert_eq!(typed("x\n", 2, "\"").as_deref(), Some("x\n\"\""), "a line's start"); + assert_eq!(typed("ñ ", 2, "\"").as_deref(), Some(r#"ñ """#), "chars, not bytes"); + assert_eq!(typed("x { : 1}", 4, "\""), None, "before a colon"); + assert_eq!(typed("x ab", 2, "\""), None, "before other text"); + assert_eq!(typed("x ab", 4, "\""), None, "after a word"); + assert_eq!(typed(r#"x """#, 4, "\""), None, "after a quote"); + assert_eq!(typed("x {}", 4, "\""), None, "after a closing brace"); + assert_eq!(typed("# x ", 4, "\""), None, "in a comment line"); + assert_eq!(typed(r#"x {"a"}"#, 5, "\"").as_deref(), Some(r#"x {"a"}"#), "stepped over"); + assert_eq!(typed(r#"x {"a\"}"#, 6, "\""), None, "an escaped quote"); + assert_eq!(typed(r#"x {"ab"#, 5, "\""), None, "in a string, before a letter"); + assert_eq!(typed(r#"# "a""#, 4, "\""), None, "not stepped over in a comment line"); + assert_eq!(backspace(r#"x """#, 3).as_deref(), Some("x ")); + assert_eq!(backspace(r#"x {"a": ""}"#, 9).as_deref(), Some(r#"x {"a": }"#), "an empty value"); + assert_eq!(backspace(r#"x "a"""#, 5), None, "a string's closing quote"); + } + /// `}` typed right before a `}` steps over it; Backspace in an empty `{}` deletes both. #[test] fn a_closing_brace_steps_over_and_backspace_deletes_both() { @@ -1788,6 +1826,37 @@ mod tests { assert_eq!(h.state().workflow.script, r#"layer.displayFilters {"filters": [{}]!"#, "and is stepped over"); } + /// In the pane: a quote closes itself and the lists still open in it; accepting a key takes + /// in its closing quote, accepting a tool keeps it, and a quote typed before it steps over. + #[test] + fn quotes_in_the_box() { + let mut h = pane(); + type_in(&mut h, "map.goToXY "); + for c in ["{", "\"", "l", "a"] { + type_in(&mut h, c); + } + assert_eq!(h.state().workflow.script, r#"map.goToXY {"la"}"#); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, r#"map.goToXY {"lat": }"#, "the key took in its quote"); + let mut h = pane(); + type_in(&mut h, "gp.run "); + for c in ["{", "\"", "tool", "\"", ": ", "\"", "buffe"] { + type_in(&mut h, c); + } + assert_eq!(h.state().workflow.script, r#"gp.run {"tool": "buffe"}"#, "the key's quote stepped over"); + h.key_press(egui::Key::Enter); + h.run(); + type_in(&mut h, "!"); + assert_eq!(h.state().workflow.script, r#"gp.run {"tool": "buffer"!}"#, "the tool kept its quote"); + let mut h = pane(); + type_in(&mut h, r#"labels.style {"layer": "#); + type_in(&mut h, "\""); + h.key_press(egui::Key::Backspace); + h.run(); + assert_eq!(h.state().workflow.script, r#"labels.style {"layer": "#, "an empty pair deleted"); + } + /// A key is being typed in a quoted word that starts an entry of the line's object: right /// after its `{` or a `,`, at the top level (second-level objects: `key_at_finds_a_key_in_a_ /// second_level_object`); not in a value, deeper, or in a comment. diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 9d8a39886..50118534a 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -246,8 +246,10 @@ one closes it. In its `"params": {…}`, the keys offered are those of the tool help; there the help by the cursor is the tool's. A `task.addStep` line works the same way: its `"command": "` offers the commands, and its `"params"` that command's keys and help. A `{` or `[` typed closes itself, the cursor inside, unless text follows it or it's in a string or a comment; -a `}` or `]` typed before the same one steps over it, and Backspace in an empty `{}` or `[]` -deletes both. Pasting adds nothing. +so does a `"` where a string can start (after a space, `{`, `[`, `,` or `:`). A `}` or `]` typed +before the same one steps over it, as does a `"` typed before the quote that ends its string, and +Backspace in an empty `{}`, `[]` or `""` deletes both. The lists open in a quote that closed +itself, and accepting keeps that quote. Pasting adds nothing. ## ModelBuilder From 0154fa2ec99ebff1b6885f9e47a60da04c6ad3d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Fri, 23 Oct 2026 09:28:38 -0500 Subject: [PATCH 059/297] fix(engine): end a parameter string at its first unescaped quote (#736) --- crates/engine/src/lib.rs | 17 +++++++++++++++-- crates/engine/src/tests.rs | 9 +++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cf7b88ce4..3a8b0d9c4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -139,11 +139,24 @@ pub fn param_list(desc: &str) -> Option>> { fn param(part: &str) -> Option> { let text = part.trim(); let rest = text.strip_prefix('"')?; - let (key, after) = rest.split_at(rest.find('"')?); + let (key, after) = rest.split_at(string_end(rest)?); let optional = after.get(1..).is_some_and(|a| a.trim_start().starts_with('?')); Some(Param { key, optional, text }) } +/// Where the string whose text starts `s` ends: the byte index of its closing quote, the first +/// one not after a backslash (`\"`). None when nothing closes it. +fn string_end(s: &str) -> Option { + let mut escaped = false; + for (i, c) in s.char_indices() { + if c == '"' && !escaped { + return Some(i); + } + escaped = c == '\\' && !escaped; + } + None +} + /// Whether a parameter description has a required key. Question marks in values do not count. pub fn needs_input(desc: &str) -> bool { let mut required = false; @@ -169,7 +182,7 @@ fn param_entries<'a>(desc: &'a str, mut visit: impl FnMut(&'a str, bool)) -> Opt while let Some(&c) = bytes.get(i) { match c { b'"' => { - let end = bytes.get(i + 1..).and_then(|r| r.iter().position(|&c| c == b'"')).map_or(bytes.len(), |n| i + 1 + n); + let end = desc.get(i + 1..).and_then(string_end).map_or(bytes.len(), |n| i + 1 + n); let next = bytes.get(end + 1..).and_then(|r| r.iter().find(|c| !c.is_ascii_whitespace())); let key = match at { At::Entry => matches!(next, Some(b'?' | b':' | b',' | b'}' | b'|')), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 075911f2a..3e12d376f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9961,6 +9961,10 @@ fn param_list_splits_a_description_into_entries() { // An escaped quote (a plugin's description may have one) doesn't end a value early. let escaped = param_list(r#"{"title"?: "a \"quoted, word\"", "n": number}"#).unwrap(); assert_eq!(escaped.iter().map(|p| p.key).collect::>(), ["title", "n"]); + // Nor a key early: it's the whole key, as `param_keys` reads it. + let key = param_list(r#"{"a\"b"?: text}"#).unwrap(); + assert_eq!(key.iter().map(|p| (p.key, p.optional)).collect::>(), [(r#"a\"b"#, true)]); + assert_eq!(crate::param_keys(r#"{"a\"b"?: text}"#), Some(vec![r#"a\"b"#])); assert_eq!(param_list("a GeoJSON text"), None); // Every command's description splits into entries, each led by a key `param_keys` finds: one // cut in the wrong place (an unclosed bracket or quote in its prose) would lead with another. @@ -9988,6 +9992,11 @@ fn param_keys_reads_descriptions() { assert_eq!(param_keys(r#"{"path": folder | "url": "https://…", "name"?: text}"#), Some(vec!["path", "url", "name"])); assert_eq!(param_keys(r#"{"lon" | "x": number, "lat" | "y": number}"#), Some(vec!["lon", "x", "lat", "y"])); assert_eq!(param_keys(r##"{"color": "#RRGGBB"|"#RRGGBBAA"}"##), Some(vec!["color"])); + // A quote after a backslash doesn't end a string (a Script line's value may have one); one + // after an escaped backslash does. + assert_eq!(param_keys(r#"{"lon": 2, "note": "a\"b", "lat": 3, ""#), Some(vec!["lon", "note", "lat"])); + assert_eq!(param_keys(r#"{"a": "x\\", "b": 1}"#), Some(vec!["a", "b"])); + assert_eq!(param_keys(r#"{"a": "x\"#), Some(vec!["a"]), "an unclosed string"); assert_eq!(param_keys("a GeoJSON text"), None); // Every command's description reads as keys, and names a key once. for c in &Session::new(Project::default()).registry.commands { From b261c7603f0a1a027891245b3e470d9a0eeb2584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Fri, 23 Oct 2026 09:37:33 -0500 Subject: [PATCH 060/297] fix(labels): let each label class label every feature it matches (#737) * fix(labels): let each label class label every feature it matches * fix(labels): key each placed label by its class for lock and tilted views * refactor(labels): name the label key and simplify its priority * fix(labels): lock the labels again when a layer's label classes change --- crates/engine/src/cmd/items.rs | 4 + crates/engine/src/cmd/labels.rs | 55 +++++--- crates/engine/src/cmd/service_group.rs | 4 +- crates/engine/src/tests.rs | 43 +++++- crates/map/src/lib.rs | 8 +- crates/map/src/symbology.rs | 47 ++++--- crates/render/src/lib.rs | 188 +++++++++++++++---------- crates/render/src/stream_tests.rs | 4 +- crates/ui-egui/src/labelpane.rs | 2 +- docs/user-guide/cartography.md | 23 +-- 10 files changed, 240 insertions(+), 138 deletions(-) diff --git a/crates/engine/src/cmd/items.rs b/crates/engine/src/cmd/items.rs index 957abb1e1..4865117d6 100644 --- a/crates/engine/src/cmd/items.rs +++ b/crates/engine/src/cmd/items.rs @@ -99,6 +99,7 @@ pub fn commands() -> Vec { } l.renderer = st.renderer; let field = l.labels.default.field.clone(); + let relock = !super::labels::same_class_list(&l.labels, &st.labels); l.labels = st.labels; // The label field stays if the style's isn't in this layer. if l.dataset.field_index(&l.labels.default.field).is_none() { @@ -106,6 +107,9 @@ pub fn commands() -> Vec { } l.labels.repoint_missing_fields(&l.dataset); l.transparency = st.transparency; + if relock { + s.map_mut().label_lock = None; + } Ok(Value::Null) }) .when(need_feature_layer) diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index 70b99a727..f0a1cfd0b 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -51,11 +51,11 @@ pub fn commands() -> Vec { .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"}"#, ), - // Label classes: a feature is labelled by the first class whose clause takes it, Default - // last (`Labeling`). + // 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", "Style › Label Class", add_class) .when(need_feature_layer) - .params(r#"{"layer"?, "name": string, "where"?: sql (a definition query's language; none takes every feature that reaches it), "from"?: class (whose field and text symbol it starts with; none: the Default class)}"#), + .params(r#"{"layer"?, "name": string, "where"?: sql (a definition query's language; none takes every feature), "from"?: class (whose field and text symbol it starts with; none: the Default class)}"#), C::new("labels.class.remove", "Remove Label Class", "Style › Label Class", |s, p| { let name = s_req(p, "class")?.trim().to_string(); with_labels(s, p, |labels| { @@ -106,7 +106,7 @@ pub fn commands() -> Vec { .params(r#"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "where"?: sql|null, "visible"?: bool}"#), C::new("labels.class.move", "Move Label Class", "Style › Label Class", |s, p| { let name = s_req(p, "class")?.trim().to_string(); - let to = f(p, "to").filter(|v| v.is_finite()).ok_or("to is required: the class's place, 0 checked first")?; + let to = f(p, "to").filter(|v| v.is_finite()).ok_or("to is required: the class's place in the list, 0 first")?; with_labels(s, p, |labels| { let i = extra_class(labels, &name)?; let c = labels.classes.remove(i); @@ -116,7 +116,7 @@ pub fn commands() -> Vec { }) }) .when(need_feature_layer) - .params(r#"{"layer"?, "class": name (not Default), "to": number (its place among the classes checked before Default, 0 first)}"#), + .params(r#"{"layer"?, "class": name (not Default), "to": number (its place among the classes listed before Default, 0 first: placed first when labels compete for room)}"#), ] } @@ -153,15 +153,26 @@ pub(crate) fn with_labels(s: &mut Session, p: &Value, f: impl FnOnce(&mut Labeli let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; let mut labels = l.labels.clone(); f(&mut labels)?; + let relock = !same_class_list(&l.labels, &labels); l.labels = labels; + if relock { + s.map_mut().label_lock = None; + } Ok(Value::Null) } +/// Whether `a` and `b` list the same classes in the same order. A lock names each label by its +/// class's place in the list (`LabelKey`), so when the list changes the labels are locked again +/// where they are placed next (`Map::label_lock` emptied, #732). +pub(crate) fn same_class_list(a: &Labeling, b: &Labeling) -> bool { + a.classes.iter().map(|c| &c.name).eq(b.classes.iter().map(|c| &c.name)) +} + /// The place of the class named `name` among those checked before Default (of the /// `MAX_LABEL_CLASSES` that count, as `Labeling::class` finds them). fn extra_class(labels: &Labeling, name: &str) -> Result { if labels.default.name.eq_ignore_ascii_case(name) { - return Err(format!("{name} is the Default class: it labels the rest of the features, and stays last")); + return Err(format!("{name} is the Default class: it stays last in the list")); } let mut counted = labels.classes.iter().take(MAX_LABEL_CLASSES); counted.position(|c| c.name.eq_ignore_ascii_case(name)).ok_or_else(|| format!("no label class named '{name}'")) @@ -270,10 +281,10 @@ fn distance_range(s: &mut Session, p: &Value) -> CmdResult { } /// 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. Each feature takes the text of -/// the class labelling it (a hidden class's take none); with classes besides Default, a CLASS -/// field keeps which, and the annotation has the same classes, picking their points by it. The -/// layer's own labels turn off. +/// carrying the label text and drawn with the same text symbol. Each class labelling a feature +/// gives it a point (a hidden class none); with classes besides Default, a CLASS field keeps +/// which, and the annotation has the same classes, each picking its points by it. 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")?; @@ -297,18 +308,18 @@ fn convert(s: &mut Session, p: &Value) -> CmdResult { if rows.get(row) != Some(&true) { continue; } - let Some((i, class)) = matcher.class_of(&l.dataset, row).filter(|(_, c)| c.visible) else { continue }; - let Some(fi) = fields.get(i).copied().flatten() else { continue }; - let v = l.dataset.value(row, fi); - if v.is_null() { - continue; - } - if let Some(c) = ft.geometry.as_ref().and_then(Geometry::label_point) { + let Some(at) = ft.geometry.as_ref().and_then(Geometry::label_point) else { continue }; + for (i, class) in matcher.classes_of(&l.dataset, row).filter(|(_, c)| c.visible) { + let Some(fi) = fields.get(i).copied().flatten() else { continue }; + let v = l.dataset.value(row, fi); + if v.is_null() { + continue; + } let mut attrs = vec![Attr::Text(v.to_string())]; if classes { attrs.push(Attr::Text(class.name.clone())); } - ds.push(Some(Geometry::Point(c)), attrs); + ds.push(Some(Geometry::Point(at)), attrs); } } if ds.features.is_empty() { @@ -322,12 +333,10 @@ fn convert(s: &mut Session, p: &Value) -> CmdResult { for c in std::iter::once(&mut labels.default).chain(labels.classes.iter_mut()) { c.field = "TEXT".into(); c.placement = LabelPlacement::Center; + // 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('\'', "''"))); } - // Each class picks the points it labelled; Default, checked last, takes the rest. - for c in &mut labels.classes { - c.where_ = Some(format!("\"CLASS\" = '{}'", c.name.replace('\'', "''"))); - } - labels.default.where_ = None; 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/service_group.rs b/crates/engine/src/cmd/service_group.rs index 034d83e60..7f72f0b45 100644 --- a/crates/engine/src/cmd/service_group.rs +++ b/crates/engine/src/cmd/service_group.rs @@ -295,7 +295,7 @@ fn refresh(s: &mut Session, p: &Value) -> CmdResult { child.layer = layer.id; // Locked labels name rows of the old features. if let Some(lock) = &mut s.map_mut().label_lock { - lock.retain(|(id, _)| *id != child.layer); + lock.retain(|(id, _, _)| *id != child.layer); } } else { child.layer = crate::cmd::layer::add_dataset(s, dataset); @@ -310,7 +310,7 @@ fn refresh(s: &mut Session, p: &Value) -> CmdResult { let gone: Vec = old.children.iter().map(|c| c.layer).filter(|id| !ids.contains(id)).collect(); crate::cmd::layer::drop_layers(s, &gone); if let Some(lock) = &mut s.map_mut().label_lock { - lock.retain(|(id, _)| !gone.contains(id)); + lock.retain(|(id, _, _)| !gone.contains(id)); } } let map = s.map_mut(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3e12d376f..cb85a4294 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1542,6 +1542,38 @@ fn label_range_placement_lock_and_convert() { assert!(!s.map().layer_by_name("Countries").unwrap().labels.enabled); } +/// 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). +#[test] +fn a_change_to_the_label_classes_locks_the_labels_again() { + use crate::render::View; + let mut s = world(); + s.run("labels.enable", &json!({"layer": "Cities", "on": true})).unwrap(); + s.run("labels.lock", &json!({"on": true})).unwrap(); + let draw = |s: &mut Session| { + let v = 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(); + }; + draw(&mut s); + assert!(s.map().label_lock.is_some()); + s.run("labels.style", &json!({"layer": "Cities", "size": 14})).unwrap(); + s.run("labels.class.update", &json!({"layer": "Cities", "visible": true})).unwrap(); + assert!(s.map().label_lock.is_some(), "a change within a class keeps the lock"); + for (id, p) in [ + ("labels.class.add", json!({"layer": "Cities", "name": "Big"})), + ("labels.class.add", json!({"layer": "Cities", "name": "Small"})), + ("labels.class.move", json!({"layer": "Cities", "class": "Small", "to": 0})), + ("labels.class.remove", json!({"layer": "Cities", "class": "Big"})), + ("labels.class.clear", json!({"layer": "Cities"})), + ] { + s.run(id, &p).unwrap(); + assert!(s.map().label_lock.is_none(), "{id} moves the classes: locked again"); + draw(&mut s); + assert!(s.map().label_lock.is_some(), "{id}: locked on the next frame"); + } +} + /// Label classes by command (#618): added from Default and checked before it, renamed, given /// a clause, moved, styled and removed; refusals change nothing, and Default stays last. #[test] @@ -1646,9 +1678,12 @@ fn label_classes_convert_and_outlive_a_deleted_field() { let a = s.map().layer(u32::try_from(r["layer"].as_u64().unwrap()).unwrap()).unwrap(); let classes: Vec<_> = a.labels.in_order().map(|c| (c.name.clone(), c.where_.clone(), c.field.clone(), c.size)).collect(); assert_eq!(classes[0], ("Big's".to_string(), Some(r#""CLASS" = 'Big''s'"#.to_string()), "TEXT".to_string(), 18.0)); - assert_eq!((classes[1].1.as_ref(), classes[1].2.as_str()), (None, "TEXT"), "Default takes the rest"); - let picked = rustgis_data::query::select(classes[0].1.as_deref().unwrap(), &a.dataset).unwrap().len(); - assert_eq!(picked, big, "the class's clause picks the points it labelled"); + assert_eq!((classes[1].1.as_deref(), classes[1].2.as_str()), (Some(r#""CLASS" = 'Default'"#), "TEXT"), "Default picks its own"); + let picked = |w: &Option| rustgis_data::query::select(w.as_deref().unwrap(), &a.dataset).unwrap().len(); + assert_eq!(picked(&classes[0].1), big, "the class's clause picks the points it labelled"); + // Default labels the big cities too (#732): each class gives them a point. + assert_eq!(picked(&classes[1].1), a.dataset.features.len() - big); + assert!(picked(&classes[1].1) > big, "Default labels every city, the big ones among them"); // A deleted field: the class labels with Default's field, and is kept. s.run("layer.select", &json!({"layer": cities})).unwrap(); s.run("labels.field", &json!({"class": "Big's", "field": "pop_max"})).unwrap(); @@ -12048,7 +12083,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), (ids[1], 0)]); + s.map_mut().label_lock = Some(vec![(ids[0], 0, 0), (ids[1], 0, 0)]); // A rotated key changes every source address but not the collections: the children stay. let mut q = doc("rotated-key"); diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index 87106faab..4f56b404a 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -29,7 +29,7 @@ pub use color::{Ramp, RampGroup, Rgba}; pub use globe::{Globe, Projection}; pub use raster::{RasterDefaults, RasterLayer, RasterRenderer, RasterStyle, Resampling, Stretch}; pub use symbology::{ - ClassMethod, LabelClass, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineStroke, Marker, Renderer, Symbol, + ClassMethod, LabelClass, LabelKey, LabelMatcher, LabelPlacement, Labeling, LineCap, LineJoin, LineStroke, Marker, Renderer, Symbol, }; pub use tiles::{TileKind, TileLayer}; @@ -790,10 +790,10 @@ pub struct Map { /// Map-wide label display switches. #[serde(default)] pub label_display: LabelDisplay, - /// The features labelled when labels were locked, as (layer id, row): only they are labelled - /// while the lock holds, wherever the map moves. Filled by the renderer. + /// The labels placed when labels were locked: only they are drawn while the lock holds, + /// wherever the map moves. Filled by the renderer. #[serde(skip)] - pub label_lock: Option>, + pub label_lock: Option>, /// Relationships, snapping, topology rules and edit settings. #[serde(default)] pub editing: design::MapEditing, diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index bc8b319c7..13f6cdd47 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1055,9 +1055,10 @@ pub fn label_class_name(name: &str) -> String { name.trim_end().to_string() } -/// Label settings for a layer: on or off, and its label classes (#618). A feature is labelled by -/// the first of `classes` whose SQL clause it matches, else by `default`, which is checked last -/// (the rest of the features). Saved as before: the Default class's settings sit beside `enabled` +/// Label settings for a layer: on or off, and its label classes (#618). As in ArcGIS Pro, each +/// class labels every feature its SQL clause matches, so a feature two classes match gets a label +/// from each (#732); `classes` come first and `default` last, the order their labels are placed +/// in when they compete for room. Saved as before: the Default class's settings sit beside `enabled` /// and the other classes are listed only when there are any, so a project without them keeps /// its old form. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -1112,12 +1113,11 @@ impl From for Labeling { pub struct LabelClass { #[serde(default = "default_class_name", skip_serializing_if = "is_default_class_name")] pub name: String, - /// A hidden class draws none of its features (they don't go on to the next class). + /// A hidden class labels none of its features (the other classes still label theirs). #[serde(default = "yes", skip_serializing_if = "is_yes")] pub visible: bool, /// The SQL `WHERE` clause choosing its features (the definition query's language); none, or - /// blank, takes every feature that reaches it. One that no longer reads (a field since - /// removed) takes none, and they go on to the next class. + /// blank, takes every feature. One that no longer reads (a field since removed) takes none. #[serde(default, rename = "where", skip_serializing_if = "Option::is_none")] pub where_: Option, pub field: String, @@ -1285,8 +1285,12 @@ impl LabelClass { } } -/// A layer's label classes read against its data, in the order a feature is matched: the class -/// labelling a row is the first whose clause takes it. +/// A label on the map, as (layer id, row, label class in `Labeling::in_order`): a feature has a +/// label for each class labelling it (#732). +pub type LabelKey = (u32, usize, usize); + +/// A layer's label classes read against its data, in `Labeling::in_order`: every class whose +/// clause takes a row labels it. pub struct LabelMatcher<'a> { classes: Vec<(&'a LabelClass, Option>)>, } @@ -1296,10 +1300,9 @@ impl<'a> LabelMatcher<'a> { LabelMatcher { classes: labels.in_order().map(|c| (c, c.query(ds))).collect() } } - /// The class `row` belongs to, and its place in `Labeling::in_order`; none when no class - /// takes it. - pub fn class_of(&self, ds: &Dataset, row: usize) -> Option<(usize, &'a LabelClass)> { - self.classes.iter().enumerate().find_map(|(i, (c, q))| match q { + /// The classes whose clause takes `row`, each with its place in `Labeling::in_order`. + pub fn classes_of(&self, ds: &Dataset, row: usize) -> impl Iterator { + self.classes.iter().enumerate().filter_map(move |(i, (c, q))| match q { Some(None) => Some((i, *c)), Some(Some(e)) if rustgis_data::query::eval(e, ds, row) => Some((i, *c)), _ => None, @@ -1444,7 +1447,7 @@ mod tests { } #[test] - fn a_feature_takes_the_first_class_that_matches_and_default_last() { + fn a_feature_takes_every_class_that_matches() { let mut ds = Dataset::new("t", GeometryType::Point, vec![Field::new("pop", FieldType::Integer)]); for pop in [5, 50, 500] { ds.push(None, vec![Value::Int(pop)]); @@ -1452,17 +1455,19 @@ mod tests { let mut l = Labeling::default_for(&ds); let class = |name: &str, w: &str| LabelClass { name: name.into(), where_: Some(w.into()), ..LabelClass::default_for(&ds) }; l.classes = vec![class("Big", "pop > 100"), class("Medium", "pop > 10"), class("Broken", "nope = 1")]; - let m = LabelMatcher::new(&l, &ds); - let names: Vec<_> = (0..3).map(|r| m.class_of(&ds, r).map(|(i, c)| (i, c.name.clone()))).collect(); + let names = |l: &Labeling| -> Vec> { + let m = LabelMatcher::new(l, &ds); + (0..3).map(|r| m.classes_of(&ds, r).map(|(i, c)| (i, c.name.clone())).collect()).collect() + }; + let (big, medium, default) = ((0, "Big".to_string()), (1, "Medium".to_string()), (3, "Default".to_string())); assert_eq!( - names, - [Some((3, "Default".to_string())), Some((1, "Medium".to_string())), Some((0, "Big".to_string()))], - "500 is Big before it is Medium; a clause that doesn't read takes nothing" + names(&l), + [vec![default.clone()], vec![medium.clone(), default.clone()], vec![big.clone(), medium.clone(), default]], + "500 is Big, Medium and Default; a clause that doesn't read takes nothing" ); - // A Default class with a clause leaves the rest unlabelled. + // A Default class with a clause labels only what it matches. l.default.where_ = Some("pop = 50".into()); - let m = LabelMatcher::new(&l, &ds); - assert!(m.class_of(&ds, 0).is_none()); + assert_eq!(names(&l), [vec![], vec![medium.clone(), (3, "Default".to_string())], vec![big, medium]]); assert_eq!(l.class("medium").map(|c| c.name.as_str()), Some("Medium"), "names match ASCII case aside"); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8b1f778eb..08a1681f4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1353,8 +1353,8 @@ fn visit_complete( Ok(result) } -/// A label waiting to be placed: the label, its priority and its (layer id, row). -type Candidate = (PlacedLabel, f64, (u32, usize)); +/// A label waiting to be placed: the label, its priority and its key. +type Candidate = (PlacedLabel, f64, rustgis_map::LabelKey); /// Keep exactly the candidates the upstream placement limit can consider, with stable ties. fn compact_label_candidates(candidates: &mut Vec) { @@ -1791,7 +1791,7 @@ fn raise_labels(map: &Map, view: &View, candidates: &mut [Candidate]) { let flat = map.flat(); let cam = view.camera; let (w, h) = (view.width, view.height); - for (l, _, (id, row)) in candidates.iter_mut() { + for (l, _, (id, row, _)) in candidates.iter_mut() { let Some(layer) = map.layer(*id).filter(|l| l.geometry_type() == GeometryType::Polygon) else { continue }; let Some(x) = &layer.extrusion else { continue }; let Some(m) = cam.try_to_map(f64::from(l.x), f64::from(l.y), w, h) else { continue }; @@ -2540,7 +2540,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac if !display.locked { map.label_lock = None; } - 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()); let distance = viewing_distance(map.ground_res_at(&view.camera), view.height); // Graphics text: always drawn, and other labels keep clear of it. let mut fixed_labels: Vec = Vec::new(); @@ -2893,8 +2893,8 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } } } - // Each feature is labelled by the first label class whose clause takes it, Default last; - // that class draws it when shown, in range and on a field the layer has (#618). + // Each label class labels every feature its clause takes, as in ArcGIS Pro (#732), when + // shown, in range and on a field the layer has (#618). let label_fields: Vec> = if labels.enabled && !display.paused { labels.in_order().map(|c| ds_ptr.field_index(&c.field).filter(|_| c.visible && c.in_range(scale, distance))).collect() } else { @@ -2905,7 +2905,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let lid = layer.id; for (row, g) in geoms.iter().enumerate() { let Some(g) = g else { continue }; - if rows.get(row) != Some(&true) || lock.as_ref().is_some_and(|k| !k.contains(&(lid, label_row(row)))) { + if rows.get(row) != Some(&true) { continue; } let Some(c) = g.label_point() else { continue }; @@ -2913,24 +2913,10 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac continue; } let Some((x, y)) = ctx.screen(c) else { continue }; - // The clauses are read for the features in view only. - let Some((ci, class)) = classes.class_of(ds_ptr, row) else { continue }; - let Some(fi) = label_fields.get(ci).copied().flatten() else { continue }; - let v = ds_ptr.value(row, fi); - if v.is_null() { - continue; - } - let text = v.to_string(); let is_point = g.kind() == GeometryType::Point; let marker = if is_point { layer.symbol(row).size } else { 0.0 }; - let (dx, dy) = class.placement.offset( - text::block_width(&text, class.size, class.bold), - text::block_height(&text, class.size), - marker, - is_point, - ); // Bigger polygons get priority. - let pri = match g { + let area = match g { Geometry::Polygon(_) => { let e = g.extent(); // (An extent of no number counts as none.) @@ -2938,28 +2924,51 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac if a.is_finite() { a.clamp(0.0, 1e8) } else { 0.0 } } _ => 0.0, - } + (n_layers - li) as f64 * 1e9; - label_candidates.push(( - PlacedLabel { - text, - x: x as f32 + dx, - y: y as f32 + dy, - size: class.size, - color: class.color, - halo: class.halo.then_some(halo_for(class.color)), - bold: class.bold, - icon: None, - align: 0.0, - framed: false, - italic: false, - spacing: 0.0, - line_align: TextAlign::Left, - angle: None, - collide: Default::default(), - }, - pri, - (lid, label_row(row)), - )); + }; + // The clauses are read for the features in view only. + for (ci, class) in classes.classes_of(ds_ptr, row) { + // Locked, only the labels placed then: one class's, not every class's. + if lock.as_ref().is_some_and(|k| !k.contains(&(lid, label_row(row), ci))) { + continue; + } + let Some(fi) = label_fields.get(ci).copied().flatten() else { continue }; + let v = ds_ptr.value(row, fi); + if v.is_null() { + continue; + } + let text = v.to_string(); + let (dx, dy) = class.placement.offset( + text::block_width(&text, class.size, class.bold), + text::block_height(&text, class.size), + marker, + is_point, + ); + // 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. + let pri = (n_layers - li) as f64 * 1e12 - ci as f64 * 1e9 + area; + label_candidates.push(( + PlacedLabel { + text, + x: x as f32 + dx, + y: y as f32 + dy, + size: class.size, + color: class.color, + halo: class.halo.then_some(halo_for(class.color)), + bold: class.bold, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + }, + pri, + (lid, label_row(row), ci), + )); + } } } if complete_source && matches!(labels_to, Labels::Own | Labels::Turned(_)) { @@ -3026,7 +3035,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // Tile labels, after every layer's own. if lock.is_none() { for (i, (l, pri)) in tile_out.labels.into_iter().enumerate() { - label_candidates.push((l, pri - 1e13, (u32::MAX, i))); + label_candidates.push((l, pri - 1e13, (u32::MAX, i, 0))); } } if let Labels::Tilted(to, out) = &mut labels_to { @@ -3040,7 +3049,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let (sx, sy) = to.camera.try_to_screen(m, tw, th).filter(|_| near)?; (sy as f32 > sky_y).then_some((sx as f32, sy as f32)) }; - let fixed = fixed_labels.into_iter().map(|l| (l, f64::MAX, (u32::MAX, usize::MAX))); + let fixed = fixed_labels.into_iter().map(|l| (l, f64::MAX, (u32::MAX, usize::MAX, 0))); for (mut l, pri, key) in fixed.chain(label_candidates) { turn_with(&mut l, &at); let Some((x, y)) = at(l.x, l.y) else { continue }; @@ -3623,8 +3632,8 @@ fn halo_for(text: Rgba) -> Rgba { struct Placement { labels: Vec, unplaced: Vec, - /// (layer id, row) of each placed label. - keys: Vec<(u32, usize)>, + /// The key of each placed label. + keys: Vec, } /// Greedy collision-free placement, highest priority first. Locked labels are all placed (they @@ -3883,9 +3892,9 @@ mod tests { }; // Two icons on the same spot collide even with no text; one far away doesn't. let cands = vec![ - (label("", 50.0, Some(icon(50.0)), 0.0), 2.0, (u32::MAX, 0)), - (label("", 52.0, Some(icon(52.0)), 0.0), 1.0, (u32::MAX, 1)), - (label("", 150.0, Some(icon(150.0)), 0.0), 0.0, (u32::MAX, 2)), + (label("", 50.0, Some(icon(50.0)), 0.0), 2.0, (u32::MAX, 0, 0)), + (label("", 52.0, Some(icon(52.0)), 0.0), 1.0, (u32::MAX, 1, 0)), + (label("", 150.0, Some(icon(150.0)), 0.0), 0.0, (u32::MAX, 2, 0)), ]; let placed = place_labels(cands, Vec::new(), 200.0, 40.0, false); assert_eq!(placed.labels.iter().map(|l| l.x).collect::>(), vec![50.0, 150.0]); @@ -3916,12 +3925,13 @@ mod tests { let a = PlacedLabel { y: 20.0, ..label("Upper", 100.0, None, 0.0) }; let gap = text::block_height("Upper", 10.0) + 1.0; let b = PlacedLabel { y: 20.0 + gap, ..label("Lower", 100.0, None, 0.0) }; - let placed = place_labels(vec![(a.clone(), 2.0, (u32::MAX, 0)), (b.clone(), 1.0, (u32::MAX, 1))], Vec::new(), 200.0, 80.0, false); + let placed = + place_labels(vec![(a.clone(), 2.0, (u32::MAX, 0, 0)), (b.clone(), 1.0, (u32::MAX, 1, 0))], Vec::new(), 200.0, 80.0, false); assert_eq!(texts(&placed), ["Upper"]); // Without padding they fit. let none = rustgis_tiles::Collide { text_padding: 0.0, ..Default::default() }; let (a, b) = (PlacedLabel { collide: none, ..a }, PlacedLabel { collide: none, ..b }); - let placed = place_labels(vec![(a, 2.0, (u32::MAX, 0)), (b, 1.0, (u32::MAX, 1))], Vec::new(), 200.0, 80.0, false); + let placed = place_labels(vec![(a, 2.0, (u32::MAX, 0, 0)), (b, 1.0, (u32::MAX, 1, 0))], Vec::new(), 200.0, 80.0, false); assert_eq!(texts(&placed), ["Upper", "Lower"]); } @@ -3929,10 +3939,11 @@ mod tests { fn optional_text_and_icons_drop_alone() { use rustgis_tiles::Collide; // A word in the way of a station's name, not of its icon (to the left of it). - let blocker = || (label("Blocker", 100.0, None, 0.0), 9.0, (u32::MAX, 0)); + let blocker = || (label("Blocker", 100.0, None, 0.0), 9.0, (u32::MAX, 0, 0)); let station = |collide: Collide| PlacedLabel { collide, ..label("Station", 100.0, Some(icon_at(40.0, 20.0, 10.0, 10.0, None)), 0.0) }; - let run = |collide: Collide| place_labels(vec![blocker(), (station(collide), 1.0, (u32::MAX, 1))], Vec::new(), 200.0, 40.0, false); + let run = + |collide: Collide| place_labels(vec![blocker(), (station(collide), 1.0, (u32::MAX, 1, 0))], Vec::new(), 200.0, 40.0, false); // Neither optional: the station isn't shown at all. let p = run(Collide::default()); assert_eq!(p.labels.len(), 1); @@ -3942,8 +3953,8 @@ mod tests { assert!(p.labels[1].text.is_empty() && p.labels[1].icon.is_some()); // Its icon optional, and the icon in the way instead: the text shows on its own. let icon_blocked = vec![ - (label("", 40.0, Some(icon_at(40.0, 20.0, 10.0, 10.0, None)), 0.0), 9.0, (u32::MAX, 0)), - (station(Collide { icon_optional: true, ..Default::default() }), 1.0, (u32::MAX, 1)), + (label("", 40.0, Some(icon_at(40.0, 20.0, 10.0, 10.0, None)), 0.0), 9.0, (u32::MAX, 0, 0)), + (station(Collide { icon_optional: true, ..Default::default() }), 1.0, (u32::MAX, 1, 0)), ]; let p = place_labels(icon_blocked, Vec::new(), 200.0, 40.0, false); assert_eq!(p.labels.len(), 2); @@ -3956,7 +3967,7 @@ mod tests { let ghost = PlacedLabel { collide: Collide { text_ignore_placement: true, ..Default::default() }, ..label("Ghost", 100.0, None, 0.0) }; let p = place_labels( - vec![(ghost, 9.0, (u32::MAX, 0)), (label("Real", 100.0, None, 0.0), 1.0, (u32::MAX, 1))], + vec![(ghost, 9.0, (u32::MAX, 0, 0)), (label("Real", 100.0, None, 0.0), 1.0, (u32::MAX, 1, 0))], Vec::new(), 200.0, 40.0, @@ -3969,7 +3980,7 @@ mod tests { fn labels_reaching_past_the_edge_are_placed() { let half_out = label("Edge of the map", 0.0, None, 0.0); let gone = label("Gone", -500.0, None, 0.0); - let p = place_labels(vec![(half_out, 1.0, (u32::MAX, 0)), (gone, 0.0, (u32::MAX, 1))], Vec::new(), 200.0, 40.0, false); + let p = place_labels(vec![(half_out, 1.0, (u32::MAX, 0, 0)), (gone, 0.0, (u32::MAX, 1, 0))], Vec::new(), 200.0, 40.0, false); assert_eq!(texts(&p), ["Edge of the map"]); assert!(p.unplaced.is_empty(), "a label wholly off the view isn't unplaced either"); } @@ -3983,7 +3994,7 @@ mod tests { let corner = PlacedLabel { x: 100.0 + w * 0.3, y: 100.0 - w * 0.3, ..label("Hi", 0.0, None, 0.0) }; let across = PlacedLabel { x: 100.0 + w * 0.3, y: 100.0 + w * 0.3, ..label("Hi", 0.0, None, 0.0) }; let p = place_labels( - vec![(turned, 3.0, (u32::MAX, 0)), (corner, 2.0, (u32::MAX, 1)), (across, 1.0, (u32::MAX, 2))], + vec![(turned, 3.0, (u32::MAX, 0, 0)), (corner, 2.0, (u32::MAX, 1, 0)), (across, 1.0, (u32::MAX, 2, 0))], Vec::new(), 400.0, 400.0, @@ -3993,6 +4004,20 @@ mod tests { assert!(p.labels[1].y < 100.0); } + /// In a tilted view, a candidate the bands repeat (the same layer, row and class) is placed + /// once, but two classes' labels of one feature are two labels (#732). + #[test] + fn tilted_views_keep_each_class_label() { + let map = Map::new("Map"); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, 0.0)), res: 5000.0, bearing: 0.0, pitch: 30.0 }; + let view = View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera, sky: false }; + let (a, b) = (label("Capitals", 100.0, None, 0.0), label("Default", 300.0, None, 0.0)); + let texts = + |c: Vec| -> Vec { place_tilted_labels(&map, &view, c, None).labels.into_iter().map(|l| l.text).collect() }; + assert_eq!(texts(vec![(a.clone(), 2.0, (1, 0, 0)), (b.clone(), 1.0, (1, 0, 1))]), ["Capitals", "Default"]); + assert_eq!(texts(vec![(a, 2.0, (1, 0, 0)), (b, 1.0, (1, 0, 0))]), ["Capitals"], "a band's repeat"); + } + #[test] fn places_many_labels_quickly() { // A grid of 20,000 candidates, a third of them fitting: placed without testing each @@ -4000,7 +4025,7 @@ mod tests { let cands: Vec = (0..20_000) .map(|i| { let (x, y) = ((i % 200) as f32 * 10.0, (i / 200) as f32 * 10.0); - (PlacedLabel { x, y, ..label("ab", x, None, 0.0) }, -f64::from(i), (u32::MAX, i as usize)) + (PlacedLabel { x, y, ..label("ab", x, None, 0.0) }, -f64::from(i), (u32::MAX, i as usize, 0)) }) .collect(); let start = web_time::Instant::now(); @@ -4235,9 +4260,9 @@ mod tests { assert!(starfield(1e9).is_some_and(|t| t.width() == 2048)); } - /// Each feature is labelled by the first label class whose clause takes it, Default last; a - /// hidden class keeps its features unlabelled, and a clause that no longer reads takes none - /// (#618). + /// 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). #[test] fn label_classes_label_their_features() { let mut m = Map::new("Map"); @@ -4258,17 +4283,38 @@ mod tests { let view = View { width: 800.0, height: 600.0, pixels_per_point: 1.0, camera, sky: false }; let sizes = |m: &mut Map| { let mut v: Vec<(String, f32)> = render(m, &view).unwrap().labels.into_iter().map(|l| (l.text, l.size)).collect(); - v.sort_by(|a, b| a.0.cmp(&b.0)); + v.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.total_cmp(&b.1))); v }; let default = m.layer(id).unwrap().labels.default.size; - assert_eq!(sizes(&mut m), [("Lyon".to_string(), default), ("Paris".to_string(), 20.0)], "Paris is a capital, Lyon the rest"); - // Hidden, the class still has Paris: it goes unlabelled, not to Default. + assert!(default < 20.0, "so Paris's labels sort Default first"); + let both = [("Lyon".to_string(), default), ("Paris".to_string(), default)]; + // Default takes Paris too, but its label sits where Capitals' does: Capitals, listed + // first, is placed and Default's collides. + assert_eq!(sizes(&mut m), [("Lyon".to_string(), default), ("Paris".to_string(), 20.0)], "Paris is a capital"); + // Default below, Capitals above: both of Paris's labels fit. + m.layer_mut(id).unwrap().labels.default.placement = rustgis_map::LabelPlacement::Below; + m.layer_mut(id).unwrap().labels.classes[0].placement = rustgis_map::LabelPlacement::Above; + assert_eq!( + sizes(&mut m), + [("Lyon".to_string(), default), ("Paris".to_string(), default), ("Paris".to_string(), 20.0)], + "a feature two classes take gets a label from each" + ); + // Locked with one of Paris's labels placed, it keeps that one only. + m.layer_mut(id).unwrap().labels.default.placement = rustgis_map::LabelPlacement::Above; + m.label_display.locked = true; + let placed = sizes(&mut m); + assert_eq!(m.label_lock.as_ref().map(Vec::len), Some(2)); + m.layer_mut(id).unwrap().labels.default.placement = rustgis_map::LabelPlacement::Below; + assert_eq!(sizes(&mut m), placed, "Default's label of Paris wasn't placed when locked"); + m.label_display.locked = false; + m.label_lock = None; + // Hidden, the class labels nothing; Default still labels Paris. m.layer_mut(id).unwrap().labels.classes[0].visible = false; - assert_eq!(sizes(&mut m), [("Lyon".to_string(), default)]); - // A clause that doesn't read takes no feature: both go to Default. + assert_eq!(sizes(&mut m), both); + // A clause that doesn't read takes no feature. capitals(m.layer_mut(id).unwrap(), "gone = 1"); - assert_eq!(sizes(&mut m), [("Lyon".to_string(), default), ("Paris".to_string(), default)]); + assert_eq!(sizes(&mut m), both); } #[test] @@ -4357,7 +4403,7 @@ mod tests { let one_line = 20.0 * 1.25; let (old_bottom, gap) = (150.0 + one_line, 2.0); let below = PlacedLabel { y: old_bottom + gap + one_line / 2.0, ..at("Below", false) }; - let p = place_labels(vec![(below, 0.0, (1, 0))], vec![at("Atlantic\nOcean", false)], 400.0, 300.0, false); + let p = place_labels(vec![(below, 0.0, (1, 0, 0))], vec![at("Atlantic\nOcean", false)], 400.0, 300.0, false); assert!(p.labels.iter().all(|l| l.text != "Below"), "placed over the second line"); // The selection frame goes round both lines. let mut pm = Pixmap::new(400, 300).unwrap(); diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index bb34a7495..bebda5a69 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -551,7 +551,7 @@ fn chunked_label_compaction_keeps_late_priorities_stable_ties_and_nan_order() { collide: Default::default(), }, if i % 17 == 0 { f64::NAN } else { (i % 7) as f64 }, - (1, i), + (1, i, 0), ) }) .collect(); @@ -959,7 +959,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)]); + let lock = Some(vec![(ground, 0, 0)]); 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/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 1735ee942..da5a9f54d 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -129,7 +129,7 @@ fn sql(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { if !labels.classes.is_empty() { let n = labels.classes.len().min(MAX_LABEL_CLASSES).to_string(); let said = crate::i18n::fmt( - "Other label classes on this layer (made with commands): {n}. They are checked first; this clause is for the features they leave.", + "Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one.", &[("n", &n)], ); ui.label(RichText::new(said).small().color(t.text_dim)); diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 56b71fc2e..648a61f66 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -192,19 +192,22 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: text in a `TEXT` field, which you can then edit, move or delete like any other features. - **Label classes** label different features differently, such as capitals larger than other cities. Each class has a SQL clause (the language of definition queries) choosing its - features, and its own field, text symbol, placement and visibility range. A feature is - labelled by the first class whose clause it matches; the **Default** class, checked last, - labels the rest (or, given a clause of its own, those of the rest it matches). A class that - is hidden, or out of its visibility range, leaves its features unlabelled: they don't go on - to the next class. For now, classes are made with commands: `labels.class.add={"layer": - "Cities", "name": "Capitals", "where": "pop_max > 5000000"}`, then `labels.class.rename`, - `labels.class.update` (its `where` or `visible`), `labels.class.move`, `labels.class.remove` + features, and its own field, text symbol, placement and visibility range. As in ArcGIS Pro, + each class labels every feature its clause matches, the **Default** class too (with no + clause, every feature), so a feature two classes match gets a label from each. To label the + capitals only once, give the classes clauses that don't overlap: `pop_max > 5000000` for + Capitals and `pop_max <= 5000000` for Default. When labels compete for room, the class + listed first is placed first, Default last. A class that is hidden, or out of its + visibility range, labels nothing; the other classes still label their features. For now, + classes are made with commands: `labels.class.add={"layer": "Cities", "name": "Capitals", + "where": "pop_max > 5000000"}`, then `labels.class.rename`, `labels.class.update` (its + `where` or `visible`), `labels.class.move` (its place in the list), `labels.class.remove` and `labels.class.clear` (every class but Default; no error when there are none, so a script can start with it). The other label commands take a `class` (`labels.style={"layer": "Cities", "class": "Capitals", "size": 14}`), and change Default without one (a renamed - Default is named by its new name, or left out). Convert keeps the classes, with a `CLASS` - field saying which labelled each point; a web style (Share › Web Style) labels with Default - only, and has no labels when Default is hidden. + Default is named by its new name, or left out). Convert keeps the classes, with a point for + each label and a `CLASS` field saying which class it is; a web style (Share › Web Style) + labels with Default only, and has no labels when Default is hidden.
![Europe with the Countries and Cities labelled; the city names sit above and to the right of their points](https://assets.rustgis.app/images/labels.webp) From 575b21fcdf54f8907aa869f3bba5d2acc1fe08b7 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Fri, 23 Oct 2026 11:48:04 -0300 Subject: [PATCH 061/297] Layouts: a publication layout's locator, a globe or an inset with the area outlined (#682, part 2) (#706) * Layouts: a publication layout's locator, a globe or an inset with the area outlined (#682, part 2) layout.publication takes "locator": "globe" (a sheet's default: a new map of the map's polygon layers on the globe, unlabelled, in a round frame at the map's top left, sized from the renderer's own globe disc), "inset" (the surrounding region on the map itself) or "none" (a figure's default). The map frame's extent indicator outlines the area on either; the legend stops short of it; undo takes the globe's map away with the layout. Co-Authored-By: Claude Opus 5.5 * Layouts: the globe locator is a new map of what it draws; no legend forced over it The globe's map was a clone of the whole map with its non-polygon layers dropped, so it copied every layer's features first and kept the map's web tiles, swipe, time slider and groups: a map on a web basemap drew its tiles on the disc. It is now a new map with the basemap (the land and borders around the area), graticule and body, and only the polygon layers that show, unlabelled. The legend's corner was given at least 10 mm, so a sheet whose frame was near its smallest drew the legend over the locator (or the scale bar). A corner too small now leaves the legend out, with a warning. Co-Authored-By: Claude Opus 5.5 * Layouts: a narrow figure keeps its legend (pinned by a test); the globe's streamed layers left out, said Co-Authored-By: Claude Opus 5.5 * Layouts: a globe locator no layout shows any more is replaced by the next, not left behind Co-Authored-By: Claude Opus 5.5 * print-layout skill: the locator's default by profile (globe on a sheet, none on a figure) Co-Authored-By: Claude Opus 5.5 * Layouts: only a locator takes the legend's room; a replaced locator map is said The legend's width keeps its old 10 mm floor, so a figure or a sheet without a locator lays its legend out as before; the legend is left out only when a locator leaves it under 10 mm of height. Reusing a locator map no layout showed now adds a warning naming it. Co-Authored-By: Claude Opus 5.5 * Layouts test: the reused locator map is rebuilt, not kept (a layer hidden meanwhile is gone) Co-Authored-By: Claude Opus 5.5 * Layouts: the globe keeps a sketch of each polygon layer, simplified to its disc Each layer copied onto the globe's map is simplified (Douglas-Peucker) to a tenth of a millimetre at the globe's scale, so a project with large polygon layers saves a sketch of them for the locator rather than a second full copy. In memory the copy shared the source's features already (Features is copy-on-write); the saved file is what grew. Co-Authored-By: Claude Opus 5.5 * Layouts: a polygon too small for the globe's simplification keeps its own ring Co-Authored-By: Claude Opus 5.5 * Layouts: only a globe map made as this command makes them is reused as a locator A map is now reused only if it also has no web tiles and only unlabelled polygon layers, so a globe map made by hand under a locator's name is left alone. A note says the layer clone shares its rows (Features is copy-on-write). Co-Authored-By: Claude Opus 5.5 * Layouts: the simplified sketch named apart from the session Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/assistant/skills/print-layout/SKILL.md | 2 +- crates/engine/src/cmd/layout.rs | 174 +++++++++++++++++- crates/engine/src/tests.rs | 162 ++++++++++++++++ docs/user-guide/layouts.md | 10 + 4 files changed, 342 insertions(+), 6 deletions(-) diff --git a/crates/assistant/skills/print-layout/SKILL.md b/crates/assistant/skills/print-layout/SKILL.md index 37af7e819..24a6ca566 100644 --- a/crates/assistant/skills/print-layout/SKILL.md +++ b/crates/assistant/skills/print-layout/SKILL.md @@ -8,7 +8,7 @@ description: Make a printable page layout — map frame, title, legend, north ar 1. Get the map right first: symbology, labels and extent. A layout's map frame shows the map's current view. 2. For a finished sheet or a figure for a paper in one step, use `layout.publication` with a `"profile"` (`"a4-portrait"`, `"a3-landscape"`, … for a sheet; `"figure-1col"`, `"thesis-full"`, … for a figure), the `"extent"` - to fit, and the texts: `"title"`, `"subtitle"`, `"sources"`, `"note"`. It places the frame at a round scale, the + to fit, and the texts: `"title"`, `"subtitle"`, `"sources"`, `"note"`, and a `"locator"` (`"globe"`: the visible polygon layers held in the project, not streamed ones, over the basemap; `"inset"` or `"none"`; when omitted, `"globe"` on a sheet and `"none"` on a figure). It places the frame at a round scale, the graticule, legend, scale bar and north arrow, and wraps the footer. Its result lists every element's id and name (`"elements"`), so skip to step 5 to adjust them. Otherwise, `layout.new` `{"name": N, "page": "letter"|"a4"|…, "orientation": "landscape"|"portrait"}` creates the layout and diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 3bb8298b4..f97708747 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -29,7 +29,7 @@ pub fn commands() -> Vec { r#"{"name"?: string, "page"?: "letter"|"legal"|"tabloid"|"a5"|"a4"|"a3"|"a2"|"a1"|"a0", "orientation"?: "landscape"|"portrait", "width"?: mm, "height"?: mm, "template"?: "map" (frame, title, legend, north arrow, scale bar; the default)|"blank"}"#, ), C::new("layout.publication", "New Publication Layout", "Publish › Layout", publication).params( - r#"{"profile"?: "a4-portrait" (the default)|"a4-landscape"|"a3-portrait"|"a3-landscape"|"a2-portrait"|"a2-landscape" (a sheet: frame, title, subtitle, the map, a boxed footer)|"figure-1col" (90 mm)|"figure-1.5col" (140 mm)|"figure-2col" (190 mm)|"thesis-full" (160 × 225 mm)|"thesis-half" (160 × 110 mm) (a figure: the map edge to edge, its title and sources left to the caption), "extent"?: [west, south, east, north] in degrees that must fit (default: the map view), "name"?: string, "title"?: text, "subtitle"?: text, "sources"?: text | [texts], "reference"?: text | [texts] (default: the projection, datum and graticule), "note"?: text, "author"?: text (default: "Made with RustGIS · {date}"; "" or [] for none), "headings"?: [the sources' heading, the reference's heading]} → a new layout of the active map at a round scale, its texts wrapped to their boxes; {projection}, {datum} and {grid} in the texts are filled from the map"#, + r#"{"profile"?: "a4-portrait" (the default)|"a4-landscape"|"a3-portrait"|"a3-landscape"|"a2-portrait"|"a2-landscape" (a sheet: frame, title, subtitle, the map, a boxed footer)|"figure-1col" (90 mm)|"figure-1.5col" (140 mm)|"figure-2col" (190 mm)|"thesis-full" (160 × 225 mm)|"thesis-half" (160 × 110 mm) (a figure: the map edge to edge, its title and sources left to the caption), "extent"?: [west, south, east, north] in degrees that must fit (default: the map view), "name"?: string, "title"?: text, "subtitle"?: text, "sources"?: text | [texts], "reference"?: text | [texts] (default: the projection, datum and graticule), "note"?: text, "author"?: text (default: "Made with RustGIS · {date}"; "" or [] for none), "headings"?: [the sources' heading, the reference's heading], "locator"?: "globe" (a globe with the area outlined; the default on sheets) | "inset" (the surrounding region, the area outlined) | "none" (the default on figures)} → a new layout of the active map at a round scale, its texts wrapped to their boxes; {projection}, {datum} and {grid} in the texts are filled from the map"#, ), C::new("layout.activate", "Open Layout", "Browser", |s, p| { let i = index(s, &by_name(p))?; @@ -463,6 +463,25 @@ fn pub_box(fill: Rgba, outline: Rgba, width: f64) -> ElementKind { ElementKind::Rectangle { fill, outline, width } } +/// What a publication layout locates its area on. +#[derive(Clone, Copy, PartialEq)] +enum Locator { + Globe, + Inset, + None, +} + +/// The scale at which `map` on the globe, centred on `center`, draws a globe `d_mm` across in a +/// frame: the globe's visible disc as the renderer lays it out for the frame's camera at a probe +/// scale, its size going as one over the scale. +fn globe_scale(map: &rustgis_map::Map, center: Coord, d_mm: f64) -> f64 { + const PROBE: f64 = 1e8; + let cam = rustgis_map::layout::frame_camera(map, center, PROBE, 0.0, 0.0); + let side = d_mm.max(1.0) * rustgis_map::layout::PT_PER_MM; + let radius = rustgis_map::globe::Globe::new(&cam, side, side).disc_radius(); + if radius.is_finite() && radius > 0.0 { (PROBE * 2.0 * radius / side).clamp(100.0, 1e10) } else { PROBE } +} + /// `layout.publication`: a finished sheet or figure of the active map, its extent fitted at a /// round scale, with a graticule, legend, scale bar and north arrow; a sheet adds a frame, title, /// subtitle and a footer of two boxed columns (sources, cartographic reference) with a note and @@ -510,6 +529,21 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { Some(t) => Some(t.join(" ")).filter(|t| !t.trim().is_empty()), }; let headings = texts_of(p, "headings")?.unwrap_or_default(); + let locator = match p.get("locator").filter(|v| !v.is_null()) { + None => { + if sheet { + Locator::Globe + } else { + Locator::None + } + } + Some(v) => match v.as_str().map(str::trim) { + Some("globe") => Locator::Globe, + Some("inset") => Locator::Inset, + Some("none") => Locator::None, + _ => return Err("locator is \"globe\", \"inset\" or \"none\"".into()), + }, + }; let heading = |i: usize, or: &str| headings.get(i).cloned().unwrap_or_else(|| or.to_string()); let (projection, datum) = projection_and_datum(s); let step = grid_step(&extent); @@ -724,6 +758,123 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let (left, right, top, bottom) = (room(edges.left, true), room(edges.right, true), room(edges.top, false), room(edges.bottom, false)); let (aw, ah) = if sheet { (8.0 * k, 12.0 * k) } else { (5.0, 7.5) }; l.add("North Arrow", (fx + fw - right - aw, fy + top, aw, ah), ElementKind::north_arrow(frame)); + // The locator, at the top left: a globe (a copy of the map's vector layers on the globe, its + // diameter nearly the frame's) or the surrounding region on the map itself, the area outlined + // on either by the map frame's extent indicator. + let side = if sheet { (0.24 * fw.min(fh)).clamp(25.0 * k, 70.0 * k) } else { (0.3 * fw.min(fh)).clamp(18.0, 45.0) }; + let locator_map = match locator { + Locator::None => None, + Locator::Globe => { + let src = s.map(); + let base = format!("{} Locator", src.name); + // A locator an earlier sheet made that no layout shows any more (its layout deleted) + // is replaced, so sheets made again and again don't leave a trail of them; one still + // shown is kept, and the new one numbered beside it. + let shown = |n: &str| { + s.project + .layouts + .iter() + .flat_map(|l| &l.elements) + .any(|e| matches!(&e.kind, ElementKind::MapFrame { map, .. } if map.eq_ignore_ascii_case(n))) + }; + let ours = |n: &str| { + n.strip_prefix(base.as_str()) + .is_some_and(|rest| rest.is_empty() || rest.strip_prefix(' ').is_some_and(|k| k.parse::().is_ok())) + }; + // And made as this command makes them: no web tiles, only unlabelled polygon layers, + // so a globe map made by hand under that name is left alone. + let made_here = |x: &rustgis_map::Map| { + x.tiles.is_empty() && x.layers.iter().all(|l| l.geometry_type() == rustgis_data::GeometryType::Polygon && !l.labels.enabled) + }; + let orphan = s + .project + .maps + .iter() + .position(|x| x.projection.is_globe() && ours(&x.name) && !shown(&x.name) && x.name != src.name && made_here(x)); + let name = match orphan.and_then(|i| s.project.maps.get(i)) { + Some(x) => x.name.clone(), + None => numbered(&base, |n| s.project.maps.iter().any(|x| x.name.eq_ignore_ascii_case(n))), + }; + // A new map of what the globe draws, not a clone of the whole map (every layer's + // features): its basemap (the land and borders around the area), graticule and body, + // and the map's own polygon layers that show, unlabelled: the land at a glance, no + // raster or streamed layer (read again) nor points and lines cluttering a small disc. + // Not its web tiles (fetched and drawn over the basemap on the disc), swipe, time + // slider, layer groups (their hidden layers left out here), bookmarks or animation. + let mut m = rustgis_map::Map::new(name); + m.basemap = src.basemap; + m.graticule = src.graticule; + m.body = src.body.clone(); + m.set_projection(rustgis_map::Projection::Globe); + let globe = globe_scale(&m, center, side * 0.92); + // Each polygon simplified to a tenth of a millimetre on the disc (its scale in metres), + // so the project keeps a sketch of the layers for it, not a second full copy of them. + let tolerance_m = globe * 1e-4; + // A polygon too small for that (an island) would collapse to a ring of under four + // points: it keeps its own shape, already small. + let sketch = |g: &rustgis_data::Geometry| { + let simple = rustgis_analysis::simplify_geometry(g, tolerance_m); + match &simple { + rustgis_data::Geometry::Polygon(polys) if polys.iter().flatten().all(|ring| ring.len() >= 4) => simple, + _ => g.clone(), + } + }; + m.layers = src + .layers + .iter() + .filter(|l| { + src.shows(l) && !l.is_raster() && l.source.is_none() && l.geometry_type() == rustgis_data::GeometryType::Polygon + }) + .map(|l| { + // (Sharing the rows: `Features` is copy-on-write, replaced just below.) + let mut layer = l.clone(); + layer.dataset.features = l + .dataset + .features + .iter() + .map(|f| { + let mut f = f.clone(); + f.geometry = f.geometry.as_ref().map(sketch); + f + }) + .collect(); + layer.touch_geometry(); + layer.selection.clear(); + layer.labels.enabled = false; + layer + }) + .collect(); + let name = m.name.clone(); + match orphan.and_then(|i| s.project.maps.get_mut(i)) { + Some(old) => { + *old = m; + warnings.push(format!("the map '{name}', which no layout showed, is replaced by this sheet's globe")); + } + None => s.project.maps.push(m), + } + Some((name, globe, FrameShape::Circle)) + } + Locator::Inset => Some((s.map().name.clone(), round_scale(scale * fw / side * 4.0), FrameShape::Rectangle)), + }; + if let Some((map, locator_scale, shape)) = &locator_map { + l.add( + "Locator", + (fx + left, fy + top, side, side), + ElementKind::MapFrame { + map: map.clone(), + center, + scale: *locator_scale, + shape: shape.clone(), + grid: None, + extent_of: Some(frame), + extent_color: rustgis_map::layout::INDICATOR_COLOR, + extent_width: rustgis_map::layout::INDICATOR_WIDTH, + bearing: 0.0, + pitch: 0.0, + }, + ); + } + let below_locator = if locator_map.is_some() { side + e } else { 0.0 }; // The legend lists what the frame draws: its map synced to the frame's view first, as an // export does, so a streamed layer has its features there. (This changes the session's map, // so it comes after the last refusal: nothing below fails.) While one is still reading, the @@ -755,10 +906,20 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { // The scale bar's box, at the bottom right: the legend stops short of it (and so of the north // arrow above it, which is narrower), however narrow the frame. let (sw, sh, nh) = ((fw * 0.3).max(25.0), if sheet { 8.0 * k } else { 6.0 }, pitch(small_pt) + 0.5); - if ch > 0.0 { + // Its corner, at least 10 mm each way; under a locator, though, a corner shorter than that + // leaves the legend out, said, rather than drawing it over the locator. + const MIN_LEGEND_MM: f64 = 10.0; + let room_w = (fw - left - right - sw - 2.0).max(MIN_LEGEND_MM); + let room_h = fh - top - bottom - below_locator; + if ch > 0.0 && below_locator > 0.0 && room_h < MIN_LEGEND_MM { + warnings.push( + "the frame leaves the legend no room below the locator, so it is left out: give the map more room, or set \"locator\": \"none\"" + .into(), + ); + } else if ch > 0.0 { + let room_h = room_h.max(MIN_LEGEND_MM); let pad = 2.0; - let room_w = (fw - left - right - sw - 2.0).max(10.0); - let (lw, lh) = ((cw + 2.0 * pad).min(fw * 0.45).min(room_w), (ch + 2.0 * pad).min(fh * 0.6)); + let (lw, lh) = ((cw + 2.0 * pad).min(fw * 0.45).min(room_w), (ch + 2.0 * pad).min(fh * 0.6).min(room_h)); if cw + 2.0 * pad > lw || ch + 2.0 * pad > lh { warnings.push("the legend lists more than fits its corner: its last rows or the ends of long labels are left out".into()); } @@ -782,7 +943,10 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { s.project.layouts.push(l); let i = s.project.layouts.len() - 1; s.active_layout = Some(i); - Ok(json!({"layout": i, "name": name, "page": [w, h], "scale": scale, "grid": step, "elements": elements, "warnings": warnings})) + let locator = locator_map.map(|(map, ..)| map); + Ok( + json!({"layout": i, "name": name, "page": [w, h], "scale": scale, "grid": step, "locator": locator, "elements": elements, "warnings": warnings}), + ) } fn page(s: &mut Session, p: &Value) -> CmdResult { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index cb85a4294..d532a76e2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2634,6 +2634,168 @@ fn publication_layouts_fit_their_pages() { let _ = std::fs::remove_dir_all(&dir); } +/// A publication layout's locator (#682, part 2): on a sheet, by default, a globe (a new map of +/// the vector layers on the globe, its diameter nearly the frame's) in a round frame at the map's +/// top left, the area outlined by the map frame's extent indicator; `"inset"` the surrounding +/// region on the map itself; `"none"`, or a figure by default, none. The legend stops short of it, +/// and undo takes the globe's map away with the layout. +#[test] +fn publication_layouts_locate_their_area() { + use rustgis_map::layout::{ElementKind, FrameShape}; + let dir = std::env::temp_dir().join(format!("rustgis-locator-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = world(); + let piaui = json!([-46.8, -11.6, -38.5, -2.2]); + let maps = s.project.maps.len(); + let main = s.map().name.clone(); + let r = s.run("layout.publication", &json!({"extent": piaui, "title": "PIAUÍ"})).unwrap(); + let globe = r["locator"].as_str().unwrap().to_string(); + assert_eq!((s.project.maps.len(), globe.as_str()), (maps + 1, format!("{main} Locator").as_str())); + let m = s.project.maps.iter().find(|m| m.name == globe).unwrap(); + assert!(m.projection.is_globe() && m.layers.iter().all(|l| !l.is_raster())); + assert_eq!(s.map().name, main, "the map in view stays the active one"); + let l = s.project.layouts.last().unwrap(); + let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}")); + let (frame, locator) = (named("Map Frame"), named("Locator")); + let ElementKind::MapFrame { map, shape, extent_of, scale, .. } = &locator.kind else { panic!() }; + assert_eq!((map.as_str(), shape, *extent_of), (globe.as_str(), &FrameShape::Circle, Some(frame.id))); + // Inside the map frame at its top left, above the legend. + assert!(locator.x > frame.x && locator.y > frame.y && locator.x + locator.w < frame.x + frame.w / 2.0); + let legend = named("Legend Box"); + assert!(legend.y >= locator.y + locator.h, "the legend stops short of the locator"); + // The globe nearly fills its frame: drawn, its ink spans most of the frame's width. + assert!(*scale > 1e7, "1:{scale}"); + let png = dir.join("globe.png"); + s.run("layout.export", &json!({"path": png.display().to_string(), "dpi": 96})).unwrap(); + // The surrounding region on the map itself: no new map, a coarser scale. + let r = s.run("layout.publication", &json!({"extent": piaui, "locator": "inset"})).unwrap(); + assert_eq!((s.project.maps.len(), r["locator"].as_str()), (maps + 1, Some(main.as_str()))); + let l = s.project.layouts.last().unwrap(); + let main_scale = r["scale"].as_f64().unwrap(); + let inset = l.elements.iter().find(|e| e.name == "Locator").unwrap(); + let ElementKind::MapFrame { map, shape, scale, extent_of, .. } = &inset.kind else { panic!() }; + assert!(map == &main && shape == &FrameShape::Rectangle && *scale > main_scale * 3.0 && extent_of.is_some(), "1:{scale}"); + s.run("layout.export", &json!({"path": dir.join("inset.png").display().to_string(), "dpi": 96})).unwrap(); + // None asked, or a figure by default: no locator, and the legend stays, the narrowest + // figure (90 mm) included: only a locator takes its room. + for p in [json!({"extent": piaui, "locator": "none"}), json!({"extent": piaui, "profile": "figure-1col"})] { + let r = s.run("layout.publication", &p).unwrap(); + let l = s.project.layouts.last().unwrap(); + assert!(r["locator"].is_null() && l.elements.iter().all(|e| e.name != "Locator"), "{p}"); + assert!(l.elements.iter().any(|e| e.name == "Legend Box"), "{p}: {r}"); + } + let e = s.run("layout.publication", &json!({"extent": piaui, "locator": "moon"})).unwrap_err(); + assert!(e.contains("locator is"), "{e}"); + // Undone, a globe locator's map goes with its layout. + let before = s.project.maps.len(); + s.run("layout.publication", &json!({"extent": piaui})).unwrap(); + assert_eq!(s.project.maps.len(), before + 1); + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(s.project.maps.len(), before); + // A sheet made again after its layout was deleted replaces the locator no layout shows any + // more, rather than leaving it beside a new one; one still shown is kept. + let r = s.run("layout.publication", &json!({"extent": piaui, "name": "Again"})).unwrap(); + let first = r["locator"].as_str().unwrap().to_string(); + s.run("layout.remove", &json!({"name": "Again"})).unwrap(); + // Made anew, not kept: a polygon layer hidden meanwhile is gone from the reused map. + let (gone, gone_name) = + s.map().layers.iter().find(|l| l.geometry_type() == rustgis_data::GeometryType::Polygon).map(|l| (l.id, l.name.clone())).unwrap(); + let globe_has = + |s: &Session, name: &str| s.project.maps.iter().find(|m| m.name == first).unwrap().layers.iter().any(|l| l.name == name); + assert!(globe_has(&s, &gone_name)); + s.map_mut().layer_mut(gone).unwrap().visible = false; + let r = s.run("layout.publication", &json!({"extent": piaui, "name": "Again"})).unwrap(); + assert_eq!((s.project.maps.len(), r["locator"].as_str()), (before + 1, Some(first.as_str()))); + assert!(r["warnings"].to_string().contains("which no layout showed, is replaced"), "{r}"); + assert!(!globe_has(&s, &gone_name), "the reused map is rebuilt"); + s.map_mut().layer_mut(gone).unwrap().visible = true; + let r = s.run("layout.publication", &json!({"extent": piaui})).unwrap(); + assert!(s.project.maps.len() == before + 2 && r["locator"].as_str() != Some(first.as_str()), "{r}"); + // A globe map made by hand under such a name (its layers labelled) is never taken for one. + let mut hand = rustgis_map::Map::new(format!("{main} Locator 9")); + hand.set_projection(rustgis_map::Projection::Globe); + let mut labelled = s.map().layers.iter().find(|l| l.geometry_type() == rustgis_data::GeometryType::Polygon).unwrap().clone(); + labelled.labels.enabled = true; + hand.layers.push(labelled); + s.project.maps.push(hand); + let r = s.run("layout.publication", &json!({"extent": piaui})).unwrap(); + assert_ne!(r["locator"].as_str(), Some(format!("{main} Locator 9").as_str()), "{r}"); + let kept = s.project.maps.iter().find(|m| m.name == format!("{main} Locator 9")).unwrap(); + assert!(kept.layers.iter().all(|l| l.labels.enabled)); + // The globe is a new map of what it draws: the polygon layers that show (a hidden one left + // out) on the map's basemap, without its web tiles, swipe or groups. + let polygon = |l: &rustgis_map::Layer| l.geometry_type() == rustgis_data::GeometryType::Polygon; + let hidden = s.map().layers.iter().find(|l| polygon(l)).map(|l| (l.id, l.name.clone())).unwrap(); + s.map_mut().layer_mut(hidden.0).unwrap().visible = false; + s.map_mut().tiles.push(rustgis_map::TileLayer::new( + "Streets", + rustgis_map::TileKind::Raster, + "https://tiles.example.com/{z}/{x}/{y}.png", + )); + s.map_mut().swipe = Some((hidden.0, 0.5)); + let shown: Vec = s.map().layers.iter().filter(|l| polygon(l) && s.map().shows(l)).map(|l| l.name.clone()).collect(); + let r = s.run("layout.publication", &json!({"extent": piaui})).unwrap(); + let m = s.project.maps.iter().find(|m| Some(m.name.as_str()) == r["locator"].as_str()).unwrap(); + assert_eq!(m.layers.iter().map(|l| l.name.clone()).collect::>(), shown, "not {}", hidden.1); + assert!(m.tiles.is_empty() && m.swipe.is_none() && m.groups.is_empty() && m.basemap == s.map().basemap); + assert!(m.layers.iter().all(|l| !l.labels.enabled && l.selection.is_empty())); + // A sketch of each layer for the small disc: every feature kept, far fewer vertices. + for g in &m.layers { + let l = s.map().layers.iter().find(|l| l.name == g.name).unwrap(); + let vertices = + |l: &rustgis_map::Layer| l.dataset.features.iter().filter_map(|f| f.geometry.as_ref()).map(|g| g.coord_count()).sum::(); + assert_eq!(g.dataset.features.len(), l.dataset.features.len()); + assert!(vertices(g) * 2 < vertices(l), "{}: {} of {} vertices", g.name, vertices(g), vertices(l)); + } + // An island far smaller than the tolerance keeps its own ring rather than collapsing; no ring + // on the globe has under four points. + let island = dir.join("island.geojson"); + std::fs::write( + &island, + r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[-40, -5], [-39.999, -5], [-39.999, -4.999], [-40, -4.999], [-40, -5]]]}}]}"#, + ) + .unwrap(); + s.run("layer.addData", &json!({"path": island.display().to_string()})).unwrap(); + let r = s.run("layout.publication", &json!({"extent": piaui})).unwrap(); + let m = s.project.maps.iter().find(|m| Some(m.name.as_str()) == r["locator"].as_str()).unwrap(); + let rings = |name: &str| -> Vec { + m.layers + .iter() + .filter(|l| l.name == name || name.is_empty()) + .flat_map(|l| l.dataset.features.iter()) + .filter_map(|f| match &f.geometry { + Some(rustgis_data::Geometry::Polygon(polys)) => Some(polys.iter().flatten().map(Vec::len).collect::>()), + _ => None, + }) + .flatten() + .collect() + }; + assert_eq!(rings("island"), [5]); + assert!(rings("").iter().all(|n| *n >= 4)); + // Sheets with ever more source lines, so ever smaller frames, down to the smallest a sheet + // takes: the legend is never drawn over the locator, and once there's no room under it, it is + // left out, said. + let mut left_out = false; + for n in 1..80 { + let sources: Vec = (0..n).map(|i| format!("Source {i}: a line long enough to take a line of its own")).collect(); + let Ok(r) = s.run("layout.publication", &json!({"profile": "a4-landscape", "extent": piaui, "sources": sources})) else { break }; + let l = s.project.layouts.last().unwrap(); + let named = |name: &str| l.elements.iter().find(|e| e.name == name); + let locator = named("Locator").unwrap(); + match named("Legend Box") { + Some(b) => assert!(b.y >= locator.y + locator.h - 1e-6, "{n} lines: the legend over the locator"), + None => { + let said = r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("no room below the locator")); + assert!(said, "{n} lines: {r}"); + left_out = true; + break; + } + } + } + assert!(left_out, "no frame got too small for a legend under the locator"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn layouts_compose_export_and_round_trip() { use base64::Engine; diff --git a/docs/user-guide/layouts.md b/docs/user-guide/layouts.md index a6e4119c2..ebfa8e5fe 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -34,6 +34,16 @@ the maps; while one is open, its groups lead the Publish tab: `{grid}` are filled from the map when the layout is made. `{date}` and the other dynamic text tokens stay live. The reference defaults to the projection, datum and graticule, and the authorship to "Made with RustGIS · {date}" (`"author": ""` leaves it out). `headings` renames the two columns. + - **The locator**, at the frame's top left: `"locator": "globe"` (a sheet's default) adds a + map of the map's polygon layers that show on the globe (those held in the project: a large + file streamed in place, which would be read again for the whole world, is left out), + unlabelled, over the map's basemap (not its web tiles), in a round frame, the globe nearly + filling it and the area outlined in red; `"inset"` shows the surrounding region (about four + times the area across) on the map itself, outlined the same way; `"none"` (a figure's + default) leaves it out. The legend stops short of it; a frame with no room for a legend under + it leaves the legend out, with a warning. Undo takes the globe's map away with the layout; a + globe map no layout shows any more (its layout deleted) is replaced by the next sheet's + rather than left beside it, which a warning says. - Every element is an ordinary one, to move or restyle afterwards. Running it again with another `profile` makes another layout of the same map. From 534b1825f07bd74b331cb67b871dfd81cf3116ad Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:01:18 +0200 Subject: [PATCH 062/297] fix(connections): drop stale listings in the add dialogs (#715) * fix(connections): drop stale listings when an add dialog's address changes Add From Database keeps its table list and chosen table only while the address they were listed for is unchanged. STAC clears its collections, items and chosen collection when another API is picked or typed. The S3 form selects the connection kept after a blank-name connect ("default"). Refs #699 (requirements 7 and 10). * fix(connections): refill the S3 form when its saved connection is edited or removed connection.edit changes an S3 connection's address or options and ends its session, but the form kept the old endpoint, still read as the selected connection and kept the old bucket listing. The dialog now compares the form's connection with its saved definition: an edit refills the form and drops what was listed; a removal lets the connection go. Refs #699 (requirements 7 and 10). * fix(connections): a failed table listing drops the old tables A failed re-list cleared only the address it was tied to, so the earlier table list and chosen table survived a later address edit and could be sent against another database. Found in review of the stale-listing change. Refs #699 (requirements 7 and 10). * fix(connections): drop STAC answers still on their way when the API changes Picking or typing another API cleared what was shown but left a pending collections, search or children request and a static catalog's walk, whose answers then landed under the new API. They are dropped now; an asset being signed or a link being opened goes on. Found in review. Refs #699 (requirements 7 and 10). * fix(connections): reset STAC listing state fully on an API change An API change now also forgets the address Save as Connection would keep and the last listing's status, and a static catalog picked while a listing was pending is browsed at once. Found in review. Refs #699 (requirements 7 and 10). * test(connections): a catalog picked during a pending listing is browsed Covers the review fix that rechecks pending work after an API switch. Refs #699. --- crates/ui-egui/src/dialogs.rs | 10 ++++ crates/ui-egui/src/s3ui.rs | 83 ++++++++++++++++++++++++++++++- crates/ui-egui/src/stacui.rs | 82 +++++++++++++++++++++++++++++-- crates/ui-egui/tests/smoke.rs | 92 +++++++++++++++++++++++++++++++++++ docs/user-guide/web-data.md | 4 ++ 5 files changed, 265 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1d3b33d6..849a814fc 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -931,6 +931,13 @@ fn add_database(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text(tl!("of a geometry that doesn't say")).desired_width(280.0)); ui.end_row(); }); + // The tables listed and the one chosen belong to the address they were listed for. + if app.dialogs.listed.as_deref().is_some_and(|l| l != database(&app.dialogs.a)) { + app.dialogs.listed = None; + app.dialogs.list.clear(); + app.dialogs.c.clear(); + app.dialogs.info = None; + } if let Some(c) = pick { if let Err(e) = crate::connectionui::open_connection(app, &c) { app.dialogs.error = Some(e); @@ -947,7 +954,10 @@ fn add_database(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { app.dialogs.info = Some(crate::i18n::fmt("{n} tables with geometry", &[("n", &app.dialogs.list.len().to_string())])); } Err(e) => { + // What a failed listing leaves can't be tied to an address any more, so it goes too. app.dialogs.listed = None; + app.dialogs.list.clear(); + app.dialogs.c.clear(); app.dialogs.info = None; app.dialogs.error = Some(e); } diff --git a/crates/ui-egui/src/s3ui.rs b/crates/ui-egui/src/s3ui.rs index e605765d9..18fc03154 100644 --- a/crates/ui-egui/src/s3ui.rs +++ b/crates/ui-egui/src/s3ui.rs @@ -29,6 +29,8 @@ pub(crate) const SOURCES: [(&str, &str); 4] = [ pub struct State { /// The saved connection this dialog was opened on, by id (see `connectionui::picked`). pub(crate) connection: Option, + /// The saved definition the form was last filled from or kept as: a later edit or removal elsewhere is noticed against it. + synced: Option, /// What Connect last connected, as Save as Connection keeps it. connected: Option, name: String, @@ -216,8 +218,49 @@ pub fn open_connection(app: &mut RustGisApp, name: &str) { } } +/// Forgets what was listed for the endpoint browsed. +fn reset_browse(st: &mut State) { + st.listing = None; + st.listing_job = None; + st.buckets.clear(); + st.buckets_job = None; + st.bucket.clear(); + st.prefix.clear(); +} + +/// Keeps the form true to the saved connection it stands for. A connection edited elsewhere +/// (its endpoint, region or login changed, which also ends its session) is filled in again +/// and what was listed for the old settings dropped; one removed is let go. The first look at +/// a connection only records it. +fn sync(app: &mut RustGisApp) { + let list = crate::sourcespane::connections_snapshot(app); + let st = &mut app.ui.s3; + let Some(id) = st.connection.clone() else { + st.synced = None; + return; + }; + match list.iter().find(|c| c.id == id && c.kind == "s3") { + None => { + st.connection = None; + st.synced = None; + reset_browse(st); + } + Some(c) => match st.synced.as_ref().filter(|s| s.id == c.id) { + None => st.synced = Some(c.clone()), + Some(s) if (s.name.as_str(), s.address.as_str(), &s.options) != (c.name.as_str(), c.address.as_str(), &c.options) => { + let c = c.clone(); + fill(app, &c); + reset_browse(&mut app.ui.s3); + app.ui.s3.synced = Some(c); + } + Some(_) => {} + }, + } +} + /// The dialog: a connection, then a bucket browsed a level at a time. pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { + sync(app); ui.label(egui::RichText::new(tl!("Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret.")).weak()); egui::CollapsingHeader::new(tl!("Connection")).default_open(s3::connections().is_empty()).show(ui, |ui| connection_form(app, ui)); ui.separator(); @@ -230,7 +273,9 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { fn connection_form(app: &mut RustGisApp, ui: &mut egui::Ui) { let list = crate::sourcespane::connections_snapshot(app); let st = &app.ui.s3; - let current = list.iter().find(|c| c.kind == "s3" && Some(c.id.as_str()) == st.connection.as_deref() && c.name == st.name.trim()); + // A blank name connects as "default" (see `connect`), so it is that connection's name here too. + let shown_name = if st.name.trim().is_empty() { "default" } else { st.name.trim() }; + let current = list.iter().find(|c| c.kind == "s3" && Some(c.id.as_str()) == st.connection.as_deref() && c.name == shown_name); if let Some(c) = crate::connectionui::picker(ui, "s3_saved", &list, &["s3"], current) { pick(app, &c); return; @@ -464,4 +509,40 @@ mod tests { assert_eq!(s3::connection(&c.name).unwrap().name, c.name); s3::disconnect(&c.name); } + + #[test] + fn editing_or_removing_the_picked_connection_refills_the_form_and_drops_its_listing() { + let mut app = RustGisApp::with_sample(); + let v = app + .run( + "connection.new", + json!({"kind":"s3","name":"edited-s3-test","address":"https://old.example.com", + "options":{"region":"eu-west-1","credentials":"none"}}), + ) + .unwrap(); + let id = v["id"].as_str().unwrap().to_string(); + let c = app.session.connections.find(&id).unwrap().clone(); + pick(&mut app, &c); + sync(&mut app); + app.ui.s3.bucket = "old-bucket".into(); + app.ui.s3.listing = Some((vec!["old/".into()], Vec::new(), false)); + // Unchanged: left as it is. + sync(&mut app); + assert_eq!(app.ui.s3.bucket, "old-bucket"); + + let edit = json!({"id": id, "address": "https://new.example.com", "options": {"region":"us-west-2","credentials":"none"}}); + app.run("connection.edit", edit).unwrap(); + sync(&mut app); + assert_eq!(app.ui.s3.endpoint, "https://new.example.com"); + assert_eq!(app.ui.s3.region, "us-west-2"); + assert!(app.ui.s3.listing.is_none() && app.ui.s3.bucket.is_empty(), "nothing listed for the old endpoint is kept"); + assert_eq!(app.ui.s3.connection.as_deref(), Some(id.as_str())); + + app.ui.s3.bucket = "other".into(); + app.run("connection.remove", json!({"id": id})).unwrap(); + sync(&mut app); + assert_eq!(app.ui.s3.connection, None); + assert!(app.ui.s3.bucket.is_empty()); + s3::disconnect("edited-s3-test"); + } } diff --git a/crates/ui-egui/src/stacui.rs b/crates/ui-egui/src/stacui.rs index beb5cd3fe..fbdf8c537 100644 --- a/crates/ui-egui/src/stacui.rs +++ b/crates/ui-egui/src/stacui.rs @@ -675,11 +675,30 @@ pub fn title(app: &RustGisApp) -> &'static str { fn switch_api(st: &mut State, url: &str) { st.api = url.to_string(); + drop_listing(st); +} + +/// Forgets what was listed for the last API or catalog, and what is still on its way from it: a +/// late answer would otherwise land under the new one. An asset being signed or a link being +/// opened was asked for already and goes on. +fn drop_listing(st: &mut State) { st.collections.clear(); - // Nothing of the last catalog's browsing is kept. + st.items.clear(); + st.collection.clear(); (st.node, st.node_title) = (String::new(), String::new()); st.trail.clear(); st.children.clear(); + // What Save as Connection would keep: listed again before it is offered. + st.listed.clear(); + if st.waiting.as_ref().is_some_and(|(a, _)| matches!(a, Asked::Collections(_) | Asked::Search(_) | Asked::Children(_))) { + st.waiting = None; + } + st.walk = None; + // The last listing's status ("Showing 10 items") isn't the new API's; an asset being signed + // or a link being opened keeps its own. + if st.waiting.is_none() { + st.info = None; + } } /// The dialog. @@ -725,9 +744,7 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { }); // A catalog typed in is browsed with Browse, from its top. if ui.add(egui::TextEdit::singleline(&mut st.api).desired_width(ui.available_width().max(80.0))).changed() { - (st.node, st.node_title) = (String::new(), String::new()); - st.trail.clear(); - st.children.clear(); + drop_listing(st); } }); ui.end_row(); @@ -825,7 +842,9 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { keep = crate::connectionui::keep_button(ui); } }); - if api_changed && catalog::is_static(&app.ui.stac.api) && !busy { + // A listing dropped by the switch no longer holds the new catalog's browsing up. + let busy_now = app.ui.stac.waiting.is_some() || app.ui.stac.walk.is_some(); + if api_changed && catalog::is_static(&app.ui.stac.api) && !busy_now { browse(app, None); } else if let Some(to) = go_to { browse(app, Some(to)); @@ -1144,4 +1163,57 @@ mod tests { find_items(&mut app); assert!(app.ui.stac.walk.is_none() && app.ui.stac.error.is_some()); } + + /// An answer asked of one API that lands after another was picked isn't shown under it; a + /// link being opened goes on. + #[test] + fn a_late_answer_from_the_previous_api_is_dropped() { + let mut app = RustGisApp::with_sample(); + let ctx = egui::Context::default(); + let late: Answer = Slot::default(); + app.ui.stac.api = "https://one.example/stac".into(); + app.ui.stac.waiting = Some((Asked::Collections("https://one.example/stac".into()), late.clone())); + switch_api(&mut app.ui.stac, "https://two.example/stac"); + assert!(app.ui.stac.waiting.is_none()); + *late.lock().unwrap() = Some(Ok(br#"{"collections":[{"id":"old","title":"Old"}]}"#.to_vec())); + poll(&mut app, &ctx); + assert!(app.ui.stac.collections.is_empty()); + assert_eq!(app.ui.stac.api, "https://two.example/stac"); + + // Listed, then away and back: Save as Connection waits for a listing of its own, and + // the last listing's status goes with it. + app.ui.stac.listed = "https://two.example/stac".into(); + app.ui.stac.info = Some("Showing 10 items".into()); + switch_api(&mut app.ui.stac, "https://one.example/stac"); + switch_api(&mut app.ui.stac, "https://two.example/stac"); + assert!(app.ui.stac.listed.is_empty()); + assert_eq!(app.ui.stac.info, None); + + app.ui.stac.waiting = Some((Asked::Link("https://two.example/item.json".into()), Slot::default())); + switch_api(&mut app.ui.stac, "https://three.example/stac"); + assert!(matches!(app.ui.stac.waiting, Some((Asked::Link(_), _))), "a link already asked for goes on"); + } + + /// A static catalog picked while a listing of the last API is pending is browsed at once: + /// the dropped listing doesn't hold it up. + #[test] + fn a_catalog_picked_during_a_pending_listing_is_browsed() { + use egui_kittest::kittest::Queryable; + let mut app = RustGisApp::with_sample(); + // Nothing listens on port 9 of the loopback address: the browse is refused, harmlessly. + app.run("connection.new", json!({"kind":"stac","name":"Local catalog","address":"http://127.0.0.1:9/catalog.json"})).unwrap(); + app.ui.stac.api = "https://one.example/stac".into(); + app.ui.stac.waiting = Some((Asked::Collections("https://one.example/stac".into()), Slot::default())); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(900.0, 700.0)) + .build_ui_state(|ui, app: &mut RustGisApp| dialog(app, ui), app); + h.step(); + h.step(); + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Other")).unwrap().click(); + h.step(); + h.get_by_label("Local catalog").click(); + h.step(); + assert_eq!(h.state().ui.stac.api, "http://127.0.0.1:9/catalog.json"); + assert!(matches!(&h.state().ui.stac.waiting, Some((Asked::Children(url), _)) if url == "http://127.0.0.1:9/catalog.json")); + } } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index c70163d4e..53bd7da3d 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -1679,6 +1679,98 @@ fn add_database_picks_and_keeps_connections() { assert!(h.query_by_label("Save as Connection").is_none(), "an unlisted endpoint cannot be kept"); } +/// A blank name connects as "default"; once that endpoint is kept, the form shows it as the +/// selected saved connection instead of offering to keep it again. +#[test] +fn s3_dialog_selects_the_kept_connection_after_a_blank_name_connect() { + let mut h = harness(); + h.state_mut().run("ui.dialog", json!({"name":"s3"})).unwrap(); + h.step(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Connect").click(); + h.step(); + h.get_by_label("Save as Connection").click(); + h.step(); + h.step(); + assert_eq!(h.state().session.connections.connections.len(), 1); + assert!(h.query_by_label("Save as Connection").is_none()); + assert!(h.query_by_label("Rename it to save it: a connection has this name").is_none()); + assert!(h.query_all_by_role(egui::accesskit::Role::ComboBox).any(|n| n.value().as_deref() == Some("default"))); +} + +/// A table list and the table chosen belong to the address they were listed for: editing the +/// address by hand drops them, so Add never reads a table name against another database. +#[test] +fn add_database_drops_stale_tables_when_the_address_is_edited() { + use egui::accesskit::Role; + let mut h = harness(); + let address = "postgres:host=db.example dbname=gis user=me"; + h.state_mut().run("ui.dialog", json!({"name":"addDatabase"})).unwrap(); + h.state_mut().dialogs.a = address.into(); + h.state_mut().dialogs.list = vec!["public.parcels".into()]; + h.state_mut().dialogs.c = "public.parcels".into(); + h.state_mut().dialogs.listed = Some(address.into()); + h.step(); + h.step(); + assert_eq!(h.state().dialogs.c, "public.parcels", "an unchanged address keeps its tables"); + h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some(address)).unwrap().focus(); + h.step(); + h.key_press(egui::Key::End); + h.event(egui::Event::Text("x".into())); + h.step(); + h.step(); + assert_eq!(h.state().dialogs.a, format!("{address}x")); + assert!(h.state().dialogs.list.is_empty()); + assert!(h.state().dialogs.c.is_empty()); + assert_eq!(h.state().dialogs.listed, None); +} + +/// A failed re-list can't leave the old tables behind: they would no longer be tied to an address +/// the stale-listing check can see, and the table chosen could be sent to another database. +#[test] +fn add_database_failed_relist_drops_the_old_tables() { + let mut h = harness(); + // Nothing listens on port 9 of the loopback address: listing is refused at once. + let address = "postgres:host=127.0.0.1 port=9 dbname=gis user=me"; + h.state_mut().run("ui.dialog", json!({"name":"addDatabase"})).unwrap(); + h.state_mut().dialogs.a = address.into(); + h.state_mut().dialogs.list = vec!["public.parcels".into()]; + h.state_mut().dialogs.c = "public.parcels".into(); + h.state_mut().dialogs.listed = Some(address.into()); + h.step(); + h.step(); + h.get_by_label("List Tables").click(); + h.step(); + h.step(); + assert!(h.state().dialogs.error.is_some(), "the listing must have failed"); + assert_eq!(h.state().dialogs.listed, None); + assert!(h.state().dialogs.list.is_empty()); + assert!(h.state().dialogs.c.is_empty()); +} + +/// Collections belong to the API they were listed from: typing another address drops them. +#[test] +fn stac_dialog_drops_stale_collections_when_the_address_is_edited() { + use egui::accesskit::Role; + let mut h = harness(); + let api = "https://one.example/stac/v1"; + h.state_mut().run("ui.dialog", json!({"name":"stac"})).unwrap(); + h.state_mut().ui.stac.api = api.into(); + h.state_mut().ui.stac.collections = vec![("old-collection".into(), "Old".into())]; + h.state_mut().ui.stac.collection = "old-collection".into(); + h.step(); + h.step(); + h.query_all_by_role(Role::TextInput).find(|n| n.value().as_deref() == Some(api)).unwrap().focus(); + h.step(); + h.key_press(egui::Key::End); + h.event(egui::Event::Text("2".into())); + h.step(); + h.step(); + assert_eq!(h.state().ui.stac.api, format!("{api}2")); + assert!(h.state().ui.stac.collections.is_empty()); + assert!(h.state().ui.stac.collection.is_empty(), "the collection picked belongs to the old API"); +} + #[test] fn stac_dialog_keeps_and_picks_connections() { use std::io::{Read, Write}; diff --git a/docs/user-guide/web-data.md b/docs/user-guide/web-data.md index f6d869838..b8a1915ba 100644 --- a/docs/user-guide/web-data.md +++ b/docs/user-guide/web-data.md @@ -100,6 +100,10 @@ and band, such as INPE's [Brazil Data Cube](https://data.inpe.br/bdc/) or Sentin rustgis-cli run --cmd 'stac.addCube={"url":"https://data.inpe.br/bdc/stac/v1","collection":"S2-16D-2","asset":"NDVI","bbox":[-55.9,-12.6,-55.5,-12.35],"start":"2024-08-01","end":"2025-06-30","range":[0,0.9]}' --cmd 'time.export={"path":"ndvi.gif"}' ``` +### Saved connections + +Once you have a STAC connection (Data Sources › Connections), its name appears in the API list at the top of STAC Search below the built-in APIs: picking it fills in the address and clears the collections and items of the previous one. Add From Service, Add From Database and S3 Buckets have the same **Saved connection** list for their kinds. An API you typed in by hand stays usable; once its collections are listed, **Save as Connection** keeps it under Data Sources › Connections (nothing is saved unless you click it, and never a password or key). Editing the address by hand drops the collections and items listed for the old one. + ### Static catalogs Many catalogs are plain files rather than an API: a `catalog.json` linking to child catalogs and From 3ec3d9c845ebaaeaa8ee73987d4777cc6d9ebeae Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Fri, 23 Oct 2026 12:21:00 -0300 Subject: [PATCH 063/297] Layouts: a publication layout's surrounds keep off the data; a decimal comma (#682, part 3) (#709) * Layouts: a publication layout's locator, a globe or an inset with the area outlined (#682, part 2) layout.publication takes "locator": "globe" (a sheet's default: a new map of the map's polygon layers on the globe, unlabelled, in a round frame at the map's top left, sized from the renderer's own globe disc), "inset" (the surrounding region on the map itself) or "none" (a figure's default). The map frame's extent indicator outlines the area on either; the legend stops short of it; undo takes the globe's map away with the layout. Co-Authored-By: Claude Opus 5.5 * Layouts: a publication layout's surrounds keep off the data; a decimal comma (#682, part 3) The north arrow, the legend, the scale bar and the locator each take the corner where they cover the least of what the map draws (render::layout::frame_coverage: the frame drawn coarse on a blank basemap), keeping their own corners when it makes no difference; one warning names those crowded onto the data. "decimal": "," (layout.publication, layout.page) sets Layout::decimal_comma: graticule, scale bar and colorbar numbers and {scale} print 0,05 and 1:6.300.000. Co-Authored-By: Claude Opus 5.5 * layout.publication: a raster theme keyed by a colorbar in the legend's box; a UTM zone's EPSG code named by its zone and datum Found making the greening lesson's map with it: the topmost visible raster drawn on a ramp now gets a colorbar under the legend's rows (the legend lists vectors only), and EPSG:31983 reads 'UTM zone 23S' on 'SIRGAS 2000' rather than its code on its GRS 1980 ellipsoid (wkt::utm_of_code, the inverse of the name-to-code table). Co-Authored-By: Claude Opus 5.5 * Layouts: the globe locator is a new map of what it draws; no legend forced over it The globe's map was a clone of the whole map with its non-polygon layers dropped, so it copied every layer's features first and kept the map's web tiles, swipe, time slider and groups: a map on a web basemap drew its tiles on the disc. It is now a new map with the basemap (the land and borders around the area), graticule and body, and only the polygon layers that show, unlabelled. The legend's corner was given at least 10 mm, so a sheet whose frame was near its smallest drew the legend over the locator (or the scale bar). A corner too small now leaves the legend out, with a warning. Co-Authored-By: Claude Opus 5.5 * Layouts: a narrow figure keeps its legend (pinned by a test); the globe's streamed layers left out, said Co-Authored-By: Claude Opus 5.5 * Layouts: a globe locator no layout shows any more is replaced by the next, not left behind Co-Authored-By: Claude Opus 5.5 * print-layout skill: the locator's default by profile (globe on a sheet, none on a figure) Co-Authored-By: Claude Opus 5.5 * Layouts: a pale fill counts as data where the surrounds go frame_coverage counted a pixel as drawn only when a channel fell under 245, so a pale fill (such as #FAFAF5) with no outline read as empty and a surround could be placed on it. The blank basemap is pure opaque white with no graticule, so anything else now counts. Co-Authored-By: Claude Opus 5.5 * Layouts: only a locator takes the legend's room; a replaced locator map is said The legend's width keeps its old 10 mm floor, so a figure or a sheet without a locator lays its legend out as before; the legend is left out only when a locator leaves it under 10 mm of height. Reusing a locator map no layout showed now adds a warning naming it. Co-Authored-By: Claude Opus 5.5 * Layouts test: the reused locator map is rebuilt, not kept (a layer hidden meanwhile is gone) Co-Authored-By: Claude Opus 5.5 * Layouts: the corner search's bound stated (four surrounds, 256 picks), its repeat check without indexing Co-Authored-By: Claude Opus 5.5 * Layouts: the globe keeps a sketch of each polygon layer, simplified to its disc Each layer copied onto the globe's map is simplified (Douglas-Peucker) to a tenth of a millimetre at the globe's scale, so a project with large polygon layers saves a sketch of them for the locator rather than a second full copy. In memory the copy shared the source's features already (Features is copy-on-write); the saved file is what grew. Co-Authored-By: Claude Opus 5.5 * Layouts: corners costed with the legend as laid out; the coverage drawing restored however it ends; the scale worked out once The corner search costed the legend at its full size, so a legend that would have been shortened to stop short of the locator above it paid the overlap penalty and moved; it is now costed as it will be laid out. frame_coverage also sets the map's graticule aside (the blank basemap already draws it transparent) and puts the basemap, graticule and label lock back through a guard, so a panic in the drawing leaves the map as it was. expand works the frame's scale out once for both its forms, and puts the decimal-comma scale in before the page and layout names, so a name holding "{scale}" isn't read as the token. Co-Authored-By: Claude Opus 5.5 * Layouts: a polygon too small for the globe's simplification keeps its own ring Co-Authored-By: Claude Opus 5.5 * Layouts: only a globe map made as this command makes them is reused as a locator A map is now reused only if it also has no web tiles and only unlabelled polygon layers, so a globe map made by hand under a locator's name is left alone. A note says the layer clone shares its rows (Features is copy-on-write). Co-Authored-By: Claude Opus 5.5 * Layouts: the simplified sketch named apart from the session Co-Authored-By: Claude Opus 5.5 * UTM names only for the zones EPSG defines (NAD 83 1-23, ETRS 89 28-38); a closure's parameter named apart from the session Co-Authored-By: Claude Opus 5.5 * Layouts: web tiles set aside with the basemap where the surrounds are placed; the sheet test pins its one placement warning Co-Authored-By: Claude Opus 5.5 * Layouts: OVER_DATA's doc comment reads Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/assistant/skills/print-layout/SKILL.md | 3 +- crates/data/src/raster/wkt.rs | 33 ++ crates/engine/src/cmd/layout.rs | 317 ++++++++++++++---- crates/engine/src/tests.rs | 182 +++++++++- crates/map/src/graphics.rs | 3 +- crates/map/src/layout.rs | 16 +- crates/render/src/layout.rs | 122 ++++++- docs/user-guide/layouts.md | 32 +- 8 files changed, 615 insertions(+), 93 deletions(-) diff --git a/crates/assistant/skills/print-layout/SKILL.md b/crates/assistant/skills/print-layout/SKILL.md index 24a6ca566..858d650a3 100644 --- a/crates/assistant/skills/print-layout/SKILL.md +++ b/crates/assistant/skills/print-layout/SKILL.md @@ -8,7 +8,8 @@ description: Make a printable page layout — map frame, title, legend, north ar 1. Get the map right first: symbology, labels and extent. A layout's map frame shows the map's current view. 2. For a finished sheet or a figure for a paper in one step, use `layout.publication` with a `"profile"` (`"a4-portrait"`, `"a3-landscape"`, … for a sheet; `"figure-1col"`, `"thesis-full"`, … for a figure), the `"extent"` - to fit, and the texts: `"title"`, `"subtitle"`, `"sources"`, `"note"`, and a `"locator"` (`"globe"`: the visible polygon layers held in the project, not streamed ones, over the basemap; `"inset"` or `"none"`; when omitted, `"globe"` on a sheet and `"none"` on a figure). It places the frame at a round scale, the + to fit, and the texts: `"title"`, `"subtitle"`, `"sources"`, `"note"`, a `"locator"` (`"globe"`: the visible polygon layers held in the project, not streamed ones, over the basemap; `"inset"` or `"none"`; when omitted, `"globe"` on a sheet and `"none"` on a figure), and `"decimal": ","` for a map in a language with a decimal comma. Its + surrounds take the corners where they cover the least data; read its `"warnings"`. It places the frame at a round scale, the graticule, legend, scale bar and north arrow, and wraps the footer. Its result lists every element's id and name (`"elements"`), so skip to step 5 to adjust them. Otherwise, `layout.new` `{"name": N, "page": "letter"|"a4"|…, "orientation": "landscape"|"portrait"}` creates the layout and diff --git a/crates/data/src/raster/wkt.rs b/crates/data/src/raster/wkt.rs index b8c5ca58f..028801b10 100644 --- a/crates/data/src/raster/wkt.rs +++ b/crates/data/src/raster/wkt.rs @@ -607,6 +607,25 @@ fn utm(name: &str) -> Option { } } +/// The datum and zone of a UTM zone's EPSG code on WGS 84, NAD 83, ETRS 89 or SIRGAS 2000, the +/// codes [`utm`] reads names into: `31983` is `("SIRGAS 2000", 23, false)` (zone 23 south). Only +/// the codes EPSG defines (NAD 83 zones 1 to 23, ETRS 89 zones 28 to 38). For naming a map's +/// projection where its PROJ definition gives only an ellipsoid. +pub fn utm_of_code(code: u32) -> Option<(&'static str, u32, bool)> { + Some(match code { + 32601..=32660 => ("WGS 84", code - 32600, true), + 32701..=32760 => ("WGS 84", code - 32700, false), + 26901..=26923 => ("NAD83", code - 26900, true), + 25828..=25838 => ("ETRS89", code - 25800, true), + 31965..=31976 => ("SIRGAS 2000", code - 31954, true), + 6210 => ("SIRGAS 2000", 23, true), + 6211 => ("SIRGAS 2000", 24, true), + 31977..=31985 => ("SIRGAS 2000", code - 31960, false), + 5396 => ("SIRGAS 2000", 26, false), + _ => return None, + }) +} + /// A projection's PROJ definition built from its WKT parameters. fn custom(p: &Node, method: &str) -> Result { let params: Vec<(String, f64)> = p @@ -1107,6 +1126,20 @@ mod tests { for code in [31965, 31976, 6210, 6211, 31977, 31985, 5396] { assert!(Crs::from_epsg(code).is_some(), "EPSG:{code}"); } + // And back: every code's datum and zone name it again. + for code in [32631, 32717, 26917, 25832, 31965, 31976, 6210, 6211, 31977, 31983, 31985, 5396] { + let (datum, zone, north) = utm_of_code(code).unwrap_or_else(|| panic!("EPSG:{code}")); + let name = format!("{}_UTM_ZONE_{zone}{}", datum.replace(' ', "_"), if north { "N" } else { "S" }); + assert_eq!(utm(&name), Some(code), "{name}"); + } + assert_eq!(utm_of_code(31983), Some(("SIRGAS 2000", 23, false))); + assert_eq!(utm_of_code(3857), None); + // Only the zones EPSG defines: NAD 83 up to zone 23, ETRS 89 from 28 to 38. + assert_eq!(utm_of_code(26923), Some(("NAD83", 23, true))); + assert_eq!(utm_of_code(25838), Some(("ETRS89", 38, true))); + for code in [26924, 26950, 25827, 25839, 25801] { + assert_eq!(utm_of_code(code), None, "EPSG:{code}"); + } // A zone without an EPSG code is still read, by its parameters, and without a note. let zone_10s = utm_24s.replace("Zone_24S", "Zone_10S").replace("-39.0", "-123.0"); let (crs, note) = read_wkt(&zone_10s).unwrap(); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index f97708747..a771732ea 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -29,7 +29,7 @@ pub fn commands() -> Vec { r#"{"name"?: string, "page"?: "letter"|"legal"|"tabloid"|"a5"|"a4"|"a3"|"a2"|"a1"|"a0", "orientation"?: "landscape"|"portrait", "width"?: mm, "height"?: mm, "template"?: "map" (frame, title, legend, north arrow, scale bar; the default)|"blank"}"#, ), C::new("layout.publication", "New Publication Layout", "Publish › Layout", publication).params( - r#"{"profile"?: "a4-portrait" (the default)|"a4-landscape"|"a3-portrait"|"a3-landscape"|"a2-portrait"|"a2-landscape" (a sheet: frame, title, subtitle, the map, a boxed footer)|"figure-1col" (90 mm)|"figure-1.5col" (140 mm)|"figure-2col" (190 mm)|"thesis-full" (160 × 225 mm)|"thesis-half" (160 × 110 mm) (a figure: the map edge to edge, its title and sources left to the caption), "extent"?: [west, south, east, north] in degrees that must fit (default: the map view), "name"?: string, "title"?: text, "subtitle"?: text, "sources"?: text | [texts], "reference"?: text | [texts] (default: the projection, datum and graticule), "note"?: text, "author"?: text (default: "Made with RustGIS · {date}"; "" or [] for none), "headings"?: [the sources' heading, the reference's heading], "locator"?: "globe" (a globe with the area outlined; the default on sheets) | "inset" (the surrounding region, the area outlined) | "none" (the default on figures)} → a new layout of the active map at a round scale, its texts wrapped to their boxes; {projection}, {datum} and {grid} in the texts are filled from the map"#, + r#"{"profile"?: "a4-portrait" (the default)|"a4-landscape"|"a3-portrait"|"a3-landscape"|"a2-portrait"|"a2-landscape" (a sheet: frame, title, subtitle, the map, a boxed footer)|"figure-1col" (90 mm)|"figure-1.5col" (140 mm)|"figure-2col" (190 mm)|"thesis-full" (160 × 225 mm)|"thesis-half" (160 × 110 mm) (a figure: the map edge to edge, its title and sources left to the caption), "extent"?: [west, south, east, north] in degrees that must fit (default: the map view), "name"?: string, "title"?: text, "subtitle"?: text, "sources"?: text | [texts], "reference"?: text | [texts] (default: the projection, datum and graticule), "note"?: text, "author"?: text (default: "Made with RustGIS · {date}"; "" or [] for none), "headings"?: [the sources' heading, the reference's heading], "locator"?: "globe" (a globe with the area outlined; the default on sheets) | "inset" (the surrounding region, the area outlined) | "none" (the default on figures), "decimal"?: "." (the default) | "," (graticule, scale bar and colorbar numbers and {scale} with a decimal comma: 0,05 and 1:6.300.000)} → a new layout of the active map at a round scale, its texts wrapped to their boxes; {projection}, {datum} and {grid} in the texts are filled from the map"#, ), C::new("layout.activate", "Open Layout", "Browser", |s, p| { let i = index(s, &by_name(p))?; @@ -79,7 +79,7 @@ pub fn commands() -> Vec { }) .params(r#"{"name"?: string, "layout"?: name|index (instead of name; default: the open layout), "newName"?: string (default: " copy")}"#), C::new("layout.page", "Page Size", "Publish › Page Setup", page).params( - r#"{"layout"?, "page"?: page id, "orientation"?: "landscape"|"portrait", "width"?: mm, "height"?: mm}"#, + r#"{"layout"?, "page"?: page id, "orientation"?: "landscape"|"portrait", "width"?: mm, "height"?: mm, "decimal"?: "."|"," (the decimal mark of the numbers its surrounds print)}"#, ), C::new("layout.addFrame", "Map Frame", "Publish › Map Frames", add_frame).params( 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}"#, @@ -380,6 +380,12 @@ fn projection_and_datum(s: &Session) -> (String, String) { if let Some((datum, name)) = label.split_once(" / ") { return (name.trim().to_string(), datum.trim().to_string()); } + // A UTM zone's EPSG code, named: its PROJ definition gives only an ellipsoid (SIRGAS 2000's + // zones have +ellps=GRS80), and its label only the code. + let code = label.strip_prefix("EPSG:").and_then(|n| n.parse::().ok()); + if let Some((datum, zone, north)) = code.and_then(rustgis_data::raster::wkt::utm_of_code) { + return (format!("UTM zone {zone}{}", if north { "N" } else { "S" }), datum.to_string()); + } let datum = rustgis_map::projection::lookup(&projection.code()).ok().and_then(|(def, _)| datum_of(&def)); (label, datum.unwrap_or_else(|| "WGS 84".to_string())) } @@ -463,6 +469,34 @@ fn pub_box(fill: Rgba, outline: Rgba, width: f64) -> ElementKind { ElementKind::Rectangle { fill, outline, width } } +/// The surrounds a publication layout places in the map frame's corners. +#[derive(Clone, Copy, PartialEq)] +enum Surround { + Locator, + Arrow, + Legend, + Scale, +} + +/// A publication layout's colorbar's height, in mm: its title, the bar and its labels. +const COLORBAR_MM: f64 = 15.0; + +/// The share of what the map draws a surround may cover before the layout warns about it, when a +/// corner freer by at least as much went to another surround. +const OVER_DATA: f64 = 0.25; + +/// A `"decimal"` parameter: `","` for a decimal comma, `"."` for a point; `None` when not given. +fn decimal_mark(p: &Value) -> Result, String> { + match p.get("decimal").filter(|v| !v.is_null()) { + None => Ok(None), + Some(v) => match v.as_str().map(str::trim) { + Some(",") => Ok(Some(true)), + Some(".") => Ok(Some(false)), + _ => Err("decimal is \".\" or \",\"".into()), + }, + } +} + /// What a publication layout locates its area on. #[derive(Clone, Copy, PartialEq)] enum Locator { @@ -529,6 +563,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { Some(t) => Some(t.join(" ")).filter(|t| !t.trim().is_empty()), }; let headings = texts_of(p, "headings")?.unwrap_or_default(); + let comma = decimal_mark(p)?.unwrap_or(false); let locator = match p.get("locator").filter(|v| !v.is_null()) { None => { if sheet { @@ -724,6 +759,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let grid = Grid { step, edges, ..Grid::default() }; let name = unique_name(s, ps(p, "name").unwrap_or("Publication")); let mut l = Layout::new(name.clone(), w, h); + l.decimal_comma = comma; let frame = l.add( "Map Frame", (fx, fy, fw, fh), @@ -740,10 +776,12 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { pitch: 0.0, }, ); - // Over the map, in its corners, clear of the graticule's labels (drawn inside the frame along - // its labelled edges): the north arrow at the top right, the legend at the bottom left sized - // to what it lists, the scale bar and the numeric scale under it at the bottom right, each on - // a pale box. + // Over the map, clear of the graticule's labels (drawn inside the frame along its labelled + // edges): the north arrow, the legend sized to what it lists, the scale bar with the numeric + // scale under it, and the locator, each on a pale box. Each takes the corner where it covers + // the least of what the map draws; by default (and when it makes no difference) the locator at + // the top left, the arrow at the top right, the legend at the bottom left and the scale at the + // bottom right. let paper = Rgba::new(0xFF, 0xFF, 0xFF, 225); let (e, small_pt) = if sheet { (4.0 * k, 6.5 * k) } else { (2.0, 6.0) }; let room = |labelled: bool, side: bool| { @@ -757,10 +795,9 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { }; let (left, right, top, bottom) = (room(edges.left, true), room(edges.right, true), room(edges.top, false), room(edges.bottom, false)); let (aw, ah) = if sheet { (8.0 * k, 12.0 * k) } else { (5.0, 7.5) }; - l.add("North Arrow", (fx + fw - right - aw, fy + top, aw, ah), ElementKind::north_arrow(frame)); - // The locator, at the top left: a globe (a copy of the map's vector layers on the globe, its - // diameter nearly the frame's) or the surrounding region on the map itself, the area outlined - // on either by the map frame's extent indicator. + // The locator: a globe (a copy of the map's polygon layers on the globe, its diameter nearly + // the frame's) or the surrounding region on the map itself, the area outlined on either by + // the map frame's extent indicator. let side = if sheet { (0.24 * fw.min(fh)).clamp(25.0 * k, 70.0 * k) } else { (0.3 * fw.min(fh)).clamp(18.0, 45.0) }; let locator_map = match locator { Locator::None => None, @@ -856,25 +893,6 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { } Locator::Inset => Some((s.map().name.clone(), round_scale(scale * fw / side * 4.0), FrameShape::Rectangle)), }; - if let Some((map, locator_scale, shape)) = &locator_map { - l.add( - "Locator", - (fx + left, fy + top, side, side), - ElementKind::MapFrame { - map: map.clone(), - center, - scale: *locator_scale, - shape: shape.clone(), - grid: None, - extent_of: Some(frame), - extent_color: rustgis_map::layout::INDICATOR_COLOR, - extent_width: rustgis_map::layout::INDICATOR_WIDTH, - bearing: 0.0, - pitch: 0.0, - }, - ); - } - let below_locator = if locator_map.is_some() { side + e } else { 0.0 }; // The legend lists what the frame draws: its map synced to the frame's view first, as an // export does, so a streamed layer has its features there. (This changes the session's map, // so it comes after the last refusal: nothing below fails.) While one is still reading, the @@ -903,38 +921,221 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { (cw, ch) = (cw.max(fw * 0.3), ch.max(fh * 0.25)); warnings.push("the legend's classes aren't known yet (a layer still reading): it has a fixed size to fit by hand".into()); } - // The scale bar's box, at the bottom right: the legend stops short of it (and so of the north - // arrow above it, which is narrower), however narrow the frame. let (sw, sh, nh) = ((fw * 0.3).max(25.0), if sheet { 8.0 * k } else { 6.0 }, pitch(small_pt) + 0.5); - // Its corner, at least 10 mm each way; under a locator, though, a corner shorter than that - // leaves the legend out, said, rather than drawing it over the locator. + let pad = 2.0; + // What the map draws where, on a coarse grid of the frame (its basemap left out). + let coverage = crate::render::layout::frame_coverage(s.map_mut(), center, scale, fw, fh, 96); + // The share of a rectangle (in page mm) over the frame's drawn cells. + let covered = |(x, y, w, h): (f64, f64, f64, f64)| -> f64 { + let Some((gw, gh, cells)) = &coverage else { return 0.0 }; + let (gw, gh) = (f64::from(*gw), f64::from(*gh)); + let col = |v: f64| (((v - fx) / fw * gw).floor().clamp(0.0, gw)) as usize; + let row = |v: f64| (((v - fy) / fh * gh).floor().clamp(0.0, gh)) as usize; + let (c0, c1, r0, r1) = (col(x), col(x + w).max(col(x) + 1), row(y), row(y + h).max(row(y) + 1)); + let (mut n, mut drawn) = (0usize, 0usize); + for r in r0..r1.min(gh as usize) { + for c in c0..c1.min(gw as usize) { + n += 1; + if cells.get(r * gw as usize + c).copied().unwrap_or(false) { + drawn += 1; + } + } + } + if n == 0 { 0.0 } else { drawn as f64 / n as f64 } + }; + // The surrounds to place: what each is, its size and its corner by default (0 top left, + // 1 top right, 2 bottom left, 3 bottom right). + let mut surrounds: Vec<(Surround, f64, f64, usize)> = Vec::new(); + if locator_map.is_some() { + surrounds.push((Surround::Locator, side, side, 0)); + } + surrounds.push((Surround::Arrow, aw, ah, 1)); + // The topmost visible raster drawn on a ramp gets a colorbar, under the legend in its box (a + // map whose theme is a raster has its key there, which the legend's rows don't give). + let bar = rustgis_map::layout::colorbar_layer(s.map(), None) + .filter(|l| l.raster.as_ref().and_then(rustgis_map::layout::color_scale).is_some()) + .map(|l| l.name.clone()); + let (bar_w, bar_h) = (if sheet { 50.0 * k } else { 40.0 }, COLORBAR_MM); + let bar_part = if bar.is_some() { bar_h + if ch > 0.0 { 2.0 } else { 0.0 } } else { 0.0 }; + let (content_w, content_h) = (if bar.is_some() { cw.max(bar_w) } else { cw }, ch + bar_part); + if content_h > 0.0 { + surrounds.push((Surround::Legend, (content_w + 2.0 * pad).min(fw * 0.45), (content_h + 2.0 * pad).min(fh * 0.6), 2)); + } + surrounds.push((Surround::Scale, sw, sh + nh, 3)); + // A corner's rectangle for a surround `w` × `h`. + let at = |corner: usize, w: f64, h: f64| { + let x = if corner.is_multiple_of(2) { fx + left } else { fx + fw - right - w }; + let y = if corner < 2 { fy + top } else { fy + fh - bottom - h }; + (x, y, w, h) + }; + // Every way of giving the surrounds a corner each, each tried once (a corner for each of + // them, the first one's changing slowest; two in one corner passed over): the one covering the + // least of the map's drawing (its area in mm²) with no surround over another, a surround off + // its own corner costing a hundredth of its area so that, all else equal, they stay where they + // belong. There are four surrounds at most (locator, arrow, legend, scale), so 4⁴ = 256 picks. const MIN_LEGEND_MM: f64 = 10.0; - let room_w = (fw - left - right - sw - 2.0).max(MIN_LEGEND_MM); - let room_h = fh - top - bottom - below_locator; - if ch > 0.0 && below_locator > 0.0 && room_h < MIN_LEGEND_MM { - warnings.push( - "the frame leaves the legend no room below the locator, so it is left out: give the map more room, or set \"locator\": \"none\"" - .into(), - ); - } else if ch > 0.0 { - let room_h = room_h.max(MIN_LEGEND_MM); - let pad = 2.0; - let (lw, lh) = ((cw + 2.0 * pad).min(fw * 0.45).min(room_w), (ch + 2.0 * pad).min(fh * 0.6).min(room_h)); - if cw + 2.0 * pad > lw || ch + 2.0 * pad > lh { - warnings.push("the legend lists more than fits its corner: its last rows or the ends of long labels are left out".into()); + let n = surrounds.len(); + let mut corners: Vec = surrounds.iter().map(|x| x.3).collect(); + let mut best = f64::INFINITY; + for code in 0..4usize.pow(n as u32) { + let pick: Vec = (0..n).map(|i| code / 4usize.pow((n - 1 - i) as u32) % 4).collect(); + if pick.iter().enumerate().any(|(i, c)| pick.iter().take(i).any(|d| d == c)) { + continue; + } + // Each as it will be laid out: the legend short of the surrounds beside and above or + // below it in this pick (down to 10 mm), so it isn't moved for an overlap its shortening + // avoids. + let other = |c: usize| surrounds.iter().zip(&pick).find(|(_, k)| **k == c).map(|(&(_, w, h, _), _)| (w, h)); + let rects: Vec<(f64, f64, f64, f64)> = surrounds + .iter() + .zip(&pick) + .map(|(&(kind, w, h, _), &corner)| { + if kind != Surround::Legend { + return at(corner, w, h); + } + let room_w = other(corner ^ 1).map_or(f64::INFINITY, |(ow, _)| fw - left - right - ow - 2.0); + let room_h = other(corner ^ 2).map_or(f64::INFINITY, |(_, oh)| fh - top - bottom - oh - e); + at(corner, w.min(room_w.max(MIN_LEGEND_MM)), h.min(room_h.max(MIN_LEGEND_MM))) + }) + .collect(); + let mut cost: f64 = surrounds + .iter() + .zip(&pick) + .zip(&rects) + .map(|((&(.., home), &corner), &r)| (covered(r) + if corner == home { 0.0 } else { 0.01 }) * r.2 * r.3) + .sum(); + // Two surrounds over each other cost ten times their overlap, more than any data under + // them: in a frame too small for them all, the corners that keep them apart win. + for (i, a) in rects.iter().enumerate() { + for b in rects.iter().skip(i + 1) { + let (ow, oh) = ((a.0 + a.2).min(b.0 + b.2) - a.0.max(b.0), (a.1 + a.3).min(b.1 + b.3) - a.1.max(b.1)); + cost += 10.0 * ow.max(0.0) * oh.max(0.0); + } + } + if cost < best - 1e-9 { + (best, corners) = (cost, pick); } - let (lx, ly) = (fx + left, fy + fh - bottom - lh); - l.add("Legend Box", (lx, ly, lw, lh), pub_box(paper, clear, 0.0)); + } + // Each surround in its corner; the legend stops short of the others beside and above it. + let corner_of = + |what: Surround| surrounds.iter().zip(&corners).find(|((kind, ..), _)| *kind == what).map(|(&(_, w, h, _), &c)| (c, w, h)); + let mut placed: Vec<(&'static str, (f64, f64, f64, f64))> = Vec::new(); + if let (Some((map, locator_scale, shape)), Some((corner, w, h))) = (&locator_map, corner_of(Surround::Locator)) { + let r = at(corner, w, h); + placed.push(("locator", r)); l.add( - "Legend", - (lx + pad, ly + pad, lw - 2.0 * pad, lh - 2.0 * pad), - ElementKind::Legend { frame, title: String::new(), all_classes: false, hide_other: false, exclude: Vec::new() }, + "Locator", + r, + ElementKind::MapFrame { + map: map.clone(), + center, + scale: *locator_scale, + shape: shape.clone(), + grid: None, + extent_of: Some(frame), + extent_color: rustgis_map::layout::INDICATOR_COLOR, + extent_width: rustgis_map::layout::INDICATOR_WIDTH, + bearing: 0.0, + pitch: 0.0, + }, ); } - let (sx, sy) = (fx + fw - right - sw, fy + fh - bottom - sh - nh); - l.add("Scale Box", (sx, sy, sw, sh + nh), pub_box(paper, clear, 0.0)); - l.add("Scale Bar", (sx, sy, sw, sh), ElementKind::scale_bar(frame)); - l.add("Scale", (sx, sy + sh, sw, nh), pub_text("{scale}".into(), small_pt, ink, false, TextAlign::Center)); + if let Some((corner, w, h)) = corner_of(Surround::Arrow) { + let r = at(corner, w, h); + placed.push(("north arrow", r)); + l.add("North Arrow", r, ElementKind::north_arrow(frame)); + } + // The legend's corner, short of the surrounds beside and above or below it: one the locator + // leaves too small for a legend leaves it out, said, rather than drawing it over the locator. + let legend_at = corner_of(Surround::Legend).and_then(|(corner, w, h)| { + let other = |c: usize| surrounds.iter().zip(&corners).find(|(_, k)| **k == c).map(|(&(kind, w, h, _), _)| (kind, w, h)); + let room_w = other(corner ^ 1).map_or(f64::INFINITY, |(_, ow, _)| fw - left - right - ow - 2.0); + let room_h = other(corner ^ 2).map_or(f64::INFINITY, |(_, _, oh)| fh - top - bottom - oh - e); + // Short of the locator, under 10 mm: left out. Beside the arrow or the scale bar, the + // legend keeps 10 mm each way, as a layout without a locator always has. + let locator_in_the_way = |room: f64, c: usize| room < MIN_LEGEND_MM && other(c).is_some_and(|o| o.0 == Surround::Locator); + if locator_in_the_way(room_h, corner ^ 2) || locator_in_the_way(room_w, corner ^ 1) { + warnings.push( + "the frame leaves the legend no room beside the locator, so it is left out: give the map more room, or set \"locator\": \"none\"" + .into(), + ); + return None; + } + Some((corner, w.min(room_w.max(MIN_LEGEND_MM)), h.min(room_h.max(MIN_LEGEND_MM)))) + }); + if let Some((corner, lw, lh)) = legend_at { + let r = at(corner, lw, lh); + placed.push(("legend", r)); + l.add("Legend Box", r, pub_box(paper, clear, 0.0)); + // The colorbar at the bottom of the box, as tall as the box allows; the legend's rows in + // what is left above it. Each says when it doesn't fit, rather than one blaming the other. + let (ix, iy, iw, ih) = (r.0 + pad, r.1 + pad, lw - 2.0 * pad, lh - 2.0 * pad); + let bar_fit = if bar.is_some() { bar_h.min(ih) } else { 0.0 }; + let rows_h = (ih - bar_fit - if bar.is_some() { bar_part - bar_h } else { 0.0 }).max(0.0); + if ch > 0.0 && (cw > iw || ch > rows_h) { + warnings.push("the legend lists more than fits its corner: its last rows or the ends of long labels are left out".into()); + } + if bar.is_some() && (bar_w > iw || bar_h > ih) { + warnings.push("the legend's box is smaller than the colorbar: it is drawn narrower or shorter".into()); + } + if ch > 0.0 && rows_h >= 1.0 { + l.add( + "Legend", + (ix, iy, iw, rows_h), + ElementKind::Legend { frame, title: String::new(), all_classes: false, hide_other: false, exclude: Vec::new() }, + ); + } + if let Some(layer) = &bar { + l.add( + "Colorbar", + (ix, iy + ih - bar_fit, iw, bar_fit), + ElementKind::Colorbar { + frame, + layer: Some(layer.clone()), + title: layer.clone(), + orientation: Orientation::Horizontal, + ticks: 5, + tick_side: TickSide::End, + reverse: false, + }, + ); + } + } + if let Some((corner, w, h)) = corner_of(Surround::Scale) { + let (sx, sy, ..) = at(corner, w, h); + placed.push(("scale bar", (sx, sy, w, h))); + l.add("Scale Box", (sx, sy, sw, sh + nh), pub_box(paper, clear, 0.0)); + l.add("Scale Bar", (sx, sy, sw, sh), ElementKind::scale_bar(frame)); + l.add("Scale", (sx, sy + sh, sw, nh), pub_text("{scale}".into(), small_pt, ink, false, TextAlign::Center)); + } + // What placing couldn't avoid: a surround over the map's drawing while a clearly freer corner + // went to another (more surrounds than free corners), or two overlapping (a frame too small + // for them all). Data under every corner alike (a context layer filling the frame) leaves + // nothing to move, so it isn't said. One warning names them all. + let crowded: Vec<&str> = placed + .iter() + .filter(|(_, r)| { + let share = covered(*r); + let freest = (0..4).map(|c| covered(at(c, r.2, r.3))).fold(f64::INFINITY, f64::min); + share > OVER_DATA && share - freest > OVER_DATA + }) + .map(|(what, _)| *what) + .collect(); + if let Some((last, rest)) = crowded.split_last() { + let (names, verb) = match rest { + [] => (format!("the {last}"), "sits"), + _ => (format!("the {} and the {last}", rest.join(", the ")), "sit"), + }; + warnings + .push(format!("{names} {verb} on what the map shows, the freer corners having gone to the others: move by hand what matters")); + } + for (i, (a, ra)) in placed.iter().enumerate() { + for (b, rb) in placed.iter().skip(i + 1) { + if ra.0 < rb.0 + rb.2 && rb.0 < ra.0 + ra.2 && ra.1 < rb.1 + rb.3 && rb.1 < ra.1 + ra.3 { + warnings.push(format!("the {a} and the {b} overlap: the frame is too small for both")); + } + } + } for (n, r, kind) in around { l.add(&n, r, kind); } @@ -953,10 +1154,14 @@ fn page(s: &mut Session, p: &Value) -> CmdResult { let i = index(s, p)?; let cur = s.project.layouts.get(i).map(|l| (l.width, l.height)); let (w, h) = page_size(p, cur)?; + let comma = decimal_mark(p)?; let l = s.project.layouts.get_mut(i).ok_or("no layout")?; l.width = w; l.height = h; - Ok(json!({"width": w, "height": h})) + if let Some(comma) = comma { + l.decimal_comma = comma; + } + Ok(json!({"width": w, "height": h, "decimal": if l.decimal_comma { "," } else { "." }})) } /// Sets a layout's ruler guides: the lists given replace those there. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d532a76e2..688788c58 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2479,9 +2479,12 @@ fn publication_layouts_fit_their_pages() { } let frame = l.elements.iter().find(|e| e.kind.code() == "mapFrame").unwrap(); let ElementKind::MapFrame { scale, grid: Some(grid), .. } = &frame.kind else { panic!("{profile}: no grid") }; - // The legend stops short of the scale bar's box, however narrow the frame. + // The legend stops short of the scale bar's box, whichever corners they take, however + // narrow the frame. let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap(); - assert!(named("Legend Box").x + named("Legend Box").w <= named("Scale Box").x, "{profile}: the legend reaches the scale bar"); + let (a, b) = (named("Legend Box"), named("Scale Box")); + let apart = a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y; + assert!(apart, "{profile}: the legend reaches the scale bar"); // A round scale, two significant figures, at which the extent still fits. let unit = 10f64.powf(scale.log10().floor() - 1.0); assert!(((scale / unit) - (scale / unit).round()).abs() < 1e-6, "{profile}: 1:{scale}"); @@ -2516,7 +2519,10 @@ fn publication_layouts_fit_their_pages() { let reference = text("Reference").unwrap(); assert!(reference.contains("Web Mercator") && reference.contains("WGS 84") && reference.contains("2°"), "{reference}"); assert!(text("Author").unwrap().contains("{date}"), "the date stays a token the layout fills"); - assert!(r["warnings"].as_array().unwrap().is_empty(), "{profile}: {r}"); + // Nothing said but, over the sample world's land, the surrounds sharing its one corner + // of sea (placement has its own test). + let said: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(|w| w.as_str()).collect(); + assert!(said.len() <= 1 && said.iter().all(|w| w.contains("on what the map shows")), "{profile}: {r}"); } else { assert!(text("Title").is_none() && text("Sources").is_none(), "{profile}: a figure's texts are its caption's"); assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("caption")), "{r}"); @@ -2605,6 +2611,13 @@ fn publication_layouts_fit_their_pages() { assert_eq!(datum_of("+proj=robin +ellps=WGS84").as_deref(), Some("WGS 84")); assert_eq!(datum_of("+proj=eqearth +R=6371008.8").as_deref(), Some("a sphere")); assert_eq!(datum_of("+proj=moll"), None); + // A UTM zone's code is named by its zone and datum, not its ellipsoid. + s.run("map.projection", &json!({"type": "EPSG:31983"})).unwrap(); + s.run("layout.publication", &json!({"extent": [-46, -11, -40, -2.7]})).unwrap(); + let l = s.project.layouts.last().unwrap(); + let ElementKind::Text { text, .. } = &l.elements.iter().find(|e| e.name == "Reference").unwrap().kind else { panic!() }; + assert!(text.contains("Projection: UTM zone 23S") && text.contains("Datum: SIRGAS 2000"), "{text}"); + s.project.layouts.pop(); // A preset on NAD83 says so, though its name holds no datum. s.run("map.projection", &json!({"type": "usaAlbers"})).unwrap(); s.run("layout.publication", &json!({"extent": [-100, 35, -90, 45]})).unwrap(); @@ -2659,10 +2672,20 @@ fn publication_layouts_locate_their_area() { let (frame, locator) = (named("Map Frame"), named("Locator")); let ElementKind::MapFrame { map, shape, extent_of, scale, .. } = &locator.kind else { panic!() }; assert_eq!((map.as_str(), shape, *extent_of), (globe.as_str(), &FrameShape::Circle, Some(frame.id))); - // Inside the map frame at its top left, above the legend. - assert!(locator.x > frame.x && locator.y > frame.y && locator.x + locator.w < frame.x + frame.w / 2.0); + // Inside the map frame (in a corner of its own: see publication_surrounds_keep_off_the_data), + // clear of the legend. + assert!( + locator.x > frame.x + && locator.y > frame.y + && locator.x + locator.w < frame.x + frame.w + && locator.y + locator.h < frame.y + frame.h + ); let legend = named("Legend Box"); - assert!(legend.y >= locator.y + locator.h, "the legend stops short of the locator"); + let apart = legend.x + legend.w <= locator.x + || locator.x + locator.w <= legend.x + || legend.y + legend.h <= locator.y + || locator.y + locator.h <= legend.y; + assert!(apart, "the legend stops short of the locator"); // The globe nearly fills its frame: drawn, its ink spans most of the frame's width. assert!(*scale > 1e7, "1:{scale}"); let png = dir.join("globe.png"); @@ -2773,9 +2796,8 @@ fn publication_layouts_locate_their_area() { assert_eq!(rings("island"), [5]); assert!(rings("").iter().all(|n| *n >= 4)); // Sheets with ever more source lines, so ever smaller frames, down to the smallest a sheet - // takes: the legend is never drawn over the locator, and once there's no room under it, it is - // left out, said. - let mut left_out = false; + // takes: the legend is never drawn over the locator, whichever corners they take (the search + // keeps them apart), and if the locator leaves it no room it is left out, said. for n in 1..80 { let sources: Vec = (0..n).map(|i| format!("Source {i}: a line long enough to take a line of its own")).collect(); let Ok(r) = s.run("layout.publication", &json!({"profile": "a4-landscape", "extent": piaui, "sources": sources})) else { break }; @@ -2783,16 +2805,148 @@ fn publication_layouts_locate_their_area() { let named = |name: &str| l.elements.iter().find(|e| e.name == name); let locator = named("Locator").unwrap(); match named("Legend Box") { - Some(b) => assert!(b.y >= locator.y + locator.h - 1e-6, "{n} lines: the legend over the locator"), + Some(b) => { + let apart = b.x >= locator.x + locator.w - 1e-6 + || locator.x >= b.x + b.w - 1e-6 + || b.y >= locator.y + locator.h - 1e-6 + || locator.y >= b.y + b.h - 1e-6; + assert!(apart, "{n} lines: the legend over the locator"); + } None => { - let said = r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("no room below the locator")); + let said = r["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w.as_str().unwrap_or("").contains("the frame leaves the legend no room beside the locator")); assert!(said, "{n} lines: {r}"); - left_out = true; - break; } } } - assert!(left_out, "no frame got too small for a legend under the locator"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A publication layout's surrounds keep off the map's data (#682, part 3): with a polygon filling +/// the bottom left of the frame, the legend leaves that corner and the smallest surround, the +/// north arrow, takes it; with data everywhere, each surround says it covers some; with none, they +/// stay in their own corners. `"decimal": ","` gives the layout a decimal comma, which +/// `layout.page` also sets. +#[test] +fn publication_surrounds_keep_off_the_data() { + let dir = std::env::temp_dir().join(format!("rustgis-placement-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let square = |name: &str, (w, so, e, n): (f64, f64, f64, f64)| { + let path = dir.join(format!("{name}.geojson")); + let ring = format!("[[{w},{so}],[{e},{so}],[{e},{n}],[{w},{n}],[{w},{so}]]"); + std::fs::write( + &path, + format!(r#"{{"type": "FeatureCollection", "features": [{{"type": "Feature", "properties": {{"name": "{name}"}}, "geometry": {{"type": "Polygon", "coordinates": [{ring}]}}}}]}}"#), + ) + .unwrap(); + path.display().to_string() + }; + let corner_of = |s: &Session, name: &str| { + let l = s.project.layouts.last().unwrap(); + let frame = l.elements.iter().find(|e| e.name == "Map Frame").unwrap(); + let e = l.elements.iter().find(|e| e.name == name).unwrap_or_else(|| panic!("no {name}")); + let (right, low) = (e.x + e.w / 2.0 > frame.x + frame.w / 2.0, e.y + e.h / 2.0 > frame.y + frame.h / 2.0); + match (low, right) { + (false, false) => "top left", + (false, true) => "top right", + (true, false) => "bottom left", + (true, true) => "bottom right", + } + }; + let extent = json!([0, 0, 10, 10]); + // No data in any corner: each in its own. + let mut s = Session::new(Project::default()); + s.run("layer.addData", &json!({"path": square("middle", (4.0, 4.0, 6.0, 6.0))})).unwrap(); + let r = s.run("layout.publication", &json!({"extent": extent, "author": ""})).unwrap(); + let corners: Vec<_> = ["Locator", "North Arrow", "Legend Box", "Scale Box"].iter().map(|n| corner_of(&s, n)).collect(); + assert_eq!(corners, ["top left", "top right", "bottom left", "bottom right"], "{r}"); + assert!(r["warnings"].as_array().unwrap().is_empty(), "{r}"); + // A polygon filling the bottom left: the legend leaves it, the arrow (smallest) takes it. + let mut s = Session::new(Project::default()); + s.run("layer.addData", &json!({"path": square("corner", (-20.0, -20.0, 5.0, 5.0))})).unwrap(); + // The coverage is drawn on the map itself, its basemap set aside meanwhile and put back. + // Web tiles, a background like the basemap, are set aside too: they don't make every corner + // look covered (a closed port, never asked). + s.map_mut().basemap = rustgis_map::Basemap::Ocean; + s.map_mut().tiles.push(rustgis_map::TileLayer::new("Streets", rustgis_map::TileKind::Raster, "http://127.0.0.1:9/{z}/{x}/{y}.png")); + let r = s.run("layout.publication", &json!({"extent": extent, "author": ""})).unwrap(); + assert!(s.map().basemap == rustgis_map::Basemap::Ocean && s.map().label_lock.is_none() && s.map().tiles.len() == 1); + assert_ne!(corner_of(&s, "Legend Box"), "bottom left", "{r}"); + s.map_mut().tiles.clear(); + assert_eq!(corner_of(&s, "North Arrow"), "bottom left", "{r}"); + // A pale fill with no outline counts as data too: the legend leaves it the same way. + let mut pale = Session::new(Project::default()); + pale.run("layer.addData", &json!({"path": square("pale", (-20.0, -20.0, 5.0, 5.0))})).unwrap(); + pale.run("layer.color", &json!({"layer": "pale", "color": "#FAFAF5"})).unwrap(); + pale.run("layer.outline", &json!({"layer": "pale", "color": "#00000000", "width": 0})).unwrap(); + let rp = pale.run("layout.publication", &json!({"extent": extent, "author": ""})).unwrap(); + assert_ne!(corner_of(&pale, "Legend Box"), "bottom left", "{rp}"); + // Four surrounds, three free corners: the arrow says it covers data a freer corner was free of. + let said = r["warnings"].to_string(); + assert!(said.contains("the north arrow sits on what the map shows"), "{said}"); + s.run("layout.export", &json!({"path": dir.join("corner.png").display().to_string(), "dpi": 72})).unwrap(); + // Data everywhere (a context layer filling the frame): nowhere better, so nothing said and each + // in its own corner. + let mut s = Session::new(Project::default()); + s.run("layer.addData", &json!({"path": square("all", (-30.0, -30.0, 40.0, 40.0))})).unwrap(); + let r = s.run("layout.publication", &json!({"extent": extent, "author": "", "decimal": ","})).unwrap(); + assert!(!r["warnings"].to_string().contains("on what the map shows"), "{r}"); + assert_eq!(corner_of(&s, "Legend Box"), "bottom left"); + // A decimal comma, set by the command and by layout.page; anything else refused. + assert!(s.project.layouts.last().unwrap().decimal_comma); + let r = s.run("layout.page", &json!({"decimal": "."})).unwrap(); + assert_eq!(r["decimal"], "."); + assert!(!s.project.layouts.last().unwrap().decimal_comma); + for p in [json!({"decimal": ";"}), json!({"decimal": 1})] { + assert!(s.run("layout.page", &p).unwrap_err().contains("decimal is"), "{p}"); + assert!(s.run("layout.publication", &json!({"extent": extent, "decimal": p["decimal"]})).unwrap_err().contains("decimal is")); + } + s.run("layout.export", &json!({"path": dir.join("comma.png").display().to_string(), "dpi": 72})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A publication layout keys a raster theme with a colorbar: the topmost visible raster drawn on a +/// ramp gets one at the bottom of the legend's box; a map of vectors only gets none. +#[test] +fn a_publication_layouts_raster_gets_a_colorbar() { + use rustgis_map::layout::ElementKind; + let dir = std::env::temp_dir().join(format!("rustgis-colorbar-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let tif = dir.join("ramp.tif"); + std::fs::write(&tif, rustgis_data::raster::cog::example(256, 128)).unwrap(); + let mut s = Session::new(Project::default()); + s.run("layer.addData", &json!({"path": tif.display().to_string()})).unwrap(); + let r = s.run("layout.publication", &json!({"extent": [10, 45, 11, 46], "author": "", "locator": "none"})).unwrap(); + let l = s.project.layouts.last().unwrap(); + let named = |n: &str| l.elements.iter().find(|e| e.name == n); + let (bar, then) = (named("Colorbar").unwrap_or_else(|| panic!("no colorbar: {r}")), named("Legend Box").unwrap()); + let ElementKind::Colorbar { layer, title, .. } = &bar.kind else { panic!() }; + assert_eq!((layer.as_deref(), title.as_str()), (Some("ramp"), "ramp")); + assert!( + bar.x >= then.x && bar.x + bar.w <= then.x + then.w + 1e-9 && bar.y + bar.h <= then.y + then.h + 1e-9, + "inside the legend's box" + ); + s.run("layout.export", &json!({"path": dir.join("colorbar.png").display().to_string(), "dpi": 72})).unwrap(); + // Smaller boxes (figures, a locator taking room): the colorbar stays wholly inside its box, never + // above its top, and a box too small for it says so, not that legend rows were cut. + for profile in ["figure-1col", "thesis-half", "a4-landscape"] { + let r = s.run("layout.publication", &json!({"extent": [10, 45, 11, 46], "profile": profile, "locator": "globe"})).unwrap(); + let l = s.project.layouts.last().unwrap(); + let named = |n: &str| l.elements.iter().find(|e| e.name == n); + let (Some(bar), Some(then)) = (named("Colorbar"), named("Legend Box")) else { continue }; + let inside = bar.x >= then.x - 1e-9 + && bar.y >= then.y - 1e-9 + && bar.x + bar.w <= then.x + then.w + 1e-9 + && bar.y + bar.h <= then.y + then.h + 1e-9; + assert!(inside, "{profile}: the colorbar outside its box: {r}"); + } + // Vectors only: no colorbar. + let mut s = world(); + s.run("layout.publication", &json!({"extent": [10, 45, 11, 46]})).unwrap(); + assert!(s.project.layouts.last().unwrap().elements.iter().all(|e| e.name != "Colorbar")); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/map/src/graphics.rs b/crates/map/src/graphics.rs index fc1061464..29163a9d5 100644 --- a/crates/map/src/graphics.rs +++ b/crates/map/src/graphics.rs @@ -189,7 +189,8 @@ pub fn expand(text: &str, v: &TokenValues) -> String { out } -fn thousands(v: f64) -> String { +/// `v` rounded, its thousands apart with commas (`6,300,000`). +pub fn thousands(v: f64) -> String { let s = format!("{:.0}", v.clamp(0.0, 1e15)); let mut out = String::new(); for (i, ch) in s.chars().enumerate() { diff --git a/crates/map/src/layout.rs b/crates/map/src/layout.rs index a5fa4d64f..0269d57c9 100644 --- a/crates/map/src/layout.rs +++ b/crates/map/src/layout.rs @@ -995,13 +995,27 @@ pub struct Layout { pub series: Option, #[serde(default, skip_serializing_if = "Guides::is_empty")] pub guides: Guides, + /// Numbers its surrounds print (graticule, scale bar and colorbar labels, `{scale}`) take a + /// decimal comma and a point between thousands (`0,05`, `1:6.300.000`), as maps in Portuguese, + /// Spanish, French or German do; off, the other way round. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub decimal_comma: bool, #[serde(default)] next_id: u32, } impl Layout { pub fn new(name: impl Into, width: f64, height: f64) -> Self { - Layout { name: name.into(), width, height, elements: Vec::new(), series: None, guides: Guides::default(), next_id: 1 } + Layout { + name: name.into(), + width, + height, + elements: Vec::new(), + series: None, + guides: Guides::default(), + decimal_comma: false, + next_id: 1, + } } /// Adds an element and returns its id. diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 295b8a268..462f50502 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -113,7 +113,7 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O continue; }; if let Some(g) = grid { - draw_grid(&mut frame_pm, map, &view, g); + draw_grid(&mut frame_pm, map, &view, g, layout.decimal_comma); } if let Some(other) = extent_of.and_then(frame) && let Some(om) = maps.get(other.1) @@ -165,7 +165,7 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O if let Some(&(_, mi, cam, _, _)) = frame(*f) && let Some(map) = maps.get(mi) { - scale_bar(&mut pm, r, map.ground_res_at(&cam), (*units, *divisions, *style), ppp); + scale_bar(&mut pm, r, map.ground_res_at(&cam), (*units, *divisions, *style), ppp, layout.decimal_comma); } } ElementKind::Legend { frame: f, title, all_classes, hide_other, exclude } => { @@ -187,7 +187,14 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O &mut pm, r, &sc, - &Bar { title, orientation: *orientation, ticks: *ticks, side: *tick_side, reverse: *reverse }, + &Bar { + title, + orientation: *orientation, + ticks: *ticks, + side: *tick_side, + reverse: *reverse, + comma: layout.decimal_comma, + }, ppp, ), None => placeholder(&mut pm, r, "Colorbar", ppp), @@ -327,6 +334,16 @@ fn finish_frame_map(mut img: crate::Rendered, ppp: f64) -> Pixmap { /// Expands layout text tokens: the page ones here, the map ones from the first frame. pub fn expand(t: &str, layout: &Layout, info: &PageInfo, frame: Option<(&Map, rustgis_map::Camera)>) -> String { + // The frame's scale, worked out once for both its forms below. + let scale = frame.map(|(m, cam)| m.ground_res_at(&cam) / rustgis_map::layout::PAPER_M_PER_PT); + // A layout with a decimal comma prints its scale with points between thousands: put in first, + // so a page or layout name holding "{scale}" isn't read as the token. + let t = match scale { + Some(scale) if layout.decimal_comma => { + t.replace("{scale}", &format!("1:{}", decimal(rustgis_map::graphics::thousands(scale), true))) + } + _ => t.to_string(), + }; let t = t .replace("{page}", &info.page.max(1).to_string()) .replace("{pages}", &info.pages.max(1).to_string()) @@ -336,7 +353,7 @@ pub fn expand(t: &str, layout: &Layout, info: &PageInfo, frame: Option<(&Map, ru let v = match frame { Some((m, cam)) => rustgis_map::graphics::TokenValues { map: m.name.clone(), - scale: m.ground_res_at(&cam) / rustgis_map::layout::PAPER_M_PER_PT, + scale: scale.unwrap_or(0.0), crs: m.projection.label(), layers: m.layers.iter().filter(|l| !l.graphics).count(), center: m.to_lonlat(cam.center).unwrap_or_default(), @@ -354,6 +371,21 @@ pub fn expand(t: &str, layout: &Layout, info: &PageInfo, frame: Option<(&Map, ru rustgis_map::graphics::expand(&t, &v) } +/// A number as printed with a decimal comma when `comma` (`0.05` to `0,05`, `6,300,000` to +/// `6.300.000`): its points and commas swapped. +fn decimal(s: String, comma: bool) -> String { + if !comma { + return s; + } + s.chars() + .map(|c| match c { + '.' => ',', + ',' => '.', + c => c, + }) + .collect() +} + fn shape_path(shape: &FrameShape, r: Rect) -> Option { match shape { FrameShape::Rectangle => Some(PathBuilder::from_rect(r)), @@ -464,7 +496,7 @@ enum Edge { /// A graticule over a frame: lines across it, ticks along its edges or both, labelled at the /// edges the grid names. -fn draw_grid(pm: &mut Pixmap, map: &Map, view: &View, g: &Grid) { +fn draw_grid(pm: &mut Pixmap, map: &Map, view: &View, g: &Grid, comma: bool) { let ppp = view.pixels_per_point as f32; let (w, h) = (view.width, view.height); let mut ext = Extent::EMPTY; @@ -530,7 +562,7 @@ fn draw_grid(pm: &mut Pixmap, map: &Map, view: &View, g: &Grid) { Edge::Right => g.edges.right, }; if g.labels && shown { - labels.push((edge, at, grid_label(g, v, lon, step))); + labels.push((edge, at, decimal(grid_label(g, v, lon, step), comma))); } } } @@ -704,7 +736,7 @@ fn scale_number(v: f64, step: f64) -> String { /// A scale bar filling most of the element's width, in `units`, with `divisions` labelled steps. /// `ground_per_pt` is ground metres per logical point of paper. -fn scale_bar(pm: &mut Pixmap, r: Rect, ground_per_pt: f64, bar: (ScaleUnits, usize, ScaleBarStyle), ppp: f64) { +fn scale_bar(pm: &mut Pixmap, r: Rect, ground_per_pt: f64, bar: (ScaleUnits, usize, ScaleBarStyle), ppp: f64, comma: bool) { let (units, divisions, style) = bar; if !(ground_per_pt.is_finite() && ground_per_pt > 0.0) { return; @@ -770,7 +802,7 @@ fn scale_bar(pm: &mut Pixmap, r: Rect, ground_per_pt: f64, bar: (ScaleUnits, usi let gap = 3.0 * ppp as f32; let labels: Vec<(f32, String)> = (0..=divisions) .map(|i| { - let v = scale_number(total * i as f64 / divisions as f64, total / divisions as f64); + let v = decimal(scale_number(total * i as f64 / divisions as f64, total / divisions as f64), comma); (x0 + seg * i as f32, if i == divisions { format!("{v} {abbr}") } else { v }) }) .collect(); @@ -984,6 +1016,54 @@ pub fn frame_render_scale(map: &Map, center: Coord, scale: f64) -> f64 { map.ground_res_at(&cam) / rustgis_geom::PIXEL_M } +/// Where a frame showing `map` at 1:`scale` draws its layers: a grid of about `cells` cells across +/// the frame's longer side, row by row from the top, each `true` where a layer (or its labels) +/// drew. The basemap is left out, so the frame's background isn't counted. For keeping a +/// layout's surrounds off what its map shows. `None` when the frame can't be drawn. +/// +/// The map itself is drawn, not a copy of it (every layer's features): its basemap, web tiles +/// (a background, like the basemap) and graticule are set aside for the drawing and put back, +/// however it ends, as is a label lock waiting to be filled, which the coarse drawing would +/// otherwise fill. +pub fn frame_coverage(map: &mut Map, center: Coord, scale: f64, w_mm: f64, h_mm: f64, cells: u32) -> Option<(u32, u32, Vec)> { + if !(w_mm > 0.0 && h_mm > 0.0) { + return None; + } + let (w, h) = (w_mm * PT_PER_MM, h_mm * PT_PER_MM); + let ppp = f64::from(cells.clamp(8, 512)) / w.max(h); + let view = View { width: w, height: h, pixels_per_point: ppp, camera: frame_camera(map, center, scale, 0.0, 0.0), sky: false }; + // Put back when this ends, a panic in the drawing included. + struct Aside<'a> { + basemap: rustgis_map::Basemap, + tiles: Vec, + graticule: bool, + lock: Option>, + map: &'a mut Map, + } + impl Drop for Aside<'_> { + fn drop(&mut self) { + self.map.basemap = self.basemap; + self.map.tiles = std::mem::take(&mut self.tiles); + self.map.graticule = self.graticule; + self.map.label_lock = self.lock.take(); + } + } + let aside = Aside { + basemap: std::mem::replace(&mut map.basemap, rustgis_map::Basemap::Blank), + tiles: std::mem::take(&mut map.tiles), + graticule: std::mem::replace(&mut map.graticule, false), + lock: map.label_lock.take(), + map, + }; + let pm = crate::render(&mut *aside.map, &view)?.pixmap; + drop(aside); + let (gw, gh) = (pm.width(), pm.height()); + // Anything not the blank basemap's paper, pure opaque white (the graticule set aside too): a + // pale or translucent fill counts (only a fill of that very white, invisible on it, doesn't). + let drawn = pm.pixels().iter().map(|p| p.alpha() > 0 && (p.red(), p.green(), p.blue(), p.alpha()) != (255, 255, 255, 255)).collect(); + Some((gw, gh, drawn)) +} + fn swatch(pm: &mut Pixmap, x: f32, y: f32, w: f32, h: f32, gt: GeometryType, s: &rustgis_map::Symbol, ppp: f32) { let cy = y + h / 2.0; match gt { @@ -1038,6 +1118,8 @@ struct Bar<'a> { ticks: usize, side: TickSide, reverse: bool, + /// Labels with a decimal comma ([`Layout::decimal_comma`]). + comma: bool, } /// `n` values from `lo` to `hi`, the ends included, with the fewest decimals that keep each within @@ -1100,6 +1182,7 @@ fn colorbar(pm: &mut Pixmap, r: Rect, scale: &ColorScale, bar: &Bar, ppp: f64) { if blocks.is_empty() || r.width() < 2.0 || bottom - top < 2.0 { return; } + let labels: Vec<(f32, String)> = labels.into_iter().map(|(at, s)| (at, decimal(s, bar.comma))).collect(); let tick = 2.5 * ppp; let gap = 1.5 * ppp; let vertical = bar.orientation == Orientation::Vertical; @@ -1953,7 +2036,7 @@ mod tests { let mut pm = Pixmap::new(300, 60).unwrap(); pm.fill(tiny_skia::Color::WHITE); // 100 m of ground per point: about 25 km across the bar. - scale_bar(&mut pm, Rect::from_xywh(0.0, 0.0, 300.0, 60.0).unwrap(), 100.0, bar, 1.0); + scale_bar(&mut pm, Rect::from_xywh(0.0, 0.0, 300.0, 60.0).unwrap(), 100.0, bar, 1.0, false); pm }; // Dark runs along the bar's middle row: one per dark division of an alternating bar. @@ -1996,4 +2079,25 @@ mod tests { assert_eq!(scale_number(200.0 / 3.0, 100.0 / 3.0), "67"); assert_eq!(scale_number(0.01 / 3.0, 0.01 / 3.0), "0.0033"); } + + /// A layout with a decimal comma swaps the marks of the numbers it prints, `{scale}` included, + /// and leaves other text alone. + #[test] + fn a_decimal_comma_swaps_the_marks() { + assert_eq!(decimal("0.05".into(), true), "0,05"); + assert_eq!(decimal("6,300,000".into(), true), "6.300.000"); + assert_eq!(decimal("0.05".into(), false), "0.05"); + let map = Map::new("m"); + let cam = frame_camera(&map, Coord::new(0.0, 0.0), 6_300_000.0, 0.0, 0.0); + let info = PageInfo { page: 1, pages: 1, page_name: String::new() }; + let mut layout = Layout::new("L", 100.0, 100.0); + let point = expand("Scale {scale}, page {page}.", &layout, &info, Some((&map, cam))); + layout.decimal_comma = true; + let comma = expand("Scale {scale}, page {page}.", &layout, &info, Some((&map, cam))); + // "Scale 1:6,300,000, page 1." and "Scale 1:6.300.000, page 1.": the scale's marks swap, the + // text's comma stays. + let scale = point.strip_prefix("Scale ").and_then(|t| t.strip_suffix(", page 1.")).unwrap_or_else(|| panic!("{point}")); + assert!(scale.starts_with("1:") && scale.contains(','), "{point}"); + assert_eq!(comma, format!("Scale {}, page 1.", scale.replace(',', "."))); + } } diff --git a/docs/user-guide/layouts.md b/docs/user-guide/layouts.md index ebfa8e5fe..d1014a693 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -26,24 +26,34 @@ the maps; while one is open, its groups lead the Publish tab: figures, rounded up so it still fits within the strips the graticule's labels take inside the frame. A graticule has its step picked from the extent, at most five lines across (2° for a state, 15′ for a city), and labels on every edge of a sheet. - The north arrow, the legend (sized to what it lists) and the scale bar with the numeric scale - under it sit in the frame's corners, on pale boxes, clear of the graticule's labels. + The north arrow, the legend (sized to what it lists), the scale bar with the numeric scale + under it and the locator sit in the frame's corners, on pale boxes, clear of the graticule's + labels. Each takes the corner where it covers the least of what the map draws (its layers and + labels, not the basemap), so a legend leaves a corner the data fills; when it makes no + difference they keep theirs: the locator at the top left, the arrow at the top right, the + legend at the bottom left and the scale at the bottom right. With more surrounds than free + corners, one warning names those left on the data, to move by hand if that matters (data + under every corner alike, such as a context layer filling the frame, isn't worth a warning); + two that overlap (a frame too small) are said too. + - **Decimal comma**: `"decimal": ","` prints the graticule, scale bar and colorbar numbers and + `{scale}` with a decimal comma and points between thousands (`0,05`, `1:6.300.000`), as maps + in Portuguese, Spanish, French or German expect. `layout.page` sets it on any layout. - **Texts**: the title, subtitle, footer lines and authorship are measured with the layout's font (the title and headings bold, as drawn) and wrapped to their box, so none runs past it, and each box grows to hold them. `{projection}`, `{datum}` and `{grid}` are filled from the map when the layout is made. `{date}` and the other dynamic text tokens stay live. The reference defaults to the projection, datum and graticule, and the authorship to "Made with RustGIS · {date}" (`"author": ""` leaves it out). `headings` renames the two columns. - - **The locator**, at the frame's top left: `"locator": "globe"` (a sheet's default) adds a - map of the map's polygon layers that show on the globe (those held in the project: a large - file streamed in place, which would be read again for the whole world, is left out), - unlabelled, over the map's basemap (not its web tiles), in a round frame, the globe nearly - filling it and the area outlined in red; `"inset"` shows the surrounding region (about four - times the area across) on the map itself, outlined the same way; `"none"` (a figure's - default) leaves it out. The legend stops short of it; a frame with no room for a legend under + - **The locator** (at the frame's top left unless the data is there): `"locator": "globe"` (a + sheet's default) adds a map of the map's polygon layers that show on the globe (those held in + the project: a large file streamed in place, which would be read again for the whole world, is + left out), unlabelled, over the map's basemap (not its web tiles), in a round frame, the globe + nearly filling it and the area outlined in red; `"inset"` shows the surrounding region (about + four times the area across) on the map itself, outlined the same way; `"none"` (a figure's + default) leaves it out. The legend stops short of it; a frame with no room for a legend beside it leaves the legend out, with a warning. Undo takes the globe's map away with the layout; a - globe map no layout shows any more (its layout deleted) is replaced by the next sheet's - rather than left beside it, which a warning says. + globe map no layout shows any more (its layout deleted) is replaced by the next sheet's rather + than left beside it, which a warning says. - Every element is an ordinary one, to move or restyle afterwards. Running it again with another `profile` makes another layout of the same map. From 2502d90db0e84207f6a6fc4032c6e873480592db Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Fri, 23 Oct 2026 17:22:42 +0200 Subject: [PATCH 064/297] feat(map): draw stacked strokes in levels (#734) * feat(map): draw stacked strokes in levels Each feature drew its whole stroke stack in turn, so where two roads met the second one's casing cut across the first one's core: black bars at every segment join of a cased road or motorway. A line layer can now draw its strokes in levels (symbol levels): every feature's strokes of one level before any of the next, so casings join underneath and the cores run through. - map: `LineStroke::level` (none: its place in the stack) and `Layer::stroke_levels`, off by default, so maps draw as before. - render: with levels on, lines with stacked strokes are drawn after the rest, a level at a time; a streamed layer a batch at a time. - engine: `layer.strokeLevels`; `layer.lineStrokes` takes a stroke's `level`. - ui: "Draw strokes in levels" in the Strokes section, and a Level row (with Auto) on each stroke while it is on. With swisstopo's SMV25 QGIS project, its rendering passes carried over as levels, roads and railways now join like QGIS's rendering of the same project; the export takes no longer. * perf(render): visit a feature only for the stroke levels it has * fix(ui): preview a layer's strokes in level order --- crates/engine/src/cmd/layer.rs | 24 +++++++++- crates/engine/src/tests.rs | 61 ++++++++++++++++++++++++- crates/map/src/lib.rs | 6 +++ crates/map/src/symbology.rs | 12 ++++- crates/render/src/lib.rs | 54 ++++++++++++++++++++++ crates/render/src/stroke_paths/tests.rs | 6 +-- crates/ui-egui/src/panes.rs | 31 ++++++++++++- docs/user-guide/cartography.md | 13 ++++++ 8 files changed, 198 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index af7b54767..ae1f7c09b 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -168,8 +168,20 @@ pub fn commands() -> Vec { C::new("layer.lineStrokes", "Line Strokes", "Style › Drawing", line_strokes) .when(need_lines) .params( - r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel"}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' )}"##, + r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' )}"##, ), + C::new("layer.strokeLevels", "Stroke Levels", "Style › Drawing", |s, p| { + let on = b(p, "on").ok_or("on is true or false")?; + let id = s.layer_id(p)?; + let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; + if l.geometry_type() != GeometryType::Polyline || l.graphics { + return Err("this works on line layers".into()); + } + l.stroke_levels = on; + Ok(json!({"on": on})) + }) + .when(need_lines) + .params(r#"{"layer"?, "on": bool (draw every feature's stacked strokes of one level before the next, so casings join under the cores)}"#), C::new("layer.marker", "Marker", "Style › Drawing", |s, p| { let shape = ps(p, "shape").map(|n| Marker::ALL.into_iter().find(|m| m.label().eq_ignore_ascii_case(n)).ok_or("unknown marker shape")).transpose()?; let size = f(p, "size").map(|v| v.clamp(1.0, 80.0) as f32); @@ -362,7 +374,15 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { Some(Value::String(j)) => LineJoin::from_code(j).ok_or_else(|| format!("stroke {}: unknown join '{j}'", i + 1))?, Some(_) => return Err(format!("stroke {}: join is \"round\", \"miter\" or \"bevel\"", i + 1)), }; - strokes.push(LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join }.clamped()); + let level = match o.get("level") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_u64() + .and_then(|l| u8::try_from(l).ok()) + .ok_or_else(|| format!("stroke {}: level is a whole number from 0 to 255", i + 1))?, + ), + }; + strokes.push(LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join, level }.clamped()); } let id = s.layer_id(p)?; let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 688788c58..bd07a5397 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -344,8 +344,22 @@ fn line_strokes_stack_and_survive_save_and_undo() { ]); s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); let want = vec![ - LineStroke { color: Some(Rgba::rgb(0, 0, 0)), width: Some(11.5), offset: 8.5, cap: LineCap::Round, join: LineJoin::Round }, - LineStroke { color: Some(Rgba::rgb(0xFF, 0x8C, 0)), width: Some(7.0), offset: 8.5, cap: LineCap::Butt, join: LineJoin::Miter }, + LineStroke { + color: Some(Rgba::rgb(0, 0, 0)), + width: Some(11.5), + offset: 8.5, + cap: LineCap::Round, + join: LineJoin::Round, + level: None, + }, + LineStroke { + color: Some(Rgba::rgb(0xFF, 0x8C, 0)), + width: Some(7.0), + offset: 8.5, + cap: LineCap::Butt, + join: LineJoin::Miter, + level: None, + }, LineStroke { offset: -2.0, ..LineStroke::default() }, ]; assert_eq!(s.map().layer_by_name("Rivers").unwrap().line_strokes, want); @@ -471,6 +485,49 @@ fn a_class_stacks_strokes_of_its_own() { assert!(s.run("layer.lineStrokes", &json!({"layer": "Rivers", "index": 0, "strokes": pair})).is_err()); } +#[test] +fn strokes_draw_in_levels() { + let mut s = world(); + let depth = s.undo_depth(); + let strokes = json!([{"color": "#000000", "width": 6, "level": 2}, {"color": "#FFFFFF", "width": 2}]); + s.run("layer.lineStrokes", &json!({"layer": "Rivers", "strokes": strokes})).unwrap(); + s.run("layer.strokeLevels", &json!({"layer": "Rivers", "on": true})).unwrap(); + assert_eq!(s.undo_depth(), depth + 2); + let check = |s: &Session| { + let l = s.map().layer_by_name("Rivers").unwrap(); + assert!(l.stroke_levels); + assert_eq!(l.line_strokes[0].level, Some(2)); + // Without a level of its own, a stroke draws at its place in the stack. + assert_eq!((l.line_strokes[0].level_at(0), l.line_strokes[1].level_at(1)), (2, 1)); + }; + check(&s); + let dir = std::env::temp_dir().join(format!("rustgis-stroke-levels-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("levels.rgis"); + s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); + check(&t); + t.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + let depth = s.undo_depth(); + for (id, bad) in [ + ("layer.lineStrokes", json!({"layer": "Rivers", "strokes": [{"level": 256}]})), + ("layer.lineStrokes", json!({"layer": "Rivers", "strokes": [{"level": -1}]})), + ("layer.lineStrokes", json!({"layer": "Rivers", "strokes": [{"level": 1.5}]})), + ("layer.lineStrokes", json!({"layer": "Rivers", "strokes": [{"level": "top"}]})), + ("layer.strokeLevels", json!({"layer": "Rivers"})), + ("layer.strokeLevels", json!({"layer": "Cities", "on": true})), + ] { + assert!(s.run(id, &bad).is_err(), "{id} {bad}"); + } + assert_eq!(s.undo_depth(), depth); + s.run("layer.strokeLevels", &json!({"layer": "Rivers", "on": false})).unwrap(); + assert!(!s.map().layer_by_name("Rivers").unwrap().stroke_levels); + s.run("edit.undo", &Value::Null).unwrap(); + check(&s); +} + #[test] fn project_save_and_open_roundtrip() { let mut s = world(); diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index 4f56b404a..cb1d1fbc0 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -144,6 +144,11 @@ pub struct Layer { /// the renderer's stroke alone). #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "symbology::de_strokes")] pub line_strokes: Vec, + /// Draws the stacked strokes in levels (symbol levels): every feature's strokes of one + /// [`LineStroke::level`] before any of the next, so road casings join under the cores + /// instead of cutting across them where lines meet. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stroke_levels: bool, /// Layers with a higher priority draw above lower ones, whatever their order (the scene's /// depth priority). #[serde(default)] @@ -220,6 +225,7 @@ impl Layer { aggregation: None, extrusion: None, line_strokes: Vec::new(), + stroke_levels: false, priority: 0, design: design::Design::default(), raster: None, diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 13f6cdd47..2b79dba9d 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -183,15 +183,25 @@ pub struct LineStroke { pub cap: LineCap, #[serde(default)] pub join: LineJoin, + /// When the layer draws its strokes in levels (`Layer::stroke_levels`), the level this one + /// draws at: every feature's strokes of one level go down before any of the next, so casings + /// join under the cores. `None` is its place in the stack, the bottom one 0. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub level: Option, } impl Default for LineStroke { fn default() -> Self { - LineStroke { color: None, width: None, offset: 0.0, cap: LineCap::Round, join: LineJoin::Round } + LineStroke { color: None, width: None, offset: 0.0, cap: LineCap::Round, join: LineJoin::Round, level: None } } } impl LineStroke { + /// The level it draws at as the `i`th stroke of its stack (see [`LineStroke::level`]). + pub fn level_at(&self, i: usize) -> u8 { + self.level.unwrap_or_else(|| u8::try_from(i).unwrap_or(u8::MAX)) + } + /// The colour and width it draws a feature whose renderer symbol is `s` with. pub fn resolve(&self, s: &Symbol) -> (Rgba, f32) { (self.color.unwrap_or(s.stroke), self.width.unwrap_or(s.stroke_width)) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 08a1681f4..9917ad950 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2789,6 +2789,10 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } } else { let mut stroke_paths = stroke_paths::Paths::default(); + // Symbol levels: lines with stacked strokes wait, and go down a level at a time + // after the rest (each batch of a streamed layer on its own). + let levels = layer.stroke_levels && layer.has_strokes(); + let mut stacked: Vec<(&[Vec], Symbol, &[LineStroke])> = Vec::new(); for (row, g) in drawn.iter().enumerate() { let Some(g) = g else { continue }; if rows.get(row) != Some(&true) || !visible(&extents.get(row).copied().unwrap_or_else(|| g.extent())) { @@ -2820,10 +2824,27 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac continue; } match g { + Geometry::Line(parts) if !strokes.is_empty() && levels => stacked.push((parts, sym, strokes)), Geometry::Line(parts) if !strokes.is_empty() => stroke_paths.draw(&ctx, dest, parts, &sym, strokes), _ => ctx.feature(dest, g, &sym), } } + // A turn per feature and level it has strokes at, by level and then the features' + // order: a feature is visited only for its own levels, however many the layer has. + let mut turns: Vec<(u8, usize)> = stacked + .iter() + .enumerate() + .flat_map(|(f, (.., st))| st.iter().enumerate().map(move |(i, s)| (s.level_at(i), f))) + .collect(); + turns.sort_unstable(); + turns.dedup(); + let mut one: Vec = Vec::with_capacity(rustgis_map::symbology::MAX_STROKES); + for (level, f) in turns { + let Some((parts, sym, strokes)) = stacked.get(f) else { continue }; + one.clear(); + one.extend(strokes.iter().enumerate().filter(|(i, s)| s.level_at(*i) == level).map(|(_, s)| *s)); + stroke_paths.draw(&ctx, dest, parts, sym, &one); + } } cpu_dirty |= drawn_here; } @@ -5387,6 +5408,39 @@ mod tests { assert!((100..200).all(|y| colour(&r, y) != (0, 160, 0)), "no green left"); } + #[test] + fn stroke_levels_join_casings_under_the_cores() { + // One road in two segments meeting at the centre, a black casing under a red core. Drawn + // a feature at a time, the second segment's round casing covers the end of the first's + // core; in levels, every casing goes down first and the core runs through the joint. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("road", GeometryType::Polyline, vec![]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, 0.0), Coord::new(0.0, 0.0)]])), vec![]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(0.0, 0.0), Coord::new(1.0, 0.0)]])), vec![]); + let id = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, 0.0)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 200.0, height: 200.0, pixels_per_point: 1.0, camera, sky: false }; + let colour = |r: &Rendered, x: u32| { + let p = r.pixmap.pixel(x, 100).unwrap(); + (p.red(), p.green(), p.blue()) + }; + let stroke = |c: Rgba, w: f32| rustgis_map::LineStroke { color: Some(c), width: Some(w), ..Default::default() }; + m.layer_mut(id).unwrap().line_strokes = vec![stroke(Rgba::rgb(0, 0, 0), 12.0), stroke(Rgba::rgb(255, 0, 0), 4.0)]; + let r = render(&mut m, &view).unwrap(); + assert_eq!(colour(&r, 96), (0, 0, 0), "the second casing cuts across the first core"); + assert_eq!(colour(&r, 60), (255, 0, 0)); + m.layer_mut(id).unwrap().stroke_levels = true; + let r = render(&mut m, &view).unwrap(); + assert!((60..140).all(|x| colour(&r, x) == (255, 0, 0)), "the core runs through the joint"); + // A level set by hand: the core at level 0 and the casing at 1 bury the cores. + let l = m.layer_mut(id).unwrap(); + l.line_strokes[0].level = Some(1); + l.line_strokes[1].level = Some(0); + let r = render(&mut m, &view).unwrap(); + assert_eq!(colour(&r, 60), (0, 0, 0)); + } + #[test] fn features_under_a_pixel_still_show() { // A dense grid of tiny squares seen whole: each is splatted rather than filled as a path, diff --git a/crates/render/src/stroke_paths/tests.rs b/crates/render/src/stroke_paths/tests.rs index 4fdc57f4a..d289ecba3 100644 --- a/crates/render/src/stroke_paths/tests.rs +++ b/crates/render/src/stroke_paths/tests.rs @@ -21,9 +21,9 @@ fn cached_pixels_equal_sequential_strokes_including_overlap_and_alpha() { expected.fill(tiny_skia::Color::WHITE); actual.fill(tiny_skia::Color::WHITE); let strokes = [ - LineStroke { offset: 2.0, width: Some(3.0), color: Some(Rgba::new(40, 70, 180, 150)), join, cap }, - LineStroke { offset: -2.0, width: Some(1.0), color: Some(Rgba::new(220, 30, 40, 200)), join, cap }, - LineStroke { offset: 2.0, width: Some(1.0), color: Some(Rgba::rgb(30, 40, 50)), join, cap }, + LineStroke { offset: 2.0, width: Some(3.0), color: Some(Rgba::new(40, 70, 180, 150)), join, cap, level: None }, + LineStroke { offset: -2.0, width: Some(1.0), color: Some(Rgba::new(220, 30, 40, 200)), join, cap, level: None }, + LineStroke { offset: 2.0, width: Some(1.0), color: Some(Rgba::rgb(30, 40, 50)), join, cap, level: None }, ]; let mut batch = Paths::default(); for i in 0..180 { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index afbd2a744..f7a770398 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3782,6 +3782,7 @@ fn stroke_json(st: &LineStroke) -> Value { "offset": st.offset, "cap": st.cap.code(), "join": st.join.code(), + "level": st.level, }) } @@ -3877,10 +3878,23 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma }); }); } + if l.has_strokes() { + let mut on = l.stroke_levels; + let tip = tl!("Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet"); + if ui.checkbox(&mut on, tl!("Draw strokes in levels")).on_hover_text(tip).changed() { + let _ = app.run("layer.strokeLevels", json!({"on": on})); + } + } // The composed symbol along a line drawn left to right: a positive offset shows below it. let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); ui.painter().rect_filled(r, CornerRadius::same(4), t.ribbon); - let preview: Vec = if strokes.is_empty() { vec![LineStroke::default()] } else { strokes.clone() }; + let mut preview: Vec = if strokes.is_empty() { vec![LineStroke::default()] } else { strokes.clone() }; + // In levels, a stroke shows over the lower levels' (ties keep their stack order), as the map has it. + if l.stroke_levels { + let mut levelled: Vec<(u8, LineStroke)> = preview.iter().enumerate().map(|(i, st)| (st.level_at(i), *st)).collect(); + levelled.sort_by_key(|(level, _)| *level); + preview = levelled.into_iter().map(|(_, st)| st).collect(); + } let reach = preview.iter().map(|st| st.offset.abs() + st.resolve(&base).1 / 2.0).fold(1.0_f32, f32::max); let k = (18.0 / reach).min(1.0); let (x0, x1) = (r.min.x + 14.0, r.max.x - 14.0); @@ -4006,6 +4020,21 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma } }); ui.end_row(); + if l.stroke_levels { + ui.label(tl!("Level")); + ui.horizontal(|ui| { + let mut lv = st.level_at(i); + if ui.add_enabled(st.level.is_some(), egui::DragValue::new(&mut lv).range(0..=255).speed(0.1)).changed() { + set(app, LineStroke { level: Some(lv), ..st }); + } + let mut auto = st.level.is_none(); + let tip = tl!("Draw at its place in the stack, the bottom stroke first"); + if ui.checkbox(&mut auto, tl!("Auto")).on_hover_text(tip).changed() { + set(app, LineStroke { level: if auto { None } else { Some(lv) }, ..st }); + } + }); + ui.end_row(); + } }); } ui.add_space(4.0); diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 648a61f66..f57aad695 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -105,6 +105,19 @@ same). By command, name the class as `layer.classStyle` does, with `value`, `ind layer.lineStrokes={"layer":"Roads","value":"Motorway","strokes":[{"color":"#5A3A1E","width":7},{"color":"#F28C28","width":5},{"color":"#FFFFFF","width":0.6}]} ``` +Each feature draws its whole stack in turn, so where two roads meet, the second one's casing +cuts across the first one's core. **Draw strokes in levels** (symbol levels) draws every +feature's strokes of one level before any of the next instead, so the casings join underneath +and the cores run through. A stroke's level is its place in the stack (the bottom one 0) unless +it is given one: a **Level** row shows on each stroke while levels are on, and strokes of +different classes that share a level go down together, such as the casings of all road classes +before any core. A layer read in place from its file draws its levels a batch of features at a +time. By command (`"level"` on a stroke, `null` for its place in the stack): + +```text +layer.strokeLevels={"layer":"Roads","on":true} +``` + ### Custom color ramps Besides the built-in color schemes, a project can hold your own color ramps: an organization's From af5b37b8a5d454d018c39594e492e1e14e84338f Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Fri, 23 Oct 2026 18:32:26 +0200 Subject: [PATCH 065/297] fix(ui): stop a tiled map redrawing for good while a frame is drawn (#740) * fix(ui): stop a tiled map redrawing for good while a frame is drawn The map view holds its key at the tile generation of the frame on screen for 0.25 s after tiles arrive. A tiled flat frame is drawn on a worker, and the hold never looked at it: when tiles arrived while a frame was being drawn, the frame drawn with them and the one before kept starting each other again. Each a full CPU frame and texture upload, it kept a core busy and the app slow; a WMS layer in a projection other than Web Mercator (EPSG:2056 with swisstopo's LK25), whose frames take longer to warp, hit it nearly every time. The hold now keeps the newest frame of the view: the one being drawn, or else the one on screen. * fix(ui): hold tiles at the newest frame of the view, drawn or shown --- crates/ui-egui/src/mapview.rs | 79 ++++++++++++++++++++++++++++--- crates/ui-egui/src/tilt_settle.rs | 8 ++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index b375a3a8f..0e1707c28 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -592,13 +592,9 @@ fn main_view(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { key.tiles = 0; } else if fetches_tiles(app.session.map()) { key.tiles = rustgis_engine::render::rustgis_tiles::cache::generation(); - if let Some(old) = app.map.key - && old.tiles != key.tiles - && (Key { tiles: old.tiles, ..key }) == old - && time - app.map.last_render < 0.25 - { - key.tiles = old.tiles; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); + if let Some(held) = held_tiles(key, app.map.tilt_settle.key(), app.map.key, time - app.map.last_render) { + key.tiles = held; + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(TILE_REDRAW_GAP)); } } if app.map.view_px != key.size { @@ -2878,6 +2874,75 @@ fn scene_covers(scene: Camera, cam: Camera, w: f64, h: f64) -> bool { !e.is_empty() && e.min_x >= held.min_x && e.min_y >= held.min_y && e.max_x <= held.max_x && e.max_y <= held.max_y } +/// How long (s) after a frame is shown tiles arriving wait before the map is drawn again. +const TILE_REDRAW_GAP: f64 = 0.25; + +/// The tile cache generation `key` holds at instead of its own, when tiles arrived within +/// [`TILE_REDRAW_GAP`] of the last frame shown: the newest frame of this view, of the one being +/// drawn (`drawing`) and the one on screen (`shown`), so the map is drawn at most a few times a +/// second while they arrive. Generations only grow: a live GPU scene can show newer tiles than +/// the frame still being drawn, and isn't held back to it. +/// +/// Holding at the frame on screen while a newer one was being drawn made the two alternate for +/// good: each, once shown, differed from the key held at meanwhile, which started the other again +/// (a tiled map in a projection other than Web Mercator, whose frames take longer, was drawn over +/// and over, keeping a core busy and the app slow). +fn held_tiles(key: Key, drawing: Option, shown: Option, since_render: f64) -> Option { + let same_view = |k: &Key| Key { tiles: k.tiles, ..key } == *k; + let newest = [drawing, shown].into_iter().flatten().filter(same_view).max_by_key(|k| k.tiles)?; + (newest.tiles != key.tiles && since_render < TILE_REDRAW_GAP).then_some(newest.tiles) +} + +#[cfg(test)] +mod tile_redraw_tests { + use super::{Key, held_tiles}; + + fn key(tiles: u64) -> Key { + Key { rev: 4, cam: (1, 2, 3, 0, 0), size: [800, 600], draft: false, tiles, sources: 0 } + } + + #[test] + fn tiles_arriving_are_held_briefly_after_a_frame() { + // Shown a moment ago: the new tiles wait; a while ago, they are drawn. + assert_eq!(held_tiles(key(28), None, Some(key(0)), 0.1), Some(0)); + assert_eq!(held_tiles(key(28), None, Some(key(0)), 0.3), None); + // Another view isn't held. + let moved = Key { cam: (9, 9, 9, 0, 0), ..key(0) }; + assert_eq!(held_tiles(key(28), None, Some(moved), 0.1), None); + } + + #[test] + fn a_frame_being_drawn_with_newer_tiles_is_not_drawn_again_with_older_ones() { + // The frame drawn with the tiles that arrived is on its way while an older one was just + // shown: the key is that frame's, not the older one's. + assert_eq!(held_tiles(key(28), Some(key(28)), Some(key(0)), 0.1), None); + // A live scene shown with newer tiles than the frame being drawn isn't held back to it. + assert_eq!(held_tiles(key(28), Some(key(5)), Some(key(28)), 0.1), None); + assert_eq!(held_tiles(key(30), Some(key(5)), Some(key(28)), 0.1), Some(28)); + // Simulated: the view's frames alternated between generations 0 and 28 for good. + let (mut shown, mut drawing, mut since) = (Some(key(0)), Some(key(28)), 0.0); + let mut started = 0; + for _ in 0..50 { + let mut k = key(28); + if let Some(t) = held_tiles(k, drawing, shown, since) { + k.tiles = t; + } + // The frame being drawn finishes and is shown; another starts if the key differs. + if let Some(d) = drawing.take() { + shown = Some(d); + since = 0.0; + } + if shown != Some(k) { + drawing = Some(k); + started += 1; + } + since += 0.016; + } + assert_eq!(started, 0); + assert_eq!(shown, Some(key(28))); + } +} + #[cfg(test)] mod picture_tests { #[test] diff --git a/crates/ui-egui/src/tilt_settle.rs b/crates/ui-egui/src/tilt_settle.rs index 015190f03..db58820a9 100644 --- a/crates/ui-egui/src/tilt_settle.rs +++ b/crates/ui-egui/src/tilt_settle.rs @@ -69,6 +69,14 @@ impl TiltSettle { return self.ready.is_some(); } + /// The key of the frame being drawn (or drawn and not yet taken). + pub fn key(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + return self.job.as_ref().map(|(k, _)| *k); + #[cfg(target_arch = "wasm32")] + return self.ready.as_ref().map(|(k, _)| *k); + } + /// Starts drawing `map` (a copy) for `view`, keyed `key`, waking `ctx` when it is done. /// Does nothing while a frame is being drawn (the next starts once that one is taken), or /// for a key that couldn't be drawn. From 6fd7d892115b781429f78d67a00a4758deb5f387 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Fri, 23 Oct 2026 13:33:55 -0300 Subject: [PATCH 066/297] Layouts: a publication sheet's style, accent and orientation; graticule labels kept apart (#682, part 4) (#742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Layouts: a publication layout's locator, a globe or an inset with the area outlined (#682, part 2) layout.publication takes "locator": "globe" (a sheet's default: a new map of the map's polygon layers on the globe, unlabelled, in a round frame at the map's top left, sized from the renderer's own globe disc), "inset" (the surrounding region on the map itself) or "none" (a figure's default). The map frame's extent indicator outlines the area on either; the legend stops short of it; undo takes the globe's map away with the layout. Co-Authored-By: Claude Opus 5.5 * Layouts: a publication layout's surrounds keep off the data; a decimal comma (#682, part 3) The north arrow, the legend, the scale bar and the locator each take the corner where they cover the least of what the map draws (render::layout::frame_coverage: the frame drawn coarse on a blank basemap), keeping their own corners when it makes no difference; one warning names those crowded onto the data. "decimal": "," (layout.publication, layout.page) sets Layout::decimal_comma: graticule, scale bar and colorbar numbers and {scale} print 0,05 and 1:6.300.000. Co-Authored-By: Claude Opus 5.5 * layout.publication: a raster theme keyed by a colorbar in the legend's box; a UTM zone's EPSG code named by its zone and datum Found making the greening lesson's map with it: the topmost visible raster drawn on a ramp now gets a colorbar under the legend's rows (the legend lists vectors only), and EPSG:31983 reads 'UTM zone 23S' on 'SIRGAS 2000' rather than its code on its GRS 1980 ellipsoid (wkt::utm_of_code, the inverse of the name-to-code table). Co-Authored-By: Claude Opus 5.5 * Layouts: the globe locator is a new map of what it draws; no legend forced over it The globe's map was a clone of the whole map with its non-polygon layers dropped, so it copied every layer's features first and kept the map's web tiles, swipe, time slider and groups: a map on a web basemap drew its tiles on the disc. It is now a new map with the basemap (the land and borders around the area), graticule and body, and only the polygon layers that show, unlabelled. The legend's corner was given at least 10 mm, so a sheet whose frame was near its smallest drew the legend over the locator (or the scale bar). A corner too small now leaves the legend out, with a warning. Co-Authored-By: Claude Opus 5.5 * Layouts: a narrow figure keeps its legend (pinned by a test); the globe's streamed layers left out, said Co-Authored-By: Claude Opus 5.5 * Layouts: a globe locator no layout shows any more is replaced by the next, not left behind Co-Authored-By: Claude Opus 5.5 * print-layout skill: the locator's default by profile (globe on a sheet, none on a figure) Co-Authored-By: Claude Opus 5.5 * Layouts: a pale fill counts as data where the surrounds go frame_coverage counted a pixel as drawn only when a channel fell under 245, so a pale fill (such as #FAFAF5) with no outline read as empty and a surround could be placed on it. The blank basemap is pure opaque white with no graticule, so anything else now counts. Co-Authored-By: Claude Opus 5.5 * Layouts: only a locator takes the legend's room; a replaced locator map is said The legend's width keeps its old 10 mm floor, so a figure or a sheet without a locator lays its legend out as before; the legend is left out only when a locator leaves it under 10 mm of height. Reusing a locator map no layout showed now adds a warning naming it. Co-Authored-By: Claude Opus 5.5 * Layouts test: the reused locator map is rebuilt, not kept (a layer hidden meanwhile is gone) Co-Authored-By: Claude Opus 5.5 * Layouts: the corner search's bound stated (four surrounds, 256 picks), its repeat check without indexing Co-Authored-By: Claude Opus 5.5 * Layouts: the globe keeps a sketch of each polygon layer, simplified to its disc Each layer copied onto the globe's map is simplified (Douglas-Peucker) to a tenth of a millimetre at the globe's scale, so a project with large polygon layers saves a sketch of them for the locator rather than a second full copy. In memory the copy shared the source's features already (Features is copy-on-write); the saved file is what grew. Co-Authored-By: Claude Opus 5.5 * Layouts: corners costed with the legend as laid out; the coverage drawing restored however it ends; the scale worked out once The corner search costed the legend at its full size, so a legend that would have been shortened to stop short of the locator above it paid the overlap penalty and moved; it is now costed as it will be laid out. frame_coverage also sets the map's graticule aside (the blank basemap already draws it transparent) and puts the basemap, graticule and label lock back through a guard, so a panic in the drawing leaves the map as it was. expand works the frame's scale out once for both its forms, and puts the decimal-comma scale in before the page and layout names, so a name holding "{scale}" isn't read as the token. Co-Authored-By: Claude Opus 5.5 * Layouts: a polygon too small for the globe's simplification keeps its own ring Co-Authored-By: Claude Opus 5.5 * Layouts: only a globe map made as this command makes them is reused as a locator A map is now reused only if it also has no web tiles and only unlabelled polygon layers, so a globe map made by hand under a locator's name is left alone. A note says the layer clone shares its rows (Features is copy-on-write). Co-Authored-By: Claude Opus 5.5 * Layouts: the simplified sketch named apart from the session Co-Authored-By: Claude Opus 5.5 * UTM names only for the zones EPSG defines (NAD 83 1-23, ETRS 89 28-38); a closure's parameter named apart from the session Co-Authored-By: Claude Opus 5.5 * Layouts: web tiles set aside with the basemap where the surrounds are placed; the sheet test pins its one placement warning Co-Authored-By: Claude Opus 5.5 * Layouts: a publication sheet's style (classic, banner, panel, editorial) and accent colour (#682, part 4) layout.publication takes "style": "classic" (the default, as before), "banner" (the title and subtitle in a band of the accent colour across the top), "panel" (the map at the left, a pale panel at the right with the title, subtitle, locator and legend, the sources and reference at its foot) or "editorial" (the title large in serif, a column at the right with a heading, the note, the locator and the legend), on any sheet, portrait or landscape; "accent" colours the band, tint, headings and rules. A figure leaves the style to its caption. The footer and the sources/reference sections are shared by the styles; a style that sets the locator (and legend) in a column beside the map takes them out of the corner search. report::serif_width_mm measures serif text as the layout draws it, for the editorial title's wrapping. Co-Authored-By: Claude Opus 5.5 * Layouts: a column's locator no larger than on an A4 portrait sheet, centred across the column Co-Authored-By: Claude Opus 5.5 * Layouts: modular and poster styles; rectangles with rounded corners layout.publication's "style" also takes "modular" (cards with rounded corners on a light page: the map in one, the locator and the legend in one each beside it, the sources and reference in tinted ones) and "poster" (the page in the accent colour, the title in light text, the map in a light card, the footer in darker blocks with amber headings). A rectangle element can round its corners ("radius", in mm, 0 to 50; saved only when rounded), from layout.addElement and layout.update. What a style draws under the map (a poster's page, a card, the panel's tint) is added before the map frame, so it no longer lies over the map or its legend. Co-Authored-By: Claude Opus 5.5 * Layouts: graticule labels keep clear of each other A layout's graticule labelled every line wherever it crossed a labelled edge, so on a projected map a slanting meridian leaving through a side was labelled over that side's parallel ("36°W" on "6°S"), and a side's label by a corner ran into the top's. Meridians are now labelled along the top and bottom and parallels along the sides first; a line leaving through the other edges is labelled there only well clear of the labels already set on that edge (a further length of its own), and a side's labels keep two label heights from the corners. The choice is a pure function (kept_labels), tested apart from the drawing. Co-Authored-By: Claude Opus 5.5 * Layouts: a panel's or an editorial page's map frame cut to its area's shape A panel's or editorial page's map is as wide as its column, so a tall area left the frame much taller than the area: the map showed a band of sea and land above and below it. When the frame is clearly taller than the area needs (with a seventh more of its surroundings), the sheet is laid out again with the frame that tall, a pass or two more until it is, and the whole is set in the middle of the page; the neatline and a poster's colour keep the page's size. A panel whose frame is cut boxes its sources and reference under the map, in the room the frame left, as a classic sheet does. Co-Authored-By: Claude Opus 5.5 * Layouts: a sheet given its size alone is turned to the area's shape layout.publication takes "a4" (now the default), "a3" or "a2": the sheet is turned to portrait or landscape, whichever the area's map comes out larger on, from its extent east to west against north to south on the map's projection and the room the style leaves the map (portrait winning a near tie). A profile given with its orientation ("a4-portrait") is kept, so the choice can be changed; the result names the profile used. Co-Authored-By: Claude Opus 5.5 * Layouts test: the arrangement checks on a portrait sheet, the orientation chosen checked apart Co-Authored-By: Claude Opus 5.5 * Layouts: OVER_DATA's doc comment reads Co-Authored-By: Claude Opus 5.5 * Layouts: the legend's room a pure function, tested; the coverage drawing documented legend_room decides how far the legend is cut by the surrounds beside and above or below it, and when the locator leaves it too little (it is then left out, said). The shrinking sheets of publication_layouts_locate_their_area may never reach that case, since the corner search keeps the legend off the locator's side, so the rule is now tested directly. The comment at frame_coverage's call says the map is drawn on purpose, as the screen draws it: a coarse grid, streamed layers reading in the background, no web tiles fetched. Co-Authored-By: Claude Opus 5.5 * Layouts: page-wide elements laid on the full page; a failing later pass settles A publication sheet's neatline and a poster's colour go in their own list, laid on the full page whatever the frame-fit passes free, instead of being picked out by name when the content is centred (#742 review). A pass after the first that fails (its shorter page leaving the texts too little room) settles on the last pass that held; only the first pass's failure is the request's. Each pass is laid out in one closure for that, which re-indents it (git diff -w shows the change). Also: a comment on the corner search's NaN costs, the panel's error indented, and the style test's doc comment in paragraphs, naming modular and poster. Co-Authored-By: Claude Opus 5.5 * Layouts: rounded corners refused off a rectangle; square ones filled as before layout.addElement and layout.update refuse "radius" for anything but a rectangle, as "font" is refused off a text, so a script doesn't take it as applied; restyle's arm that could never run is gone (#742 review). A rectangle with square corners is filled with fill_rect again, so existing layouts draw to the same pixels. hostile_params_never_panic gets junk style, accent, profile and radius values. Co-Authored-By: Claude Opus 5.5 * Layouts: a style or accent not given as text refused; sizes named in the profile error layout.publication refused an unknown style's or accent's name but took a number in their place as not given; either is now refused (#742 review). An unknown profile's message names a4, a3 and a2 (turned to the area's shape) before the profiles with their orientation. Co-Authored-By: Claude Opus 5.5 * Layouts: the editorial serif's fallback said; hostile inputs reach style and accent An editorial sheet's title and subtitle are serif: where no serif is installed (the web app, a minimal system) they are drawn in the sans, now with a warning, as layout.addElement gives. hostile_params_never_panic's new inputs leave the profile valid, so style, accent and radius are reached; a rectangle's radius not given as a number is refused. The print-layout skill names the styles and the accent (#742 review). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/assistant/skills/print-layout/SKILL.md | 4 +- crates/engine/src/cmd/layout.rs | 968 +++++++++++++++--- crates/engine/src/cmd/presentation.rs | 8 +- crates/engine/src/tests.rs | 218 ++++ crates/map/src/layout.rs | 13 +- crates/render/src/layout.rs | 126 ++- crates/render/src/report.rs | 7 + crates/ui-egui/src/layoutview.rs | 2 +- docs/user-guide/layouts.md | 24 +- 9 files changed, 1186 insertions(+), 184 deletions(-) diff --git a/crates/assistant/skills/print-layout/SKILL.md b/crates/assistant/skills/print-layout/SKILL.md index 858d650a3..c073d33ea 100644 --- a/crates/assistant/skills/print-layout/SKILL.md +++ b/crates/assistant/skills/print-layout/SKILL.md @@ -7,8 +7,8 @@ description: Make a printable page layout — map frame, title, legend, north ar 1. Get the map right first: symbology, labels and extent. A layout's map frame shows the map's current view. 2. For a finished sheet or a figure for a paper in one step, use `layout.publication` with a `"profile"` - (`"a4-portrait"`, `"a3-landscape"`, … for a sheet; `"figure-1col"`, `"thesis-full"`, … for a figure), the `"extent"` - to fit, and the texts: `"title"`, `"subtitle"`, `"sources"`, `"note"`, a `"locator"` (`"globe"`: the visible polygon layers held in the project, not streamed ones, over the basemap; `"inset"` or `"none"`; when omitted, `"globe"` on a sheet and `"none"` on a figure), and `"decimal": ","` for a map in a language with a decimal comma. Its + (`"a4"`, `"a3"` or `"a2"` for a sheet turned to the area's shape, or `"a4-portrait"`, `"a3-landscape"`, … to choose; `"figure-1col"`, `"thesis-full"`, … for a figure), the `"extent"` + to fit, and the texts: `"title"`, `"subtitle"`, `"sources"`, `"note"`, a `"locator"` (`"globe"`: the visible polygon layers held in the project, not streamed ones, over the basemap; `"inset"` or `"none"`; when omitted, `"globe"` on a sheet and `"none"` on a figure), and `"decimal": ","` for a map in a language with a decimal comma. A sheet's `"style"` arranges it: `"classic"` (the default), `"banner"`, `"panel"`, `"editorial"`, `"modular"` or `"poster"` (for a poster-like sheet), with an `"accent"` colour as `"#RRGGBB"`. Its surrounds take the corners where they cover the least data; read its `"warnings"`. It places the frame at a round scale, the graticule, legend, scale bar and north arrow, and wraps the footer. Its result lists every element's id and name (`"elements"`), so skip to step 5 to adjust them. Otherwise, diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index a771732ea..cb6ac0fdf 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -29,7 +29,7 @@ pub fn commands() -> Vec { r#"{"name"?: string, "page"?: "letter"|"legal"|"tabloid"|"a5"|"a4"|"a3"|"a2"|"a1"|"a0", "orientation"?: "landscape"|"portrait", "width"?: mm, "height"?: mm, "template"?: "map" (frame, title, legend, north arrow, scale bar; the default)|"blank"}"#, ), C::new("layout.publication", "New Publication Layout", "Publish › Layout", publication).params( - r#"{"profile"?: "a4-portrait" (the default)|"a4-landscape"|"a3-portrait"|"a3-landscape"|"a2-portrait"|"a2-landscape" (a sheet: frame, title, subtitle, the map, a boxed footer)|"figure-1col" (90 mm)|"figure-1.5col" (140 mm)|"figure-2col" (190 mm)|"thesis-full" (160 × 225 mm)|"thesis-half" (160 × 110 mm) (a figure: the map edge to edge, its title and sources left to the caption), "extent"?: [west, south, east, north] in degrees that must fit (default: the map view), "name"?: string, "title"?: text, "subtitle"?: text, "sources"?: text | [texts], "reference"?: text | [texts] (default: the projection, datum and graticule), "note"?: text, "author"?: text (default: "Made with RustGIS · {date}"; "" or [] for none), "headings"?: [the sources' heading, the reference's heading], "locator"?: "globe" (a globe with the area outlined; the default on sheets) | "inset" (the surrounding region, the area outlined) | "none" (the default on figures), "decimal"?: "." (the default) | "," (graticule, scale bar and colorbar numbers and {scale} with a decimal comma: 0,05 and 1:6.300.000)} → a new layout of the active map at a round scale, its texts wrapped to their boxes; {projection}, {datum} and {grid} in the texts are filled from the map"#, + r#"{"profile"?: "a4" (the default) | "a3" | "a2" (a sheet turned to portrait or landscape, whichever the area's map comes out larger on: its extent east to west against north to south, on the map's projection) | "a4-portrait"|"a4-landscape"|"a3-portrait"|"a3-landscape"|"a2-portrait"|"a2-landscape" (a sheet: frame, title, subtitle, the map, a boxed footer)|"figure-1col" (90 mm)|"figure-1.5col" (140 mm)|"figure-2col" (190 mm)|"thesis-full" (160 × 225 mm)|"thesis-half" (160 × 110 mm) (a figure: the map edge to edge, its title and sources left to the caption), "extent"?: [west, south, east, north] in degrees that must fit (default: the map view), "name"?: string, "title"?: text, "subtitle"?: text, "sources"?: text | [texts], "reference"?: text | [texts] (default: the projection, datum and graticule), "note"?: text, "author"?: text (default: "Made with RustGIS · {date}"; "" or [] for none), "headings"?: [the sources' heading, the reference's heading, an editorial column's heading], "locator"?: "globe" (a globe with the area outlined; the default on sheets) | "inset" (the surrounding region, the area outlined) | "none" (the default on figures), "decimal"?: "." (the default) | "," (graticule, scale bar and colorbar numbers and {scale} with a decimal comma: 0,05 and 1:6.300.000), "style"?: "classic" (the default: the title over the map, the footer under it) | "banner" (the title and subtitle in a band of the accent colour across the top) | "panel" (the map at the left, a panel at the right with the title, locator, legend, sources and reference) | "editorial" (the title large in serif, a column at the right with the note, the locator and the legend) | "modular" (cards on a light page: the map, the locator and the legend each in one, the sources and reference in tinted ones) | "poster" (the page in the accent colour, the title in light text, the map in a light card, the footer in darker blocks) (a sheet's arrangement; figures leave it to the caption), "accent"?: a colour as #RRGGBB (the style's colour: its band, panel tint, headings and rules, a poster's page)} → a new layout of the active map at a round scale, its texts wrapped to their boxes; {projection}, {datum} and {grid} in the texts are filled from the map"#, ), C::new("layout.activate", "Open Layout", "Browser", |s, p| { let i = index(s, &by_name(p))?; @@ -85,10 +85,10 @@ pub fn commands() -> Vec { 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" (text, dynamicText; an installed serif, else the sans with a warning; refused for other kinds), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "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), "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 (0 picks one), "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" (text, dynamicText; an installed serif, else the sans with a warning; refused for other kinds), "title"?: legend title (default "Legend"), "allClasses"?: bool (legend: every class, not only those the frame draws), "hideOther"?: bool (legend: leave out ), "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 (0 picks one), "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}"##, ), 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" (text; 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), "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), "bold"?, "italic"?, "align"?, "letterSpacing"?, "font"?: "sans"|"serif" (text; 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"?}"##, ), 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)}"#, @@ -461,23 +461,136 @@ pub(crate) fn capitals(t: &str) -> String { /// A text element in a publication layout's style: upright, sans, in `align`. fn pub_text(text: String, size: f64, color: Rgba, bold: bool, align: TextAlign) -> ElementKind { - ElementKind::Text { text, size, color, bold, italic: false, align, letter_spacing: 0.0, font: FontFamily::Sans } + pub_text_in(text, size, color, bold, align, FontFamily::Sans) +} + +/// [`pub_text`] in `font`. +fn pub_text_in(text: String, size: f64, color: Rgba, bold: bool, align: TextAlign, font: FontFamily) -> ElementKind { + ElementKind::Text { text, size, color, bold, italic: false, align, letter_spacing: 0.0, font } +} + +/// How a publication sheet is arranged around its map. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Style { + /// The title over the map, the footer's boxes under it. + Classic, + /// The title and subtitle in a band of the accent colour across the top. + Banner, + /// The map at the left; a panel at the right with the title, the locator, the legend, the + /// sources and the reference. + Panel, + /// A magazine's page: the title large in serif, a column at the right with the note, the + /// locator and the legend. + Editorial, + /// Cards on a light page: the map in one, the locator and the legend in one each beside it, + /// the sources and the reference in tinted ones under them. + Modular, + /// The page in the accent colour: the title and subtitle in light text across the top, the + /// map in a light card, the footer in darker blocks. + Poster, +} + +impl Style { + fn code(self) -> &'static str { + match self { + Style::Classic => "classic", + Style::Banner => "banner", + Style::Panel => "panel", + Style::Editorial => "editorial", + Style::Modular => "modular", + Style::Poster => "poster", + } + } + + /// The accent colour when none is given: a deep blue, green or brown (a poster's page). + fn accent(self) -> Rgba { + match self { + Style::Classic => Rgba::rgb(0x2B, 0x26, 0x20), + Style::Banner => Rgba::rgb(0x4A, 0x34, 0x26), + Style::Panel => Rgba::rgb(0x2E, 0x5E, 0x3A), + Style::Editorial => Rgba::rgb(0x1F, 0x2D, 0x4D), + Style::Modular => Rgba::rgb(0x2F, 0x6B, 0x4F), + Style::Poster => Rgba::rgb(0x3A, 0x26, 0x18), + } + } + + /// Whether its surrounds sit on cards with rounded corners. + fn cards(self) -> bool { + matches!(self, Style::Modular | Style::Poster) + } +} + +/// How a style draws its footer: its boxes' fill, outline and corners, and its texts' colours. +#[derive(Clone, Copy)] +struct Look { + head: Rgba, + body: Rgba, + small: Rgba, + fill: Rgba, + outline: Rgba, + width: f64, + radius: f64, +} + +/// Where a style sets the locator (and, in a panel, the legend) beside the map rather than over +/// it: the room left for them there, in page millimetres. +#[derive(Clone, Copy, Debug)] +struct Column { + x: f64, + y: f64, + w: f64, + h: f64, + /// The legend goes here too, under the locator. + legend: bool, } /// A rectangle with no outline (`width` 0) or no fill (`fill` transparent). fn pub_box(fill: Rgba, outline: Rgba, width: f64) -> ElementKind { - ElementKind::Rectangle { fill, outline, width } + pub_card(fill, outline, width, 0.0) +} + +/// [`pub_box`] with its corners rounded by `radius` millimetres. +fn pub_card(fill: Rgba, outline: Rgba, width: f64, radius: f64) -> ElementKind { + ElementKind::Rectangle { fill, outline, width, radius } } /// The surrounds a publication layout places in the map frame's corners. #[derive(Clone, Copy, PartialEq)] -enum Surround { +pub(crate) enum Surround { Locator, Arrow, Legend, Scale, } +/// The least a publication layout's legend is cut to, each way, by the surrounds beside and above +/// or below it, in mm. +const MIN_LEGEND_MM: f64 = 10.0; + +/// A publication layout's legend, `w` × `h` in its corner, cut short of the surround beside it +/// (`beside`: what it is and its width) and the one above or below it (`over`: what it is and its +/// height), in a frame with `across` and `down` mm of room (its margins and the gap between +/// surrounds taken off). Beside the arrow or the scale bar the legend keeps [`MIN_LEGEND_MM`] each +/// way, as a layout without a locator always has; the locator leaving it less, `None`: left out, +/// rather than drawn over the locator. +pub(crate) fn legend_room( + w: f64, + h: f64, + across: f64, + down: f64, + beside: Option<(Surround, f64)>, + over: Option<(Surround, f64)>, +) -> Option<(f64, f64)> { + let room = |other: Option<(Surround, f64)>, total: f64| other.map_or(f64::INFINITY, |(_, size)| total - size); + let (room_w, room_h) = (room(beside, across), room(over, down)); + let locator_in_the_way = + |room: f64, other: Option<(Surround, f64)>| room < MIN_LEGEND_MM && other.is_some_and(|(kind, _)| kind == Surround::Locator); + if locator_in_the_way(room_w, beside) || locator_in_the_way(room_h, over) { + return None; + } + Some((w.min(room_w.max(MIN_LEGEND_MM)), h.min(room_h.max(MIN_LEGEND_MM)))) +} + /// A publication layout's colorbar's height, in mm: its title, the bar and its labels. const COLORBAR_MM: f64 = 15.0; @@ -516,18 +629,42 @@ fn globe_scale(map: &rustgis_map::Map, center: Coord, d_mm: f64) -> f64 { if radius.is_finite() && radius > 0.0 { (PROBE * 2.0 * radius / side).clamp(100.0, 1e10) } else { PROBE } } +/// Whether a sheet `short` × `long` mm suits an area `aspect` times as tall as it is wide better +/// turned to landscape: the map comes out clearly wider there (by more than 3 %, portrait winning +/// a near tie), as near as the sheet's texts can be told before they are laid out (a header and a +/// footer of about 70 mm on A4 between them, a panel's title in its panel; a style with a column +/// leaves the map two thirds of the width). +fn landscape_suits(aspect: f64, short: f64, long: f64, style: Style) -> bool { + let k = short / 210.0; + let map_w = |w: f64, h: f64| { + let texts = if style == Style::Panel { 50.0 } else { 100.0 } * k; + let (iw, ih) = (w - 30.0 * k, h - texts); + let fw = if matches!(style, Style::Panel | Style::Editorial | Style::Modular) { 0.68 * iw } else { iw }; + let (fw, fh) = (fw - 2.0 * LABEL_ROOM_SIDE, ih - 2.0 * LABEL_ROOM_END); + fw.min(fh / aspect.max(1e-6)) + }; + map_w(long, short) > 1.03 * map_w(short, long) +} + /// `layout.publication`: a finished sheet or figure of the active map, its extent fitted at a /// round scale, with a graticule, legend, scale bar and north arrow; a sheet adds a frame, title, /// subtitle and a footer of two boxed columns (sources, cartographic reference) with a note and /// authorship under them, each text wrapped to its box with the layout's own font. fn publication(s: &mut Session, p: &Value) -> CmdResult { let id = match p.get("profile").filter(|v| !v.is_null()) { - None => "a4-portrait".to_string(), + None => "a4".to_string(), Some(v) => v.as_str().map(|t| t.trim().to_ascii_lowercase()).ok_or("profile is a profile's name, such as \"a4-portrait\"")?, }; + // A sheet's size alone ("a4", the default): its orientation chosen below, from the area's + // shape, portrait until then. + let auto = matches!(id.as_str(), "a4" | "a3" | "a2").then(|| id.clone()); + let id = match &auto { + Some(size) => format!("{size}-portrait"), + None => id, + }; let profile = PUBLICATION_PROFILES.iter().find(|pr| pr.id == id).ok_or_else(|| { let names: Vec<&str> = PUBLICATION_PROFILES.iter().map(|pr| pr.id).collect(); - format!("unknown profile '{id}': {}", names.join(", ")) + format!("unknown profile '{id}': a4, a3 or a2 (turned to the area's shape), {}", names.join(", ")) })?; let (sheet, page_w, page_h) = (profile.sheet, profile.width, profile.height); let extent = match p.get("extent").filter(|v| !v.is_null()) { @@ -579,6 +716,34 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { _ => return Err("locator is \"globe\", \"inset\" or \"none\"".into()), }, }; + // A style or an accent that isn't text is refused, as an unknown one is, rather than left out. + for k in ["style", "accent"] { + if p.get(k).is_some_and(|v| !v.is_null() && !v.is_string()) { + return Err(format!("{k} is text: {}", if k == "style" { "a style's name, such as \"panel\"" } else { "a colour as #RRGGBB" })); + } + } + let style = match ps(p, "style").map(str::trim) { + None | Some("classic") => Style::Classic, + Some("banner") => Style::Banner, + Some("panel") => Style::Panel, + Some("editorial") => Style::Editorial, + Some("modular") => Style::Modular, + Some("poster") => Style::Poster, + Some(x) => { + return Err(format!("style is \"classic\", \"banner\", \"panel\", \"editorial\", \"modular\" or \"poster\", not \"{x}\"")); + } + }; + let accent = color(p, "accent", style.accent())?; + // No orientation asked: the one the area's map comes out larger on, from its extents east to + // west and north to south on the map's projection and the room the style leaves the map. + let profile = match &auto { + Some(size) if aspect(s, &extent).is_some_and(|a| landscape_suits(a, page_w.min(page_h), page_w.max(page_h), style)) => { + let id = format!("{size}-landscape"); + PUBLICATION_PROFILES.iter().find(|pr| pr.id == id).unwrap_or(profile) + } + _ => profile, + }; + let (page_w, page_h) = (profile.width, profile.height); let heading = |i: usize, or: &str| headings.get(i).cloned().unwrap_or_else(|| or.to_string()); let (projection, datum) = projection_and_datum(s); let step = grid_step(&extent); @@ -616,134 +781,541 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let mut warnings: Vec = Vec::new(); // Texts and boxes around the map, placed while its frame is worked out. let mut around: Vec<(String, (f64, f64, f64, f64), ElementKind)> = Vec::new(); + // What a style draws under the map and its surrounds: a page's colour, a card or a panel. + let mut behind: Vec<(String, (f64, f64, f64, f64), ElementKind)> = Vec::new(); + // What spans the whole page (a sheet's neatline, a poster's colour), laid on the full page + // whatever a pass below frees, so never moved: over the map (`true`) or under everything, + // each before the rest of its side. + let mut page_wide: Vec<(String, (f64, f64, f64, f64), ElementKind, bool)> = Vec::new(); // A sheet's sizes follow its short side (A4's 210 mm is 1); a figure keeps its texts at their // printed size, whatever its width. let k = if sheet { page_w.min(page_h) / 210.0 } else { 1.0 }; - let (w, h, (fx, fy, fw, fh)) = if sheet { - let (m, pad) = (10.0 * k, 5.0 * k); - let (x0, iw) = (m + pad, page_w - 2.0 * (m + pad)); - let (title_pt, sub_pt, head_pt, body_pt, small_pt) = (18.0 * k, 10.5 * k, 7.5 * k, 7.0 * k, 6.5 * k); - around.push(("Frame".into(), (m, m, page_w - 2.0 * m, page_h - 2.0 * m), pub_box(clear, ink, 0.8))); - let mut top = m + pad; - if let Some(t) = &title { - let lines = wrap_as(t, iw, title_pt, true); - let th = lines.len() as f64 * pitch(title_pt) + 1.5 * k; - around.push(("Title".into(), (x0, top, iw, th), pub_text(lines.join("\n"), title_pt, ink, true, TextAlign::Center))); - top += th; - } - if let Some(t) = &subtitle { - let lines = wrap(t, iw, sub_pt); - let sh = lines.len() as f64 * pitch(sub_pt) + 1.0 * k; - around.push(("Subtitle".into(), (x0, top, iw, sh), pub_text(lines.join("\n"), sub_pt, soft, false, TextAlign::Center))); - top += sh; - } - // The footer, from the bottom up: authorship, the note, then the two boxes side by side, - // as tall as the longer column. - let bottom = page_h - m - pad; - let author_lines = author.as_deref().map(|t| wrap(t, iw, small_pt)).unwrap_or_default(); - let author_h = author_lines.len() as f64 * pitch(small_pt); - let author_y = bottom - author_h; - if !author_lines.is_empty() { - around.push(( - "Author".into(), - (x0, author_y, iw, author_h), - pub_text(author_lines.join("\n"), small_pt, soft, false, TextAlign::Right), - )); - } - let note_lines = note.as_deref().map(|t| wrap(t, iw, body_pt)).unwrap_or_default(); - let mut foot = author_y; - if !note_lines.is_empty() { - let nh = note_lines.len() as f64 * pitch(body_pt); - foot -= nh + 1.5 * k; - around.push(("Note".into(), (x0, foot, iw, nh), pub_text(note_lines.join("\n"), body_pt, soft, false, TextAlign::Left))); - } - // The columns with something in them, side by side (one alone takes the width). Each one's - // elements keep their names (`Sources`, `Reference`) whatever its heading reads, the heading - // filled and wrapped like the rest. - let (gap, inner) = (4.0 * k, 2.5 * k); - let given = [("Sources", heading(0, "Sources"), &sources), ("Reference", heading(1, "Cartographic reference"), &reference)]; - let given: Vec<_> = given.into_iter().filter(|(.., texts)| !texts.is_empty()).collect(); - let n = given.len().max(1) as f64; - let cw = (iw - gap * (n - 1.0)) / n; - let tw = cw - 2.0 * inner; - let columns: Vec<_> = given - .into_iter() - .map(|(name, head, texts)| { - // Filled first, so `{projection}` reads in capitals too; the live tokens left keep - // their case (wrap_as fills again, which then finds nothing). - let head = wrap_as(&capitals(&fill(&head)), tw, head_pt, true); - (name, head, texts.iter().flat_map(|t| wrap(t, tw, body_pt)).collect::>()) - }) - .collect(); - let heads = columns.iter().map(|(_, h, _)| h.len()).max().unwrap_or(1); - let rows = columns.iter().map(|(.., l)| l.len()).max().unwrap_or(0); - if rows > 0 { - let head_h = heads as f64 * pitch(head_pt); - let bh = 2.0 * inner + head_h + rows as f64 * pitch(body_pt); - foot -= bh + 2.0 * k; - for (i, (name, head, lines)) in columns.into_iter().enumerate() { - let bx = x0 + i as f64 * (cw + gap); - around.push((format!("{name} Box"), (bx, foot, cw, bh), pub_box(clear, Rgba::new(0x2B, 0x26, 0x20, 140), 0.5))); - let tx = bx + inner; + // A panel's or an editorial page's map is as wide as its column: where the area is less tall + // than the frame the sheet leaves it, the sheet is laid out again with the frame the area's + // height (and a little room), and the whole put in the middle of the page, rather than the map + // showing more of its surroundings. + let page_full = page_h; + let mut freed = 0.0f64; + let (mut target, mut passes): (Option, u32) = (None, 0); + // A later pass failing where an earlier one held (its shorter page leaving the texts too + // little room) settles on the last that held: only the first pass's failure is the request's. + let (mut held, mut settled) = (0.0f64, false); + let warned = warnings.len(); + let (w, h, (fx, fy, fw, fh), column) = loop { + around.clear(); + behind.clear(); + page_wide.clear(); + warnings.truncate(warned); + let page_h = page_full - freed; + let attempt = (|| -> Result<_, String> { + Ok(if sheet { + let (m, pad) = (10.0 * k, 5.0 * k); + let (x0, iw) = (m + pad, page_w - 2.0 * (m + pad)); + let (title_pt, sub_pt, head_pt, body_pt, small_pt) = (18.0 * k, 10.5 * k, 7.5 * k, 7.0 * k, 6.5 * k); + // A neatline round the sheet, but on a poster's coloured page or a page of cards. + if !style.cards() { + page_wide.push(("Frame".into(), (m, m, page_w - 2.0 * m, page_full - 2.0 * m), pub_box(clear, ink, 0.8), true)); + } + // The sources and the reference that have something in them, each its heading and lines + // `tw` wide. Each one's elements keep their names (`Sources`, `Reference`) whatever its + // heading reads, the heading filled and wrapped like the rest. + let sections = |tw: f64| -> Vec<(&'static str, Vec, Vec)> { + [("Sources", heading(0, "Sources"), &sources), ("Reference", heading(1, "Cartographic reference"), &reference)] + .into_iter() + .filter(|(.., texts)| !texts.is_empty()) + .map(|(name, head, texts)| { + // Filled first, so `{projection}` reads in capitals too; the live tokens left + // keep their case (wrap_as fills again, which then finds nothing). + let head = wrap_as(&capitals(&fill(&head)), tw, head_pt, true); + (name, head, texts.iter().flat_map(|t| wrap(t, tw, body_pt)).collect::>()) + }) + .collect() + }; + // The footer, from the bottom up across the sheet: authorship, the note (unless the style + // sets it beside the map), then the sources and reference boxed side by side (unless it + // stacks them in its panel), as tall as the longer column. Its top. + type Around = Vec<(String, (f64, f64, f64, f64), ElementKind)>; + let footer = |around: &mut Around, look: &Look, note_here: bool, boxes_here: bool| -> f64 { + let bottom = page_h - m - pad; + let author_lines = author.as_deref().map(|t| wrap(t, iw, small_pt)).unwrap_or_default(); + let author_h = author_lines.len() as f64 * pitch(small_pt); + let author_y = bottom - author_h; + if !author_lines.is_empty() { + around.push(( + "Author".into(), + (x0, author_y, iw, author_h), + pub_text(author_lines.join("\n"), small_pt, look.small, false, TextAlign::Right), + )); + } + let mut foot = author_y; + let note_lines = note.as_deref().filter(|_| note_here).map(|t| wrap(t, iw, body_pt)).unwrap_or_default(); + if !note_lines.is_empty() { + let nh = note_lines.len() as f64 * pitch(body_pt); + foot -= nh + 1.5 * k; + around.push(( + "Note".into(), + (x0, foot, iw, nh), + pub_text(note_lines.join("\n"), body_pt, look.small, false, TextAlign::Left), + )); + } + if !boxes_here { + return foot; + } + // The columns side by side (one alone takes the width). + let (gap, inner) = (4.0 * k, 2.5 * k); + let n = [!sources.is_empty(), !reference.is_empty()].iter().filter(|x| **x).count().max(1) as f64; + let cw = (iw - gap * (n - 1.0)) / n; + let tw = cw - 2.0 * inner; + let columns = sections(tw); + let heads = columns.iter().map(|(_, h, _)| h.len()).max().unwrap_or(1); + let rows = columns.iter().map(|(.., l)| l.len()).max().unwrap_or(0); + if rows > 0 { + let head_h = heads as f64 * pitch(head_pt); + let bh = 2.0 * inner + head_h + rows as f64 * pitch(body_pt); + foot -= bh + 2.0 * k; + for (i, (name, head, lines)) in columns.into_iter().enumerate() { + let bx = x0 + i as f64 * (cw + gap); + around.push(( + format!("{name} Box"), + (bx, foot, cw, bh), + pub_card(look.fill, look.outline, look.width, look.radius), + )); + let tx = bx + inner; + around.push(( + format!("{name} Heading"), + (tx, foot + inner, tw, head.len() as f64 * pitch(head_pt)), + pub_text(head.join("\n"), head_pt, look.head, true, TextAlign::Left), + )); + if !lines.is_empty() { + let body = (tx, foot + inner + head_h, tw, lines.len() as f64 * pitch(body_pt)); + around.push(( + name.to_string(), + body, + pub_text(lines.join("\n"), body_pt, look.body, false, TextAlign::Left), + )); + } + } + } + foot + }; + // The map's frame between `top` and `bottom`, at least 40 mm tall (on A4). + let room = |top: f64, bottom: f64| { + if bottom - top < 40.0 * k { + Err("the texts leave the map no room: shorten the footer, the note or the subtitle".to_string()) + } else { + Ok(bottom - top) + } + }; + let white = Rgba::rgb(0xFF, 0xFF, 0xFF); + let plain = Look { + head: ink, + body: soft, + small: soft, + fill: clear, + outline: Rgba::new(0x2B, 0x26, 0x20, 140), + width: 0.5, + radius: 0.0, + }; + let (card_r, card_pad) = (3.0 * k, 2.5 * k); + let card_line = Rgba::new(0x2B, 0x26, 0x20, 60); + match style { + Style::Classic => { + let mut top = m + pad; + if let Some(t) = &title { + let lines = wrap_as(t, iw, title_pt, true); + let th = lines.len() as f64 * pitch(title_pt) + 1.5 * k; + around.push(( + "Title".into(), + (x0, top, iw, th), + pub_text(lines.join("\n"), title_pt, ink, true, TextAlign::Center), + )); + top += th; + } + if let Some(t) = &subtitle { + let lines = wrap(t, iw, sub_pt); + let sh = lines.len() as f64 * pitch(sub_pt) + 1.0 * k; + around.push(( + "Subtitle".into(), + (x0, top, iw, sh), + pub_text(lines.join("\n"), sub_pt, soft, false, TextAlign::Center), + )); + top += sh; + } + let foot = footer(&mut around, &plain, true, true); + let (ft, fb) = (top + 3.0 * k, foot - 4.0 * k); + (page_w, page_h, (x0, ft, iw, room(ft, fb)?), None) + } + Style::Banner | Style::Poster => { + // A band in the accent colour across the top: the title at its left in white, the + // subtitle beside it past a thin rule. With neither, no band. A poster's whole page + // is that colour, its map in a light card and its footer in darker blocks. + let poster = style == Style::Poster; + if poster { + page_wide.push(("Background".into(), (0.0, 0.0, page_w, page_full), pub_box(accent, clear, 0.0), false)); + } + let mut top = m + pad; + if title.is_some() || subtitle.is_some() { + let big = title_pt * 1.2; + let t_max = if subtitle.is_some() { 0.42 * iw } else { iw }; + let t_lines = title.as_deref().map(|t| wrap_as(t, t_max, big, true)).unwrap_or_default(); + let t_w = t_lines + .iter() + .map(|x| crate::render::report::width_mm(&drawn(x), big as f32, true)) + .fold(0.0, f64::max) + .min(t_max) + .ceil(); + let rule = if t_lines.is_empty() { 0.0 } else { 6.0 * k }; + let s_x = x0 + t_w + rule; + let s_w = (x0 + iw - s_x).max(20.0 * k); + let s_lines = subtitle.as_deref().map(|t| wrap(t, s_w, sub_pt)).unwrap_or_default(); + let (t_h, s_h) = (t_lines.len() as f64 * pitch(big), s_lines.len() as f64 * pitch(sub_pt)); + let band_h = t_h.max(s_h) + 2.0 * pad; + let mid = m + band_h / 2.0; + if !poster { + around.push(("Banner".into(), (m, m, page_w - 2.0 * m, band_h), pub_box(accent, clear, 0.0))); + } + if !t_lines.is_empty() { + around.push(( + "Title".into(), + (x0, mid - t_h / 2.0, t_w, t_h), + pub_text(t_lines.join("\n"), big, white, true, TextAlign::Left), + )); + } + if !t_lines.is_empty() && !s_lines.is_empty() { + around.push(( + "Banner Rule".into(), + (x0 + t_w + rule / 2.0 - 0.5, mid - t_h.max(s_h) / 2.0, 1.0, t_h.max(s_h)), + ElementKind::Line { + color: Rgba::new(0xFF, 0xFF, 0xFF, 170), + width: 0.75, + dir: LineDir::Vertical, + head: LineHead::None, + }, + )); + } + if !s_lines.is_empty() { + around.push(( + "Subtitle".into(), + (s_x, mid - s_h / 2.0, s_w, s_h), + pub_text(s_lines.join("\n"), sub_pt, Rgba::rgb(0xF2, 0xEE, 0xE6), false, TextAlign::Left), + )); + } + top = m + band_h; + } + // A poster's footer: blocks a shade lighter than its page, amber headings, light text. + let shade = |c: u8| c.saturating_add(((255 - u16::from(c)) / 8) as u8); + let look = if poster { + Look { + head: Rgba::rgb(0xE8, 0xB0, 0x4B), + body: Rgba::rgb(0xED, 0xE6, 0xDA), + small: Rgba::rgb(0xCF, 0xC6, 0xB8), + fill: Rgba::rgb(shade(accent.r()), shade(accent.g()), shade(accent.b())), + outline: clear, + width: 0.0, + radius: card_r, + } + } else { + plain + }; + let foot = footer(&mut around, &look, true, true); + let (ft, fb) = (top + 4.0 * k, foot - 4.0 * k); + let h = room(ft, fb)?; + if poster { + behind.push(("Map Card".into(), (x0, ft, iw, h), pub_card(Rgba::rgb(0xF6, 0xF1, 0xE7), clear, 0.0, card_r))); + (page_w, page_h, (x0 + card_pad, ft + card_pad, iw - 2.0 * card_pad, h - 2.0 * card_pad), None) + } else { + (page_w, page_h, (x0, ft, iw, h), None) + } + } + Style::Modular => { + // Cards on a light page: the title and subtitle at the top left over a short bar of + // the accent colour; the map in a card at the left; at the right the locator and + // the legend, each in a card; the sources and reference in tinted cards side by + // side under them, the note and authorship under all. + let mut y = m + pad; + let tw = 0.75 * iw; + if let Some(t) = &title { + let big = title_pt * 1.3; + let lines = wrap_as(t, tw, big, true); + let th = lines.len() as f64 * pitch(big); + around.push(("Title".into(), (x0, y, tw, th), pub_text(lines.join("\n"), big, ink, true, TextAlign::Left))); + y += th; + } + if let Some(t) = &subtitle { + let lines = wrap(t, tw, sub_pt); + let sh = lines.len() as f64 * pitch(sub_pt); + around.push(( + "Subtitle".into(), + (x0, y, tw, sh), + pub_text(lines.join("\n"), sub_pt, soft, false, TextAlign::Left), + )); + y += sh; + } + if y > m + pad { + around.push(("Title Rule".into(), (x0, y + 1.5 * k, 14.0 * k, 1.2 * k), pub_box(accent, clear, 0.0))); + y += 5.0 * k; + } + let look = Look { + head: accent, + body: soft, + small: soft, + fill: Rgba::new(accent.r(), accent.g(), accent.b(), 0x1A), + outline: clear, + width: 0.0, + radius: card_r, + }; + let foot = footer(&mut around, &look, true, true); + let bottom = foot - 4.0 * k; + let h = room(y, bottom)?; + let gap = 5.0 * k; + let cw = (0.3 * iw).max(48.0 * k).min(0.45 * iw); + let (mw, cx) = (iw - cw - gap, x0 + iw - cw); + behind.push(("Map Card".into(), (x0, y, mw, h), pub_card(white, card_line, 0.4, card_r))); + let frame = (x0 + card_pad, y + card_pad, mw - 2.0 * card_pad, h - 2.0 * card_pad); + let column = + Column { x: cx + card_pad, y: y + card_pad, w: cw - 2.0 * card_pad, h: h - 2.0 * card_pad, legend: true }; + (page_w, page_h, frame, Some(column)) + } + Style::Panel => { + // The map at the left, as tall as the sheet allows; a pale panel at the right with + // the title and subtitle, then the locator and the legend, and the sources and the + // reference at its foot; the note and authorship across the bottom. + let gap = 5.0 * k; + let pw = (0.3 * iw).max(48.0 * k).min(0.45 * iw); + let (mw, px) = (iw - pw - gap, x0 + iw - pw); + let top = m + pad; + // With its frame cut to the area, the sources and reference go under the map, in + // the room the frame left, and the panel holds the title, locator and legend. + let under = freed > 0.0; + let foot = footer(&mut around, &plain, true, under); + let panel_bottom = foot - 4.0 * k; + let tint = Rgba::new(accent.r(), accent.g(), accent.b(), 0x18); + behind.push(("Panel Box".into(), (px, top, pw, panel_bottom - top), pub_box(tint, clear, 0.0))); + let ip = 3.0 * k; + let (ix, tw) = (px + ip, pw - 2.0 * ip); + let mut y = top + ip; + if let Some(t) = &title { + let lines = wrap_as(t, tw, title_pt, true); + let th = lines.len() as f64 * pitch(title_pt); + around.push(( + "Title".into(), + (ix, y, tw, th), + pub_text(lines.join("\n"), title_pt, ink, true, TextAlign::Left), + )); + y += th + 1.0 * k; + } + if let Some(t) = &subtitle { + let lines = wrap(t, tw, sub_pt); + let sh = lines.len() as f64 * pitch(sub_pt); + around.push(( + "Subtitle".into(), + (ix, y, tw, sh), + pub_text(lines.join("\n"), sub_pt, soft, false, TextAlign::Left), + )); + y += sh; + } + if y > top + ip { + around.push(( + "Panel Rule".into(), + (ix, y + 1.5 * k, tw, 1.0), + ElementKind::Line { color: accent, width: 1.0, dir: LineDir::Horizontal, head: LineHead::None }, + )); + y += 4.0 * k; + } + // The sources and the reference, stacked up from the panel's foot. + let mut yb = panel_bottom - ip; + for (name, head, lines) in sections(tw).into_iter().rev().filter(|_| !under) { + let (hh, bh) = (head.len() as f64 * pitch(head_pt), lines.len() as f64 * pitch(body_pt)); + yb -= hh + bh; + around.push(( + format!("{name} Heading"), + (ix, yb, tw, hh), + pub_text(head.join("\n"), head_pt, accent, true, TextAlign::Left), + )); + if !lines.is_empty() { + around.push(( + name.to_string(), + (ix, yb + hh, tw, bh), + pub_text(lines.join("\n"), body_pt, soft, false, TextAlign::Left), + )); + } + yb -= 3.0 * k; + } + if yb - y < 30.0 * k { + return Err( + "the panel's texts leave no room for the locator and the legend: shorten the title, the sources or the reference" + .into(), + ); + } + (page_w, page_h, (x0, top, mw, room(top, panel_bottom)?), Some(Column { x: ix, y, w: tw, h: yb - y, legend: true })) + } + Style::Editorial => { + // A magazine's page: the title large in serif at the top left in the accent colour, + // the subtitle under it, the map below; a column at the right, under a rule, with + // its heading (the third of `headings`), the note (the map's story), the locator + // and the legend; the sources and reference boxed under both. + // Its title and subtitle are serif: with none installed, drawn in the sans, said. + if (title.is_some() || subtitle.is_some()) + && let Some(w) = no_serif() + { + warnings.push(format!("the editorial title and subtitle: {w}")); + } + let gap = 6.0 * k; + let cw = (0.3 * iw).max(45.0 * k).min(0.4 * iw); + let (mw, cx) = (iw - cw - gap, x0 + iw - cw); + let serif_wrap = |t: &str, w: f64, pt: f64, bold: bool| { + crate::render::report::wrap_with(&fill(t), w.max(1.0), |x| { + crate::render::report::serif_width_mm(&drawn(x), pt as f32, bold) + }) + }; + let (big, sub) = (30.0 * k, 13.0 * k); + let mut y = m + pad; + if let Some(t) = &title { + let lines = serif_wrap(t, mw, big, true); + let th = lines.len() as f64 * pitch(big); + around.push(( + "Title".into(), + (x0, y, mw, th), + pub_text_in(lines.join("\n"), big, accent, true, TextAlign::Left, FontFamily::Serif), + )); + y += th + 1.0 * k; + } + if let Some(t) = &subtitle { + let lines = serif_wrap(t, mw, sub, false); + let sh = lines.len() as f64 * pitch(sub); + around.push(( + "Subtitle".into(), + (x0, y, mw, sh), + pub_text_in(lines.join("\n"), sub, soft, false, TextAlign::Left, FontFamily::Serif), + )); + y += sh; + } + let foot = footer(&mut around, &plain, false, true); + let fb = foot - 4.0 * k; + let mut cy = m + pad; + around.push(( + "Column Rule".into(), + (cx, cy, cw, 1.0), + ElementKind::Line { color: accent, width: 1.5, dir: LineDir::Horizontal, head: LineHead::None }, + )); + cy += 3.0 * k; + if let Some(hd) = headings.get(2).filter(|h| !h.trim().is_empty()) { + let pt = head_pt * 1.2; + let lines = wrap_as(&capitals(&fill(hd)), cw, pt, true); + let hh = lines.len() as f64 * pitch(pt); + around.push(( + "Column Heading".into(), + (cx, cy, cw, hh), + pub_text(lines.join("\n"), pt, accent, true, TextAlign::Left), + )); + cy += hh + 2.0 * k; + } + if let Some(t) = ¬e { + let pt = body_pt * 1.15; + let lines = wrap(t, cw, pt); + let nh = lines.len() as f64 * pitch(pt); + around.push(("Note".into(), (cx, cy, cw, nh), pub_text(lines.join("\n"), pt, soft, false, TextAlign::Left))); + cy += nh + 4.0 * k; + } + if fb - cy < 30.0 * k { + warnings + .push("the column's note leaves the locator and the legend little room under it: shorten the note".into()); + } + let ft = y + 4.0 * k; + ( + page_w, + page_h, + (x0, ft, mw, room(ft, fb)?), + Some(Column { x: cx, y: cy, w: cw, h: (fb - cy).max(0.0), legend: true }), + ) + } + } + } else { + if style != Style::Classic { + warnings.push(format!( + "style \"{}\" arranges a sheet's texts, which a figure leaves to its caption: left out", + style.code() + )); + } + // A figure: the map edge to edge, a line of the projection and datum under it. + // Every text a figure leaves to its caption, given anyway, is said to be left out. + let asked = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); + for (field, given) in [ + ("title", title.is_some()), + ("subtitle", subtitle.is_some()), + ("sources", !sources.is_empty()), + ("note", note.is_some()), + // An empty author asks for none: nothing left out. + ("author", asked("author") && author.is_some()), + ("headings", !headings.is_empty()), + ] { + if given { + warnings.push(format!("{field} goes in the figure's caption, not on it: left out")); + } + } + let line = reference.iter().map(|t| fill(t)).collect::>().join(" · "); + // A millimetre in from the paper's edges, which a journal may trim. + let lines = wrap(&line, page_w - 2.0, FIGURE_PT); + if lines.len() > 1 { + warnings.push("the reference is wider than the figure: it wraps under the map".into()); + } + let note_h = lines.len() as f64 * pitch(FIGURE_PT) + 1.0; + let fh = if page_h > 0.0 { + page_h - note_h + } else { + let shape = aspect(s, &extent).unwrap_or_else(|| { + warnings.push("the extent's shape on the map's projection isn't known: the figure is 4 wide by 3 tall".into()); + 0.75 + }); + (page_w * shape).clamp(0.45 * page_w, 1.3 * page_w).round() + }; + if fh < 40.0 { + return Err("the reference leaves the map no room: shorten it".into()); + } + let h = if page_h > 0.0 { page_h } else { (fh + note_h).ceil() }; around.push(( - format!("{name} Heading"), - (tx, foot + inner, tw, head.len() as f64 * pitch(head_pt)), - pub_text(head.join("\n"), head_pt, ink, true, TextAlign::Left), + "Reference".into(), + (1.0, fh + 1.0, page_w - 2.0, note_h - 1.0), + pub_text(lines.join("\n"), FIGURE_PT, ink, false, TextAlign::Left), )); - if !lines.is_empty() { - let body = (tx, foot + inner + head_h, tw, lines.len() as f64 * pitch(body_pt)); - around.push((name.to_string(), body, pub_text(lines.join("\n"), body_pt, soft, false, TextAlign::Left))); - } + (page_w, h, (0.0, 0.0, page_w, fh), None) + }) + })(); + let laid = match attempt { + Ok(laid) => laid, + Err(e) if passes == 0 || settled => return Err(e), + Err(_) => { + (freed, settled) = (held, true); + continue; } + }; + let (_, _, (_, _, lw, lh), _) = laid; + // The frame's height for the area at its width, with a seventh more of its surroundings; + // worked out on the first pass (the width doesn't change), aimed at on the others, whose + // footer can grow (a panel's sources move under the map) and so need a pass or two more. + if passes == 0 { + target = (sheet && matches!(style, Style::Panel | Style::Editorial)) + .then(|| aspect(s, &extent)) + .flatten() + .map(|a| (((lw - 2.0 * LABEL_ROOM_SIDE).max(1.0) * a + 2.0 * LABEL_ROOM_END) * 1.15).max(40.0 * k)) + // Only a frame clearly too tall is cut. + .filter(|n| *n < 0.97 * lh); + } + passes += 1; + match target { + Some(n) if !settled && (lh - n).abs() > 0.5 && passes < 5 => (held, freed) = (freed, (freed + lh - n).max(0.0)), + _ => break laid, } - let (frame_top, frame_bottom) = (top + 3.0 * k, foot - 4.0 * k); - if frame_bottom - frame_top < 40.0 * k { - return Err("the texts leave the map no room: shorten the footer, the note or the subtitle".into()); - } - (page_w, page_h, (x0, frame_top, iw, frame_bottom - frame_top)) + }; + // The content in the middle of the page: what the sheet laid out on its shorter page moves down + // by half of what it freed (what spans the whole page was laid on it, and stays). + for (_, r, _) in around.iter_mut().chain(behind.iter_mut()) { + r.1 += freed / 2.0; + } + let (fy, column, h) = if freed > 0.0 && sheet { + (fy + freed / 2.0, column.map(|c| Column { y: c.y + freed / 2.0, ..c }), page_full) } else { - // A figure: the map edge to edge, a line of the projection and datum under it. - // Every text a figure leaves to its caption, given anyway, is said to be left out. - let asked = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); - for (field, given) in [ - ("title", title.is_some()), - ("subtitle", subtitle.is_some()), - ("sources", !sources.is_empty()), - ("note", note.is_some()), - // An empty author asks for none: nothing left out. - ("author", asked("author") && author.is_some()), - ("headings", !headings.is_empty()), - ] { - if given { - warnings.push(format!("{field} goes in the figure's caption, not on it: left out")); - } - } - let line = reference.iter().map(|t| fill(t)).collect::>().join(" · "); - // A millimetre in from the paper's edges, which a journal may trim. - let lines = wrap(&line, page_w - 2.0, FIGURE_PT); - if lines.len() > 1 { - warnings.push("the reference is wider than the figure: it wraps under the map".into()); - } - let note_h = lines.len() as f64 * pitch(FIGURE_PT) + 1.0; - let fh = if page_h > 0.0 { - page_h - note_h - } else { - let shape = aspect(s, &extent).unwrap_or_else(|| { - warnings.push("the extent's shape on the map's projection isn't known: the figure is 4 wide by 3 tall".into()); - 0.75 - }); - (page_w * shape).clamp(0.45 * page_w, 1.3 * page_w).round() - }; - if fh < 40.0 { - return Err("the reference leaves the map no room: shorten it".into()); - } - let h = if page_h > 0.0 { page_h } else { (fh + note_h).ceil() }; - around.push(( - "Reference".into(), - (1.0, fh + 1.0, page_w - 2.0, note_h - 1.0), - pub_text(lines.join("\n"), FIGURE_PT, ink, false, TextAlign::Left), - )); - (page_w, h, (0.0, 0.0, page_w, fh)) + (fy, column, h) }; // The extent fits within the graticule's label strips (drawn inside the frame along its // labelled edges), on both sides so it stays centred, and its labels cover none of it. @@ -760,6 +1332,13 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let name = unique_name(s, ps(p, "name").unwrap_or("Publication")); let mut l = Layout::new(name.clone(), w, h); l.decimal_comma = comma; + let (over, under): (Vec<_>, Vec<_>) = page_wide.into_iter().partition(|e| e.3); + for (n, r, kind, _) in under { + l.add(&n, r, kind); + } + for (n, r, kind) in behind { + l.add(&n, r, kind); + } let frame = l.add( "Map Frame", (fx, fy, fw, fh), @@ -798,7 +1377,13 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { // The locator: a globe (a copy of the map's polygon layers on the globe, its diameter nearly // the frame's) or the surrounding region on the map itself, the area outlined on either by // the map frame's extent indicator. - let side = if sheet { (0.24 * fw.min(fh)).clamp(25.0 * k, 70.0 * k) } else { (0.3 * fw.min(fh)).clamp(18.0, 45.0) }; + let side = match column { + // In a column, its width up to an A4 portrait column's (a landscape sheet's wider + // column doesn't make it larger), leaving the legend most of the height under it. + Some(c) => c.w.min(52.0 * k).min(if c.legend { 0.45 * c.h } else { c.h }).max(0.0), + None if sheet => (0.24 * fw.min(fh)).clamp(25.0 * k, 70.0 * k), + None => (0.3 * fw.min(fh)).clamp(18.0, 45.0), + }; let locator_map = match locator { Locator::None => None, Locator::Globe => { @@ -923,7 +1508,10 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { } let (sw, sh, nh) = ((fw * 0.3).max(25.0), if sheet { 8.0 * k } else { 6.0 }, pitch(small_pt) + 0.5); let pad = 2.0; - // What the map draws where, on a coarse grid of the frame (its basemap left out). + // What the map draws where, on a coarse grid of the frame (its basemap left out). The map + // itself is drawn for it, on purpose, as the screen draws it: a grid of 96 cells, a small part + // of what exporting the sheet draws; a streamed layer draws what it holds and starts reading + // the frame's area in the background (which the export reads anyway); web tiles aren't fetched. let coverage = crate::render::layout::frame_coverage(s.map_mut(), center, scale, fw, fh, 96); // The share of a rectangle (in page mm) over the frame's drawn cells. let covered = |(x, y, w, h): (f64, f64, f64, f64)| -> f64 { @@ -946,7 +1534,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { // The surrounds to place: what each is, its size and its corner by default (0 top left, // 1 top right, 2 bottom left, 3 bottom right). let mut surrounds: Vec<(Surround, f64, f64, usize)> = Vec::new(); - if locator_map.is_some() { + if locator_map.is_some() && column.is_none() { surrounds.push((Surround::Locator, side, side, 0)); } surrounds.push((Surround::Arrow, aw, ah, 1)); @@ -958,7 +1546,8 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let (bar_w, bar_h) = (if sheet { 50.0 * k } else { 40.0 }, COLORBAR_MM); let bar_part = if bar.is_some() { bar_h + if ch > 0.0 { 2.0 } else { 0.0 } } else { 0.0 }; let (content_w, content_h) = (if bar.is_some() { cw.max(bar_w) } else { cw }, ch + bar_part); - if content_h > 0.0 { + let legend_in_column = column.is_some_and(|c| c.legend); + if content_h > 0.0 && !legend_in_column { surrounds.push((Surround::Legend, (content_w + 2.0 * pad).min(fw * 0.45), (content_h + 2.0 * pad).min(fh * 0.6), 2)); } surrounds.push((Surround::Scale, sw, sh + nh, 3)); @@ -973,7 +1562,6 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { // least of the map's drawing (its area in mm²) with no surround over another, a surround off // its own corner costing a hundredth of its area so that, all else equal, they stay where they // belong. There are four surrounds at most (locator, arrow, legend, scale), so 4⁴ = 256 picks. - const MIN_LEGEND_MM: f64 = 10.0; let n = surrounds.len(); let mut corners: Vec = surrounds.iter().map(|x| x.3).collect(); let mut best = f64::INFINITY; @@ -1012,6 +1600,8 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { cost += 10.0 * ow.max(0.0) * oh.max(0.0); } } + // A cost that isn't a number never wins, so with no pick costing a number the surrounds keep + // their own corners: the layout without the search, never a pick made of NaNs. if cost < best - 1e-9 { (best, corners) = (cost, pick); } @@ -1020,9 +1610,27 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let corner_of = |what: Surround| surrounds.iter().zip(&corners).find(|((kind, ..), _)| *kind == what).map(|(&(_, w, h, _), &c)| (c, w, h)); let mut placed: Vec<(&'static str, (f64, f64, f64, f64))> = Vec::new(); - if let (Some((map, locator_scale, shape)), Some((corner, w, h))) = (&locator_map, corner_of(Surround::Locator)) { - let r = at(corner, w, h); - placed.push(("locator", r)); + // In a column, at its top, centred across it; otherwise in its corner over the map. + let locator_rect = match column { + Some(c) => Some((c.x + (c.w - side) / 2.0, c.y, side, side)), + None => corner_of(Surround::Locator).map(|(corner, w, h)| { + let r = at(corner, w, h); + placed.push(("locator", r)); + r + }), + }; + // A page of cards gives the locator in its column a card of the column's width. + let card = style == Style::Modular && column.is_some(); + let (card_r, card_pad) = (3.0 * k, 2.5 * k); + let surround_box = |fill: Rgba| if style.cards() { pub_card(fill, clear, 0.0, 2.0 * k) } else { pub_box(fill, clear, 0.0) }; + if let (Some((map, locator_scale, shape)), Some(r)) = (&locator_map, locator_rect) { + if let (true, Some(c)) = (card, column) { + l.add( + "Locator Card", + (c.x - card_pad, r.1 - card_pad, c.w + 2.0 * card_pad, r.3 + 2.0 * card_pad), + pub_card(Rgba::rgb(0xFF, 0xFF, 0xFF), Rgba::new(0x2B, 0x26, 0x20, 60), 0.4, card_r), + ); + } l.add( "Locator", r, @@ -1049,24 +1657,45 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { // leaves too small for a legend leaves it out, said, rather than drawing it over the locator. let legend_at = corner_of(Surround::Legend).and_then(|(corner, w, h)| { let other = |c: usize| surrounds.iter().zip(&corners).find(|(_, k)| **k == c).map(|(&(kind, w, h, _), _)| (kind, w, h)); - let room_w = other(corner ^ 1).map_or(f64::INFINITY, |(_, ow, _)| fw - left - right - ow - 2.0); - let room_h = other(corner ^ 2).map_or(f64::INFINITY, |(_, _, oh)| fh - top - bottom - oh - e); - // Short of the locator, under 10 mm: left out. Beside the arrow or the scale bar, the - // legend keeps 10 mm each way, as a layout without a locator always has. - let locator_in_the_way = |room: f64, c: usize| room < MIN_LEGEND_MM && other(c).is_some_and(|o| o.0 == Surround::Locator); - if locator_in_the_way(room_h, corner ^ 2) || locator_in_the_way(room_w, corner ^ 1) { + let beside = other(corner ^ 1).map(|(kind, ow, _)| (kind, ow)); + let over = other(corner ^ 2).map(|(kind, _, oh)| (kind, oh)); + let fit = legend_room(w, h, fw - left - right - 2.0, fh - top - bottom - e, beside, over); + if fit.is_none() { warnings.push( "the frame leaves the legend no room beside the locator, so it is left out: give the map more room, or set \"locator\": \"none\"" .into(), ); - return None; } - Some((corner, w.min(room_w.max(MIN_LEGEND_MM)), h.min(room_h.max(MIN_LEGEND_MM)))) + fit.map(|(lw, lh)| (corner, lw, lh)) }); - if let Some((corner, lw, lh)) = legend_at { - let r = at(corner, lw, lh); - placed.push(("legend", r)); - l.add("Legend Box", r, pub_box(paper, clear, 0.0)); + // In a panel, the legend under the locator, as tall as it needs within what is left. + let legend_rect = match column.filter(|c| c.legend) { + Some(c) if content_h > 0.0 => { + let ly = c.y + if locator_map.is_some() { side + 3.0 * k + if card { 2.0 * card_pad } else { 0.0 } } else { 0.0 }; + let room_h = c.y + c.h - ly; + if room_h < MIN_LEGEND_MM { + warnings.push("the side column leaves the legend no room under the locator, so it is left out: shorten its texts".into()); + None + } else { + // On a page of cards, a card as wide as the locator's. + let (x, w) = if card { (c.x - card_pad, c.w + 2.0 * card_pad) } else { (c.x, c.w) }; + Some((x, ly, w, (content_h + 2.0 * pad).min(room_h))) + } + } + Some(_) => None, + None => legend_at.map(|(corner, lw, lh)| { + let r = at(corner, lw, lh); + placed.push(("legend", r)); + r + }), + }; + if let Some(r) = legend_rect { + let (lw, lh) = (r.2, r.3); + l.add( + "Legend Box", + r, + if card { pub_card(Rgba::rgb(0xFF, 0xFF, 0xFF), Rgba::new(0x2B, 0x26, 0x20, 60), 0.4, card_r) } else { surround_box(paper) }, + ); // The colorbar at the bottom of the box, as tall as the box allows; the legend's rows in // what is left above it. Each says when it doesn't fit, rather than one blaming the other. let (ix, iy, iw, ih) = (r.0 + pad, r.1 + pad, lw - 2.0 * pad, lh - 2.0 * pad); @@ -1075,7 +1704,8 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { if ch > 0.0 && (cw > iw || ch > rows_h) { warnings.push("the legend lists more than fits its corner: its last rows or the ends of long labels are left out".into()); } - if bar.is_some() && (bar_w > iw || bar_h > ih) { + // In a column the colorbar takes the column's width, as its rows do. + if bar.is_some() && ((bar_w > iw && !legend_in_column) || bar_h > ih) { warnings.push("the legend's box is smaller than the colorbar: it is drawn narrower or shorter".into()); } if ch > 0.0 && rows_h >= 1.0 { @@ -1104,7 +1734,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { if let Some((corner, w, h)) = corner_of(Surround::Scale) { let (sx, sy, ..) = at(corner, w, h); placed.push(("scale bar", (sx, sy, w, h))); - l.add("Scale Box", (sx, sy, sw, sh + nh), pub_box(paper, clear, 0.0)); + l.add("Scale Box", (sx, sy, sw, sh + nh), surround_box(paper)); l.add("Scale Bar", (sx, sy, sw, sh), ElementKind::scale_bar(frame)); l.add("Scale", (sx, sy + sh, sw, nh), pub_text("{scale}".into(), small_pt, ink, false, TextAlign::Center)); } @@ -1136,6 +1766,9 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { } } } + for (n, r, kind, _) in over { + l.add(&n, r, kind); + } for (n, r, kind) in around { l.add(&n, r, kind); } @@ -1146,7 +1779,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { s.active_layout = Some(i); let locator = locator_map.map(|(map, ..)| map); Ok( - json!({"layout": i, "name": name, "page": [w, h], "scale": scale, "grid": step, "locator": locator, "elements": elements, "warnings": warnings}), + json!({"layout": i, "name": name, "profile": profile.id, "page": [w, h], "scale": scale, "grid": step, "locator": locator, "style": style.code(), "elements": elements, "warnings": warnings}), ) } @@ -1313,6 +1946,16 @@ fn frame_of(l: &Layout, p: &Value) -> Result { } } +/// A rectangle's `radius`: its corners rounded by up to 50 mm; `d` when not given. +fn corner_radius(p: &Value, d: f64) -> Result { + match p.get("radius").filter(|v| !v.is_null()) { + None => Ok(d), + Some(v) => { + v.as_f64().filter(|r| r.is_finite() && *r >= 0.0).map(|r| r.min(50.0)).ok_or_else(|| "radius is millimetres, 0 or more".into()) + } + } +} + pub(crate) fn color(p: &Value, k: &str, d: Rgba) -> Result { match ps(p, k) { Some(c) => Rgba::from_hex(c).ok_or_else(|| format!("{k} must be #RRGGBB or #RRGGBBAA")), @@ -1330,6 +1973,9 @@ fn add_element(s: &mut Session, p: &Value) -> CmdResult { if !matches!(kind, "text" | "dynamicText") && p.get("font").is_some_and(|v| !v.is_null()) { return Err(ONLY_TEXT_FONT.into()); } + if kind != "rectangle" && p.get("radius").is_some_and(|v| !v.is_null()) { + return Err(ONLY_RECTANGLE_RADIUS.into()); + } let i = index(s, p)?; let image = if kind == "picture" { Some(super::graphics::picture(p)?) } else { None }; let layer_name = |s: &Session, l: &Layout, frame: u32| -> Result { @@ -1437,6 +2083,7 @@ fn add_element(s: &mut Session, p: &Value) -> CmdResult { fill: color(p, "fill", Rgba::new(0, 0, 0, 0))?, outline: color(p, "color", Rgba::rgb(0x22, 0x22, 0x22))?, width: shape_width(p, 1.0)?.clamp(0.0, 50.0), + radius: corner_radius(p, 0.0)?, }; // Without a box it frames the page, as a neatline does; with any part of one it's a shape. if ["x", "y", "w", "h"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())) { @@ -1683,6 +2330,10 @@ pub(crate) fn text_style(p: &Value, italic: bool, align: TextAlign, spacing: f64 /// What a `font` given for an element that isn't a text is told. const ONLY_TEXT_FONT: &str = "only text elements take a font"; +/// Rounded corners are a rectangle's: refused elsewhere, as a family is, so a script doesn't take +/// them as applied. +const ONLY_RECTANGLE_RADIUS: &str = "only a rectangle rounds its corners"; + /// A text element's family from `font` in `p`, the current one when not given (#517). pub(crate) fn font_family(p: &Value, current: FontFamily) -> Result { match ps(p, "font") { @@ -1764,16 +2415,18 @@ fn restyle(kind: &ElementKind, p: &Value) -> Result, String> }; Ok(Some(ElementKind::ScaleBar { frame: *frame, units, divisions, style })) } - ElementKind::Rectangle { fill, outline, width } | ElementKind::Ellipse { fill, outline, width } => { - if ["fill", "outline", "color", "width"].iter().all(|k| p.get(k).is_none()) { + ElementKind::Rectangle { fill, outline, width, .. } | ElementKind::Ellipse { fill, outline, width } => { + if ["fill", "outline", "color", "width", "radius"].iter().all(|k| p.get(k).is_none()) { return Ok(None); } // The outline's colour is "outline", or "color" as when adding one. let (fill, outline) = (color(p, "fill", *fill)?, color(p, "outline", color(p, "color", *outline)?)?); let width = shape_width(p, *width)?.clamp(0.0, 50.0); Ok(Some(match kind { - ElementKind::Ellipse { .. } => ElementKind::Ellipse { fill, outline, width }, - _ => ElementKind::Rectangle { fill, outline, width }, + ElementKind::Rectangle { radius, .. } => { + ElementKind::Rectangle { fill, outline, width, radius: corner_radius(p, *radius)? } + } + _ => ElementKind::Ellipse { fill, outline, width }, })) } ElementKind::Line { color: c, width, dir, head } => { @@ -1954,6 +2607,11 @@ fn update(s: &mut Session, p: &Value) -> CmdResult { _ if p.get("font").is_some_and(|v| !v.is_null()) => return Err(ONLY_TEXT_FONT.into()), _ => None, }; + if p.get("radius").is_some_and(|v| !v.is_null()) + && !matches!(s.project.layouts.get(li).and_then(|l| l.element(id)).map(|e| &e.kind), Some(ElementKind::Rectangle { .. })) + { + return Err(ONLY_RECTANGLE_RADIUS.into()); + } // A legend's left-out layers, checked against its frame's map before anything changes. let new_exclude = { let l = s.project.layouts.get(li).ok_or("no layout")?; diff --git a/crates/engine/src/cmd/presentation.rs b/crates/engine/src/cmd/presentation.rs index 55c29f19e..ed260250f 100644 --- a/crates/engine/src/cmd/presentation.rs +++ b/crates/engine/src/cmd/presentation.rs @@ -284,7 +284,11 @@ pub fn page_layout(s: &Session, pres: &Presentation, page: &Page) -> Layout { let text = |text: &str, size: f64, color: Rgba, bold: bool| ElementKind::text(text, size, color, bold); match &page.kind { PageKind::Blank { text: body, background } => { - l.add("Background", (0.0, 0.0, w, h), ElementKind::Rectangle { fill: *background, outline: *background, width: 0.0 }); + l.add( + "Background", + (0.0, 0.0, w, h), + ElementKind::Rectangle { fill: *background, outline: *background, width: 0.0, radius: 0.0 }, + ); // Light text on dark pages. let lum = 0.299 * background.r() as f64 + 0.587 * background.g() as f64 + 0.114 * background.b() as f64; let fg = if lum < 140.0 { Rgba::rgb(0xFF, 0xFF, 0xFF) } else { dark }; @@ -351,7 +355,7 @@ pub fn page_layout(s: &Session, pres: &Presentation, page: &Page) -> Layout { } let (bw, bh) = (w - 2.0 * m, h - top - m - h * 0.08); let bg = Rgba::rgb(0x11, 0x18, 0x27); - l.add("Video", (m, top, bw, bh), ElementKind::Rectangle { fill: bg, outline: bg, width: 0.0 }); + l.add("Video", (m, top, bw, bh), ElementKind::Rectangle { fill: bg, outline: bg, width: 0.0, radius: 0.0 }); l.add( "Play", (m + bw * 0.3, top + bh / 2.0 - 6.0, bw * 0.4, 12.0), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bd07a5397..caedf143c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1263,6 +1263,11 @@ fn hostile_params_never_panic() { json!({"type": "albers", "lon_0": f64::MAX, "lat_1": "x", "lat_2": -1e308}), json!({"kind": "legend", "element": 1, "exclude": [null, 7, "", {"x": 1}]}), json!({"kind": "text", "element": 1, "font": {"family": "serif"}, "italic": [], "align": 7}), + // A publication sheet's style, accent and orientation, and a shape's rounded corners. + // (The profile left out or valid, so the style and accent are reached.) + json!({"style": 7, "accent": "#XYZ", "kind": "ellipse", "element": 1, "radius": "x"}), + json!({"style": "", "accent": [], "kind": "rectangle", "element": 1, "radius": -1e308}), + json!({"profile": "a3", "style": "poster", "accent": "#ZZZZZZ", "kind": "rectangle", "radius": 1e308}), // A STAC trend's source: a valid band and period so the junk after them is reached, and an // API on a closed local port, so nothing is read. json!({"collection": "c", "asset": "NDVI", "url": "http://127.0.0.1:9/v1", "bbox": "nowhere", "cellSize": -1e308, "resampling": 3, "start": "2020", "end": "2021"}), @@ -2704,6 +2709,210 @@ fn publication_layouts_fit_their_pages() { let _ = std::fs::remove_dir_all(&dir); } +/// A publication sheet's style (#682): `classic` (the default) as before; `banner` the title and +/// subtitle in a band of the accent colour across the top; `panel` the map at the left and a panel +/// at the right holding the title, the locator, the legend, the sources and the reference; +/// `editorial` the title large in serif and a column at the right with the note, the locator and +/// the legend; `modular` cards with rounded corners; `poster` the page in the accent colour. +/// +/// A figure leaves a style to its caption; an unknown style or a bad accent is refused. +#[test] +fn publication_layouts_take_a_style() { + use rustgis_map::layout::{ElementKind, FontFamily}; + let dir = std::env::temp_dir().join(format!("rustgis-styles-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = world(); + let piaui = json!([-46.8, -11.6, -38.5, -2.2]); + // On a portrait sheet (the arrangement is what is checked; the orientation chosen for an area + // has its own check below). + let texts = json!({"extent": piaui, "profile": "a4-portrait", "title": "PIAUÍ", "subtitle": "Greening and browning of the vegetation, 2001–2025", + "sources": ["NDVI: MOD13Q1 v6.1", "Boundaries: IBGE"], "note": "Only the cells with a significant trend are coloured.", + "headings": ["Sources", "Reference", "About this map"]}); + let make = |s: &mut Session, style: &str, extra: Value| { + let mut p = texts.clone(); + p["style"] = json!(style); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + let r = s.run("layout.publication", &p).unwrap(); + s.run("layout.export", &json!({"path": dir.join(format!("{style}.png")).display().to_string(), "dpi": 48})).unwrap(); + r + }; + let inside = |a: &rustgis_map::layout::Element, b: &rustgis_map::layout::Element| { + a.x >= b.x - 1e-6 && a.y >= b.y - 1e-6 && a.x + a.w <= b.x + b.w + 1e-6 && a.y + a.h <= b.y + b.h + 1e-6 + }; + // Classic, the default: no band, no panel. + let r = s.run("layout.publication", &texts).unwrap(); + assert_eq!(r["style"], "classic"); + let l = s.project.layouts.last().unwrap(); + assert!(l.elements.iter().all(|e| !matches!(e.name.as_str(), "Banner" | "Panel Box" | "Column Rule"))); + // Banner: a band of the accent colour at the top, the title on it in white, the map under it. + let r = make(&mut s, "banner", json!({"accent": "#123456"})); + let l = s.project.layouts.last().unwrap(); + let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); + let (band, title, frame) = (named("Banner"), named("Title"), named("Map Frame")); + let ElementKind::Rectangle { fill, .. } = &band.kind else { panic!() }; + assert_eq!(*fill, rustgis_map::Rgba::rgb(0x12, 0x34, 0x56)); + let ElementKind::Text { color, .. } = &title.kind else { panic!() }; + assert_eq!(*color, rustgis_map::Rgba::rgb(0xFF, 0xFF, 0xFF)); + assert!(inside(title, band) && inside(named("Subtitle"), band) && frame.y >= band.y + band.h, "{r}"); + // Panel: the map at the left, the panel at the right holding the texts, the locator and the + // legend; no boxed footer. + // An area as tall as the map's narrow column (its frame filled, not cut): everything in the + // panel. + let tall = json!([-46.0, -34.0, -40.0, 4.0]); + let r = make(&mut s, "panel", json!({"extent": tall})); + let l = s.project.layouts.last().unwrap(); + let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); + let (panel, frame) = (named("Panel Box"), named("Map Frame")); + assert!(frame.x + frame.w <= panel.x, "the map left of the panel: {r}"); + for n in ["Title", "Subtitle", "Locator", "Legend Box", "Sources Heading", "Sources", "Reference"] { + assert!(inside(named(n), panel), "{n} in the panel: {r}"); + } + assert!(l.elements.iter().all(|e| e.name != "Sources Box")); + // The legend under the locator, apart from it. + assert!(named("Legend Box").y >= named("Locator").y + named("Locator").h, "{r}"); + let full = frame.h; + // A less tall area (Piauí) on that narrow column: the frame cut to its shape (a seventh more of its + // surroundings), the sources and reference boxed under the map, and all of it in the middle of + // the page; the neatline still round the whole sheet. + let r = make(&mut s, "panel", json!({})); + let l = s.project.layouts.last().unwrap(); + let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); + let (panel, frame, sources) = (named("Panel Box"), named("Map Frame"), named("Sources Box")); + assert!(frame.h < 0.9 * full && (panel.h - frame.h).abs() < 1e-6, "{} of {full}: {r}", frame.h); + assert!(sources.y >= frame.y + frame.h && inside(named("Legend Box"), panel), "{r}"); + let (top, bottom) = (frame.y, l.height - named("Author").y - named("Author").h); + assert!((top - bottom).abs() < 1.0, "the content in the middle: {top} above, {bottom} below"); + let neat = named("Frame"); + assert!((neat.y + neat.h - (l.height - neat.y)).abs() < 1e-6, "the neatline round the sheet"); + // In landscape too, every style on any sheet: the panel at the right of a wide map. + for style in ["banner", "panel", "editorial"] { + let r = make(&mut s, style, json!({"profile": "a4-landscape"})); + let l = s.project.layouts.last().unwrap(); + let frame = l.elements.iter().find(|e| e.name == "Map Frame").unwrap(); + assert!(frame.w > frame.h, "{style}: a wide map on a landscape sheet: {r}"); + if style != "banner" { + assert!( + l.elements.iter().filter(|e| e.name == "Locator" || e.name == "Legend Box").all(|e| e.x >= frame.x + frame.w), + "{style}: {r}" + ); + // The locator no larger than on a portrait sheet, though the column is wider. + let locator = l.elements.iter().find(|e| e.name == "Locator").unwrap(); + assert!(locator.w <= 52.0 + 1e-6, "{style}: {}", locator.w); + } + } + // No locator: the legend takes its place at the top of the panel's free room. + let r = make(&mut s, "panel", json!({"locator": "none"})); + let l = s.project.layouts.last().unwrap(); + assert!(l.elements.iter().all(|e| e.name != "Locator") && l.elements.iter().any(|e| e.name == "Legend Box"), "{r}"); + // Editorial: the title in serif in the accent colour, a column at the right with its heading, + // the note, the locator and the legend. + let r = make(&mut s, "editorial", json!({})); + let l = s.project.layouts.last().unwrap(); + let named = |n: &str| l.elements.iter().find(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); + let ElementKind::Text { font, color, .. } = &named("Title").kind else { panic!() }; + assert_eq!((*font, *color), (FontFamily::Serif, rustgis_map::Rgba::rgb(0x1F, 0x2D, 0x4D))); + // Drawn in the sans where no serif is installed, said. + let said = r["warnings"].to_string().contains("the editorial title and subtitle: no serif font"); + assert_eq!(said, !crate::render::fonts::serif_available(), "{r}"); + let frame = named("Map Frame"); + for n in ["Column Heading", "Note", "Locator", "Legend Box"] { + assert!(named(n).x >= frame.x + frame.w, "{n} right of the map: {r}"); + } + // Modular: the map in a card drawn under it, the locator and the legend each in a card at the + // right, the footer's boxes with rounded corners and no neatline. + let radius = |e: &rustgis_map::layout::Element| match &e.kind { + ElementKind::Rectangle { radius, .. } => *radius, + _ => 0.0, + }; + let r = make(&mut s, "modular", json!({})); + let l = s.project.layouts.last().unwrap(); + let at = |n: &str| l.elements.iter().position(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); + let named = |n: &str| &l.elements[at(n)]; + assert!(at("Map Card") < at("Map Frame") && inside(named("Map Frame"), named("Map Card")), "{r}"); + let frame = named("Map Frame"); + for n in ["Locator Card", "Locator", "Legend Box"] { + assert!(named(n).x >= frame.x + frame.w, "{n} right of the map: {r}"); + } + assert!(inside(named("Locator"), named("Locator Card")) && named("Legend Box").y >= named("Locator Card").y + named("Locator Card").h); + assert!((named("Legend Box").w - named("Locator Card").w).abs() < 1e-6, "the two cards as wide: {r}"); + assert!(radius(named("Map Card")) > 0.0 && radius(named("Legend Box")) > 0.0 && radius(named("Sources Box")) > 0.0); + assert!(l.elements.iter().all(|e| e.name != "Frame")); + // Poster: the page in the accent colour under everything, the title in white, the map in a + // light card, the footer in rounded blocks. + let r = make(&mut s, "poster", json!({})); + let l = s.project.layouts.last().unwrap(); + let at = |n: &str| l.elements.iter().position(|e| e.name == n).unwrap_or_else(|| panic!("no {n}: {r}")); + let named = |n: &str| &l.elements[at(n)]; + let back = named("Background"); + assert_eq!((at("Background"), back.w, back.h), (0, l.width, l.height)); + let ElementKind::Rectangle { fill, .. } = &back.kind else { panic!() }; + assert_eq!(*fill, rustgis_map::Rgba::rgb(0x3A, 0x26, 0x18)); + let ElementKind::Text { color, .. } = &named("Title").kind else { panic!() }; + assert_eq!(*color, rustgis_map::Rgba::rgb(0xFF, 0xFF, 0xFF)); + assert!(at("Map Card") < at("Map Frame") && inside(named("Map Frame"), named("Map Card")) && radius(named("Sources Box")) > 0.0); + assert!(l.elements.iter().all(|e| e.name != "Banner" && e.name != "Frame")); + // A rectangle's rounded corners by hand: added, changed, kept when its colour changes, saved only + // when rounded. + let id = s.run("layout.addElement", &json!({"kind": "rectangle", "x": 10, "y": 10, "w": 40, "h": 20, "radius": 4})).unwrap()["element"] + .clone(); + let rect = |s: &Session| s.project.layouts.last().unwrap().elements.iter().find(|e| json!(e.id) == id).unwrap().kind.clone(); + assert!(matches!(rect(&s), ElementKind::Rectangle { radius, .. } if radius == 4.0)); + s.run("layout.update", &json!({"element": id, "fill": "#FFFFFF"})).unwrap(); + assert!(matches!(rect(&s), ElementKind::Rectangle { radius, .. } if radius == 4.0)); + s.run("layout.update", &json!({"element": id, "radius": 0})).unwrap(); + assert!(!serde_json::to_string(&rect(&s)).unwrap().contains("radius"), "square corners aren't saved"); + assert!(s.run("layout.update", &json!({"element": id, "radius": -1})).is_err()); + assert!(s.run("layout.addElement", &json!({"kind": "rectangle", "radius": "x"})).is_err()); + // Anything but a rectangle refuses rounded corners, rather than taking them as applied. + let n = s.project.layouts.last().unwrap().elements.len(); + let e = s.run("layout.addElement", &json!({"kind": "ellipse", "radius": 4})).unwrap_err(); + assert!(e.contains("only a rectangle rounds its corners") && s.project.layouts.last().unwrap().elements.len() == n, "{e}"); + let oval = s.run("layout.addElement", &json!({"kind": "circle"})).unwrap()["element"].clone(); + let e = s.run("layout.update", &json!({"element": oval, "radius": 4, "fill": "#FF0000"})).unwrap_err(); + assert!(e.contains("only a rectangle rounds its corners"), "{e}"); + let fill = |s: &Session| { + s.project.layouts.last().unwrap().elements.iter().find(|e| json!(e.id) == oval).and_then(|e| match e.kind { + ElementKind::Ellipse { fill, .. } => Some(fill), + _ => None, + }) + }; + assert!(fill(&s).is_some_and(|f| f != rustgis_map::Rgba::rgb(0xFF, 0, 0)), "an ellipse, unchanged"); + // A sheet given its size alone (or none) is turned to the area's shape: portrait for a tall + // one, landscape for a wide one, the size kept; one given its orientation keeps it. + let wide = json!([-75.0, -14.0, -35.0, -2.0]); + for (p, want) in [ + (json!({"extent": piaui}), "a4-portrait"), + (json!({"extent": wide}), "a4-landscape"), + (json!({"extent": wide, "profile": "a3"}), "a3-landscape"), + (json!({"extent": wide, "profile": "a4-portrait"}), "a4-portrait"), + (json!({"extent": piaui, "profile": "a4-landscape"}), "a4-landscape"), + ] { + let r = s.run("layout.publication", &p).unwrap(); + assert_eq!(r["profile"], want, "{p}"); + let l = s.project.layouts.last().unwrap(); + assert_eq!(l.width > l.height, want.ends_with("landscape"), "{p}"); + } + // A figure leaves the style to its caption; refused: an unknown style, a bad accent, either not + // given as text, and an unknown profile (its message naming the sizes turned to the area). + let r = s.run("layout.publication", &json!({"extent": piaui, "profile": "figure-1col", "style": "banner"})).unwrap(); + assert!(r["warnings"].to_string().contains("style \\\"banner\\\""), "{r}"); + let before = s.project.layouts.len(); + for bad in [ + json!({"extent": piaui, "style": "magazine"}), + json!({"extent": piaui, "style": "banner", "accent": "blue"}), + json!({"extent": piaui, "style": 3}), + json!({"extent": piaui, "accent": 7}), + ] { + assert!(s.run("layout.publication", &bad).is_err(), "{bad}"); + } + let e = s.run("layout.publication", &json!({"extent": piaui, "profile": "a5"})).unwrap_err(); + assert!(e.contains("a4, a3 or a2") && e.contains("a4-portrait"), "{e}"); + assert_eq!(s.project.layouts.len(), before); + let _ = std::fs::remove_dir_all(&dir); +} + /// A publication layout's locator (#682, part 2): on a sheet, by default, a globe (a new map of /// the vector layers on the globe, its diameter nearly the frame's) in a round frame at the map's /// top left, the area outlined by the map frame's extent indicator; `"inset"` the surrounding @@ -2879,6 +3088,15 @@ fn publication_layouts_locate_their_area() { } } } + // The sheets above may never leave the legend that little room (the search keeps it off the + // locator's side), so the rule itself: short of the arrow or the scale bar it keeps 10 mm, short + // of the locator under 10 mm it is left out. + use crate::cmd::layout::{Surround, legend_room}; + assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, None, None), Some((40.0, 30.0))); + assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, Some((Surround::Arrow, 70.0)), Some((Surround::Locator, 60.0))), Some((30.0, 20.0))); + assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, Some((Surround::Arrow, 95.0)), Some((Surround::Scale, 75.0))), Some((10.0, 10.0))); + assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, None, Some((Surround::Locator, 75.0))), None); + assert_eq!(legend_room(40.0, 30.0, 100.0, 80.0, Some((Surround::Locator, 95.0)), None), None); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/map/src/layout.rs b/crates/map/src/layout.rs index 0269d57c9..21ee72ed3 100644 --- a/crates/map/src/layout.rs +++ b/crates/map/src/layout.rs @@ -829,7 +829,14 @@ pub enum ElementKind { }, /// A rectangle drawn on the page (a neatline or a backdrop). `width` is the outline's, in /// points (0: none). - Rectangle { fill: Rgba, outline: Rgba, width: f64 }, + Rectangle { + fill: Rgba, + outline: Rgba, + width: f64, + /// Its corners rounded by this much, in millimetres (0: square corners). + #[serde(default, skip_serializing_if = "is_zero")] + radius: f64, + }, /// An ellipse filling the element's box: a circle in a square one. Ellipse { fill: Rgba, outline: Rgba, width: f64 }, /// A straight line across the element's box, `width` points wide. @@ -1412,7 +1419,7 @@ mod tests { let a = big.add( "a", (0.0, 0.0, 1.0, 1.0), - ElementKind::Rectangle { fill: Rgba::rgb(0, 0, 0), outline: Rgba::rgb(0, 0, 0), width: 1.0 }, + ElementKind::Rectangle { fill: Rgba::rgb(0, 0, 0), outline: Rgba::rgb(0, 0, 0), width: 1.0, radius: 0.0 }, ); assert!(a != big.elements[0].id && a <= MAX_ID + 1); } @@ -1420,7 +1427,7 @@ mod tests { #[test] fn hairline_rectangles_keep_their_height() { let mut l = Layout::new("Layout", 210.0, 297.0); - let rule = ElementKind::Rectangle { fill: Rgba::rgb(0, 0, 0), outline: Rgba::rgb(0, 0, 0), width: 0.0 }; + let rule = ElementKind::Rectangle { fill: Rgba::rgb(0, 0, 0), outline: Rgba::rgb(0, 0, 0), width: 0.0, radius: 0.0 }; let id = l.add("Rule", (14.0, 33.0, 182.0, 0.15), rule.clone()); assert_eq!(l.element(id).map(|e| e.h), Some(0.15)); // A zero height becomes the thinnest element, not a 1 mm box. diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 462f50502..52626570a 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -239,10 +239,15 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O None => placeholder(&mut pm, r, "Chart", ppp), } } - ElementKind::Rectangle { fill, outline, width } => { - pm.fill_rect(r, &paint(*fill, 1.0), Transform::identity(), None); + ElementKind::Rectangle { fill, outline, width, radius } => { + // Square corners filled as before, so an existing layout draws to the same pixels. + let rounded = rounded_rect(r, (*radius * PT_PER_MM * ppp) as f32); + match &rounded { + Some(p) => pm.fill_path(p, &paint(*fill, 1.0), FillRule::Winding, Transform::identity(), None), + None => pm.fill_rect(r, &paint(*fill, 1.0), Transform::identity(), None), + } + let p = rounded.unwrap_or_else(|| PathBuilder::from_rect(r)); if *width > 0.0 { - let p = PathBuilder::from_rect(r); pm.stroke_path( &p, &paint(*outline, 1.0), @@ -371,6 +376,30 @@ pub fn expand(t: &str, layout: &Layout, info: &PageInfo, frame: Option<(&Map, ru rustgis_map::graphics::expand(&t, &v) } +/// A rectangle with its corners rounded by `rad` pixels (at most half its shorter side), each a +/// quarter circle drawn as a cubic; `None` for square corners, drawn as the plain rectangle. +fn rounded_rect(r: tiny_skia::Rect, rad: f32) -> Option { + let rad = rad.min(r.width() / 2.0).min(r.height() / 2.0); + if rad.is_nan() || rad <= 0.0 { + return None; + } + // The control points' distance that makes a cubic a quarter circle. + let c = rad * 0.552_284_8; + let (l, t, rt, b) = (r.left(), r.top(), r.right(), r.bottom()); + let mut pb = PathBuilder::new(); + pb.move_to(l + rad, t); + pb.line_to(rt - rad, t); + pb.cubic_to(rt - rad + c, t, rt, t + rad - c, rt, t + rad); + pb.line_to(rt, b - rad); + pb.cubic_to(rt, b - rad + c, rt - rad + c, b, rt - rad, b); + pb.line_to(l + rad, b); + pb.cubic_to(l + rad - c, b, l, b - rad + c, l, b - rad); + pb.line_to(l, t + rad); + pb.cubic_to(l, t + rad - c, l + rad - c, t, l + rad, t); + pb.close(); + pb.finish() +} + /// A number as printed with a decimal comma when `comma` (`0.05` to `0,05`, `6,300,000` to /// `6.300.000`): its points and commas swapped. fn decimal(s: String, comma: bool) -> String { @@ -533,7 +562,8 @@ fn draw_grid(pm: &mut Pixmap, map: &Map, view: &View, g: &Grid, comma: bool) { let label_color = Rgba::rgb(0x20, 0x20, 0x20); let vertical = g.label_orientation == Orientation::Vertical; let mut pb = PathBuilder::new(); - let mut labels: Vec<(Edge, f32, String)> = Vec::new(); + // Each label's edge, place along it, text, and whether it is a meridian's. + let mut labels: Vec<(Edge, f32, String, bool)> = Vec::new(); let mut draw_line = |pb: &mut PathBuilder, pts: Vec, v: f64, lon: bool| { let px: Vec> = pts.into_iter().map(to_px).collect(); // Runs of points that projected, so a line breaks where its projection does. @@ -562,7 +592,7 @@ fn draw_grid(pm: &mut Pixmap, map: &Map, view: &View, g: &Grid, comma: bool) { Edge::Right => g.edges.right, }; if g.labels && shown { - labels.push((edge, at, decimal(grid_label(g, v, lon, step), comma))); + labels.push((edge, at, decimal(grid_label(g, v, lon, step), comma), lon)); } } } @@ -587,20 +617,14 @@ fn draw_grid(pm: &mut Pixmap, map: &Map, view: &View, g: &Grid, comma: bool) { if let Some(p) = pb.finish() { pm.stroke_path(&p, &paint_line, &stroke, Transform::identity(), None); } - // Labels sit just inside the frame, past the ticks, away from the corners: a label is - // skipped unless all of it, half its length either side of the crossing, fits on its edge. + // Labels sit just inside the frame, past the ticks. let inset = tick + 3.0 * ppp; - for (edge, at, s) in labels { + let along = |edge: Edge, s: &str| match edge { + Edge::Left | Edge::Right if !vertical => label_px, + _ => text::measure(s, label_px / 1.33, false), + }; + for (edge, at, s) in kept_labels(labels, (pw, ph), along, label_px, ppp) { let tw = text::measure(&s, label_px / 1.33, false); - let (along_limit, along) = match edge { - Edge::Top | Edge::Bottom => (pw, tw), - Edge::Left | Edge::Right if vertical => (ph, tw), - Edge::Left | Edge::Right => (ph, label_px), - }; - let margin = (along / 2.0 + 3.0 * ppp).max(12.0 * ppp); - if at < margin || at > along_limit - margin { - continue; - } match edge { Edge::Top => text::text_at(pm, &s, at, inset + label_px * 0.4, label_px, label_color, false, text::Align::Center, true), Edge::Bottom => text::text_at(pm, &s, at, ph - inset - label_px * 0.4, label_px, label_color, false, text::Align::Center, true), @@ -612,6 +636,47 @@ fn draw_grid(pm: &mut Pixmap, map: &Map, view: &View, g: &Grid, comma: bool) { } } +/// The graticule labels drawn, of those at each edge crossing (`labels`: edge, place along it, +/// text, a meridian's or not) on a frame `size` pixels, `label_h` tall: away from the corners (all +/// of a label, `along` its edge, fits, and none at a side's ends by a top or bottom label), and +/// none over another. Meridians are labelled along the top and +/// bottom and parallels along the sides first; a line that leaves the frame through the other +/// edges (a slanting meridian on a projected map) is labelled there only well clear of the labels +/// already set on that edge. +fn kept_labels( + mut labels: Vec<(Edge, f32, String, bool)>, + (pw, ph): (f32, f32), + along: impl Fn(Edge, &str) -> f32, + label_h: f32, + ppp: f32, +) -> Vec<(Edge, f32, String)> { + let own_edge = |edge: Edge, lon: bool| matches!(edge, Edge::Top | Edge::Bottom) == lon; + labels.sort_by_key(|(edge, _, _, lon)| !own_edge(*edge, *lon)); + let mut taken: Vec<(Edge, f32, f32)> = Vec::new(); + let mut kept = Vec::new(); + for (edge, at, s, lon) in labels { + let length = along(edge, &s); + let side = matches!(edge, Edge::Left | Edge::Right); + let limit = if side { ph } else { pw }; + // A side's labels keep two label heights from the corners, below the top's labels and + // above the bottom's, so a corner holds no two. + let margin = (length / 2.0 + 3.0 * ppp).max(12.0 * ppp).max(if side { 2.0 * label_h } else { 0.0 }); + if at < margin || at > limit - margin { + continue; + } + let (a, z) = (at - length / 2.0 - 2.0 * ppp, at + length / 2.0 + 2.0 * ppp); + // Off its own edge, a label keeps a further length of its own clear of the others, so + // it doesn't read as one of theirs. + let clear = if own_edge(edge, lon) { 0.0 } else { length }; + if taken.iter().any(|(e, ta, tz)| *e == edge && a - clear < *tz && *ta < z + clear) { + continue; + } + taken.push((edge, a, z)); + kept.push((edge, at, s)); + } + kept +} + /// One line of haloed text centred on (`cx`, `cy`), turned to read from bottom to top. fn rotated_text(pm: &mut Pixmap, s: &str, cx: f32, cy: f32, px: f32, color: Rgba) { // Whole pixels throughout, so the quarter turn moves pixels without resampling them. @@ -1838,7 +1903,7 @@ mod tests { l.add( "Rule", (5.0, 10.0, 40.0, 0.15), - ElementKind::Rectangle { fill: Rgba::rgb(0, 0, 0), outline: Rgba::rgb(0, 0, 0), width: 0.0 }, + ElementKind::Rectangle { fill: Rgba::rgb(0, 0, 0), outline: Rgba::rgb(0, 0, 0), width: 0.0, radius: 0.0 }, ); let pm = render(&l, &mut [], 300.0, &PageInfo::default()).unwrap(); let x = pm.width() / 2; @@ -2080,6 +2145,31 @@ mod tests { assert_eq!(scale_number(0.01 / 3.0, 0.01 / 3.0), "0.0033"); } + /// Graticule labels: meridians along the top and bottom and parallels along the sides come + /// first; a slanting meridian leaving through a side is labelled there only clear of the + /// parallels' labels, and none lands by a corner. + #[test] + fn graticule_labels_keep_clear_of_each_other() { + let along = |_: Edge, s: &str| s.len() as f32 * 5.0; + let label = |edge, at: f32, s: &str, lon| (edge, at, s.to_string(), lon); + let labels = vec![ + // A meridian leaving through the right edge just by a parallel's label (put first, so + // only the order fixes it), and another well clear of any. + label(Edge::Right, 205.0, "36°W", true), + label(Edge::Right, 200.0, "6°S", false), + label(Edge::Right, 400.0, "34°W", true), + label(Edge::Top, 150.0, "42°W", true), + // A parallel curving out through the top near a meridian's label: clear of it, but + // too close to read as its own. + label(Edge::Top, 185.0, "2°S", false), + // By a corner: left out, and a side's label within two label heights of the top. + label(Edge::Top, 4.0, "44°W", true), + label(Edge::Right, 15.0, "2°S", false), + ]; + let kept: Vec = kept_labels(labels, (600.0, 500.0), along, 10.0, 1.0).into_iter().map(|(.., s)| s).collect(); + assert_eq!(kept, ["6°S", "42°W", "34°W"]); + } + /// A layout with a decimal comma swaps the marks of the numbers it prints, `{scale}` included, /// and leaves other text alone. #[test] diff --git a/crates/render/src/report.rs b/crates/render/src/report.rs index a40995ad2..ed3b0b249 100644 --- a/crates/render/src/report.rs +++ b/crates/render/src/report.rs @@ -52,6 +52,13 @@ pub fn width_mm(s: &str, pt: f32, bold: bool) -> f64 { text::measure(s, pt, bold) as f64 * 25.4 / 96.0 } +/// [`width_mm`] in the serif a layout draws a `"font": "serif"` text in (the sans where no serif is +/// installed): the ink's width, which the line has to hold. +pub fn serif_width_mm(s: &str, pt: f32, bold: bool) -> f64 { + let (lo, hi) = text::styled_extent(s, pt * 1.33, text::Face { bold, italic: false, serif: true }, 0.0); + f64::from((hi - lo).max(0.0)) * 25.4 / 96.0 +} + /// Point size and line height (mm) of each kind of block. fn style(b: &Block) -> (f32, bool, f64) { match b { diff --git a/crates/ui-egui/src/layoutview.rs b/crates/ui-egui/src/layoutview.rs index a13f72ef1..522607eed 100644 --- a/crates/ui-egui/src/layoutview.rs +++ b/crates/ui-egui/src/layoutview.rs @@ -1536,7 +1536,7 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { } ui.end_row(); } - ElementKind::Rectangle { fill, outline, width } | ElementKind::Ellipse { fill, outline, width } => { + ElementKind::Rectangle { fill, outline, width, .. } | ElementKind::Ellipse { fill, outline, width } => { // A fill with no opacity leaves only the outline; an outline 0 wide, only the fill. for (label, key, current) in [("Fill", "fill", *fill), ("Outline", "outline", *outline)] { ui.label(tl!(label)); diff --git a/docs/user-guide/layouts.md b/docs/user-guide/layouts.md index d1014a693..1676eebed 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -17,7 +17,11 @@ the maps; while one is open, its groups lead the Publish tab: the map, and a footer of two boxed columns (sources, and the cartographic reference; one with nothing in it is left out, the other taking the width) with a note and the authorship under them. Text sizes follow the sheet: an A3's are 1.41 times an - A4's. + A4's. Given its size alone (`"a4"`, the default, `"a3"` or `"a2"`), a sheet is turned to + portrait or landscape, whichever the map comes out larger on: the area's extent east to west + against north to south, on the map's projection, with the room the style leaves the map. The + result names the profile chosen (`"profile": "a4-landscape"`); give it with its orientation + (`"a4-portrait"`) to have the other. - **Figures** for a journal (90, 140 or 190 mm wide, as tall as the map's shape asks) or a thesis (160 × 225 or 160 × 110 mm): the map edge to edge with a line of its projection and datum under it, at 7 pt. A figure's title and sources belong in its caption, so they are @@ -54,6 +58,19 @@ the maps; while one is open, its groups lead the Publish tab: it leaves the legend out, with a warning. Undo takes the globe's map away with the layout; a globe map no layout shows any more (its layout deleted) is replaced by the next sheet's rather than left beside it, which a warning says. + - **The style** (`"style"`, on any sheet, portrait or landscape): `"classic"` (the default) sets + the title over the map and the footer's boxes under it; `"banner"` puts the title and subtitle + in a band of the accent colour across the top, the title in white; `"panel"` sets the map at + the left and a pale panel at the right with the title, subtitle, locator and legend, and the + sources and reference at its foot, the note across the bottom; `"editorial"` sets the title + large in serif and a column at the right under a rule, with a heading (the third of + `headings`), the note, the locator and the legend; `"modular"` sets cards with rounded + corners on a light page: the map in one, the locator and the legend in one each beside it, + the sources and reference in tinted ones under them; `"poster"` colours the whole page, sets + the title and subtitle in light text across the top, the map in a light card and the footer + in darker blocks with amber headings. `"accent"` (`#RRGGBB`) colours the band, the panel's + tint, the headings and rules, and a poster's page (a deep brown, green or blue by default). + A figure leaves the style to its caption. - Every element is an ordinary one, to move or restyle afterwards. Running it again with another `profile` makes another layout of the same map. @@ -124,8 +141,9 @@ the maps; while one is open, its groups lead the Publish tab: - **Shapes**: **Rectangle**, **Circle**, **Ellipse** and **Line** (horizontal, vertical or either diagonal) add a shape in the middle of the page, to frame, highlight or divide parts of it. A circle is an ellipse in a square box. The properties set a shape's fill, outline and - outline width (0 for none; a fill with no opacity leaves only the outline), and a line's colour, - width, arrowheads and angle. From a command: + outline width (0 for none; a fill with no opacity leaves only the outline), a rectangle's + rounded corners (`"radius"`, in mm), and a line's colour, width, arrowheads and angle. From a + command: `layout.addElement={"kind":"line","dir":"falling","color":"#C0392B","width":2}`. - **Arrows**: **Arrow** adds an arrow pointing right. Drag the round handle above a selected arrow or line to turn it about its centre: it snaps to level, upright and diagonal within 3°, and From ec2b4dacc38aeca58352668f668e7f53bacd6e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Fri, 23 Oct 2026 11:34:19 -0500 Subject: [PATCH 067/297] refactor(engine,ui): read quoted text by one rule (#743) --- crates/engine/src/lib.rs | 64 ++++++++++++++++++++++------ crates/engine/src/tests.rs | 30 +++++++++++++ crates/ui-egui/src/scriptcolor.rs | 18 ++------ crates/ui-egui/src/scriptcomplete.rs | 53 +++++++++++------------ 4 files changed, 108 insertions(+), 57 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3a8b0d9c4..352b490b1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -115,16 +115,15 @@ pub fn param_list(desc: &str) -> Option>> { let body = desc.trim().strip_prefix('{')?; let inner = body.get(..body.rfind('}')?)?; let mut out = Vec::new(); - let (mut depth, mut quoted, mut start, mut escaped) = (0usize, false, 0, false); + let (mut depth, mut start, mut q) = (0usize, 0, Quotes::default()); for (i, c) in inner.char_indices() { - // A quote after a backslash (`\"`, in a plugin's description) doesn't open or close one. - let was_escaped = escaped; - escaped = c == '\\' && !was_escaped; + if !q.read(c) { + continue; + } match c { - '"' if !was_escaped => quoted = !quoted, - '(' | '[' | '{' if !quoted => depth += 1, - ')' | ']' | '}' if !quoted => depth = depth.saturating_sub(1), - ',' if !quoted && depth == 0 => { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { out.extend(inner.get(start..i).and_then(param)); start = i + 1; } @@ -144,15 +143,52 @@ fn param(part: &str) -> Option> { Some(Param { key, optional, text }) } -/// Where the string whose text starts `s` ends: the byte index of its closing quote, the first -/// one not after a backslash (`\"`). None when nothing closes it. -fn string_end(s: &str) -> Option { - let mut escaped = false; +/// Quoted text read one char at a time (`read`), as JSON has it: a `"` opens a string and the +/// next one not escaped closes it; a backslash escapes the char after it only in a string. The +/// one rule for everything that reads a description or a Script line by hand (#741). +#[derive(Clone, Copy, Debug, Default)] +pub struct Quotes { + quoted: bool, + escaped: bool, +} + +impl Quotes { + /// Inside a string, after its opening quote. + pub fn in_string() -> Self { + Quotes { quoted: true, escaped: false } + } + + /// Reads `c`: whether it's code, outside every string (a string's quotes aren't). + pub fn read(&mut self, c: char) -> bool { + if !self.quoted { + self.quoted = c == '"'; + return !self.quoted; + } + if self.escaped { + self.escaped = false; + } else if c == '\\' { + self.escaped = true; + } else if c == '"' { + self.quoted = false; + } + false + } + + /// Whether what was read ends in a string. + pub fn quoted(&self) -> bool { + self.quoted + } +} + +/// Where the string whose text starts `s` ends: the byte index of its closing quote. None when +/// nothing closes it. +pub fn string_end(s: &str) -> Option { + let mut q = Quotes::in_string(); for (i, c) in s.char_indices() { - if c == '"' && !escaped { + q.read(c); + if !q.quoted() { return Some(i); } - escaped = c == '\\' && !escaped; } None } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index caedf143c..d0c225f94 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10604,6 +10604,36 @@ fn param_list_splits_a_description_into_entries() { } } +/// Quoted text is read as JSON has it (#741): strings and their quotes aren't code, and a +/// backslash escapes the char after it only in a string. +#[test] +fn quotes_read_text_as_json_does() { + use crate::{Quotes, string_end}; + let code = |s: &str| { + let (mut q, mut out) = (Quotes::default(), String::new()); + for c in s.chars() { + if q.read(c) { + out.push(c); + } + } + out + }; + assert_eq!(code(r#"{"a": "b,c", "d": 1}"#), "{: , : 1}"); + assert_eq!(code(r#"{"a\"b": 1}"#), "{: 1}", "an escaped quote"); + assert_eq!(code(r#"{"a\\": 1}"#), "{: 1}", "an escaped backslash"); + assert_eq!(code(r#"x \"a" y"#), r#"x \ y"#, "a backslash outside a string escapes nothing"); + assert_eq!(code("ñ \"é\" ü"), "ñ ü", "chars, not bytes"); + let mut q = Quotes::default(); + for c in r#"{"a": "b"#.chars() { + q.read(c); + } + assert!(q.quoted(), "ends in a string"); + assert_eq!(string_end(r#"a\"b", 1"#), Some(4)); + assert_eq!(string_end("é\""), Some(2), "a byte index"); + assert_eq!(string_end(r#"a\""#), None, "nothing closes it"); + assert_eq!(string_end(""), None); +} + #[test] fn param_keys_reads_descriptions() { use crate::param_keys; diff --git a/crates/ui-egui/src/scriptcolor.rs b/crates/ui-egui/src/scriptcolor.rs index 19a29bcdf..8e2918b6e 100644 --- a/crates/ui-egui/src/scriptcolor.rs +++ b/crates/ui-egui/src/scriptcolor.rs @@ -8,6 +8,7 @@ use std::ops::Range; use egui::text::{LayoutJob, TextFormat}; use egui::{Color32, FontId, Stroke}; use rustgis_engine::cmd::workflows::{MAX_SCRIPT_LINES, refused_in_script}; +use rustgis_engine::string_end; /// What a piece of a script is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -122,16 +123,8 @@ fn json_spans(text: &str, at: usize, broken: bool, out: &mut Vec) { let s = i; match c { b'"' => { - i += 1; - while let Some(&c) = b.get(i) { - i += 1; - match c { - // The escaped char, whole: a multi-byte one isn't cut. - b'\\' => i = next_char(text, i), - b'"' => break, - _ => {} - } - } + // Past its closing quote, or to the end when nothing closes it. + i = text.get(i + 1..).and_then(string_end).map_or(text.len(), |n| i + n + 2); // A key is a string with a colon after it. let after = text.get(i..).map(str::trim_start); // (A command's parameter description marks a key that may be left out with `?`: @@ -172,11 +165,6 @@ fn json_spans(text: &str, at: usize, broken: bool, out: &mut Vec) { } } -/// The byte after the char starting at `i` of `text` (its end when `i` is past it). -fn next_char(text: &str, i: usize) -> usize { - text.get(i..).and_then(|t| t.chars().next()).map_or(text.len(), |c| i + c.len_utf8()) -} - /// The colours of a script, one per kind. The theme's text, dimmed text and accent, and colours /// of the script's own for the rest: the theme's green and amber are made for icons and marks and /// read poorly as small text on the light theme's white, and comments are a colour of their own, diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 8f797336c..7c9464371 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -5,11 +5,13 @@ //! its `{…}` and in its second-level objects (`key_at`), and the ids some values take: tools for //! a `gp.run` line's `"tool"`, commands for a `task.addStep` line's `"command"` (`value_at`), //! whose own keys then go in its `"params": {…}`. Typing aids beside it: a command's parameters -//! added on accept (`template`), and braces and brackets that close themselves (`brace`). +//! added on accept (`template`), and braces, brackets and quotes that close themselves (`brace`). +//! Quoted text is read by one rule everywhere (`rustgis_engine::Quotes`), the one the colours use. use egui::text::{CCursor, CCursorRange}; use egui::widgets::text_edit::{TextEditOutput, TextEditState}; use egui::{Id, Key, Modifiers, Ui}; +use rustgis_engine::Quotes; use rustgis_engine::cmd::workflows::refused_in_script; /// The most suggestions the list shows. @@ -92,14 +94,14 @@ pub fn hint_at(script: &str, at: usize) -> Option { let line = before.get(line_start..)?; let id = crate::scriptcolor::line_id(line)?; let brace = line.find('{')?; - let (mut depth, mut quoted, mut escaped) = (0usize, false, false); + let (mut depth, mut q) = (0usize, Quotes::default()); for c in line.get(brace..)?.chars() { - let was_escaped = escaped; - escaped = c == '\\' && !was_escaped; + if !q.read(c) { + continue; + } match c { - '"' if !was_escaped => quoted = !quoted, - '{' if !quoted => depth += 1, - '}' if !quoted => depth = depth.saturating_sub(1), + '{' => depth += 1, + '}' => depth = depth.saturating_sub(1), _ => {} } } @@ -180,7 +182,7 @@ pub struct Nested { /// deeper. A top-level value follows its key and colon. fn scan(text: &str) -> Scan { let chars: Vec = text.chars().collect(); - let (mut depth, mut quoted, mut escaped, mut entry, mut start) = (0usize, false, false, false, None); + let (mut depth, mut q, mut entry, mut start) = (0usize, Quotes::default(), false, None); // The top-level key just read, the one whose colon was just read (its value comes next), // where the key being read starts, and the top-level value being read. let (mut named, mut valued, mut from, mut value) = (None::, None::, None, None); @@ -189,22 +191,24 @@ fn scan(text: &str) -> Scan { let (mut array, mut nested) = (None::, None::<(Nested, usize)>); let mut values = Vec::new(); for (i, &c) in chars.iter().enumerate() { - let was_escaped = escaped; - escaped = c == '\\' && !was_escaped; - if quoted { - if c == '"' && !was_escaped { + let was_quoted = q.quoted(); + q.read(c); + if was_quoted { + // Its closing quote. + if !q.quoted() { if let Some((v, key)) = value.take() { values.push((key, chars.get(v..i).unwrap_or_default().iter().collect())); } - (quoted, start) = (false, None); + start = None; named = from.take().and_then(|f| chars.get(f..i)).map(|k| k.iter().collect()); } continue; } let top = depth == 1 || nested.as_ref().is_some_and(|(_, d)| *d == depth); match c { + // An opening quote. '"' => { - (quoted, start) = (true, (top && entry).then_some(i + 1)); + start = (top && entry).then_some(i + 1); from = (depth == 1 && entry).then_some(i + 1); value = valued.take().filter(|_| depth == 1).map(|k| (i + 1, k)); entry = false; @@ -444,16 +448,12 @@ fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::S /// its array, a later key's) isn't read as holding its keys. fn object_rest(after: &str) -> &str { // In the key's quotes, then one level in. - let (mut depth, mut quoted, mut escaped) = (1usize, true, false); + let (mut depth, mut q) = (1usize, Quotes::in_string()); for (i, c) in after.char_indices() { - let was_escaped = escaped; - escaped = c == '\\' && !was_escaped; - if quoted { - quoted = !(c == '"' && !was_escaped); + if !q.read(c) { continue; } match c { - '"' => quoted = true, '{' | '[' | '(' => depth += 1, '}' | ']' | ')' => { depth = depth.saturating_sub(1); @@ -1125,17 +1125,13 @@ fn char_at(script: &str, at: usize) -> Option { script.get(byte_at(script, at)?..)?.chars().next() } -/// Whether the end of `line` is inside a string (quotes after a backslash don't count). +/// Whether the end of `line` is inside a string. fn in_string(line: &str) -> bool { - let (mut quoted, mut escaped) = (false, false); + let mut q = Quotes::default(); for c in line.chars() { - let was_escaped = escaped; - escaped = c == '\\' && !was_escaped; - if c == '"' && !was_escaped { - quoted = !quoted; - } + q.read(c); } - quoted + q.quoted() } /// What this frame's input did to the box, as `brace` needs it: a brace, bracket or quote typed, @@ -1722,6 +1718,7 @@ mod tests { assert_eq!(typed("x ab", 2, "{"), None, "before other text"); assert_eq!(end(r#"x {"where": "a "#), None, "in a string"); assert_eq!(end(r#"x {"where": "a \" "#), None, "an escaped quote doesn't end it"); + assert_eq!(end(r#"x \"a "#), None, "a backslash outside a string escapes nothing (#741)"); assert_eq!(end(" # x "), None, "in a comment line"); assert_eq!(end("ñandú ").as_deref(), Some("ñandú {}"), "chars, not bytes"); assert_eq!(typed("x ", 2, "{{"), None, "more than the brace typed"); From 251ec89472099a1906ca9f1bcb35a1c3956be46a Mon Sep 17 00:00:00 2001 From: Li-Sheng Chou <142213184+choupeanut@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:34:53 +0800 Subject: [PATCH 068/297] feat(symbology): add No Fill for polygon layers (#745) * feat(symbology): add No Fill for polygon layers * docs(symbology): describe No Fill helpers and tests * docs(tests): describe UI test helpers --- crates/engine/src/cmd/layer.rs | 15 +++++++++++ crates/engine/src/tests.rs | 47 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/panes.rs | 7 +++++ crates/ui-egui/src/ribbon.rs | 12 +++++++++ crates/ui-egui/tests/smoke.rs | 34 ++++++++++++++++++++++++ docs/user-guide/cartography.md | 10 ++++++++ 7 files changed, 126 insertions(+) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index ae1f7c09b..7d98fe36a 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -10,6 +10,7 @@ use serde_json::{Value, json}; use super::{b, f, need_feature_layer, need_layer, ps, uint}; use crate::{CmdResult, CommandSpec as C, Session}; +/// Registers layer commands shared by the UI, CLI and command search. pub fn commands() -> Vec { vec![ C::new("layer.addData", "Add Data", "Home › Add", add_data) @@ -149,6 +150,11 @@ pub fn commands() -> Vec { }) .when(need_feature_layer) .params(r##"{"layer"?, "color": "#RRGGBB"|"#RRGGBBAA" (the fill; a polygon's outline is left as it is, set it with layer.outline)}"##), + C::new("layer.noFill", "No Fill", "Style › Drawing", |s, p| { + with(s, p, |l| l.renderer.map_symbols(|sym| sym.fill = sym.fill.with_alpha(0))) + }) + .when(need_polygons) + .params(r#"{"layer"?: name|id (default: the active polygon layer; removes every symbol's fill, keeping its RGB and outline)}"#), C::new("layer.outline", "Outline", "Style › Drawing", |s, p| { let c = s.get_color(p, "color")?; let w = f(p, "width").map(|w| w.clamp(0.0, 20.0) as f32); @@ -327,6 +333,15 @@ impl Session { } } +/// A polygon layer is active. +fn need_polygons(s: &Session) -> Option<&'static str> { + match s.active_layer.and_then(|id| s.map().layer(id)) { + Some(l) if !l.is_raster() && l.geometry_type() == GeometryType::Polygon && !l.graphics => None, + Some(_) => Some("this works on polygon layers"), + None => Some("select a polygon layer in the Layers pane first"), + } +} + /// A line layer is active. fn need_lines(s: &Session) -> Option<&'static str> { match s.active_layer.and_then(|id| s.map().layer(id)) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d0c225f94..feb515119 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -12847,6 +12847,53 @@ fn layer_color_leaves_the_outline_alone() { assert_eq!(rgb(symbol(&s).fill), (0x99, 0x66, 0x33), "changing the fill must set the requested RGB"); } +/// No Fill preserves renderer classes and outlines across persistence and one-step undo/redo. +#[test] +fn polygon_no_fill_keeps_classes_and_outlines_and_undoes_as_one_step() { + use rustgis_map::Symbol; + + for kind in ["single", "unique", "graduated"] { + let mut s = world(); + let field = if kind == "unique" { "continent" } else { "pop_est" }; + s.run("layer.symbology", &json!({"layer": "Countries", "type": kind, "field": field})).unwrap(); + s.run("layer.outline", &json!({"layer": "Countries", "color": "#2376A9CC", "width": 2.5})).unwrap(); + let renderer = |s: &Session| s.map().layer_by_name("Countries").unwrap().renderer.clone(); + let before = renderer(&s); + // The named polygon layer works even while a point layer is active. + s.run("layer.select", &json!({"layer": "Cities"})).unwrap(); + s.run("layer.noFill", &json!({"layer": "Countries"})).unwrap(); + let after = renderer(&s); + assert_eq!(after.kind_label(), before.kind_label(), "{kind}: classification stays in place"); + let (old, new) = (before.legend(), after.legend()); + assert_eq!(new.len(), old.len()); + for (old, new) in old.iter().zip(&new) { + assert_eq!(new.label, old.label); + assert_eq!(new.symbol, Symbol { fill: old.symbol.fill.with_alpha(0), ..old.symbol }); + } + let reopened = Project::from_json(&serde_json::to_string(&s.project).unwrap()).unwrap(); + assert_eq!(reopened.map().layer_by_name("Countries").unwrap().renderer, after); + s.run("edit.undo", &Value::Null).unwrap(); + assert_eq!(renderer(&s), before, "{kind}: one undo restores every symbol"); + s.run("edit.redo", &Value::Null).unwrap(); + assert_eq!(renderer(&s), after); + s.run("layer.noFill", &json!({"layer": "Countries"})).unwrap(); + assert_eq!(renderer(&s), after, "No Fill is idempotent"); + } +} + +/// Unsupported or missing layers are refused without changing the project. +#[test] +fn polygon_no_fill_refuses_other_layers() { + let mut s = with_raster(); + let raster = s.active_layer.unwrap(); + for layer in [json!("Cities"), json!("Rivers"), json!(raster), json!(99999)] { + let before = serde_json::to_value(&s.project).unwrap(); + assert!(s.run("layer.noFill", &json!({"layer": layer})).is_err()); + assert_eq!(serde_json::to_value(&s.project).unwrap(), before); + } + assert!(Session::new(Project::default()).run("layer.noFill", &Value::Null).is_err()); +} + #[test] fn buffer_point_adds_only_the_buffer() { let mut s = world(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 9e2301bd7..3bffb4393 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1043,6 +1043,7 @@ pub fn paint(painter: &Painter, r: Rect, name: &str, ink: Color32, accent: Color p.polygon_shape(&[(4.0, 10.0), (7.0, 5.0), (13.0, 4.0), (19.0, 7.0), (20.0, 13.0), (15.0, 17.0), (8.0, 16.0)], 50); p.line(&[(8.0, 16.0), (7.0, 20.0), (9.0, 22.0)], 1.3, ink); } + "no_fill" => p.rect(3.0, 4.0, 18.0, 16.0, None, Some((1.6, ink)), 0.0), "g_rect" => { p.rect(3.0, 6.0, 18.0, 12.0, Some(p.tint(70)), Some((1.4, ink)), 0.5); } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index f7a770398..59f7d0401 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3288,6 +3288,7 @@ fn ramp_swatch(ui: &mut Ui, ramp: &Ramp, w: f32, selected: bool, t: &Tokens) -> resp } +/// Shows the active layer's renderer controls, including No Fill for polygon layers. fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let Some(l) = app.session.active_layer.and_then(|id| app.session.map().layer(id)).cloned() else { ui.label(egui::RichText::new(tl!("Select a layer in the Layers pane.")).color(t.text_dim)); @@ -3354,6 +3355,12 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }); ui.add_space(8.0); + if g == GeometryType::Polygon && !l.graphics { + if ui.button(tl!("No Fill")).on_hover_text(tl!("Draw only polygon outlines, keeping their colors and widths.")).clicked() { + let _ = app.run("layer.noFill", Value::Null); + } + ui.add_space(6.0); + } let numeric: Vec = l.dataset.fields.iter().filter(|f| f.ty.is_numeric()).map(|f| f.name.clone()).collect(); let all: Vec = l.dataset.fields.iter().map(|f| f.name.clone()).collect(); match &l.renderer { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9f59714c6..6552b2ed0 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -3733,6 +3733,7 @@ pub(crate) fn live_drag( } } +/// Style ribbon controls for the layer's renderer, fill, outline and marker size. fn drawing_group(app: &mut RustGisApp, ui: &mut Ui, layer: &rustgis_engine::map::Layer) { group(ui, "Drawing", |ui| { big( @@ -3804,6 +3805,17 @@ fn drawing_group(app: &mut RustGisApp, ui: &mut Ui, layer: &rustgis_engine::map: } ui.label(egui::RichText::new(tl!("Size")).size(11.5)); }); + } else if layer.geometry_type() == GeometryType::Polygon && !layer.graphics { + small( + ui, + app, + "no_fill", + "No Fill", + "Draw only polygon outlines, keeping their colors and widths.", + "layer.noFill", + Value::Null, + false, + ); } }); }); diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 53bd7da3d..645315738 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -4,6 +4,7 @@ use egui_kittest::kittest::Queryable; use rustgis_ui_egui::RustGisApp; use serde_json::json; +/// Builds a headless World Atlas UI for interaction tests. fn harness() -> egui_kittest::Harness<'static, RustGisApp> { egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( |ui, app: &mut RustGisApp| { @@ -15,6 +16,7 @@ fn harness() -> egui_kittest::Harness<'static, RustGisApp> { ) } +/// Runs a layer command through Command Search and checks that the search closes. fn search_layer_command(h: &mut egui_kittest::Harness<'_, RustGisApp>, command: &str) { h.state_mut().ui.command_search = Some(command.into()); for _ in 0..3 { @@ -27,6 +29,38 @@ fn search_layer_command(h: &mut egui_kittest::Harness<'_, RustGisApp>, command: assert!(h.state().ui.command_search.is_none()); } +/// Both No Fill controls preserve unique-value styling and undo, and stay hidden for points. +#[test] +fn polygon_no_fill_is_reachable_from_the_pane_and_the_ribbon() { + for pane in [true, false] { + let mut h = harness(); + h.state_mut().run("layer.select", json!({"layer": "Countries"})).unwrap(); + h.state_mut().run("layer.symbology", json!({"type": "unique", "field": "continent"})).unwrap(); + h.state_mut().run("layer.outline", json!({"color": "#2E6B4F", "width": 2})).unwrap(); + let symbol = + |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer_by_name("Countries").unwrap().renderer.base_symbol(); + let before = symbol(&h); + if pane { + h.state_mut().run("ui.pane.symbology", json!(null)).unwrap(); + } else { + h.state_mut().ui.tab = "Style".into(); + } + h.run_steps(6); + h.get_by_label("No Fill").click(); + h.run_steps(3); + let after = symbol(&h); + assert_eq!(after.fill.a(), 0); + assert_eq!(after.stroke, before.stroke); + assert_eq!(after.stroke_width, before.stroke_width); + assert_eq!(h.state().session.map().layer_by_name("Countries").unwrap().renderer.kind_label(), "Unique Values"); + h.state_mut().run("edit.undo", json!(null)).unwrap(); + assert_eq!(symbol(&h), before); + h.state_mut().run("layer.select", json!({"layer": "Cities"})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("No Fill").is_none(), "points must keep their fill"); + } +} + #[test] fn command_search_adds_tiles_only_after_valid_dialog_submission() { let mut h = harness(); diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index f57aad695..12222f6e8 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -34,6 +34,16 @@ and **Full Extent** zooms to them. A layer with [subtypes](editing.md#data-desig by them. **Style › Styles** saves symbology as styles to reuse on other layers and in other projects. +For polygons, **No Fill** in the Symbology pane or **Style › Drawing** draws only the outlines. +It keeps each symbol's outline colour and width, including in Unique Values and Graduated Colors, +without changing the classes. Change the outline with **Outline color** and **Outline width** in +the Single Symbol editor, or `layer.outline` for all classes. Undo restores the previous fills; +the colour picker's opacity control can also make a fill visible again. By command: + +```text +layer.noFill={"layer":"Countries"} +``` + ### Edit a class With Unique Values or Graduated Colors, each class in the Symbology pane's list has a colour button From 694e6f81f6774d3fc00997dd401a23bd1ebd33c5 Mon Sep 17 00:00:00 2001 From: Mavrick Fitzgerald Date: Fri, 23 Oct 2026 11:37:45 -0500 Subject: [PATCH 069/297] fix(data): preserve UTF-8 field content (#746) --- crates/data/src/dbf.rs | 5 ++++ crates/data/tests/shapefile_encoding.rs | 37 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/data/src/dbf.rs b/crates/data/src/dbf.rs index 4cad3f1c3..f9c13e76b 100644 --- a/crates/data/src/dbf.rs +++ b/crates/data/src/dbf.rs @@ -14,8 +14,13 @@ pub(crate) enum DbfEncoding { } impl DbfEncoding { + /// Create a per-read decoder, preserving leading U+FEFF and replacing malformed + /// bytes for UTF-8 field names and values regardless of the metadata source. pub(crate) fn decoder(&self) -> DynEncoding { match self { + // DBF fields are not standalone files: preserve a leading U+FEFF as + // content instead of letting EncodingRs strip it as a UTF-8 signature. + Self::Standard(encoding) if *encoding == rs::UTF_8 => UnicodeLossy.into(), Self::Standard(encoding) => shapefile::dbase::encoding::EncodingRs::from(*encoding).into(), Self::Oem(mark) => mark.to_encoding().unwrap_or_else(|| UnicodeLossy.into()), Self::Utf8 => UnicodeLossy.into(), diff --git a/crates/data/tests/shapefile_encoding.rs b/crates/data/tests/shapefile_encoding.rs index 1d859044f..caa4f8e67 100644 --- a/crates/data/tests/shapefile_encoding.rs +++ b/crates/data/tests/shapefile_encoding.rs @@ -283,3 +283,40 @@ fn dos_support_preserves_other_encodings_and_lossy_utf8_fallback() { assert!(layers[0].1.messages.is_empty(), "{:?}", layers[0].1.messages); } } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn utf8_metadata_preserves_leading_bom_in_fields_and_values() { + use rustgis_data::source::{FeatureSource, Request, shp::ShpSource}; + for (cpg, ldid) in [(None, 0), (None, 0xf0), (Some(b"UTF-8".as_slice()), 0x03), (Some(b"65001".as_slice()), 0x03)] { + // A BOM inside a fixed-width DBF field is content, not a file signature. + let mut files = parts(b"\xef\xbb\xbfCaf\xff", ldid, cpg); + let dbf = &mut files.iter_mut().find(|(n, _)| n.ends_with(".dbf")).unwrap().1; + dbf[32..43].fill(0); + dbf[32..39].copy_from_slice(b"\xef\xbb\xbfname"); + let layers = io::read_zip("places.zip", &rustgis_data::zip::write(&files).unwrap()).unwrap(); + assert_eq!(layers[0].0.fields[0].name, "\u{feff}name"); + assert_eq!(layers[0].0.value(0, 0), &Value::Text("\u{feff}Caf�".into())); + assert!(layers[0].1.messages.is_empty(), "{:?}", layers[0].1.messages); + let dir = tempfile::tempdir().unwrap(); + for (name, bytes) in files { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + let path = dir.path().join("places.shp"); + let (ds, report) = io::read_path(&path).unwrap(); + assert_eq!(ds.fields[0].name, "\u{feff}name"); + assert_eq!(ds.value(0, 0), &Value::Text("\u{feff}Caf�".into())); + assert!(report.messages.is_empty(), "{:?}", report.messages); + let src = ShpSource::open_path(&path, "places").unwrap(); + assert_eq!(src.info().fields[0].name, "\u{feff}name"); + assert_eq!(src.read(&Request::all()).unwrap().features[0].attrs, [Value::Text("\u{feff}Caf�".into())]); + let mut attrs = Vec::new(); + src.visit_chunks(None, 1024, &mut |features| { + attrs.extend(features.into_iter().map(|f| f.attrs)); + Ok(()) + }) + .unwrap(); + assert_eq!(attrs, vec![vec![Value::Text("\u{feff}Caf�".into())]]); + assert!(src.info().notes.is_empty(), "{:?}", src.info().notes); + } +} From b108dfd6043d8ae8802d588e430629d1254aaa9a Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Fri, 23 Oct 2026 12:53:44 -0400 Subject: [PATCH 070/297] fix(layout): a publication legend that fits exactly isn't said to overflow (#749) The legend's box is its rows' size plus the padding; the inside is the box less the padding again, which can come out a rounding error short of the rows. With macOS's fonts a one-layer legend did, so publication_surrounds_keep_off_the_data failed there on main. Compare with a tolerance. --- crates/engine/src/cmd/layout.rs | 7 +++++-- crates/engine/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index cb6ac0fdf..3629a7541 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -1701,11 +1701,14 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let (ix, iy, iw, ih) = (r.0 + pad, r.1 + pad, lw - 2.0 * pad, lh - 2.0 * pad); let bar_fit = if bar.is_some() { bar_h.min(ih) } else { 0.0 }; let rows_h = (ih - bar_fit - if bar.is_some() { bar_part - bar_h } else { 0.0 }).max(0.0); - if ch > 0.0 && (cw > iw || ch > rows_h) { + // A box sized to its content, less the padding again, can come out a rounding error + // short of it: that still fits. + const FIT_MM: f64 = 1e-6; + if ch > 0.0 && (cw > iw + FIT_MM || ch > rows_h + FIT_MM) { warnings.push("the legend lists more than fits its corner: its last rows or the ends of long labels are left out".into()); } // In a column the colorbar takes the column's width, as its rows do. - if bar.is_some() && ((bar_w > iw && !legend_in_column) || bar_h > ih) { + if bar.is_some() && ((bar_w > iw + FIT_MM && !legend_in_column) || bar_h > ih + FIT_MM) { warnings.push("the legend's box is smaller than the colorbar: it is drawn narrower or shorter".into()); } if ch > 0.0 && rows_h >= 1.0 { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index feb515119..d8e9f95cb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3183,6 +3183,29 @@ fn publication_surrounds_keep_off_the_data() { let _ = std::fs::remove_dir_all(&dir); } +/// A publication legend sized to its rows fits them: its box, less the padding, may come out a +/// rounding error short of the rows' width, which isn't "more than fits" (macOS CI, whose fonts +/// gave one such width). Layer names of many widths, so some land on a rounding error with any font. +#[test] +fn a_publication_legend_sized_to_its_rows_fits_them() { + let dir = std::env::temp_dir().join(format!("rustgis-legend-fit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let names: Vec = (0..300).map(|i| format!("{}{i}", &"WmilQxkaRb"[i % 10..])).collect(); + for name in &names { + let path = dir.join(format!("{name}.geojson")); + std::fs::write( + &path, + r#"{"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}, "geometry": {"type": "Polygon", "coordinates": [[[4,4],[6,4],[6,6],[4,6],[4,4]]]}}]}"#, + ) + .unwrap(); + let mut s = Session::new(Project::default()); + s.run("layer.addData", &json!({"path": path.display().to_string()})).unwrap(); + let r = s.run("layout.publication", &json!({"extent": [0, 0, 10, 10], "author": ""})).unwrap(); + assert!(!r["warnings"].to_string().contains("more than fits"), "{name}: {r}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// A publication layout keys a raster theme with a colorbar: the topmost visible raster drawn on a /// ramp gets one at the bottom of the legend's box; a map of vectors only gets none. #[test] From e050924fed8c57bf2f5c8b5e1b8ad4d27b696e26 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:56:00 +0200 Subject: [PATCH 071/297] docs: rework the roadmap; GeoLibre parity and CONTRIBUTING.md in the repo (#747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: rework the roadmap, move GeoLibre parity into the repo, add CONTRIBUTING.md The roadmap gets goals, an Underway list, releases 0.4-0.9 and an Exploratory list with linked issues and a last-reviewed date. The GeoLibre parity checklist from #77 moves to GEOLIBRE_PARITY.md, brought up to date, so anyone can tick items in the PR that lands them. CONTRIBUTING.md says what to pick freely and what to coordinate first. Refs #739 * docs(roadmap): group Next by theme instead of release numbers Themes keep the useful part of the 0.4–0.9 grouping, the dependencies between items, without reading as a release plan or going stale when work lands out of order. GEOLIBRE_PARITY.md and CONTRIBUTING.md now name the theme too. Co-Authored-By: Claude Opus 5.5 * docs(roadmap): say what counts as underway; add #745 and #671 A rule anyone can check on GitHub lets the weekly review start from the open pull requests instead of memory. Co-Authored-By: Claude Opus 5.5 * docs: coordinate first on shared contracts; point agents at the roadmap The `.rgis` format, existing command ids and parameters, and the plugin interface are depended on outside the code that changes them. Most pull requests start from an agent reading AGENTS.md, so it now points at the roadmap and the contributing guide. Co-Authored-By: Claude Opus 5.5 * docs(roadmap): add a goal for staying free and self-contained Co-Authored-By: Claude Opus 5.5 * docs: address review on the roadmap and parity checklist Underway hands off on an issue or a pull request; GeoLibre's baseline links to its v3.4.0 tag; the ArcGIS token line reads clearly; the COG check says what is unverified; the terrio bullet is wrapped. Drops #671, which looks abandoned. --------- Co-authored-by: mavrick Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 3 + CONTRIBUTING.md | 49 +++++++ GEOLIBRE_PARITY.md | 311 +++++++++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 192 +++++++++++++++++++++++++--- 4 files changed, 536 insertions(+), 19 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 GEOLIBRE_PARITY.md diff --git a/AGENTS.md b/AGENTS.md index a51d3ce9c..d054f3875 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,9 @@ RustGIS is a fast, free and open-source GIS in pure Rust for mapping, analyzing geospatial data, on the desktop and in the browser. Its architecture: layered crates, everything is a command, a thin egui front end, headless screenshots for UI work. +Before picking work, read [ROADMAP.md](ROADMAP.md) (check Underway) and +[CONTRIBUTING.md](CONTRIBUTING.md) (what to coordinate first). + ## Never crash - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` in non-test code; no `unsafe` (`unsafe_code = "forbid"`). Crate roots carry the `#![deny(clippy::unwrap_used, …)]` block. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..65ae726a5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing to RustGIS + +Contributions are welcome. This page covers what to work on and how to propose it. For build, +test, docs and release mechanics see [docs/contributing.md](docs/contributing.md); for code +conventions see [AGENTS.md](AGENTS.md). + +## Most roadmap items have no issue + +[ROADMAP.md](ROADMAP.md) lists where RustGIS is heading. Only a few of its items link to an +issue; **most features have no issue yet**. Don't wait for one to appear before starting. If +something on the roadmap (or not on it) interests you, propose it and open a pull request. + +## How to contribute a feature + +1. **Check for an existing issue.** Search the + [issues](https://github.com/opengeos/RustGIS/issues) and the roadmap. If there is an issue, + comment on it to say you're taking it. +2. **Write a short proposal** if there is no issue. It covers: + - the problem or workflow it serves; + - what you plan to build: the commands it adds, where it sits in the ribbon, which formats or + tools are involved; + - which crates it touches and whether it adds a dependency; + - how you will test it. + + Put it in an issue with the + [feature request](https://github.com/opengeos/RustGIS/issues/new?template=feature_request.yml) + form, or in the description of a draft pull request. For changes that need coordination (see + below) use an issue and wait for a maintainer's reply; otherwise either works. +3. **Open a pull request.** Draft pull requests are welcome early, to get feedback on the + approach before the work is finished. Link the issue (`Closes #123`) and fill in the + [pull request template](.github/pull_request_template.md). Before it is ready for review, it + must meet the rules and quality gates in [AGENTS.md](AGENTS.md). + +## Pick freely or coordinate first + +- **Pick freely**: roadmap items marked _(open)_ are self-contained. Open a pull request directly, + with the proposal in its description; an issue is optional. +- **Coordinate first**: open a proposal issue and wait for a maintainer's reply before writing much + code if your change + - changes the data model (`Feature`, `Coord`, `Renderer`); + - adds a dependency or a crate; + - changes the `.rgis` project format, or an existing command's id or parameters (the CLI, + Python, MCP and saved scripts call commands by id); + - changes the plugin interface; + - falls under _Exploratory_ in the roadmap. + +Roadmap themes are not releases or an order; an item moves to Done when it lands. A +pull request that lands a roadmap item, or an item of [GEOLIBRE_PARITY.md](GEOLIBRE_PARITY.md), +updates that file in the same change. diff --git a/GEOLIBRE_PARITY.md b/GEOLIBRE_PARITY.md new file mode 100644 index 000000000..838bff6c7 --- /dev/null +++ b/GEOLIBRE_PARITY.md @@ -0,0 +1,311 @@ +# Feature parity with GeoLibre + +_Last reviewed: 2026-10-23 (v0.3.0)._ + +What [GeoLibre v3.4.0](https://github.com/opengeos/GeoLibre/tree/v3.4.0) offers and where RustGIS stands: +**missing** or **partial**, or ticked when done. It moved here from [#77](https://github.com/opengeos/RustGIS/issues/77) so that anyone can keep it +current: **tick or update an item in the pull request that lands it**. Where an item is planned, +_Roadmap_ names its theme in [ROADMAP.md](ROADMAP.md). The GeoLibre and Whitebox geoprocessing +suite RustGIS runs is not counted either way. + +Sources: GeoLibre [v3.4.0](https://github.com/opengeos/GeoLibre/tree/v3.4.0) `docs/features.md`, +`docs/data-formats.md` and the other GeoLibre docs named per +section; RustGIS [ROADMAP.md](ROADMAP.md), [docs/](docs/) and the command registry +(`crates/engine/src/cmd`). Some items may be out of scope for a pure-Rust GIS (noted inline); +strike those through with a reason rather than leaving them open. + +## Data formats & sources +_GeoLibre: docs/data-formats.md, docs/features.md › Adding data, SQL and databases_ + +- [x] **GeoPackage, GeoParquet, Shapefile (and KMZ) export** ([#92](https://github.com/opengeos/RustGIS/issues/92)): GeoLibre writes GeoJSON, + GeoParquet, Shapefile, GeoPackage, CSV, KML and KMZ; RustGIS writes all of them, plus + FlatGeobuf, GPX and WKT. +- [x] **Shapefile and GeoPackage in the web app** ([#94](https://github.com/opengeos/RustGIS/issues/94), [#95](https://github.com/opengeos/RustGIS/issues/95)). +- [ ] **Native KML/KMZ, GPX, GML, DXF/DWG, File Geodatabase, MapInfo and OSM PBF readers**: + partial. KML/KMZ (styles and folders) and GPX (split into layers) are native on the desktop and + the web since [#97](https://github.com/opengeos/RustGIS/issues/97); GML, MapInfo MIF/MID, OSM PBF and TopoJSON since [#122](https://github.com/opengeos/RustGIS/issues/122); KML GroundOverlays as + raster layers since [#123](https://github.com/opengeos/RustGIS/issues/123); DXF (blocks, hatches, splines, text; a CAD layer picked with + "layer") since [#159](https://github.com/opengeos/RustGIS/issues/159); Esri File Geodatabases without GDAL on the desktop (3D multipatches as + footprints). Still missing: DWG (GDAL only) and File Geodatabase in the web app. +- [x] **Excel, LandXML, encoded polylines, GeoRSS** ([#118](https://github.com/opengeos/RustGIS/issues/118)). +- [x] **Delimited text with a projected CRS or address geocoding**: x/y columns in any coordinate + system, WKT columns and any separator ([#115](https://github.com/opengeos/RustGIS/issues/115)); Find Place and address-table geocoding ([#127](https://github.com/opengeos/RustGIS/issues/127)). +- [x] **Remote vector and COG by URL**: Add Data › From URL ([#116](https://github.com/opengeos/RustGIS/issues/116)); COGs ([#132](https://github.com/opengeos/RustGIS/issues/132)), FlatGeobuf and + GeoParquet ([#152](https://github.com/opengeos/RustGIS/issues/152)) read in place by HTTP range on the desktop and the web, from `s3://` too + ([#150](https://github.com/opengeos/RustGIS/issues/150)). +- [x] **PMTiles and MBTiles** ([#74](https://github.com/opengeos/RustGIS/issues/74), [#113](https://github.com/opengeos/RustGIS/issues/113)): raster and vector archives, desktop and web. +- [ ] **Multidimensional rasters**: Zarr/Icechunk, NetCDF-4/HDF5 and kerchunk, with variable and + dimension pickers, hyperspectral RGB and a 3D image cube. Partial: Zarr 2 and 3 stores with + variable and dimension pickers and CF dates ([#147](https://github.com/opengeos/RustGIS/issues/147)), NetCDF/HDF variables through GDAL on the + desktop ([#117](https://github.com/opengeos/RustGIS/issues/117)), data cubes on the time slider with trends. Still missing: native + NetCDF-4/HDF5 ([#396](https://github.com/opengeos/RustGIS/issues/396), _Exploratory_), kerchunk, Icechunk, sharded Zarr 3, hyperspectral RGB and + the 3D cube. +- [x] **GDAL raster formats beyond GeoTIFF and ASCII grid** ([#117](https://github.com/opengeos/RustGIS/issues/117), desktop). +- [ ] **OGC services**: partial. WMS, WMTS, WFS and OGC API Features from their capabilities + ([#120](https://github.com/opengeos/RustGIS/issues/120)), OGC API Tiles ([#144](https://github.com/opengeos/RustGIS/issues/144)), WCS and CSW ([#178](https://github.com/opengeos/RustGIS/issues/178)). Still missing: auto-refresh (_Roadmap: any + time_). +- [ ] **ArcGIS services**: FeatureServer, MapServer, ImageServer and VectorTileServer, with OAuth + and a Portal browser. Partial: the four layer types through From Service… ([#120](https://github.com/opengeos/RustGIS/issues/120)); a token in + the service address is sent with its requests (saved sources drop it). Missing: OAuth and a + Portal browser (_Roadmap: data at the source_). +- [ ] **STAC browser and cloud catalogs**: STAC, Planetary Computer, Earth Engine, Overture, + Source Cooperative, NASA Earthdata, Planet and Hugging Face. Partial: STAC Search ([#128](https://github.com/opengeos/RustGIS/issues/128)), + Planetary Computer with signed assets ([#144](https://github.com/opengeos/RustGIS/issues/144)), Overture Maps and Source Cooperative ([#158](https://github.com/opengeos/RustGIS/issues/158)), NASA + Earthdata (desktop) and Hugging Face. Missing: Earth Engine and Planet. +- [ ] **Open-data portal browsers**: partial. Overpass and geoBoundaries ([#139](https://github.com/opengeos/RustGIS/issues/139)), CKAN, Socrata and + ArcGIS Hub ([#176](https://github.com/opengeos/RustGIS/issues/176)), four Natural Earth sample layers. Missing: more Natural Earth themes; a Web + Data menu of global and per-country services ([#528](https://github.com/opengeos/RustGIS/issues/528)). +- [ ] **Database connections**: PostgreSQL/PostGIS, SQL Server/Azure SQL with write-back, Apache + Iceberg. Partial: PostGIS ([#125](https://github.com/opengeos/RustGIS/issues/125)), MySQL, MariaDB and SQL Server tables read live on the desktop. + Missing: write-back (_Roadmap: data at the source_) and Iceberg. +- [x] **Spatial SQL workspace** ([#131](https://github.com/opengeos/RustGIS/issues/131)): an SQL pane over DuckDB Spatial on the desktop. The web app + has no in-browser engine yet (_Exploratory_). +- [ ] **Private S3 buckets**: partial. `s3://` addresses, SigV4-signed connections, saved + connections without their secrets and an S3 browser, desktop and web ([#150](https://github.com/opengeos/RustGIS/issues/150)). Missing: SSO, IAM + roles and secrets kept in the keychain (_Roadmap: solid ground_). +- [ ] **3D and media data**: LiDAR (LAS/LAZ/COPC/EPT) as layers, 3D Tiles, I3S, Gaussian splats, + placed glTF models, georeferenced video, geotagged photos, ICESat-2/GEDI. Partial: LAS, LAZ and + COPC as point layers ([#137](https://github.com/opengeos/RustGIS/issues/137)), geotagged photos with thumbnails ([#175](https://github.com/opengeos/RustGIS/issues/175)); the rest is missing. +- [x] **Browser / Data Source Manager** ([#157](https://github.com/opengeos/RustGIS/issues/157)): a Data Sources pane with favourites, recents, + folders, saved services, databases, S3 connections, STAC APIs and a library of public services. +- [x] **Data deep links** (`?data=`, `?style=`, `?tiles=` in [#121](https://github.com/opengeos/RustGIS/issues/121), `?stac=` in [#144](https://github.com/opengeos/RustGIS/issues/144)). + +## Map rendering & 3D +_GeoLibre: docs/features.md › Map workspace, Rendering engines and 3D globe; docs/*-renderer.md_ + +- [ ] **3D terrain**: exaggeration, keyless Terrarium tiles, a custom DEM/COG terrain source, + Cesium World Terrain. Partial: a globe, 2.5D extrusion, and tilt, rotation and terrain (from a + DEM or elevation tiles, [#395](https://github.com/opengeos/RustGIS/issues/395)) in the GPU map. +- [ ] **Full 3D scene content**: 3D Tiles, I3S, point clouds, splats, CZML time-dynamic scenes, + true-Z vectors. Missing (_Roadmap: 3D scenes_, [#726](https://github.com/opengeos/RustGIS/issues/726)). +- [ ] **MapLibre style fidelity**: line labels and road shields, line patterns, hillshade and + fill-extrusion in styles. Partial; vector tiles and labels on the GPU map are underway ([#635](https://github.com/opengeos/RustGIS/issues/635), + [#596](https://github.com/opengeos/RustGIS/issues/596)). +- [ ] **Basemap catalog**: OpenFreeMap, Protomaps, EOX S2 cloudless, regional basemaps, planetary + bodies, thumbnail previews. Partial: a basemap gallery (OpenFreeMap, USGS Imagery), custom tile + layers and a Basemaps plugin. +- [ ] **Multi-map grid**: N synchronized panes. Partial: Side by Side with two maps and linked + views and cursors (_Roadmap: any time_). +- [ ] **Coordinate grids and readouts**: UTM and MGRS/USNG grids, UTM/MGRS/DDM readouts, + grid-reference search, pointer elevation. Partial: graticule, decimal/DMS and Go To XY + (_Roadmap: any time_). +- [ ] **Live data overlays**: RainViewer radar, a Timelapse of annual cloudless imagery, live feeds + (quakes, satellites, fires, AIS). Missing. +- [ ] **Wikipedia cards, Street View/Mapillary, OpenAerialMap, historical imagery**. Missing. +- [ ] **On-map right-click menu with quick analysis**: copy the coordinate, open it elsewhere, run + a buffer, isochrone or viewshed from the point. Missing (_Roadmap: any time_). +- [ ] **Flight simulator, camera tours, atmosphere**. Partial: a First-Person walk and animation + keyframes. +- [ ] ~~Multiple rendering engines (MapLibre, Mapbox, Cesium, ArcGIS)~~: out of scope by design; + RustGIS has its own renderer. + +## Cartography & styling +_GeoLibre: docs/features.md › Layers, styling, and labels; docs/user-guide/styling.md_ + +- [x] **Layer groups**. +- [ ] **Rule-based and expression renderers**: nested rules with scale ranges. Missing; display + filters are per-scale SQL only (_Roadmap: expressions and cartography_). +- [ ] **Heatmap and point-clustering renderers**. Partial: hexagon/square aggregation (_Roadmap: + expressions and cartography_). +- [ ] **More classification methods**: standard deviation, geometric interval, manual breaks. + Partial: Jenks, equal interval, quantile (_Roadmap: fixes and quick wins_). +- [ ] **Symbol library**: a marker library, custom SVG/PNG markers, fill patterns, pie/donut/bar + diagrams, arrow lines, inverted-polygon masks, geometry generators. Partial: 5 marker shapes + (_Roadmap: expressions and cartography_ for markers, patterns and a library). +- [ ] **Style interchange**: import/export SLD, QGIS QML, Mapbox GL JSON. Partial: MapLibre style + export and `.rgisstyle` (_Roadmap: expressions and cartography_ for QML and SLD import). +- [ ] **Smart styling**: automatic palette on add, one-click style suggestions. Missing. +- [ ] **Label engine depth**: expression labels, offset, rotation, wrap, case, priority, + de-duplication, number formats. Partial: label classes with SQL queries, field labels with + placement and scale ranges; a labeling pane is underway ([#618](https://github.com/opengeos/RustGIS/issues/618), [#722](https://github.com/opengeos/RustGIS/issues/722)). Expression labels: + _Roadmap: expressions and cartography_. +- [ ] **On-map legend and colorbar controls**. Partial: the legend is in Contents and layouts only + (_Roadmap: expressions and cartography_). +- [ ] **Popup and tooltip designer**: field picking, formats (date, link, image), an expression + title, hover tooltips. Partial: popup modes only (_Roadmap: expressions and cartography_). +- [ ] **Raster histogram stretch UI and Raster Attribute Table**. Partial: stretch types and DRA + (_Roadmap: expressions and cartography_). +- [ ] **Colour-ramp library and style presets manager**. Partial: custom colour ramps and QGIS, + GDAL and GMT palette files. + +## Attribute data & editing +_GeoLibre: docs/features.md › Attribute data and expressions; docs/arcgis-editing.md_ + +- [x] **Vertex editing**: move, add and delete vertices. +- [ ] **Topological digitizing and CAD dimensions**: shared-edge drawing, dimension lines, + building massing. Partial: snapping, topology validation, traverse. +- [ ] **Write-back to sources**: GeoPackage, GeoJSON, PostGIS, SQL Server, ArcGIS FeatureService. + Missing; edits stay in the project (_Roadmap: data at the source_ for GeoPackage and PostGIS). +- [ ] **Expression builder and engine**: one engine shared by filters, labels, styles and Calculate + Field, with a function reference. Partial: Calculate Field is `field op field/number` + (_Roadmap: expressions and cartography_). +- [ ] **Virtual fields and persistent attribute joins**. Missing; relationships exist (_Roadmap: + data at the source_; a one-off join field tool under solid ground). +- [ ] **Attribute form designer**: widgets, constraints, conditional visibility. Partial: domains, + subtypes, attribute rules (_Roadmap: data at the source_). +- [ ] **Editor tracking fields**: created/edited by and at. Partial: archiving keeps edit times + (_Roadmap: data at the source_). +- [ ] **Table column management**: rename, hide and reorder columns, a column explorer. Partial: + sort, filter, add/delete field (_Roadmap: data at the source_). +- [ ] **Quick filters**: per-field checkboxes and range/date controls. Partial: a definition query + (_Roadmap: expressions and cartography_). +- [ ] **Selection gestures**: polygon, freehand, radius. Partial: click and box; Neighborhood + Explorer selects by radius (_Roadmap: fixes and quick wins_). +- [ ] **Charts panel and dashboards**: histogram, scatter, bar, line, box and pie charts, + indicators, cross-filtering. Partial: Exploratory Analysis, Infographics, layout/report charts + (_Roadmap: analysis_ for charts linked to the selection). + +## Analysis +_GeoLibre: docs/features.md › Processing and analysis; docs/user-guide/processing.md, +model-builder.md, segmentation.md, obia.md_ + +- [ ] **Spatial join, intersect, union**. Partial: spatial join, clip and erase are built in; + intersect, union, identity and near are missing (_Roadmap: solid ground_). +- [ ] **More built-in vector tools**: attribute join, merge, extract vertices, points along lines, + fix/check geometry, random extract, buffer options. Partial (_Roadmap: solid ground_). +- [ ] **Routing services**: isochrones, service areas, OD matrix, multi-stop routing, + turn-by-turn (Valhalla/OSRM). Partial: shortest path over a line layer (_Roadmap: analysis_). +- [ ] **Geocoding**: forward, batch and reverse, with multiple providers and a place search box. + Partial: Find Place and address tables (Nominatim, Photon or your own server), a local locator, + a search box. Missing: reverse geocoding (_Roadmap: analysis_). +- [ ] **Spatial statistics**: Emerging Hot Spot (Getis-Ord Gi\*), composite score builder. + Partial: Suitability Modeler (_Roadmap: analysis_ for hot spots). +- [ ] **H3 and DGGS grids**: H3, A5, DGGRID, DGGAL. Partial: A5 grids (_Roadmap: any time_ for + H3). +- [ ] **AI segmentation and detection**: SAM/SamGeo, ONNX/YOLO detection, OBIA with eCognition + import. Partial: pixel classification (_Exploratory_). +- [ ] **Visual Model Builder canvas**: a node graph with validation and export to code. Partial: + ModelBuilder with a node canvas and models from history. +- [ ] **Re-run or copy history as code; batch runs over a folder**. Partial: history copied as + code; batch runs are missing (_Roadmap: solid ground_). +- [ ] **Viewshed from public terrain tiles (no DEM needed)**. Partial: needs a loaded elevation + raster (_Roadmap: analysis_ for a DEM downloader). +- [ ] **Conversion**: vector to PMTiles/GeoParquet, raster to COG. Partial: GeoParquet and COG + out; vector to PMTiles is missing (_Roadmap: any time_, [#557](https://github.com/opengeos/RustGIS/issues/557)). +- [x] **Time Slider**: vector layers by a date field (`time.bind`), data cubes, trends (Sen's + slope, Mann–Kendall), GIF export. +- [ ] **Point cloud annotation**: ASPRS classes, 3D boxes. Missing. +- [ ] **Hydrology and elevation downloaders**: NLDI navigation, USGS 3DEP and LiDAR, a global DEM + downloader. Missing (_Roadmap: analysis_ for a DEM downloader). + +## Layouts & printing +_GeoLibre: docs/features.md › Map tools, printing, and media; docs/user-guide/print-layout.md_ + +- [ ] **SVG layout export**. Partial: PDF and PNG (_Roadmap: any time_). +- [ ] **Atlas along a line, page-filtered tables and charts, copy to clipboard**. Partial: + per-feature map series (_Roadmap: any time_ for the clipboard). +- [ ] **Map and video recording**: canvas recording, a marker along a line to MP4, camera tours. + Partial: keyframe PNG frames, time slider GIFs. +- [ ] **STL 3D export**. Partial: OBJ and glTF (_Roadmap: any time_). + +## Sharing, collaboration & publishing +_GeoLibre: docs/collaboration.md, docs/server-api.md, docs/user-guide/embedding.md, projects.md, +storymaps.md_ + +- [ ] **Real-time multi-user collaboration**: live sessions, permissions, roster, chat. Missing + (_Exploratory_). +- [ ] **Review comments**: anchored, threaded, resolvable. Missing (_Exploratory_). +- [ ] **Share links, project gallery, accounts**: roles, expiry, passwords, a server API. Missing; + Share As writes static files (_Exploratory_). +- [ ] **Story maps**: chapters that fly the live map, a presenter view, HTML export. Partial: + Presentations. +- [ ] **Embed API**: a `postMessage` protocol, an npm client, viewer-only and map-only modes, deep + links. Partial: the web app embeddable with `?embed`, exchanging the project through + `postMessage` (`apps/rustgis-web/src/embed.rs`); no npm client. +- [ ] **Import QGIS (`.qgs/.qgz`) and ArcGIS Pro (`.aprx/.mapx`) projects**. Missing + (_Exploratory_). +- [ ] **Autosave, project history, crash recovery, snapshot compare**. Partial: undo and per-layer + archive restore (_Roadmap: solid ground_ for autosave and crash recovery). +- [ ] **Startup preferences and shareable interface settings**. Partial: start-up preferences, + Pane Sets and ribbon/QAT customization. +- [ ] **Layer metadata with STAC Item export**. Missing (_Roadmap: data at the source_). +- [ ] **Browser extension ("Open data in …")**. Missing. + +## Scripting & integrations +_GeoLibre: docs/python.md, docs/r.md, docs/notebook.md, docs/mcp.md, docs/plugin-api.md, +docs/agent-skill.md_ + +- [x] **Python package / Jupyter widget**: the `rustgis` package on PyPI runs the engine + in-process, and `rustgis.Map` is a two-way Jupyter widget (anywidget). No leafmap-style API. +- [ ] **In-app Python console**. Partial: the "Python Window" runs command scripts, not Python + (_Exploratory_). +- [ ] **Live notebook kernel**: JupyterLite on the web, JupyterLab on the desktop, driving the live + map. Partial: built-in command notebooks with `.ipynb` in and out. +- [ ] **R package**: RStudio, Quarto, Shiny, `sf`. Missing (_Exploratory_). +- [x] **MCP: drive the running desktop app**: the loopback relay lets external AI clients drive the + app; `rustgis-cli mcp` ([#75](https://github.com/opengeos/RustGIS/issues/75)) runs on its own project. +- [ ] **MCP Apps interactive map preview**. Partial: `view_map` returns a static PNG. +- [x] **A shipped agent skill (SKILL.md)**: [skills/rustgis](skills/rustgis/SKILL.md) installs and + manages the app; the assistant has built-in skills too. +- [ ] **Plugin API breadth**: side and floating panels, menus, custom layers, map-click tools, + assistant tools. Partial: commands, tools, a ribbon tab, assistant skills, process plugins. +- [ ] ~~Python FastAPI / GeoPandas processing sidecar~~: out of scope for pure Rust; process + plugins can run Python. + +## Platforms & distribution +_GeoLibre: docs/android.md, ios.md, mac-app-store.md, downloads.md, self-hosting.md, +deployment-capabilities.md, ui-profiles.md_ + +- [ ] **Android app**. Missing (_Exploratory_). +- [ ] **iOS/iPadOS app**. Missing (_Exploratory_). +- [ ] **Touch and responsive layout**. Missing (_Exploratory_). +- [ ] **Field data collection**: forms, photos, GPS or tap placement. Missing (_Exploratory_, + [#665](https://github.com/opengeos/RustGIS/issues/665)). +- [ ] **GPS**: live position, track logs, digitizing from GPS, NMEA receivers. Missing + (_Exploratory_). +- [ ] **PWA and offline basemap tile pre-caching**. Partial: the web app is installable with + offline start; offline data areas with sync. Tile pre-caching unverified. +- [ ] **Docker self-hosting image with runtime config**. Partial: the static web build (_Roadmap: + any time_). +- [ ] **Deployment capabilities, policies and UI profiles**: restrict edit, add, run and export per + deployment. Missing. +- [ ] **Package-manager and store channels**: winget, a Homebrew Cask, the Mac App Store. Partial: + winget, Homebrew, the AUR, Fedora COPR and the Microsoft Store; the Mac App Store is missing. +- [ ] **Windows code signing**. Missing (_Roadmap: solid ground_). +- [ ] **Auto-update**. Partial: Check for Updates (_Roadmap: solid ground_ for in-place updates). +- [ ] **Desktop network features**: OS trust store, mTLS client certificates, network log, + reloading files changed on disk. Missing (_Roadmap: solid ground_ for reloading files). + +## AI +_GeoLibre: docs/user-guide/ai-assistant.md, docs/features.md › AI, Python, and automation_ + +- [ ] **Voice commands and spoken answers**. Missing. +- [ ] **Provider profiles and per-feature providers; AWS Bedrock, Vercel AI Gateway**. Partial: + one provider and model at a time. +- [ ] **AI-authored processing models**. Missing. + +## Other +_GeoLibre: docs/features.md › Platforms and interface; docs/i18n.md_ + +- [ ] **More languages and right-to-left layout**: GeoLibre has 20 languages including Arabic, + Persian and Hebrew. Partial: 17 languages; Persian text is drawn right to left, while the panes + keep their left-to-right layout. +- [ ] **Translated geoprocessing tool names**. Missing (possibly deliberate). +- [ ] **Keyboard-shortcut cheat sheet**. Partial: Command Search; rebindable shortcuts are on the + roadmap (_any time_, [#562](https://github.com/opengeos/RustGIS/issues/562)). + +## Already at or beyond parity +- A native single-binary app, with every action a command shared by the ribbon, CLI, notebooks, + models, the assistant and MCP +- Map projections on the main map (presets and projected EPSG codes) +- Reports: grouped, with statistics, charts and subreports +- Data design: domains, subtypes, attribute rules, contingent values, relationships, archiving, + topology rules, traverse/COGO +- Large GeoPackage, GeoParquet and FlatGeobuf files streamed a view at a time +- Layouts: map frames, grids, extent indicators, map series and publication sheets to PDF +- Sandboxed WASM plugins with permissions and a hash-pinned registry, the same file on desktop, + CLI and web +- AI Assistant with local models and per-command approval; `rustgis-cli ask`; an MCP server with + built-in docs +- Imagery: mosaics, mensuration, pixel editor, a trainable classifier, hosted imagery tiles +- Offline areas with two-way sync of edited features + +## To verify +- Whether exported COGs (written today) pass a COG validator: overviews and tile layout +- GeoLibre's language count: [#77](https://github.com/opengeos/RustGIS/issues/77) said 20, + `docs/comparison/geolibre.md` says 21 +- Accessibility compared with GeoLibre's axe-checked screens +- Whether offline areas or the tile cache pre-fetch basemap tiles +- Whether the shortcuts view is a full cheat sheet +- Some GeoLibre features above are taken from its docs and live in plugins or the Cesium engine + only diff --git a/ROADMAP.md b/ROADMAP.md index 732506709..e7291bf16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,44 +1,203 @@ # Roadmap -Many features are first versions: there are no full 3D scenes or enterprise portal sharing yet. +_Last reviewed: 2026-10-23 (v0.3.0). Reviewed weekly; if this date is old, trust the linked issues +over this page._ + +This page says where RustGIS is heading and where help is most useful. Details live in the linked +issues; feature-by-feature comparison with GeoLibre is tracked in +[GEOLIBRE_PARITY.md](GEOLIBRE_PARITY.md). + +## Goals +- **What exists works.** A feature that crashes, misreads data or is undocumented costs more + than a missing one. Robustness and correct coordinates come before breadth. +- **Finish the everyday workflow first.** Add data, style it, edit it, analyse it, print or share + it: depth in these beats a long list of first versions. +- **Foundations before features.** Build the shared piece once (an expression engine, a secret + store, Z coordinates) and let several features stand on it. +- **Free and self-contained.** Pure Rust with nothing else to install; GDAL and DuckDB are + optional extras that add formats, not requirements. +- **One program everywhere.** Desktop, browser, CLI, Python and AI agents share the same commands; + nothing below `apps/` breaks the WebAssembly build. + +## Contributing +Most items below have no issue yet: propose one and open a pull request. See +[CONTRIBUTING.md](CONTRIBUTING.md) for which items to pick freely, which to coordinate first, and +how to write a proposal. Themes under Next are not releases and not in order; where an item +needs another first, it says so. + +## Underway +An item is underway when it has an open pull request, or someone said on its issue in the last +week that they are working on it. Check here and the +[open pull requests](https://github.com/opengeos/RustGIS/pulls) before starting; to take an item, +comment on its issue or pull request and open a draft pull request early. +- GPU map: vector tiles and labels on the GPU tile layer + ([#635](https://github.com/opengeos/RustGIS/issues/635), + [#596](https://github.com/opengeos/RustGIS/issues/596)). +- Labeling pane: label classes managed in the pane, query editor, more options + ([#618](https://github.com/opengeos/RustGIS/issues/618), + [#722](https://github.com/opengeos/RustGIS/issues/722)). +- Georeferencer: side-by-side window, transform picker, more formats + ([#609](https://github.com/opengeos/RustGIS/issues/609)). +- Script pane: completion of values such as layer and field names + ([#662](https://github.com/opengeos/RustGIS/issues/662)). +- Connection manager for databases and web services + ([#699](https://github.com/opengeos/RustGIS/issues/699)). +- Zonal statistics as a built-in tool ([#718](https://github.com/opengeos/RustGIS/pull/718)). +- A "No fill" option for polygons ([#745](https://github.com/opengeos/RustGIS/pull/745), for + [#644](https://github.com/opengeos/RustGIS/issues/644)). + +## Next + +### Fixes and quick wins +The bugs users hit, the docs and a few quick wins: good first pull requests. +- Robustness: the Windows 11 start-up failure + ([#375](https://github.com/opengeos/RustGIS/issues/375)), Control-click menus on macOS + ([#707](https://github.com/opengeos/RustGIS/issues/707)), Load Off-Map Data + ([#661](https://github.com/opengeos/RustGIS/issues/661)). +- A pass over the user guide, the comparison pages and the README against what the app does + today (vertex editing, spatial join and kriging are listed as missing or undocumented; some + pages still say 16 languages). +- Styling: standard deviation, geometric interval and manual breaks _(open)_. +- Selection by polygon and lasso, and a radius tool on the map (Neighborhood Explorer already + selects by radius) _(open)_. + +### Solid ground +Correct, robust and recoverable, with the foundations other themes stand on. +- Correct coordinates: grid datum shifts (NTv2 and GeoTIFF grids) for NAD27, NAD83(HARN), + GDA94↔GDA2020 and OSGB36, and a notice whenever a layer is read without its shift; GDA94 + ([#730](https://github.com/opengeos/RustGIS/issues/730)) is one case. _Coordinate first._ +- Reloading files changed on disk. +- Autosave and crash recovery: reopen unsaved work after a crash. +- Signed Windows builds (no SmartScreen warning), then in-place updates. +- A secret store in the OS keychain for database, S3 and service connections (they are kept for + the session only today). +- Overlay tools beside Clip and Erase: intersect, union, identity and near _(open)_. +- Vector tools, each writing a new layer: join field (a one-off attribute join), merge (append + layers), extract vertices, points along lines, check and fix geometry _(open)_. +- Batch runs: one tool over many layers or a folder of files. + +### Expressions and cartography +One expression engine, a superset of today's SQL where clause so existing definition queries, +label-class queries and display filters keep working. It replaces the three parsers of today +(where clauses, Calculate Field, attribute rules) and serves Calculate Field, attribute rules, +labels, definition queries, display filters, pop-ups and symbol properties. On top of it: +- Calculate Field with functions, not only `field op value`. +- Expression labels, with offset, rotation, wrapping, case, priority, number formats and halo + colour/width. +- Data-defined symbol size, colour and rotation. +- Rule-based renderer: ordered rules with an expression, symbol and scale range. +- Quick filters in the attribute table; pop-up templates (chosen fields, formats for dates, links + and images, a title). + +And alongside it: +- Kernel density (heat map) as a tool and as a renderer; point clustering. +- SVG and PNG markers, fill patterns and a symbol library. +- A legend and colour bar on the map. +- Style import: QGIS QML and SLD styles applied to layers. +- A histogram for raster stretches; raster attribute tables. + +### Data at the source +- Write edits back to GeoPackage and PostGIS (edits stay in the project today). _Coordinate + first._ +- Editor tracking fields (created/edited by and at). +- Attribute table columns: rename, hide and reorder _(open)_. +- Persistent attribute joins and virtual fields. _Coordinate first_ (they change `Feature`). +- Attribute forms: widgets, constraints and conditional visibility, on top of domains and + subtypes. +- Layer metadata, exported as STAC items. +- ArcGIS sign-in (OAuth) and a Portal browser, once the secret store (Solid ground) exists. + +### Analysis +- Routing: isochrones, service areas and an origin–destination matrix (today shortest path over + a line layer). +- Hot-spot statistics (Getis-Ord Gi\*), and emerging hot spots on the time slider. +- Charts linked to the selection: histogram, scatter, bar and box plots of a layer's fields. +- Reverse geocoding; a DEM downloader so viewshed works without a loaded elevation raster + _(open)_. + +### 3D scenes, first steps +- Phases 1–2 of [#726](https://github.com/opengeos/RustGIS/issues/726): Z coordinates kept from + the data and written back; a scene with a free camera and each layer's elevation mode (on the + ground, relative, absolute). +- LiDAR point layers (LAS/LAZ/COPC, read today as points) drawn at their heights in the scene. +- 3D objects and 3D Tiles follow. + +### Self-contained, any time _(open)_ +Built in means no runner download, the selection honoured, geodesic distances and attributes +kept. Before adding a tool, check whether the GeoLibre suite already has it (`gp.tools`); a +built-in version must justify itself on one of those. +- SVG layout export; STL export; copy a layout to the clipboard. +- UTM and MGRS grids and coordinate readouts. +- H3 grids built in, next to the A5 grids (the GeoLibre suite has H3 tools only when its runner + is installed). +- An on-map right-click menu: copy the coordinate, run a buffer or viewshed from the point. +- More than two synced maps side by side. +- Auto-refresh for web service layers. +- Vector to PMTiles export: the PMTiles writer and MVT encoder in `rustgis-tiles` exist but are + only used by tests ([#557](https://github.com/opengeos/RustGIS/issues/557)). +- A catalog of data cubes for the time slider + ([#683](https://github.com/opengeos/RustGIS/issues/683)). +- A Dockerfile for self-hosting the web app. +- Rebindable keyboard shortcuts ([#562](https://github.com/opengeos/RustGIS/issues/562)). + +## Exploratory +Open questions rather than plans; discuss before building. +- Layers kept in their own coordinate system rather than WGS 84. +- Integrating [opengeos/terrio](https://github.com/opengeos/terrio), the pure-Rust raster, vector + and point cloud I/O library: which of its drivers, coordinate transformations and GDAL-style + operations could back or replace RustGIS's own readers, or stand in for the GDAL program, and + which of them build for the web app. +- Collaboration: shared projects, review comments, accounts and a server. +- Mobile and field work: touch layout, GPS, data collection, QFieldCloud + ([#665](https://github.com/opengeos/RustGIS/issues/665)). +- Native NetCDF-4/HDF5 without GDAL ([#396](https://github.com/opengeos/RustGIS/issues/396)), + kerchunk and Icechunk. +- An SQL engine in the web app. +- Planetary coordinate systems ([#223](https://github.com/opengeos/RustGIS/issues/223)). +- Opening QGIS and ArcGIS Pro projects. +- An in-app Python console; an R package. +- AI segmentation and detection (SAM, ONNX models). ## Done - Layered workspace, command registry with undo, project files (`.rgis`), `rustgis-cli`, the web app (WebAssembly, installable with offline start, opened with a project or data by its - web address), 15 interface languages; a ribbon laid out in the order of the work + web address), 17 interface languages; a ribbon laid out in the order of the work (Home, Data, Style, Edit, Analyze, View, Publish), start-up preferences, Check for Updates. - Data: files by their web address; GeoJSON, CSV/TSV (x/y in any EPSG system, or WKT), Excel and OpenDocument spreadsheets, GeoRSS, LandXML, encoded polylines, Shapefile (zipped too, with the coordinate system in its `.prj`), GeoPackage, GeoParquet, FlatGeobuf, KML/KMZ (styles included), GPX, GML, MapInfo MIF, OSM PBF, TopoJSON, DXF and LiDAR (LAS/LAZ/COPC) in, all of them in the web app too (large files read in place, a view at a time); Esri File Geodatabases without GDAL (desktop; 3D multipatches - as footprints with their heights); the vector formats GDAL reads when it is installed, PostGIS, MySQL and MariaDB tables read live (desktop), and SQL queries + as footprints with their heights); the vector formats GDAL reads when it is installed, PostGIS, MySQL, MariaDB and SQL Server tables read live (desktop), and SQL queries and DuckDB databases (DuckDB Spatial) when DuckDB is; GeoJSON, CSV, GeoPackage, GeoParquet, Shapefile, FlatGeobuf, KML/KMZ, GPX and WKT out; standalone tables; geotagged photos and KML ground overlays; geocoding (Find Place, address tables); a Data Sources pane (favourites, recent, saved services, a library) and an SQL pane (DuckDB Spatial); layer, map, style, template, task and package files. -- Open data: STAC search (Planetary Computer assets), Overture Maps, Source Cooperative, +- Open data: STAC search (Planetary Computer assets), NASA Earthdata (desktop), Hugging Face, Overture Maps, Source Cooperative, OpenStreetMap by area (Overpass), geoBoundaries, CKAN, Socrata and ArcGIS Hub portals, and S3 buckets with signed connections and a bucket browser. - Rasters: GeoTIFF/COG (by web address, read in place by range; Zstandard too) and ASCII grids, Zarr 2 and 3 stores (a variable's step, by folder or web address), and JPEG 2000, NetCDF, HDF, ERDAS Imagine… through GDAL, EPSG reprojection, stretch/RGB/classified/shaded-relief - symbology, raster functions, indices, change detection, classification, georeferencing. + symbology, raster functions, indices, change detection, classification, georeferencing; + cloud-optimised GeoTIFFs out; data cubes on a time slider, with trends (Sen's slope, Mann–Kendall). - Services: WMS, WMTS, WFS, WCS, OGC API Features and Tiles, and ArcGIS REST, listed from their capabilities; CSW catalogue search. -- Maps: multiple maps, a GPU renderer (beta) with smooth zoom and pan, a 3D globe with the flat +- Maps: multiple maps, a GPU renderer (beta) with smooth zoom and pan, tilt, rotation and terrain, a 3D globe with the flat map draped over it, projection presets and EPSG codes (features cut along the edge of world projections), a search box for layers, places and coordinates, bookmarks, web tile layers (XYZ/TMS/WMS, Mapbox Vector Tiles, MapLibre styles, PMTiles and MBTiles archives), a basemap gallery (OpenFreeMap, USGS Imagery), worldviews. -- Cartography: single, unique values, graduated colours and proportional symbols; labels with - placement and annotation; custom colour ramps (and QGIS, GDAL and GMT palette files); - masking, display filters, aggregation, extrusion; graphics. -- Editing: create, move, rotate, scale, merge, split, explode; snapping, templates, elevation, +- Cartography: single, unique values, graduated colours and proportional symbols; layer groups; + label classes with SQL queries, placement and annotation; custom colour ramps (and QGIS, GDAL + and GMT palette files); masking, display filters, blend modes, aggregation, extrusion; graphics. +- Editing: create, move, rotate, scale, merge, split, explode, vertex editing; snapping, templates, elevation, topology; domains, subtypes, attribute rules, contingent values, relationships, archiving. -- Analysis: selection by attributes and location; overlay, proximity and summary tools; guided - workflows (viewshed, line of sight, IDW, shortest path, suitability); ModelBuilder and - scripts; the GeoLibre and Whitebox suite (1,000+ tools) natively and in the browser. -- Layouts (map frames, surrounds, map series, PDF), reports, presentations and notebooks. +- Analysis: selection by attributes and location; overlay (clip, erase), proximity (buffer) and summary tools, spatial + join, A5 grids, kriging; guided workflows (viewshed, line of sight, IDW, shortest path, + suitability); ModelBuilder and scripts, and history copied as code; the GeoLibre and Whitebox + suite (1,000+ tools) natively and in the browser. +- Layouts (map frames, surrounds, map series, publication sheets and figures in one command, PDF), + reports, presentations and notebooks; the web app embeddable in a page (`?embed`). - Sharing as open formats (web maps, OGC API Features collections, MapLibre styles, glTF scenes, locators), offline areas with sync, deep learning pixel classification, hosted imagery tiles. @@ -52,8 +211,3 @@ Many features are first versions: there are no full 3D scenes or enterprise port - Desktop packages: Windows installers (setup.exe, .msi), a portable zip and a Store MSIX; a signed and notarized macOS DMG; Linux .deb, .rpm, AppImage and tarballs; Homebrew, the AUR, Fedora COPR and winget; a download mirror at downloads.rustgis.app. - -## Next -- Editing: vertex editing (move, add and delete vertices of existing features). -- Analysis: spatial join, intersect and union, near, heat maps. -- 3D: full scenes with terrain, beyond the globe and 2.5D extrusion. From cc9fc81111ca60dab278720834c44e96cdbd029b Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Fri, 23 Oct 2026 13:18:37 -0400 Subject: [PATCH 072/297] i18n: translate the 306 new interface strings in every language (#753) Fill the strings added since the last catalog update (A5 grids, Zarr time series, S3 and database connections, layout shapes, label classes, kriging and trend tools, keymap) in all 16 catalogs and drop 27 stale entries whose English was reworded or removed. --- crates/ui-egui/src/i18n/az.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/de.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/es.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/fa.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/fr.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/id.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/it.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/ja.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/ka.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/ko.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/nl.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/pt.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/ru.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/tr.tsv | 333 +++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/vi.tsv | 337 ++++++++++++++++++++++++++++++--- crates/ui-egui/src/i18n/zh.tsv | 333 +++++++++++++++++++++++++++++--- 16 files changed, 4896 insertions(+), 436 deletions(-) diff --git a/crates/ui-egui/src/i18n/az.tsv b/crates/ui-egui/src/i18n/az.tsv index 68dc2d7a2..fc1369580 100644 --- a/crates/ui-egui/src/i18n/az.tsv +++ b/crates/ui-egui/src/i18n/az.tsv @@ -6,6 +6,7 @@ (filtered) (süzülmüş) + Add Stop + Dayanacaq əlavə et + Input + Giriş ++ New expression + Yeni ifadə + Output + Çıxış /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh 3D şəbəkə A 3D globe seen from space. Kosmosdan görünən 3D qlobus. A blank map with a basemap Əsas xəritəli boş xəritə +A call for the rustgis Python package rustgis Python paketi üçün çağırış A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. Kataloqun (CSW 2.0.2) ünvanı: onun qeydlərini sözlərlə və istəsəniz xəritənin ərazisi ilə axtarın, sonra istinad etdikləri xidmətləri və faylları əlavə edin. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. ChatGPT planı və ya tətbiq üzrə istifadə həddinə çatılıb. Yenidən cəhd etməzdən əvvəl həftəlik istifadəyə baxın. A ChatGPT sign-in can only be used with the OpenAI API. ChatGPT girişi yalnız OpenAI API ilə istifadə oluna bilər. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). GeoLens A globe scene. Qlobus səhnəsi. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place En və uzunluq (40.7, -74.0 və ya 40°42'N 74°0'W), lay, əlfəcin və ya layın cədvəlindəki dəyər. Enter işıqlandırılmış sətri seçir; «Yer axtar» mətni yer kimi axtarır A layer the model reads Modelin oxuduğu lay +A line for the Script pane Skript paneli üçün sətir A magnifying lens that follows the pointer. Göstəricini izləyən böyüdücü linza. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). Xəritə, obyekt və ya örtük xidmətinin ünvanı: WMS, WMTS, WFS, WCS, OGC API Features və ya Tiles, yaxud ArcGIS FeatureServer, MapServer, ImageServer və ya VectorTileServer (və ya baxmaq üçün xidmətlər kataloqu). a name, or owner/name such as giswqs/geospatial ad və ya sahib/ad, məsələn giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Yenidən istifadə edə biləcəyiniz adlı son nöqtə. Parollar və açarlar RustGIS bağlanana qədər saxlanılır, heç vaxt yadda saxlanmır. A node cannot connect to itself. Düyün özünə qoşula bilməz. A notebook of text and command cells. Mətn və əmr xanalarından ibarət bloknot. +A password or token in the address was left out: connections never keep secrets. Ünvandakı parol və ya token çıxarıldı: bağlantılar heç vaxt gizli məlumat saxlamır. A place or an address Yer və ya ünvan A project keeps at least one map Layihədə ən azı bir xəritə qalır A projected coordinate system. Proyeksiyalı koordinat sistemi. A report listing the selected layer's records, page after page. Seçilmiş layın qeydlərini səhifə-səhifə sadalayan hesabat. A result that only feeds other tools is removed once the model has run; add an output to keep it. Yalnız digər alətlərə ötürülən nəticə model işlədikdən sonra silinir; onu saxlamaq üçün çıxış əlavə edin. A result the model keeps, under a name Modelin ad altında saxladığı nəticə +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) POSIX shell üçün rustgis-cli əmri (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Xidmətin idxalı və ya yenilənməsi artıq davam edir. +A service's layers stay in their service group. Xidmətin layları öz xidmət qrupunda qalır. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Tapşırıq ən çox 200 addım saxlayır: onu yaratmaq üçün yazmanı dayandırın, sonra qalanını başqa tapşırıq kimi yazın. A template with the first selected feature's values İlk seçilmiş obyektin qiymətləri ilə şablon +A tile layer per grid tile, each following the time slider through its dates. Hər tor kafeli üçün bir kafel layı; hər biri öz tarixləri boyunca zaman sürgüsünü izləyir. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. Kafel xidmətinin ünvanı: {z}/{x}/{y} ilə rastr kafellər (və ya WMS üçün {-y}, {q}, {bbox-epsg-3857}), vektor kafellər (.pbf) və ya TileJSON, MapLibre üslubu, PMTiles arxivi (veb ünvan və ya bu kompüterdəki fayl) və ya bu kompüterdəki MBTiles faylı. A token is set. Token təyin edilib. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Qiymət, sahə və ya + − × ÷ ilə birləşdirilmiş ikisi. Seçilmiş sətirlərə, heç nə seçilməyibsə bütün sətirlərə tətbiq olunur. a vector a pixel hər piksel üçün bir vektor a vector an image patch hər təsvir fraqmenti üçün bir vektor A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Veb ünvanı ilə, konsolidasiya edilmiş metaməlumatlı Zarr anbarı (format 2 və ya 3), yaxud bu kompüterdəki qovluq. Dəyişənin xəritəsi onun digər ölçülərinin, məsələn zamanın, seçilmiş addımları üçün oxunur. +A Zarr variable's values through time at points clicked on the map. Xəritədə kliklənən nöqtələrdə Zarr dəyişəninin zamana görə qiymətləri. +A2 landscape sheet A2 üfüqi vərəq +A2 portrait sheet A2 şaquli vərəq +A3 landscape sheet A3 üfüqi vərəq +A3 portrait sheet A3 şaquli vərəq +A4 landscape sheet A4 üfüqi vərəq +A4 portrait sheet A4 şaquli vərəq +A5 Grid A5 toru +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. Müəyyən çözümlülükdə, xəritə hüdudu, lay və ya bütün dünya üzrə A5-in bərabərsahəli beşbucaqlı xanaları, identifikatorları ilə poliqonlar kimi. About Haqqında About plugins Plaginlər haqqında About RustGIS RustGIS haqqında @@ -65,6 +83,7 @@ Add Əlavə et Add\nData Məlumat\nəlavə et Add ({n}) Əlavə et ({n}) Add a bar chart of a layer's numeric field. Layın ədədi sahəsinin sütun diaqramını əlavə edin. +Add a circle. Dairə əlavə edin. Add a colorbar of a raster layer's colour scale in the map frame. Xəritə çərçivəsinə rastr layının rəng şkalasının rəng zolağını əlavə edin. Add a description. Təsvir əlavə edin. Add a field. Sahə əlavə edin. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Xəritəni çap və ixrac Add a page to the presentation. Təqdimata səhifə əlavə edin. Add a PNG or JPEG picture to the page. Səhifəyə PNG və ya JPEG şəkil əlavə edin. Add a presentation file. Təqdimat faylı əlavə edin. +Add a rectangle, to frame or highlight part of the page. Səhifənin bir hissəsini çərçivəyə almaq və ya vurğulamaq üçün düzbucaqlı əlavə edin. Add a report file, or a report template for the selected layer. Hesabat faylı və ya seçilmiş lay üçün hesabat şablonu əlavə edin. Add a scale bar for the map frame. Xəritə çərçivəsi üçün miqyas xətkeşi əlavə edin. add a second map first (Data › New Map) əvvəlcə ikinci xəritə əlavə edin (Məlumat › Yeni xəritə) Add a second map frame first Əvvəlcə ikinci xəritə çərçivəsi əlavə edin +Add a straight line. Düz xətt əlavə edin. Add a text element. Mətn elementi əlavə edin. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Veb əsas xəritəni digərlərini əvəz etmədən onların üzərinə əlavə edin ki, onlarla qarışsın. Götürmək üçün görünənlərdən birinə klikləyin. +Add a Zarr variable first (Add Data › Zarr…). Əvvəlcə Zarr dəyişəni əlavə edin (Məlumat əlavə et › Zarr…). Add Administrative Boundaries İnzibati sərhədlər əlavə et +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Ox əlavə edin. Onu yuxarısındakı dairəvi tutacaqla döndərin və ya bucağını xüsusiyyətlərində təyin edin. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. NASA GeoTIFF-lərini xəritəyə qoymaq üçün Earthdata Login tokeni əlavə edin (yuxarıda, Earthdata Login). Add an elevation raster first. Əvvəlcə hündürlük rastrı əlavə edin. Add an elevation raster to the map first Əvvəlcə xəritəyə hündürlük rastrı əlavə edin +Add an ellipse. Ellips əlavə edin. Add an empty layer with a ready-made schema, to draw into. Çəkmək üçün hazır sxemli boş lay əlavə edin. Add an input for each layer the model reads. Modelin oxuduğu hər lay üçün giriş əlavə edin. Add an output to keep a result under a name. Nəticəni ad altında saxlamaq üçün çıxış əlavə edin. Add and Open Əlavə et və aç Add and Open Notebook Bloknotu əlavə et və aç Add Archive Arxiv əlavə et +Add as Time Series Zaman sırası kimi əlavə et add at least two keyframes ən azı iki açar kadr əlavə edin Add Cell Xana əlavə et Add Color Ramp File… Rəng şkalası faylı əlavə et… @@ -132,6 +157,7 @@ Add Graphics Layer Qrafika layı əlavə et Add Input Giriş əlavə et Add Job İş əlavə et Add Keyframe Açar kadr əlavə et +Add Label Class Etiket sinfi əlavə et Add Layer File Lay faylı əlavə et Add Open Data Açıq məlumat əlavə et Add Output Çıxış əlavə et @@ -147,9 +173,11 @@ Add Rule Qayda əlavə et Add sample countries Nümunə ölkələri əlavə et Add Sample Data Nümunə məlumat əlavə et Add Satellite Embeddings Peyk embeddinqləri əlavə et +Add Selected ({n}) Seçilmişləri əlavə et ({n}) Add Service Layer Xidmət layı əlavə et Add skill… Bacarıq əlavə et… Add STAC Asset STAC resursu əlavə et +Add STAC Data Cube STAC məlumat kubu əlavə et Add STAC Footprints STAC konturları əlavə et Add Step Addım əlavə et Add Stroke Cizgi əlavə et @@ -171,6 +199,7 @@ Add to Favourites Seçilmişlərə əlavə et Add to Map Xəritəyə əlavə et Add vector data. Vektor məlumatı əlavə edin. Add view Görünüş əlavə et +Add With Options… Seçimlərlə əlavə et… Add Zarr Variable Zarr dəyişəni əlavə et Add Zarr Variable… Zarr dəyişəni əlavə et… Add ▾ Əlavə et ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Agentlərin tam girişi var: skriptləri, tapşırıqları, modelləri və plaginləri işə sala bilərlər. MCP relyni dayandırmaq üçün klikləyin Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Qoşulan bu kompüterdəki agentlər (Claude Code, Codex, Cursor və digərləri) RustGIS-in alətləri ilə burada açıq olan xəritə üzərində işləyir: faylları aça, saxlaya və vebdən istifadə edə bilərlər. Skriptlər və plagin idarəsi söndürülü qalır. Agents then run commands, change files and use the web on this computer without asking you. Agentlər bu kompüterdə sizdən soruşmadan əmrlər işlədəcək, faylları dəyişəcək və vebdən istifadə edəcək. +Aggregate Points to A5 Nöqtələri A5 üzrə aqreqasiya et Aggregation Aqreqasiya Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Fields of the World modelinin Sentinel-2 təsvirlərində bütün dünya üzrə, hər dəfə bir il üçün tapdığı kənd təsərrüfatı sahələrinin sərhədləri (2nd Edition, CC BY 4.0). Bunlar ölçülmüş torpaq sahələri deyil, model proqnozlarıdır. AI Sİ @@ -221,15 +251,18 @@ All steps done. Bütün addımlar tamamlandı. All tables with geometry Həndəsəli bütün cədvəllər Allow İcazə ver Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Giriş zamanı ChatGPT planından istifadəyə icazə verin. İş sahəniz buna icazə vermirsə, onun adminindən soruşun və ya API açarı olan provayder seçin. +Allow Feature Selection Obyekt seçiminə icazə ver Allow only certain combinations of values across fields. Sahələr arasında yalnız müəyyən qiymət kombinasiyalarına icazə verin. Allow such actions for this conversation Bu söhbətdə belə əməliyyatlara icazə ver Allow this action? Bu əməliyyata icazə verilsin? Allow this agent's RustGIS action? Bu agentin RustGIS əməliyyatına icazə verilsin? Along Selected Line Seçilmiş xətt boyunca +Alt+ Alt+ Alternating bar Növbələşən zolaq Altitude Hündürlük Always allow Həmişə icazə ver -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3 və ya S3 ilə uyğun xidmət (MinIO, Cloudflare R2, Wasabi…) son nöqtəsi ilə. Açarlar RustGIS bağlanana qədər saxlanılır; yadda saxlanmış bağlantı adını, son nöqtəsini və regionunu saxlayır, gizli açarını isə heç vaxt. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3 və ya S3 ilə uyğun xidmət (MinIO, Cloudflare R2, Wasabi…) son nöqtəsi ilə. Açarlar RustGIS bağlanana qədər saxlanılır; saxlanmış bağlantı adını, son nöqtəsini və regionunu saxlayır, gizli açarını isə heç vaxt. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Bu kompüterdəki agent bunu etmək istəyir: An AWS profile AWS profili @@ -248,6 +281,7 @@ API key API açarı Apply Tətbiq et Apply a function to a raster Rastra funksiya tətbiq et Apply Control Points Nəzarət nöqtələrini tətbiq et +Apply elevation Hündürlüyü tətbiq et Apply Style Üslubu tətbiq et Apply to layers Laylara tətbiq et Apply to the selected layer Seçilmiş laya tətbiq et @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° İstifadə sahəsi: {lon}°, {lat} Area {area} · Perimeter {perimeter} Sahə {area} · Perimetr {perimeter} Areas (name, land use, area) Ərazilər (ad, torpaq istifadəsi, sahə) Arrange Düz +Arrow Ox +Arrowheads Ox ucları Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Məlumatlarınız haqqında sual verin və ya xəritə ilə nə edilməli olduğunu təsvir edin. Köməkçi sizin üçün RustGIS əmrlərini icra edir; hər dəyişiklik geri alına bilər. Ask about the map, or tell it what to do… Xəritə haqqında soruşun və ya nə edəcəyini deyin… Ask about your data, or tell the assistant what to do with the map. Məlumatlarınız haqqında soruşun və ya köməkçiyə xəritə ilə nə edəcəyini deyin. @@ -275,12 +311,13 @@ Asking NASA's CMR… NASA CMR-ə sorğu… Asking the catalog… Kataloqa sorğu… Asking the NLDI… NLDI-yə sorğu… Assistant settings Köməkçi parametrləri +At the end Sonda +At the start Əvvəldə Atlas Atlas Attachments Qoşmalar Attribute\nTable Atribut\ncədvəli Attribute Rules Atribut qaydaları Attribute Table Atribut cədvəli -Attribute table Atribut cədvəli Attributes Atributlar Attribution Müəlliflik qeydi Aurora Aurora @@ -295,11 +332,9 @@ Automatic (system language) Avtomatik (sistem dili) Automatic (system time zone and region) Avtomatik (sistem saat qurşağı və regionu) Automatic ({kind}) Avtomatik ({kind}) Avoid Qaçın -AWS AWS AWS, anonymous AWS, anonim Azimuth Azimut Back Geri -Back to Explore; cancel a sketch Kəşfə qayıt; eskizi ləğv et Back to the Map Xəritəyə qayıt Back to the map Xəritəyə qayıt Back to tools Alətlərə qayıt @@ -314,8 +349,6 @@ Basemap Əsas xəritə Basemaps Əsas xəritələr Basin from hydrolocation Hidrolokasiyadan hövzə Bearing Azimut -Bearing {b}° · Pitch {p}° Azimut {b}° · Meyl {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Azimut: xəritənin yuxarı tərəfinin baxdığı istiqamət, şimaldan saat əqrəbi istiqamətində. Meyl: düz aşağı baxışdan əyilmə. Hər ikisini sıfırlamaq üçün klikləyin. Before the event Hadisədən əvvəl Below Aşağı Below 1:5,000,000 show everything; beyond it only what matches: 1:5 000 000-dan iri miqyasda hər şeyi göstər; ondan kiçikdə yalnız uyğun gələnləri: @@ -328,10 +361,12 @@ Bilinear Bilinear Bind To Time Slider Zaman sürgüsünə bağla Bind to Time Slider Zaman sürgüsünə bağla Blank Boş +Blank for Amazon S3 Amazon S3 üçün boş qoyun Blank Letter page Boş Letter səhifəsi Blank Map Boş xəritə Blank Page Boş səhifə Blend: {mode} Qarışdırma: {mode} +Block Feature Selection Obyekt seçimini blokla Blue Mavi Bold Qalın Bold label text. Qalın etiket mətni. @@ -339,6 +374,7 @@ Bookmark Description Əlfəcin təsviri Bookmarks Əlfəcinlər Bookmarks Pane Əlfəcinlər paneli Boolean Məntiqi +Both ends Hər iki uc Bottom Aşağı Boundaries… Sərhədlər… Bounding\nGeometry Sərhəd\nhəndəsəsi @@ -346,6 +382,7 @@ Brightness Parlaqlıq Brightness, contrast, saturation and sepia for the whole map. Bütün xəritə üçün parlaqlıq, kontrast, doyğunluq və sepiya. Bring this bookmark's map into view to update it Yeniləmək üçün bu əlfəcinin xəritəsini görünüşə gətirin Browse Bax +Browse Connection Bağlantıya bax Browse it in the Browser Brauzerdə bax Browse Plugins Plaginlərə bax Browse the geoprocessing toolbox. Geoemal alətlər qutusuna baxın. @@ -357,6 +394,7 @@ Browser Brauzer Browser\nPane Brauzer\npaneli Browser View Brauzer görünüşü Bucket Bucket +Buckets Bucket-lər Buffer Bufer Buffer A Point Nöqtəyə bufer Buffer the cities by 200 km Şəhərlərin ətrafında 200 km bufer yarat @@ -381,7 +419,9 @@ Calculate Field Sahəni hesabla Calculate field Sahəni hesabla Calculate field values. Sahə qiymətlərini hesablayın. Calculate fields and enforce conditions on every edit. Sahələri hesablayın və hər redaktədə şərtləri tətbiq edin. +Calculate Geometry Həndəsəni hesabla Calculate Geometry Attributes Həndəsə atributlarını hesabla +Calculated {rows} features {rows} obyekt hesablandı Calculation Hesablama Camera Kamera Camera Effect Kamera effekti @@ -396,12 +436,14 @@ Case Field Qrup sahəsi Cast Shadow Kölgə sal Catalog Kataloq catalog is a static STAC catalog's catalog.json or collection.json catalog statik STAC kataloqunun catalog.json və ya collection.json faylıdır +Catalogue (CSW) Kataloq (CSW) Catalogue search (CSW) Kataloqda axtarış (CSW) Celestial Bodies Göy cisimləri Celestial Body Göy cismi Celestial body: {body} Göy cismi: {body} Cell size Xana ölçüsü cell size ° xana ölçüsü ° +Cells Xanalar Center Mərkəz Center Map Here Xəritəni burada mərkəzləşdir Centered Mərkəzdə @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. Bu hesab və ya i ChatGPT sign-in failed. Check the connection and local settings, then try again. ChatGPT-yə giriş alınmadı. Bağlantını və yerli parametrləri yoxlayıb yenidən cəhd edin. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. ChatGPT istifadəsi müvəqqəti olaraq mümkün deyil. Girişinizi saxlayın və sonra yenidən cəhd edin. CHE CHE +Check Yoxla Check Again Yenidən yoxla Check for\nUpdates Yeniləmələri\nyoxla Check for Updates Yeniləmələri yoxla @@ -428,6 +471,7 @@ Check for Updates… Yeniləmələri yoxla… Check the address and the service type. Ünvanı və xidmət növünü yoxlayın. Check the address and your connection. Ünvanı və bağlantınızı yoxlayın. Check the address. Ünvanı yoxlayın. +Check the clause, and count the features it takes. Şərti yoxlayın və onun əhatə etdiyi obyektləri sayın. Check the integration's client, permissions, workspace policy and route configuration. İnteqrasiyanın müştərisini, icazələrini, iş sahəsi siyasətini və marşrut konfiqurasiyasını yoxlayın. Checking for updates… Yeniləmələr yoxlanılır… China Çin @@ -455,6 +499,7 @@ Choose which layers a click identifies. Klikin hansı layları müəyyən etdiyi Choose which tabs the ribbon shows. Lentin hansı tabları göstərdiyini seçin. Circle Dairə Circle Radius (km) Dairə radiusu (km) +Class Sinif Class color Sinif rəngi Class Style Sinif üslubu Classes Siniflər @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Təpə əlavə etmək Click to edit the description Təsviri redaktə etmək üçün klikləyin Click to place a point graphic. Nöqtə qrafikası yerləşdirmək üçün klikləyin. Click to select the node Düyünü seçmək üçün klikləyin +Click to select, double-click to add, right-click for more Seçmək üçün klikləyin, əlavə etmək üçün iki dəfə klikləyin, daha çoxu üçün sağ klikləyin Click to switch between decimal degrees and DMS Onluq dərəcə ilə DMS arasında keçid üçün klikləyin Clip Kəs Clip a raster to the view, or resample it. Rastrı görünüşə görə kəsin və ya yenidən nümunələyin. @@ -532,7 +578,7 @@ Close thread Axını bağla Close {name} Bağla: {name} Closest Ən yaxın Coded values, one per line: code=description Kodlu qiymətlər, hər sətirdə bir: kod=təsvir -Collapse or expand the ribbon Lenti yığ və ya aç +Collapse or Expand the Ribbon Lenti yığ və ya aç Collapse the ribbon (Ctrl+F1) Lenti yığ (Ctrl+F1) Collection Kolleksiya Collection {id}: choose dates and Find Items Kolleksiya {id}: tarixləri seçin və «Elementləri tap» düyməsini basın @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Yuxarıdakı qutudan kombinasiyalar, hər sətirdə bir COMID {comid} COMID {comid} Comma Vergül +Comma-separated (optional) Vergüllə ayrılmış (istəyə görə) Command\nSearch Əmr\naxtarışı command is required command tələb olunur Command Search Əmr axtarışı @@ -572,10 +619,13 @@ Connect Qoş Connect a folder to the Browser. Brauzerə qovluq qoşun. Connect to S3 S3-ə qoşul Connect, or pick a layer Qoşulun və ya lay seçin +Connected to {kind} {title} {kind} {title} ilə bağlantı quruldu Connecting to {url}… {url} ünvanına qoşulur… Connection Bağlantı +connection.new returned no id connection.new identifikator qaytarmadı Connections Bağlantılar -Connect… Qoş… +Connections you add are listed here Əlavə etdiyiniz bağlantılar burada sadalanır +Connections… Bağlantılar… Constant Sabit Constant height Sabit hündürlük Constant… Sabit… @@ -583,11 +633,14 @@ Contingent Values Şərti qiymətlər Continue Davam et Continue with ChatGPT ChatGPT ilə davam et Contrast Kontrast +Control Point Fit Nəzarət nöqtələrinə uyğunlaşma Conversion Çevirmə Convert Çevir Convert a layer to another format Layı başqa formata çevir Convert the selected layer's labels into an annotation layer. Seçilmiş layın etiketlərini annotasiya layına çevirin. +Convert this layer's labels into an annotation layer. Bu layın etiketlərini annotasiya layına çevirin. Convert To Annotation Annotasiyaya çevir +Convert to Annotation Annotasiyaya çevir Converted data Çevrilmiş məlumat Convert… Çevir… Coordinate Koordinat @@ -599,11 +652,15 @@ Copied the map to the clipboard Xəritə mübadilə buferinə kopyalandı Copied {path} {path} kopyalandı Copies the (selected) features of a layer to a new layer. Layın (seçilmiş) obyektlərini yeni laya kopyalayır. Copy Kopyala +Copy / Cut / Paste features Obyektləri kopyala / kəs / yapışdır Copy a model.run command that runs this model, for the Script pane or Python Bu modeli işə salan model.run əmrini Skript paneli və ya Python üçün kopyala Copy Address Ünvanı kopyala Copy address Ünvanı kopyala Copy an image of the map to the clipboard. Xəritənin təsvirini mübadilə buferinə kopyalayın. +Copy as CLI Command CLI əmri kimi kopyala +Copy as Command Əmr kimi kopyala Copy As GeoJSON GeoJSON kimi kopyala +Copy as Python Python kimi kopyala Copy Command Əmri kopyala Copy coordinates to the clipboard Koordinatları mübadilə buferinə kopyala Copy details Təfərrüatları kopyala @@ -611,11 +668,13 @@ Copy Group Qrupu kopyala Copy Layer Layı kopyala Copy Layers Layları kopyala Copy Path Yolu kopyala +Copy Run As Code İcranı kod kimi kopyala Copy sign-in link Giriş keçidini kopyala Copy the selected features (also as GeoJSON text). Seçilmiş obyektləri kopyalayın (həm də GeoJSON mətni kimi). Copy the selected features and delete them. Seçilmiş obyektləri kopyalayın və silin. Copy the selected layer's data source path. Seçilmiş layın məlumat mənbəyi yolunu kopyalayın. Copy this element (Ctrl+D, ⌘D on a Mac) Bu elementi kopyala (Ctrl+D, Mac-də ⌘D) +Copy this run as code, or open it in its tool Bu icranı kod kimi kopyala və ya öz alətində aç Corrections Düzəlişlər Cost Xərc could not draw the map xəritəni çəkmək mümkün olmadı @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Plagin reyestrini yükləmək mümkü Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Sahənin say, cəm, orta, min, maks və standart sapması, istəyə görə qrup sahəsi üzrə. Countries, cities, rivers and lakes Ölkələr, şəhərlər, çaylar və göllər Country Ölkə +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Layın nöqtələrini onları ehtiva edən A5 xanasına görə sayır (xanalar bərabərsahəlidir, ona görə say sıxlıqdır), sahənin cəmi və ortası ilə birlikdə. Counts the points that fall inside each polygon. Hər poliqonun daxilinə düşən nöqtələri sayır. Counts, totals and top categories of what's in view. Görünüşdəkilərin sayları, cəmləri və əsas kateqoriyaları. CPU CPU @@ -647,12 +707,8 @@ Cross Section En kəsik Cross Section (2 clicks) En kəsik (2 klik) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Kumulyativ Current date Cari tarix Current Project Cari layihə @@ -680,13 +736,16 @@ Data type Məlumat tipi Data you add is listed here Əlavə etdiyiniz məlumatlar burada göstərilir data.gov and CKAN catalogues data.gov və CKAN kataloqları Database Verilənlər bazası -Databases Verilənlər bazaları +Database connections are used by the desktop app Verilənlər bazası bağlantılarından masaüstü tətbiq istifadə edir +databases are read through DuckDB, a program the browser can't run verilənlər bazaları brauzerin işə sala bilmədiyi DuckDB proqramı ilə oxunur Database… Verilənlər bazası… Dataset Məlumat dəsti Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Hugging Face-də ada və ya sahib/ada görə məlumat dəstləri; dəstin fayllarına baxın və GeoTIFF, GeoParquet, PMTiles, LiDAR və digər fayllar əlavə edin. Data… Məlumat… Date Tarix Dates Tarixlər +Dates a cell needs Xananın tələb etdiyi tarix sayı +Dates a cell needs is a whole number, or empty for half Xananın tələb etdiyi tarix sayı tam ədəddir, yarısı üçün isə boş qalır Day Gün Day and night shading for a time. Müəyyən vaxt üçün gecə-gündüz kölgələnməsi. Decimal Onluq @@ -715,7 +774,6 @@ Delete Notebook Bloknotu sil Delete Page Səhifəni sil Delete Presentation Təqdimatı sil Delete Report Hesabatı sil -Delete selected features Seçilmiş obyektləri sil Delete selected rows Seçilmiş sətirləri sil Delete Style Üslubu sil Delete Task Tapşırığı sil @@ -736,11 +794,15 @@ Desktop and command line only Yalnız masaüstü tətbiq və komanda sətri Details Təfərrüatlar Details of the selected layer. Seçilmiş layın təfərrüatları. Deviations Sapmalar +DGGS DGGS Diagnostic\nMonitor Diaqnostika\nmonitoru Diagnostic Monitor Diaqnostika monitoru Diagnostics Diaqnostika +Diagonal ↗ Diaqonal ↗ +Diagonal ↘ Diaqonal ↘ Diamond Romb Difference, ratio or percent change between two rasters. İki rastr arasında fərq, nisbət və ya faiz dəyişikliyi. +Digit Digit Directions Marşrut Directions From Here Buradan marşrut Directions To Here Bura marşrut @@ -758,6 +820,7 @@ Dissolve Field Birləşdirmə sahəsi Distance Məsafə Distance (km) Məsafə (km) Distance Coefficient P Məsafə əmsalı P +Distance units Məsafə vahidləri Distances along the surface, heights, areas and volumes. Səth boyunca məsafələr, hündürlüklər, sahələr və həcmlər. DISTINCT DISTINCT Distinct Unikal @@ -804,18 +867,23 @@ Drag to draw Çəkmək üçün sürükləyin Drag to draw a freehand line. Sərbəst xətt çəkmək üçün sürükləyin. Drag to draw a freehand polygon. Sərbəst poliqon çəkmək üçün sürükləyin. Drag to draw a rectangle. Düzbucaqlı çəkmək üçün sürükləyin. +Drag to resize the message box Mesaj qutusunun ölçüsünü dəyişmək üçün sürükləyin Drag tools from the palette onto the canvas. Alətləri palitradan kətana sürükləyin. Drag vertices on the map; click an edge to add one Xəritədə təpələri dartın; təpə əlavə etmək üçün kənara klikləyin Draw a halo around label text. Etiket mətni ətrafında halo çəkin. Draw a labelled latitude/longitude grid over the map frame. Xəritə çərçivəsinin üzərində etiketli en/uzunluq toru çəkin. Draw a model: chain tools from input layers to the results to keep Model çəkin: alətləri giriş laylarından saxlanacaq nəticələrə qədər zəncirləyin Draw a model: chain tools from input layers to the results to keep. Model çəkin: alətləri giriş laylarından saxlanacaq nəticələrə qədər zəncirləyin. +Draw at its place in the stack, the bottom stroke first Yığındakı yerində çək, əvvəlcə ən alt cizgi +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Hər obyektin bir səviyyədəki cizgilərini növbətidən əvvəl çək ki, xətlər qovuşduqda üzlüklər nüvələrin altında birləşsin Draw labels at every scale and distance. Etiketləri hər miqyasda və məsafədə çəkin. Draw new features in the selected layer. Double-click to finish lines and polygons. Seçilmiş layda yeni obyektlər çəkin. Xətləri və poliqonları bitirmək üçün iki dəfə klikləyin. Draw new single-band rasters with the inverted grey stretch again. Yeni tək zolaqlı rastrları yenidən tərs boz genişləndirmə ilə çək. +Draw only polygon outlines, keeping their colors and widths. Rənglərini və qalınlıqlarını saxlayaraq yalnız poliqon konturlarını çəkin. Draw points as bins coloured by how many fall in each. Nöqtələri hər birinə düşən saya görə rənglənmiş xanalar kimi çəkin. Draw polygons as outlines only. Poliqonları yalnız kontur kimi çəkin. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Bundan sonra əlavə edilən tək zolaqlı rastrları bu rəng sxemi, genişləndirmə və yenidən nümunələmə ilə çək. +Draw strokes in levels Cizgiləri səviyyələrlə çək Draw the layer at every scale. Layı bütün miqyaslarda çəkin. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Xəritəni GPU-da çəkin (beta; qlobus, görünüş effektləri və rəng görmə simulyasiyası CPU təsvir edicisində qalır). Draw the selected layer above others. Seçilmiş layı digərlərinin üstündə çəkin. @@ -828,6 +896,7 @@ drawn by the server server tərəfindən çəkilir Drive Avtomobil Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. OpenAerialMap-də paylaşılan dron və aerofoto təsvirləri, ən yenisi əvvəl, bulud üçün optimallaşdırılmış GeoTIFF kimi əlavə edilir. Duplicate Dublikat et +Duplicate Connection Bağlantını dublikat et Duplicate Element Elementi dublikat et Duplicate Layout Tərtibatı dublikat et Duplicate Map Xəritəni dublikat et @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name məs. pop_est / 1000000 v e.g. 0, 255, -9999 məs. 0, 255, -9999 e.g. continent = 'Europe' məs. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' məs. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 məs. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Hər geoemal icrası çıxışları ilə zəncirlənən bir addıma çevrilir Earth Index has no points in the view Earth Index-də görünüşdə nöqtə yoxdur Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Təpələri\nredaktə et Edit and run the notebook. Bloknotu redaktə edin və işə salın. Edit Attribute Atributu redaktə et Edit Cell Xananı redaktə et +Edit Connection Bağlantını redaktə et Edit Description Təsviri redaktə et Edit Graphic Qrafikanı redaktə et Edit Status Redaktə vəziyyəti @@ -882,6 +953,7 @@ Editor\nTools Redaktə\nalətləri Editor Tools Redaktə alətləri Edits since the project was saved. Layihə saxlanıldıqdan sonrakı redaktələr. Edits the project Layihəni redaktə edir +Edit… Redaktə et… Effects Effektlər Element Element Element Properties Element xüsusiyyətləri @@ -898,21 +970,25 @@ Enable Lighting İşıqlandırmanı aktivləşdir Enable Plugin Plagini aktivləşdir Enabled Aktiv Encloses each feature, or all features, in its convex hull. Hər obyekti və ya bütün obyektləri qabarıq örtüyə alır. +Encoded polyline Kodlanmış polixətt End Son End date Bitmə tarixi Endpoint Son nöqtə Enhancement Gücləndirmə Enter Enter Enter an API key. API açarını daxil edin. +Enter an EPSG code as a number, such as 32650 EPSG kodunu ədəd kimi daxil edin, məsələn 32650 Enter the provider's address. Provayderin ünvanını daxil edin. Enter to send, Shift+Enter for a new line Göndərmək üçün Enter, yeni sətir üçün Shift+Enter EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Mühitlər EPSG EPSG +EPSG code EPSG kodu EPSG or ESRI code EPSG və ya ESRI kodu EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Bərabər interval Equirectangular (Plate Carrée) Bərabər məsafəli silindrik (Plate Carrée) @@ -922,14 +998,18 @@ Error Xəta Error Inspector Xəta yoxlayıcısı Error Inspector (validate) Xəta yoxlayıcısı (yoxla) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Avropa bərabər sahəli (ETRS89 LAEA) Every band's value at a pixel. Pikseldə hər kanalın qiyməti. Every editing tool. Bütün redaktə alətləri. +Every label setting of this layer, in the Labeling pane. Bu layın bütün etiket parametrləri, Etiketləmə panelində. Every layer, as FEMA draws them Bütün laylar, FEMA-nın çəkdiyi kimi +Every run, in order, as a script that opens the project Layihəni açan skript kimi, ardıcıllıqla hər icra every Socrata site, or one such as data.cityofnewyork.us bütün Socrata saytları və ya biri, məsələn data.cityofnewyork.us Everywhere Hər yerdə +Existing field Mövcud sahə Existing Template Mövcud şablon exit rotary dairəvi yoldan çıxın exit roundabout dairəvi yoldan çıxın @@ -948,6 +1028,7 @@ Export\nRaster Rastrı\nixrac et Export Animation Animasiyanı ixrac et Export Bookmarks Əlfəcinləri ixrac et Export Bookmarks… Əlfəcinləri ixrac et… +Export CSV… CSV ixrac et… Export Features Obyektləri ixrac et Export Features (tool) Obyektləri ixrac et (alət) Export Features needs a vector layer (Data › Export Raster writes a raster) Obyektləri ixrac et vektor layı tələb edir (Məlumat › Rastrı ixrac et rastr yazır) @@ -996,6 +1077,7 @@ Face Culling Üzlərin gizlədilməsi Faces Üzlər Factor Əmsal Failed Uğursuz oldu +FALSE FALSE Farthest Ən uzaq Faster drawing: no antialiasing. Daha sürətli çəkmə: hamarlaşdırma yoxdur. Favourite Data Source Seçilmiş məlumat mənbəyi @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Sahə sərhədləri Field density Sahə sıxlığı Field name Sahə adı +Field Prefix Sahə prefiksi +Field to Sum and Average Cəmlənəcək və ortalanacaq sahə Fields Sahələr Fields are drawn from zoom 9: zoom in to see them. Sahələr 9-cu miqyas səviyyəsindən çəkilir: görmək üçün yaxınlaşdırın. Fields colored by score, drawn from zoom 9 Bala görə rənglənmiş sahələr, 9-cu miqyas səviyyəsindən çəkilir @@ -1079,6 +1163,8 @@ Folder Connections Qovluq bağlantıları folder connections need the desktop app qovluq bağlantıları masaüstü tətbiq tələb edir Folders Qovluqlar Folder… Qovluq… +Follow the time slider Zaman sürgüsünü izlə +Font Şrift for a store that doesn't say bunu göstərməyən anbar üçün Forest Meşə Forget Earthdata Login Token Earthdata Login tokenini unut @@ -1090,9 +1176,11 @@ France Fransa Free the prepared geometry. Hazırlanmış həndəsəni boşaldın. Freehand Sərbəst FROM FROM +From Başlanğıc From Selection Seçimdən From Service… Xidmətdən… From symbol Simvoldan +From the address Ünvandan from the address ünvandan from the service xidmətdən From URL… URL-dən… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Görünüşün mərkəzindən uzaq oby Hide Group Qrupu gizlət Hide labels when the viewer is closer than this. Baxan bundan yaxın olduqda etiketləri gizlədin. Hide labels when the viewer is farther than this. Baxan bundan uzaq olduqda etiketləri gizlədin. +Hide Layers Layları gizlət Hide other values Digər dəyərləri gizlət Hide Steps Addımları gizlət Hide the description Təsviri gizlət @@ -1213,6 +1302,8 @@ Hold Gözləmədə HOME HOME Home Əsas Home › Add Əsas › Əlavə et +Home › Add Data Əsas › Məlumat əlavə et +Home › Inspect › Measure Əsas › Araşdır › Ölç Home › Navigate Əsas › Naviqasiya Home › Navigate › Bookmarks Əsas › Naviqasiya › Əlfəcinlər Home › Web Data Əsas › Veb məlumatlar @@ -1220,7 +1311,7 @@ Home › Web Data › United States Əsas › Veb məlumatlar › ABŞ Homepage Ana səhifə Horizontal Üfüqi host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=… və ya .duckdb faylı +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Yerləşdirilmiş təsvirlər Hour Saat How the layer's colours combine with the tile layers and basemap beneath. Layın rənglərinin altındakı plitə layları və əsas xəritə ilə necə birləşdiyi. @@ -1248,6 +1339,7 @@ Imagery Sharing Təsvir paylaşımı Imagery Tools Təsvir alətləri ImageServer ImageServer Image… Təsvir… +Imperial İngilis Import İdxal Import\nProject Layihəni\nidxal et Import All Hamısını idxal et @@ -1267,6 +1359,7 @@ Import Task Tapşırığı idxal et Import Task… Tapşırığı idxal et… Import Thumbnail Miniatürü idxal et Import… İdxal et… +IN IN In Beyond Yaxınlaşma həddi In memory Yaddaşda In Progress İcra olunur @@ -1281,6 +1374,8 @@ Information Məlumat INPUT GİRİŞ Input Giriş Input Features Giriş obyektləri +Input Layer Giriş layı +Input Points Giriş nöqtələri Input Polygons Giriş poliqonları Input Rows Giriş sətirləri Input Summary Features Giriş yekun obyektləri @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Quraşdırılmış plaginlər burada saxl Installed with Homebrew, winget, the AUR or COPR? Update it there. Homebrew, winget, AUR və ya COPR ilə quraşdırmısınız? Oradan yeniləyin. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} quraşdırıldı. Onu Plaginlər tabında tapa bilərsiniz; alətləri Alətlər panelində də var. Installing it downloads Python {version} and the packages it needs from the internet. Quraşdırma internetdən Python {version} və lazım olan paketləri yükləyir. +Integer field: values are rounded. Tam ədəd sahəsi: qiymətlər yuvarlaqlaşdırılır. Interface font: {font} İnterfeys şrifti: {font} Interpolate a surface from points (IDW) Nöqtələrdən səth interpolyasiya et (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Adi kriqinq üsulu ilə nöqtə obyektlərindən 2 kanallı rastr səthi (Proqnoz və Dispersiya) interpolyasiya edir. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. QGIS-də olduğu kimi, obyekt qiymətlərindən tərs məsafə çəkisi üsulu ilə rastr səthi interpolyasiya edir. Interpolation İnterpolyasiya Interpolation Attribute İnterpolyasiya atributu @@ -1316,10 +1413,13 @@ Invalid: {error} Yanlışdır: {error} Invert Tərsinə çevir Invert color scheme Rəng sxemini tərsinə çevir Invert the selection. Seçimi tərsinə çevirin. +IS IS IS NULL IS NULL it connects to an address on the internet internetdəki ünvana qoşulur It has no code: nothing in it runs. Onda kod yoxdur: içində heç nə işə düşmür. +It is locked, or one of its surrounds is: unlock it first O və ya onun elementlərindən biri kilidlidir: əvvəlcə kilidini açın it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) yalnız sizin üçün nəzərdə tutulub (plaginlər, skriptlər, tapşırıqlar, modellər və GeoLibre icraçısı) +it needs input: open it from its pane or button, or run it from a script or the assistant giriş tələb edir: onu paneli və ya düyməsi ilə açın, yaxud skriptdən və ya assistentdən işə salın it reads or writes files on this computer bu kompüterdəki faylları oxuyur və ya yazır it replaces the open project açıq layihəni əvəz edir it runs a plugin's command plaginin əmrini icra edir @@ -1332,20 +1432,30 @@ Italic Kursiv Item Pop-ups Element açılan pəncərələri Item {id} Element {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. Onun firewall-u ({firewall}) RustGIS-i blokladı. Xidmət yalnız bəzi tətbiqlərə icazə verə bilər: onun administratoru ilə əlaqə saxlayın və aşağıdakı təfərrüatları göndərin. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Onun aləti hazırda əlçatan deyil (yüklənməmiş plaginin və ya quraşdırılmamış paketin aləti) Jobs İşlər JOIN JOIN Join Birləşmə +Join Features Obyektləri birləşdir +Join Operation Birləşmə əməliyyatı +Joins attributes from a join layer to a target layer based on their spatial relationship. Məkan əlaqələrinə əsasən birləşdirmə layının atributlarını hədəf laya birləşdirir. +Journal figure, 1 column (90 mm) Jurnal şəkli, 1 sütun (90 mm) +Journal figure, 1.5 columns (140 mm) Jurnal şəkli, 1,5 sütun (140 mm) +Journal figure, 2 columns (190 mm) Jurnal şəkli, 2 sütun (190 mm) Keep a history of every edit to the layer, to look back or restore. Geriyə baxmaq və ya bərpa etmək üçün layın hər redaktəsinin tarixçəsini saxlayın. Keep an archive of edits Redaktələrin arxivini saxla keep current cari saxla +Keep it from being moved, resized, deleted or edited by mistake Səhvən köçürülməsinin, ölçüsünün dəyişdirilməsinin, silinməsinin və ya redaktə edilməsinin qarşısını al Keep one value of each point's vector as its value field Hər nöqtənin vektorunun bir dəyərini onun dəyər sahəsi kimi saxla Keep Result Nəticəni saxla Keep the labelled features fixed as the map moves. Xəritə hərəkət etdikcə etiketlənmiş obyektləri sabit saxlayın. Keep the last drawing tool active after each graphic. Hər qrafikadan sonra son çəkmə alətini aktiv saxlayın. Keep the last tool active Son aləti aktiv saxla +Keep this endpoint under Data Sources › Connections, without its password or keys Bu son nöqtəni parolu və ya açarları olmadan Məlumat mənbələri › Bağlantılar altında saxla Keep this result Bu nəticəni saxla Keep Tool Aləti saxla Keep value Dəyəri saxla +Key Key Keyboard Shortcuts Klaviatura qısayolları Keyboard shortcuts. Klaviatura qısayolları. Keys from Açarların mənbəyi @@ -1356,13 +1466,20 @@ KMZ KMZ Label Etiket Label Class Etiket sinfi Label Distance Range Etiket məsafə diapazonu +Label features in this layer Bu laydakı obyektləri etiketlə Label Field Etiket sahəsi +Label only the features that match. Yalnız uyğun gələn obyektləri etiketləyin. Label Placement Etiket yerləşdirilməsi Label Range Etiket diapazonu Label the cities with their names Şəhərləri adları ilə etiketlə Label Visibility Range Etiket görünmə diapazonu Labeling Etiketləmə +Labeling Pane Etiketləmə paneli +Labeling Properties… Etiketləmə xüsusiyyətləri… Labels Etiketlər +labels are drawn for feature layers, not rasters etiketlər rastrlar üçün deyil, obyekt layları üçün çəkilir +Labels are drawn for feature layers, not rasters. Etiketlər rastrlar üçün deyil, obyekt layları üçün çəkilir. +Labels are off for this layer: the clause applies once they are on. Bu lay üçün etiketlər söndürülüb: şərt onlar yandırıldıqda tətbiq olunur. Lagoon Laquna Lambert Conformal Conic Lambertin konform konik proyeksiyası Lambert Cylindrical Equal Area Lambert bərabər sahəli silindrik @@ -1383,12 +1500,14 @@ Layer Blend Lay qarışdırma Layer Extrusion Layın qaldırılması Layer File Lay faylı Layer From\nSelection Seçimdən\nlay +Layer not found Lay tapılmadı Layer Template Lay şablonu Layer Templates Lay şablonları layer {id} lay {id} Layers Laylar Layers ({n}) Laylar ({n}) Layers and Table Views Laylar və cədvəl görünüşləri +Layers, a date each Laylar, hər biri bir tarix Layout Tərtibat Layout\nFile Tərtibat\nfaylı Layout after: {name} Sonrakı tərtibat: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Öyrənmə\nresursları Left Sol Legend Legenda Legend label. Clear it to show the value again. Legenda etiketi. Qiyməti yenidən göstərmək üçün onu təmizləyin. -Length {length} · gain {gain} m · loss {loss} m Uzunluq {length} · artım {gain} m · azalma {loss} m +Length {length} · gain {gain} · loss {loss} Uzunluq {length} · qalxma {gain} · enmə {loss} Let the assistant hand tasks to agents on this computer Köməkçiyə tapşırıqları bu kompüterdəki agentlərə ötürməyə icazə ver Let your Agents Control RustGIS Agentlərinizə RustGIS-i idarə etməyə icazə verin Letter spacing Hərflərarası məsafə @@ -1439,6 +1558,7 @@ List by drawing order Çəkmə sırasına görə sırala List by selection Seçimə görə sırala List Columns in File Fayldakı sütunların siyahısı List Commands Əmrlərin siyahısı +List Connections Bağlantıları sadala List Data Sources Məlumat mənbələrinin siyahısı List Database Columns Verilənlər bazası sütunlarının siyahısı List Database Tables Verilənlər bazası cədvəllərinin siyahısı @@ -1475,6 +1595,11 @@ Locate… Tap… Location Məkan Locator Lokator Lock Kilidlə +Locked Kilidli +Locked: unlock it in the Format pane to move or change it Kilidli: köçürmək və ya dəyişmək üçün Format panelində kilidini açın +Log In to Connection Bağlantıya daxil ol +Log In to {name}… {name} bağlantısına daxil ol… +Log In… Daxil ol… Long Long Longitude Uzunluq dairəsi Longitude (X) Uzunluq dairəsi (X) @@ -1493,6 +1618,7 @@ Manage İdarə et Manage Bookmarks Əlfəcinləri idarə et Manage Edits Redaktələri idarə et Manage Templates Şablonları idarə et +Mann–Kendall Mann–Kendall Map Xəritə Map\nFrame Xəritə\nçərçivəsi Map (the current view) Xəritə (cari görünüş) @@ -1504,6 +1630,7 @@ Map File Xəritə faylı Map Frame Xəritə çərçivəsi Map Frames Xəritə çərçivələri Map frames per second (live while the map moves) Xəritənin saniyədə kadr sayı (xəritə hərəkət edərkən canlı) +Map Labeling Xəritə etiketləməsi Map Link Xəritə keçidi Map name Xəritə adı Map Page Xəritə səhifəsi @@ -1515,6 +1642,7 @@ Map style (JSON) Xəritə üslubu (JSON) Map Surrounds Xəritə elementləri Map Thumbnail Xəritə miniatürü Map tiles of the whole world Bütün dünyanın xəritə kafelləri +Map Tool: {tool} Xəritə aləti: {tool} Map View Xəritə görünüşü map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Xəritə: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Marker Mask Values Qiymətləri maskala Masking Maskalama +Match Option Uyğunluq seçimi Matching places Uyğun yerlər Max Maks Max % Maks % +Max Neighbors Maksimum qonşu sayı Maximum zoom Maksimal miqyas səviyyəsi MCP Relay MCP Relay MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCP rely artıq 127.0.0.1:{port} ünvanında dinləyir; {asked} portunu istifadə etmək üçün əvvəlcə dayandırın @@ -1542,6 +1672,8 @@ Measure Distance Məsafəni ölç Measure geodesic distances and areas on the map. Xəritədə geodeziya məsafələrini və sahələrini ölçün. Measure on this body, with one of its basemaps. Bu cisim üzərində, onun əsas xəritələrindən biri ilə ölçün. Measure with an elevation raster Hündürlük rastrı ilə ölç +Measurement units Ölçü vahidləri +Measurement units must be metric or imperial Ölçü vahidləri metrik və ya İngilis olmalıdır Measurements so far. İndiyədək ölçmələr. Mensuration Ölçmə Mensuration Tools Ölçmə alətləri @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Eyni sahə Messages appear here. Mesajlar burada görünür. Meters Metr Method Üsul +Metric Metrik Midnight Gecəyarısı Miles Mil Miller Cylindrical Miller silindrik @@ -1560,21 +1693,43 @@ Min must be less than Max Min Maks-dan kiçik olmalıdır Min must not be more than Max Min Maks-dan böyük olmamalıdır Minimum Bounding Geometry Minimal sərhəd həndəsəsi Minimum Maximum Minimum maksimum +Minus Minus Minutes Dəqiqə minutes ago dəqiqə əvvəl Miter İti Mobile Map Mobil xəritə Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Model Model\nBuilder Model\nqurucusu model id model identifikatoru Model output Model çıxışı ModelBuilder Model qurucusu Models Modellər +Modified Mann–Kendall (Hamed & Rao) Modifikasiya olunmuş Mann–Kendall (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Modifikasiya olunmuş Mann–Kendall (Yue & Wang) Modify Dəyişdir Modify Selected Features Seçilmiş obyektləri dəyişdir Mollweide (equal-area world) Mollveyde (bərabər sahəli dünya) Month Ay +Months Aylar +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Aylar 1-dən 12-yə qədər ədədlərdir (dekabrdan fevrala qədər üçün 12, 1, 2) More Daha çox More basemaps: Daha çox əsas xəritə: Mosaic images into an orthomosaic Təsvirləri ortomozaikaya birləşdir @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Görünən rastrları ortomozaik Most cloud cover (%) Maks. buludluluq (%) Mouse wheel Siçan çarxı Move Köçür -Move (m) Köçür (m) Move Bookmark Əlfəcini köçür Move Down Aşağı köçür Move down Aşağı köçür Move Group Qrupu köçür +Move Label Class Etiket sinfini köçür Move Task Step Tapşırıq addımını köçür +Move To Bottom Ən aşağıya köçür Move To Group Qrupa köçür +Move To Top Ən yuxarıya köçür Move To {group} Köçür: {group} Move Up Yuxarı köçür Move up Yuxarı köçür @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Mənim bucket-lərim My OGC Layer Mənim OGC layım +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Ad name the plugin whose panel to open: {"id": plugin id} paneli açılacaq plagini göstərin: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Yeni\nbloknot New\nPresentation Yeni\ntəqdimat New\nReport Yeni\nhesabat New\nWorkspace Yeni iş\nsahəsi -New / Open / Save project Yeni / Aç / Layihəni saxla New Bookmark Yeni əlfəcin New Bookmark… Yeni əlfəcin… +New Connection Yeni bağlantı +New Connection ▾ Yeni bağlantı ▾ New conversation Yeni söhbət New Custom Template Yeni fərdi şablon New Custom Template… Yeni fərdi şablon… New features use this template's values Yeni obyektlər bu şablonun qiymətlərini istifadə edir +New field Yeni sahə New Group Yeni qrup New job name Yeni işin adı New Layout Yeni tərtibat @@ -1649,6 +1809,7 @@ New Notebook Yeni bloknot New Presentation Yeni təqdimat New Project Yeni layihə New project Yeni layihə +New Publication Layout Yeni nəşr tərtibatı New Relationship… Yeni əlaqə… New Report Yeni hesabat New Selection Yeni seçim @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Hələ əlfəc No catalogs below this one: Find Items lists its items Bundan aşağıda kataloq yoxdur: «Elementləri tap» onun elementlərini göstərir No COMID was returned for this point. Bu nöqtə üçün COMID qaytarılmadı. no command əmr yoxdur -No connections this session Bu sessiyada bağlantı yoxdur +No data at the points picked. Seçilmiş nöqtələrdə məlumat yoxdur. No datasets match. Uyğun məlumat dəsti yoxdur. No datasets with data RustGIS can add RustGIS-in əlavə edə biləcəyi məlumat dəsti yoxdur +no date in the name adda tarix yoxdur No edits since the project was saved Layihə saxlanıldıqdan sonra redaktə yoxdur No field of this layer holds dates. Bu layın heç bir sahəsində tarix yoxdur. +No Fill Doldurma yoxdur +No geometry to calculate Hesablanacaq həndəsə yoxdur No granules in this view and date range. Bu görünüşdə və tarix aralığında qranul yoxdur. no home folder ev qovluğu yoxdur no layer lay yoxdur @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) Əlaqə yoxdur (Məlumat dizayn No relationships yet Hələ əlaqə yoxdur No reports yet. Publish › New Report lists the selected layer's records. Hələ hesabat yoxdur. Nəşr › Yeni hesabat seçilmiş layın qeydlərini sadalayır. no row sətir yoxdur +No serif font is installed, so this text is drawn in the sans-serif Serif şrift quraşdırılmayıb, ona görə bu mətn sans-serif şriftlə çəkilir no service or file links xidmət və ya fayl keçidi yoxdur No services match your search. Axtarışınıza uyğun xidmət yoxdur. No standalone tables Müstəqil cədvəl yoxdur @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Heç biri None (public buckets) Yoxdur (ictimai bucket-lər) none, a .duckdb file, or host=… dbname=… user=… yoxdur, .duckdb faylı və ya host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD yoxdur və ya PGPASSWORD ya da MYSQL_PWD-dən +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD yoxdur və ya PGPASSWORD, MYSQL_PWD ya da SQLCMDPASSWORD-dən None. Add one by address, or from a basemap plugin. Heç biri. Ünvanla və ya əsas xəritə plaginindən əlavə edin. North Şimal North\nArrow Şimal\noxu North Polar Stereographic Şimal qütb stereoqrafik +NOT NOT not a picture şəkil deyil not a RustGIS layout RustGIS tərtibatı deyil not a RustGIS project RustGIS layihəsi deyil +Not applied yet. Hələ tətbiq olunmayıb. Not for texts: a text is edited in the Format pane. Mətnlər üçün deyil: mətn Format panelində redaktə edilir. not needed lazım deyil Not sandboxed Qum qutusundan kənar @@ -1739,9 +1906,12 @@ Nothing found where the map is Xəritənin olduğu yerdə heç nə tapılmadı Nothing here Burada heç nə yoxdur Nothing named {name} to rename Adını dəyişmək üçün {name} adlı heç nə yoxdur Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Hələ heç nə paylaşılmayıb. Nəşr › Fərqli paylaş veb xəritələr, laylar, üslublar, səhnələr və s. yaradır. +Nothing to add here Burada əlavə ediləcək heç nə yoxdur +Nothing to test Yoxlanılacaq heç nə yoxdur Nothing was found at this address. Bu ünvanda heç nə tapılmadı. Nothing was recorded, so no task was made. Heç nə yazılmadı, buna görə tapşırıq yaradılmadı. Now İndi +Nugget Nugget NULL NULL Nulls Boş qiymətlər Observations (observer, date, species, count) Müşahidələr (müşahidəçi, tarix, növ, say) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. Masaüstündə açar {vars} d On-screen compass, zoom slider and pan pad. Ekran kompası, miqyas sürüşdürücüsü və sürüşdürmə paneli. One command per line: id {json}. Lines starting with # are comments. Hər sətirdə bir əmr: id {json}. # ilə başlayan sətirlər şərhdir. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Bir DuckDB SELECT: DuckDB Spatial-ın ST_ funksiyaları, fayllar və veb ünvanlar (read_parquet('…')) və aşağıdakı verilənlər bazasının cədvəlləri. Ctrl+Enter onu işə salır. +One layer: a Zarr variable or a raster's bands Bir lay: Zarr dəyişəni və ya rastrın kanalları One page per feature of an index layer. İndeks layının hər obyekti üçün bir səhifə. One per line: code=name Hər sətirdə bir: kod=ad One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Simvoldan bir cizgi. Haşiyələr, paralel xətlər və sürüşmələr üçün cizgilər əlavə edin. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Qovluğu aç Open In Geoprocessing… Geoemalda aç… Open in ModelBuilder Model qurucusunda aç +Open in Tool Alətdə aç Open Layout Tərtibatı aç Open ModelBuilder Model qurucusunu aç Open Notebook Bloknotu aç @@ -1797,6 +1969,7 @@ Open Presentation Təqdimatı aç Open Project Layihəni aç Open project Layihəni aç Open Project (from text) Layihəni aç (mətndən) +Open Project… Layihəni aç… Open Report Hesabatı aç Open the attribute table of the selected layer. Seçilmiş layın atribut cədvəlini açın. Open the Basemaps tab Əsas xəritələr tabını aç @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Və ya Claude Code ya da Code Or run in PowerShell, for Claude Code or Codex: Və ya Claude Code ya da Codex üçün PowerShell-də işə salın: Or run, for Claude Code or Codex: Və ya Claude Code və ya Codex üçün bunu işlədin: ORDER BY ORDER BY +Ordinary Kriging Adi kriqinq Orientation Oriyentasiya Ortho Mapping Orto xəritələşdirmə Other Digər Other Data Digər məlumatlar +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Bu laydakı digər etiket sinifləri (əmrlərlə yaradılmış): {n}. Hər biri öz şərtinə uyğun gələn obyektləri etiketləyir, ona görə bir obyekt birdən çox sinifdən etiket ala bilər. Out Beyond Uzaqlaşma həddi Outline Kontur Outline another frame's extent in this map frame. Bu xəritə çərçivəsində başqa çərçivənin hüdudunu kontur ilə göstərin. @@ -1851,6 +2026,7 @@ Output Çıxış Output Name Çıxış adı Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Təxminən 25 MB-lıq birdəfəlik endirmə ilə brauzerdə işləyən 1000-dən çox raster, vektor, LiDAR, hidrologiya və relyef aləti. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Təxminən 20 MB-lıq istəyə bağlı endirmə ilə 1000-dən çox raster, vektor, LiDAR, hidrologiya və relyef aləti. +Overlapping Kəsişən Overlay Üst-üstə qoyma overlay üst-üstə qoyma Overlay (a transparent layer kept on top, such as labels or roads) Üst-üstə qoyma (yazılar və ya yollar kimi yuxarıda saxlanan şəffaf lay) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture-un xəritənin olduğu yerd Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Overture Maps-in açıq məlumatı: binalar, yerlər, nəqliyyat, inzibati bölgülər, əsas laylar və ünvanlar. Xəritənin olduğu yerdə obyektlər Overture-un GeoParquet fayllarından yerində oxunur (şəhər və ya daha kiçik əraziyə yaxınlaşdırın); bütün dünya xəritə kafelləri kimi gəlir. Overture Maps… Overture Maps… +Overwrite existing values Mövcud qiymətlərin üzərinə yaz Package Paket Package a trained deep learning model. Təlim keçmiş dərin öyrənmə modelini paketləyin. Package Map Xəritəni paketlə @@ -1907,8 +2084,12 @@ Performance Performans Phase Faza Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Miqyas və ya tarix kimi canlı qiymət seçin, sonra yerləşdirmək üçün xəritəyə klikləyin. +Pick a Point Nöqtə seç +Pick a point on the map to see the variable's values through time there. Dəyişənin orada zamana görə qiymətlərini görmək üçün xəritədə nöqtə seçin. Pick a route in the list, or click a grey route on the map. Siyahıdan marşrut seçin və ya xəritədəki boz marşruta klikləyin. +Pick a single layer. Tək bir lay seçin. Pick it on the map Xəritədə seç +Pick one… Birini seçin… Picture Şəkil Pictures Şəkillər Pictures, attachments and chart Şəkillər, qoşmalar və diaqram @@ -1966,8 +2147,11 @@ Portal Portal Portolan Portolan Portolan… Portolan… Portrait Kitab +Position Mövqe +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Sonrakı emal power 2 qüvvət 2 +Precision 6 (OSRM, Valhalla) Dəqiqlik 6 (OSRM, Valhalla) Prepare Hazırla Prepare every layer's geometry for this projection. Hər layın həndəsəsini bu proyeksiya üçün hazırlayın. Prepare Plugin Environment Plagin mühitini hazırla @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Layihə şablonları: layi project's path is required layihənin yolu tələb olunur Projection Proyeksiya Projections Proyeksiyalar +Properties Xüsusiyyətlər +Property Xüsusiyyət Proportional Symbols Mütənasib simvollar Provider Provayder +Provinces: Əyalətlər: Proximity Yaxınlıq Public GIS portals of US counties and cities, by state. ABŞ qraflıqlarının və şəhərlərinin açıq CİS portalları, ştatlar üzrə. Public GIS portals of US federal agencies, by department. ABŞ federal agentliklərinin açıq CİS portalları, idarələr üzrə. Public GIS portals of US states and the District of Columbia. ABŞ ştatlarının və Kolumbiya dairəsinin açıq CİS portalları. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. ArcGIS Online və ya digər ArcGIS portalındakı açıq laylar: obyekt, xəritə, təsvir və vektor kafel xidmətləri, veb xəritələr və GeoJSON faylları. +Publication Layout Nəşr tərtibatı Publish Nəşr Publish › Output Nəşr › Çıxış Put the layer back as it was then Layı o vaxtkı vəziyyətinə qaytar Python environment failed: {error} Python mühiti alınmadı: {error} Python environment not prepared Python mühiti hazırlanmayıb Python environment ready Python mühiti hazırdır +Python script Python skripti Qualitative Keyfiyyət Quantile Kvantil Query files, web addresses and databases with DuckDB Spatial; add results to the map. DuckDB Spatial ilə fayllara, veb ünvanlara və verilənlər bazalarına sorğu göndərin; nəticələri xəritəyə əlavə edin. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Poliqonları hündürlüyə və ya sahəy Raise Priority Prioriteti artır Random points in polygons Poliqonlarda təsadüfi nöqtələr Range Diapazon +Range (Degrees) Diapazon (dərəcə) +range: 0, 0.9 diapazon: 0, 0.9 Raster Rastr Raster\nAnalysis Rastr\nanalizi Raster\nInformation Rastr\nməlumatı @@ -2075,6 +2266,7 @@ Redo {action} {action} təkrarla Red–Blue Qırmızı–Mavi Refine the search to see {n} more Daha {n} ədəd görmək üçün axtarışı dəqiqləşdirin Refresh Yenilə +Refresh every connection's lists Hər bağlantının siyahılarını yenilə Refresh Map Series Xəritə seriyasını yenilə Refresh the map series from its index layer. Xəritə seriyasını indeks layından yeniləyin. Region Region @@ -2091,11 +2283,10 @@ Relationships Əlaqələr Release Buraxılış Reload Yenidən yüklə Reload Web Layers Veb layları yenidən yüklə -Remember (without the secret) Yadda saxla (gizli açar olmadan) Remember the key on this computer Açarı bu kompüterdə yadda saxla -Remembered: Yadda saxlanmış: Remove Sil Remove All Hamısını sil +Remove All Label Classes Bütün etiket siniflərini sil Remove an offline area. Oflayn ərazini silin. Remove Bookmark Əlfəcini sil Remove Connection Bağlantını sil @@ -2108,11 +2299,14 @@ Remove from Favourites Seçilmişlərdən sil Remove From Group Qrupdan çıxar Remove Group Qrupu sil Remove Keyframe Açar kadrı sil +Remove Label Class Etiket sinfini sil Remove Section Bölməni sil Remove Table Cədvəli sil +Remove the clause. Şərti silin. Remove the last keyframe. Son açar kadrı silin. Remove the layer from the map. Layı xəritədən silin. Remove the strokes and draw the symbol's line alone Cizgiləri sil və yalnız simvolun xəttini çək +Remove this class's own strokes and draw it with the layer's Bu sinfin öz cizgilərini sil və onu layın cizgiləri ilə çək Remove this stop Bu dayanacağı sil Remove Tile Layer Kafel layını sil Remove token Tokeni sil @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Toleran Rename Adını dəyiş Rename Bookmark Əlfəcinin adını dəyiş Rename Group Qrupun adını dəyiş +Rename it to save it: a connection has this name Saxlamaq üçün adını dəyişin: bu adda bağlantı var +Rename Label Class Etiket sinfinin adını dəyiş Rename Layer Layın adını dəyiş Rename Layout Tərtibatın adını dəyiş Rename Map Xəritənin adını dəyiş @@ -2149,6 +2345,7 @@ Reset Default Standartı sıfırla Reset Effects Effektləri sıfırla Reset North Şimala sıfırla Reset north Şimala sıfırla +Resolution (0–30) Çözümlülük (0–30) Response Cavab Restore Bərpa et Restore From Archive Arxivdən bərpa et @@ -2162,6 +2359,7 @@ RGB RGB Ribbon Lent Right Sağ Right-click a source to add it here Mənbəni bura əlavə etmək üçün onun üzərində sağ klikləyin +Right-click for more Daha çoxu üçün sağ klikləyin Robinson Robinson Rotate Döndər Rotate (°) Döndər (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Nümunə məlumat (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Nümunə məlumat © Natural Earth (ictimai mülkiyyət). RustGIS Apache-2.0 lisenziyası altındadır. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Nümunə məlumat: Natural Earth (ictimai mülkiyyət). Apache-2.0 lisenziyası altındadır. +Sans-serif Sans-serif Satellite Embedding Datasets Peyk embeddinqi məlumat dəstləri Satellite Embeddings Peyk embeddinqləri Satellite Embeddings… Peyk embeddinqləri… Saturation Doyğunluq Save Saxla Save As Fərqli saxla +Save as Connection Bağlantı kimi saxla Save as Default Standart kimi saxla Save as Default Raster Style Standart rastr üslubu kimi saxla Save as Layer Lay kimi saxla Save As Presentation File… Təqdimat faylı kimi saxla… +Save as Python Script… Python skripti kimi saxla… Save As Report File… Hesabat faylı kimi saxla… Save As Report Template… Hesabat şablonu kimi saxla… Save as {name} {name} kimi saxla Save As… Fərqli saxla… Save Color Ramps Rəng şkalalarını saxla Save Data Source Məlumat mənbəyini saxla +Save History As Python Script Tarixçəni Python skripti kimi saxla Save Layer File Lay faylını saxla Save Layout File Tərtibat faylını saxla Save Map File Xəritə faylını saxla @@ -2254,6 +2456,7 @@ save or load a pane set by name panel dəstini adla saxlayın və ya yükləyin Save Project Layihəni saxla Save project Layihəni saxla Save Project As Layihəni fərqli saxla +Save Project As… Layihəni fərqli saxla… Save Project Template Layihə şablonunu saxla Save Style Items Üslub elementlərini saxla Save Task Item Tapşırıq elementini saxla @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Modeli fayl kimi saxla (*.model.json) Save the model in the project Modeli layihədə saxla Save the open layout as a layout file. Açıq tərtibatı tərtibat faylı kimi saxlayın. Save the panes you have open, and switch back to them. Açıq panellərinizi saxlayın və onlara geri qayıdın. +Save the project too: the script opens its saved file, without changes made since. Layihəni də saxlayın: skript onun saxlanmış faylını, sonradan edilən dəyişikliklər olmadan açır. Save the project under a new name. Layihəni yeni adla saxlayın. Save the project with your edits. Layihəni redaktələrinizlə birlikdə saxlayın. Save the project's custom color ramps to a color ramp file. Layihənin fərdi rəng şkalalarını rəng şkalası faylına saxlayın. @@ -2275,13 +2479,16 @@ Save Web Link Chart Veb əlaqə diaqramını saxla Save Web Map Veb xəritəni saxla Save Web Scene Veb səhnəni saxla Saved Saxlanıldı +Saved as the connection {name} {name} bağlantısı kimi saxlanıldı Saved as the default style for new single-band rasters. Yeni tək zolaqlı rastrlar üçün standart üslub kimi saxlanıldı. +Saved connection Saxlanmış bağlantı Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Saxlanılan açarlar tətbiqin tənzimləmələrində şifrələnmədən saxlanılır (vebdə brauzerin yaddaşında). Saved models Saxlanmış modellər Saved symbology: apply it to the selected layer, or save the layer's. Saxlanmış simvolika: onu seçilmiş laya tətbiq edin və ya layınkını saxlayın. Saved View Saxlanmış görünüş Saved {name} {name} saxlanıldı Saved {name}. {name} saxlanıldı. +Saved {path} Saxlanıldı: {path} saving failed unexpectedly saxlama gözlənilmədən uğursuz oldu Scale Miqyas Scale\nBar Miqyas\nxətkeşi @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Axtarış (bu portal sözlər tələb edir) Search Catalogue Kataloqda axtar Search commands — e.g. buffer, basemap, labels Əmr axtarın — məs. bufer, əsas xəritə, etiketlər Search datasets, e.g. GEDI biomass Məlumat dəstlərində axtar, məs. GEDI biomass -Search Distance (km) Axtarış məsafəsi (km) +Search Distance Axtarış məsafəsi Search every command and its parameters Hər əmri və onun parametrlərini axtarın Search layers (e.g. temperature) Laylarda axtar (məs. temperature) Search layers, places, lat/lon… Lay, yer, en/uzunluq axtar… @@ -2329,6 +2536,8 @@ Searching the folders… Qovluqlarda axtarılır… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. Axtarış burada işləyir, lakin NASA-nın məlumat serverləri veb səhifələrin onların fayllarını oxumasına icazə vermir (CORS yoxdur), ona görə GeoTIFF-lər və qorunan məlumatların endirilməsi masaüstü tətbiq tələb edir. «Endir» faylı yeni vərəqdə açır və orada Earthdata Login sizdən daxil olmağı xahiş edir. Searching… Axtarılır… Searching… try again in a moment. Axtarılır… bir azdan yenidən cəhd edin. +Seasonal Mann–Kendall Mövsümi Mann–Kendall +Seasons a year İldə mövsüm sayı Second standard parallel İkinci standart paralel Seconds Saniyə Secret access key Gizli giriş açarı @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Ayırıcı Sepia Sepiya Sequential Ardıcıl +Serif Serif Server Server Service Group Xidmət qrupu Service group data exceeds 64 MB. Xidmət qrupunun məlumatları 64 MB həddini keçir. Service Import Requests Xidmət idxal sorğuları Service Layers Xidmət layları -Services Xidmətlər +Session token Sessiya tokeni Set Təyin et Set Cell Size Xana ölçüsünü təyin et Set Domain Domen təyin et @@ -2411,6 +2621,7 @@ Shaded Kölgəli Shaded Relief Kölgəli relyef Shape Forma Shapefile Shapefile +Shapes Fiqurlar Share Paylaş Share As Fərqli paylaş Share every layer and table as OGC API Features collections. Hər layı və cədvəli OGC API Features kolleksiyaları kimi paylaşın. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Brauzer görünüşündə Show different features at different scales. Müxtəlif miqyaslarda müxtəlif obyektləri göstərin. Show grid Toru göstər Show Group Qrupu göstər +Show labels Etiketləri göstər Show latitude/longitude lines on the basemap. Əsas xəritədə en/uzunluq xətlərini göstərin. +Show Layers Layları göstər Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Layları zamanda göstər: yalnız tarixi sürgünün pəncərəsinə düşən obyektlər və onun tarixinə aid tarixli təsvirlər. Show Map Xəritəni göstər Show Me Mənə göstər @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Alətlər panelini göstərin və ya gizlədin. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Xətkeşləri göstərin. Bələdçi xətt əlavə etmək üçün xətkeşdən dartın; silmək üçün bələdçi xətti xətkeşə geri dartın. Show the bands picked, read in place as the map needs them Seçilmiş kanalları göstər, xəritəyə lazım olduqca yerində oxunur Show the description Təsviri göstər +Show the Labeling pane for the selected layer. Seçilmiş lay üçün Etiketləmə panelini göstərin. Show the labels that don't fit, in red. Sığmayan etiketləri qırmızı rəngdə göstərin. Show the layer only inside another layer's polygons. Layı yalnız başqa layın poliqonları daxilində göstərin. Show the layer only left of a divider, to compare it with what's beneath. Altındakı ilə müqayisə etmək üçün layı yalnız ayırıcının solunda göstərin. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Giriş ləğv edildi. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. Giriş üçün vaxt bitdi. Yenidən başlayın və brauzerin bu kompüterin geri döngə ünvanına çatmasına icazə verin. signed in daxil olunub Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Bu kompüterdə çıxış edildi, lakin OpenAI bunu təsdiqləmədi. RustGIS-i ChatGPT ayarlarında ayıra bilərsiniz. +Significance level Əhəmiyyətlilik səviyyəsi Signing in with ChatGPT works in the desktop app. ChatGPT ilə giriş masaüstü tətbiqində işləyir. Signing the asset (Planetary Computer)… Resurs imzalanır (Planetary Computer)… +Sill Astana Simplify Sadələşdir Simulate the drive, or save the route as a layer. Sürüşü simulyasiya edin və ya marşrutu lay kimi saxlayın. Simulation Simulyasiya @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} Sİ köməkçisi üçün bacarıqlar: {name Slideshow, PDF or PNG Slayd şou, PDF və ya PNG slight left azca sola dönün slight right azca sağa dönün +Slope per Meyl vahidi Slope, aspect, hillshade, NDVI, band math, thresholds. Meyl, ekspozisiya, relyef kölgəsi, NDVI, kanal riyaziyyatı, hədlər. Slope, hillshade, NDVI and more. Meyl, relyef kölgəsi, NDVI və s. Small scales Kiçik miqyaslar @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Cənub South Polar Stereographic Cənub qütb stereoqrafik +Spatial Join Məkan birləşməsi Speaker notes Natiq qeydləri Spectral Analysis Spektral analiz Spectral indices Spektral indekslər @@ -2530,6 +2748,7 @@ Split (click the line) Böl (xəttə klikləyin) SQL SQL SQL Pane SQL paneli SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL sorğuları masaüstü tətbiqin çağırdığı DuckDB proqramı ilə icra olunur: sorğu üçün bu layihəni masaüstü tətbiqdə açın. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 Doğru olmalı olan SQL, məs. pop > 0 Square Kvadrat Squares Kvadratlar @@ -2588,6 +2807,7 @@ Start over at the end while playing. Oynadılarkən sonda yenidən başla. Start With Bununla başla Start-up Başlanğıc starting with a project file needs the desktop app layihə faylı ilə başlamaq üçün masaüstü tətbiq lazımdır +Statistic Statistika Statistics Statistika Statistics Field Statistika sahəsi Status Vəziyyət @@ -2613,8 +2833,10 @@ Stretch\nType Genişləndirmə\ntipi Stretch it over the map view, or give control points (one per line: column row lon lat). Onu xəritə görünüşünə uyğun dartın və ya nəzarət nöqtələri verin (hər sətirdə bir: sütun sətir uzunluq enlik). Stretch Type Genişləndirmə tipi Stretch type Genişləndirmə tipi +Stroke Levels Cizgi səviyyələri Stroke {n} Cizgi {n} Strokes Cizgilər +Strokes for Cizgilərin aid olduğu Style Üslub Style Items Üslub elementləri Style › Drawing Üslub › Çəkmə @@ -2624,7 +2846,10 @@ Subreport: {name} Alt hesabat: {name} Subtypes Alt tiplər Subtypes, one per line: code=name Alt tiplər, hər sətirdə bir: kod=ad Suitability Modeler Uyğunluq modelləşdiricisi +Summarise Yekunlaşdır +Summarise over time Zaman üzrə yekunlaşdır Summarise the conversation so far, so a long chat stays within the model's context Uzun söhbət modelin kontekstinə sığsın deyə indiyədək olan söhbəti xülasə et +Summarise Zarr Over Time Zarr-ı zaman üzrə yekunlaşdır Summarising the earlier conversation to keep it within the model's context… Əvvəlki söhbət modelin kontekstinə sığması üçün xülasə edilir… Summarize Yekunlaşdır Summarize\nWithin Daxilində\nyekunlaşdır @@ -2658,6 +2883,8 @@ Table or layer Cədvəl və ya lay Tables Cədvəllər Tags Teqlər Take the data in view offline, to edit and sync back later. Görünüşdəki məlumatları sonra redaktə edib sinxronlaşdırmaq üçün oflayn götürün. +Target Features Hədəf obyektlər +Target Records Hədəf qeydlər Task Tapşırıq Task Item Tapşırıq elementi Task name Tapşırığın adı @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Tennessinin açıq CİS məlumatları (Tennessee GeoData, geodata.tn.gov): mülk sərhədləri, nəqliyyat, səhiyyə, hidrologiya və s. Terrain Relyef Test Test +Test Connection Bağlantını yoxla +Testing… Yoxlanılır… Text Mətn Text Symbol Mətn simvolu That connection would create a loop. Bu bağlantı dövr yaradardı. The address fields are empty in every row. Ünvan sahələri bütün sətirlərdə boşdur. +The answer isn't UTF-8 text Cavab UTF-8 mətni deyil the answer isn't UTF-8 text cavab UTF-8 mətni deyil The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. Köməkçi bacarıqdan istifadə edərkən onun təlimatlarına əməl edir. Nə deyirlərsə desinlər, təsdiqinizi tələb edən əmrləri yenə də soruşur. The assistant is busy; stop it or wait for it to finish. Köməkçi məşğuldur; onu dayandırın və ya bitirməsini gözləyin. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. Əsas xəritələr plagininin icti the capture failed unexpectedly çəkiliş gözlənilmədən uğursuz oldu The connection failed; trying again in {seconds} s. Bağlantı alınmadı; {seconds} san sonra yenidən cəhd ediləcək. The country list couldn't be read ({e}): type a country's code Ölkə siyahısını oxumaq mümkün olmadı ({e}): ölkə kodunu yazın +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. Girişləri redaktə edərkən cari hündürlük qüvvədə qalır. Qiyməti və vahidləri tətbiq etmək üçün «Hündürlüyü tətbiq et» düyməsinə klikləyin. The dataset has nothing RustGIS can add Məlumat dəstində RustGIS-in əlavə edə biləcəyi heç nə yoxdur The environment (AWS_ACCESS_KEY_ID…) Mühit (AWS_ACCESS_KEY_ID…) The features where the map is Xəritənin olduğu yerdəki obyektlər @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. Flow The installed GeoLibre runner lists no tools. Quraşdırılmış GeoLibre işlədicisi heç bir alət göstərmir. The job covers the map view İş xəritə görünüşünü əhatə edir The key is kept for this session only. Açar yalnız bu sessiya üçün saxlanılır. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. Lay zaman sürgüsünün olduğu addımı göstərir və sürgünü dəyişənin tarixləri üzrə yandırır. The layer's file couldn't be read: {error} Layın faylı oxuna bilmədi: {error} +The layer's strokes Layın cizgiləri the layout export failed unexpectedly tərtibatın ixracı gözlənilmədən uğursuz oldu The link doesn't say its STAC API: Find Items searches {api} Keçid öz STAC API-sini göstərmir: «Elementləri tap» {api} üzrə axtarır The list of plugins to browse. Leave it empty for the RustGIS plugin registry. Baxmaq üçün plaginlərin siyahısı. RustGIS plagin reyestri üçün boş saxlayın. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. Xəritə görünüşündən layihən The projection the map opens in. Xəritənin açıldığı proyeksiya. The provider's content filter stopped the reply. Provayderin məzmun filtri cavabı dayandırdı. The public server is free and shared by many: it answers light use, and may ask you to wait. İctimai server pulsuzdur və çoxları tərəfindən istifadə olunur: yüngül istifadəyə cavab verir və sizdən gözləməyi xahiş edə bilər. +The range is two numbers, low and high (0, 0.9) Diapazon iki ədəddir, aşağı və yuxarı (0, 0.9) The raster's data couldn't be read. Rastrın məlumatı oxuna bilmədi. The raster's data couldn't be read: {path} Rastrın məlumatı oxuna bilmədi: {path} the release isn't known buraxılış məlum deyil @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Shapefile faylları bir zip arxivi ki The share of each cell covered by fields, for the zoomed-out view Hər xananın sahələrlə örtülü payı, uzaqlaşdırılmış görünüş üçün The sign-in expired or was revoked. Sign in again. Girişin müddəti bitib və ya icazə ləğv edilib. Yenidən daxil olun. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. Giriş məlumatı tətbiqin parametrlər qovluğunda yalnız istifadəçi hesabınız üçün saxlanılır, heç vaxt layihə faylında saxlanılmır. +The significance level is a number, such as 0.05 Əhəmiyyətlilik səviyyəsi ədəddir, məsələn 0.05 The start-up project {path} didn't open: {error} {path} başlanğıc layihəsi açılmadı: {error} The start-up project {path} isn't a RustGIS project file. {path} başlanğıc layihəsi RustGIS layihə faylı deyil. The step's layer {name} isn't in the map. Addımın layı {name} xəritədə yoxdur. The store has no variables with two or more dimensions. Anbarda iki və ya daha çox ölçülü dəyişən yoxdur. the store is gone anbar artıq yoxdur +The summary couldn't start: {error} Yekunlaşdırma başlaya bilmədi: {error} +The summary failed unexpectedly Yekunlaşdırma gözlənilmədən uğursuz oldu +The summary finished after another project was opened, so it wasn't added Yekunlaşdırma başqa layihə açıldıqdan sonra başa çatdığı üçün əlavə edilmədi The task's steps changed, so the step being edited was closed: edit it again. Tapşırığın addımları dəyişdi, buna görə redaktə olunan addım bağlandı: onu yenidən redaktə edin. the time slider is off zaman sürgüsü sönülüdür The token expires on {date}. Tokenin müddəti {date} tarixində bitir. +The trend couldn't start: {error} Trend analizi başlaya bilmədi: {error} +The trend failed unexpectedly Trend analizi gözlənilmədən uğursuz oldu +The trend finished after another project was opened, so it wasn't added Trend analizi başqa layihə açıldıqdan sonra başa çatdığı üçün əlavə edilmədi +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. Qaradan ağa göstərilən qiymətlər, hər tarix və kafel üçün eynidir (kanalın öz vahidlərində). Boş: hər faylın öz genişləndirməsi. the view has no extent görünüşün hüdudu yoxdur The view is larger than one read: its centre was read (zoom in for the rest) Görünüş bir oxumadan böyükdür: onun mərkəzi oxundu (qalanı üçün yaxınlaşdırın) The web app always runs the latest release: reload the page to get it. Veb tətbiq həmişə ən son buraxılışla işləyir: onu almaq üçün səhifəni yeniləyin. the window's screenshot didn't come back in time, so the map was drawn offscreen pəncərənin ekran görüntüsü vaxtında gəlmədi, ona görə xəritə ekrandan kənarda çəkildi the window's screenshot didn't hold the map, so it was drawn offscreen pəncərənin ekran görüntüsündə xəritə yox idi, ona görə ekrandan kənarda çəkildi Theme Mövzu +There is no expression defined. Təyin olunmuş ifadə yoxdur. Thermal Termal +Thesis figure, full page (160 × 225 mm) Dissertasiya şəkli, tam səhifə (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Dissertasiya şəkli, yarım səhifə (160 × 110 mm) Thinking… Düşünür… +Third-party licences Üçüncü tərəf lisenziyaları +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins bu yığım plaginləri endirə bilmir this build can't reach AI providers bu yığım Sİ provayderlərinə qoşula bilmir this build saves projects with Save As bu versiya layihələri «Fərqli saxla» ilə saxlayır This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Bu ChatGPT hesabı artıq yadda saxlanılıb. Növbəti dəfə onu yenidən əlavə etmək əvəzinə siyahıdan seçin. +This class is drawn with the layer's strokes. Changing them here gives it its own. Bu sinif layın cizgiləri ilə çəkilir. Onları burada dəyişmək sinfə öz cizgilərini verir. +This class is hidden, so it labels nothing. Bu sinif gizlidir, ona görə heç nəyi etiketləmir. +This clause no longer reads, so no feature is labelled: {error} Bu şərt artıq oxunmur, ona görə heç bir obyekt etiketlənmir: {error} This map has no layers yet. Bu xəritədə hələ lay yoxdur. This map's thumbnail, from an image. Təsvirdən bu xəritənin miniatürü. This map's thumbnail, from the view. Görünüşdən bu xəritənin miniatürü. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Bu sorğu üçün birdəfəlik icazə seçimi yoxdur. This request uses an unsupported capability. Check the parameter and change the request. Bu sorğu dəstəklənməyən funksiyadan istifadə edir. Parametri yoxlayın və sorğunu dəyişin. This result is kept Bu nəticə saxlanılır +This run has no tool form to open Bu icranın açılacaq alət forması yoxdur +This session only (optional) Yalnız bu sessiya (istəyə görə) This step Bu addım This step names no tool to show. Bu addım göstəriləcək alət göstərmir. Thumbnail Miniatür @@ -2789,15 +3041,21 @@ Tilt Map Xəritəni əy Tilt-shift Tilt-shift Tilt-shift blur and film grain. Tilt-shift bulanıqlığı və film dənəvərliyi. Time Zaman +Time\nSeries Zaman\nsırası Time\nSlider Zaman\nsürgüsü Time Fields Zaman sahələri +Time Series Zaman sırası +Time series Zaman sırası +Time Series at a Point Nöqtədə zaman sırası Time Slider Zaman sürgüsü Time Slider Settings Zaman sürgüsü parametrləri Tint by elevation Hündürlüyə görə rənglə Tissot's Indicatrix Tisso indikatrisası Title Başlıq +To Son Toggle Dark Mode Qaranlıq rejimi dəyiş Toggle GPU Rendering GPU təsvirini dəyiş +Toggle Imperial Measurements İngilis ölçü vahidlərini aç/bağla Tolerance (km) Tolerantlıq (km) Tolls Ödənişli yollar Tool Gallery Alət qalereyası @@ -2828,7 +3086,10 @@ Train Deep Learning Model Dərin öyrənmə modelini öyrət Trained models Öyrədilmiş modellər Transparency Şəffaflıq Traverse Poliqonometriya +Trend Analysis Trend analizi +Trend over a series of rasters Rastrlar sırası üzrə trend Triangle Üçbucaq +TRUE TRUE Try again in a moment. Bir azdan yenidən cəhd edin. Try again later. Sonra yenidən cəhd edin. Turn editing on or off for the map. Xəritə üçün redaktəni yandırın və ya söndürün. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map uzunluq və enlik yazın və ya Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Miqyas yazın, məsələn 1:12,345, 12345 və ya 25k, və Enter basın Type an address or coordinates and press Enter, or click the map. Ünvan və ya koordinatlar yazıb Enter basın, ya da xəritəyə klikləyin. Type numbers separated by commas Vergüllə ayrılmış rəqəmlər yazın +Type the keys to log in for this session, then Save. Bu sessiyada daxil olmaq üçün açarları yazın, sonra «Saxla» düyməsini basın. +Type the password or keys to log in for this session, then Save. Bu sessiyada daxil olmaq üçün parolu və ya açarları yazın, sonra «Saxla» düyməsini basın. Typed below Aşağıda yazılan UM UM Unavailable: {reason} Əlçatan deyil: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Zaman sürgüsündən ayır Unbind from Time Slider Zaman sürgüsündən ayır Undo Geri al Undo (Ctrl+Z) Geri al (Ctrl+Z) -Undo / Redo Geri al / Təkrarla Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Geri al yazılmış addımı silmir: yazmanı dayandırdıqdan sonra onu «Addımları redaktə et» bölməsində silin. Undo the last change. Son dəyişikliyi geri alın. Undo {action} {action} geri al @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools GeoLibre alətlərini sil Uninstall Plugin Plagini sil Uninstalled {id}. {id} silindi. Unique Values Unikal qiymətlər +Unit Vahid United States ABŞ Units Vahidlər Units per step Addım başına vahid @@ -2877,6 +3140,7 @@ Up a level Bir səviyyə yuxarı Update Yenilə Update Bookmark Əlfəcini yenilə Update Job İşi yenilə +Update Label Class Etiket sinfini yenilə Update to {version} {version} versiyasına yenilə Upload File Fayl yüklə Upload File… Fayl yüklə… @@ -2895,6 +3159,7 @@ Use another account Başqa hesabdan istifadə et Use Current View Cari görünüşdən istifadə et Use each feature's line color Hər obyektin xətt rəngindən istifadə et Use each feature's line width Hər obyektin xətt qalınlığından istifadə et +Use the Layer's Strokes Layın cizgilərindən istifadə et USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan ChatGPT planı istifadə olunur Utilities Kommunal şəbəkələr Valid combinations, one per line: a, b Etibarlı kombinasiyalar, hər sətirdə bir: a, b Valid expression · {n} matching rows İfadə düzgündür · {n} uyğun sətir +Valid: {n} of {of} features match Düzgündür: {of} obyektdən {n} uyğun gəlir Validate Yoxla Validate Model Modeli yoxla value (empty: no-data) qiymət (boş: məlumat yoxdur) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Vantor-un fəlakətlərə aid açıq təsvirləri: hadisə və faza (ondan əvvəl və ya sonra) seçin, sonra «Elementləri tap». Vantor… Vantor… Variable Dəyişən +Variogram Model Varioqram modeli Vector and raster data Vektor və rastr məlumatı Vector Layer Vektor layı +vector tiles vektor kafellər Vector tiles (MVT) Vektor kafellər (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Geoməkan əsas modelinin hər piksel və ya təsvir fraqmenti üçün yaratdığı vektorlar; xəritənin olduğu yerdə tapılır və rənglər kimi göstərilir. Version {version} Versiya {version} @@ -2933,6 +3201,9 @@ Video file or URL Video faylı və ya URL Video Page Video səhifəsi Videos Videolar Video… Video… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. WGS 84-ə 7 parametrli Helmert çevirməsi ilə 3 dərəcəlik Transverse Mercator istifadə edən Vyetnam milli koordinat sistemi (VN-2000). +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Vyetnam milli koordinat sistemi (VN-2000) UTM zonası 48N (102° ş.u.–108° ş.u., mərkəzi meridian 105° ş.u.). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Vyetnam milli koordinat sistemi (VN-2000) UTM zonası 49N (108° ş.u.–114° ş.u., mərkəzi meridian 111° ş.u.). View Görünüş View Clipping Görünüşün kəsilməsi View Effects Görünüş effektləri @@ -2952,6 +3223,9 @@ Visibility Range Görünmə diapazonu Visible Görünən Visible Layers Görünən laylar Visual Effect Vizual effekt +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vyetnam) +VN-2000 / UTM zone 48N VN-2000 / UTM zonası 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zonası 49N Volume Həcm Waiting for the browser… Brauzer gözlənilir… Waiting for your answer Cavabınızı gözləyir @@ -2970,6 +3244,7 @@ Web Link Chart Veb əlaqə diaqramı Web maps Veb xəritələr Web Mercator Web Mercator Web Scene Veb səhnə +Web service (detected) Veb xidmət (aşkarlandı) Web Style Veb üslub Web Tile Layers Veb kafel layları Web Tool Veb alət @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Etiketlərin obyektlərinə nisbət Where new features get their elevation (the z field). Yeni obyektlərin hündürlüyünü haradan aldığı (z sahəsi). Where the map opens: the full extent or a view you saved. Xəritənin harada açılacağı: tam əhatə və ya saxladığınız görünüş. Where tools work Alətlərin işlədiyi ərazi +Whether the Select tool picks these layers' features on the map Seç alətinin xəritədə bu layların obyektlərini seçib-seçməməsi Which 5 countries have the largest GDP? Hansı 5 ölkənin ÜDM-i ən böyükdür? Whitebox and GeoLibre tools Whitebox və GeoLibre alətləri Whole map Bütün xəritə @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Dünya, bərabərsahəli: həq World, equal-area: true sizes, shapes bend towards the edges. Dünya, bərabərsahəli: həqiqi ölçülər, formalar kənarlara doğru əyilir. Worldview Sərhədlər Write a plugin Plagin yaz +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Sen meylini, testin Z və p-qiymətini, həmçinin trendin əhəmiyyətli olduğu yerlərdə meyli yazır. X (mm) X (mm) X column X sütunu X Field X sahəsi @@ -3050,7 +3327,6 @@ Yours Sizin YYYY-MM-DD İİİİ-AA-GG YYYY-MM-DD, for the layers that change with time İİİİ-AA-GG, zamanla dəyişən laylar üçün YYYY-MM-DD, for time layers İİİİ-AA-GG, zaman layları üçün -z (m) z (m) Z factor Z əmsalı Zarr Zarr Zarr Variables Zarr dəyişənləri @@ -3061,7 +3337,6 @@ Zoom Box Çərçivə ilə yaxınlaşdır Zoom Frame Çərçivəni miqyasla Zoom In Yaxınlaşdır Zoom in Yaxınlaşdır -Zoom in / out Yaxınlaşdır / uzaqlaşdır Zoom In Here Burada yaxınlaşdır Zoom in on the centre of the map. Xəritənin mərkəzində yaxınlaşdırın. Zoom in · finish a sketch Yaxınlaşdır · eskizi bitir @@ -3073,6 +3348,7 @@ Zoom To\nLayer Laya\nyaxınlaşdır Zoom to a box (Explore) Çərçivəyə yaxınlaşdır (Kəşf) Zoom To Extent Hüduda yaxınlaşdır Zoom To Layer Laya yaxınlaşdır +Zoom To Layers Laylara yaxınlaşdır Zoom To Map View Xəritə görünüşünə yaxınlaşdır Zoom To Selection Seçimə yaxınlaşdır Zoom to selection Seçimə yaxınlaşdır @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Layın hüduduna yaxınlaşdırın. Zoom to the selected features. Seçilmiş obyektlərə yaxınlaşdırın. Zoom to the whole world. Bütün dünyaya yaxınlaşdırın. Zoom to this bookmark Bu əlfəcinə yaxınlaşdır +Zoom To Tile Layer Kafel layına yaxınlaşdır Zoom to Tokyo Tokioya yaxınlaşdır Zoom To World Dünyaya yaxınlaşdır +Zoom {z} · Bearing {b}° · Pitch {p}° Miqyas səviyyəsi {z} · Azimut {b}° · Meyl {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Miqyas səviyyəsi: veb xəritələrin saydığı kimi xəritənin yaxınlaşma səviyyəsi. Azimut: xəritənin yuxarı tərəfinin baxdığı istiqamət, şimaldan saat əqrəbi istiqamətində. Meyl: düz aşağı baxışdan əyilmə. Azimutu və meyli sıfırlamaq üçün klikləyin. {agent} was stopped by you. {agent} sizin tərəfinizdən dayandırıldı. {id} changed since it was installed; install it again from the Plugin Manager. {id} quraşdırıldıqdan sonra dəyişib; onu Plagin menecerindən yenidən quraşdırın. {names}: needs Node.js (npx) for its ACP adapter. {names}: ACP adapteri üçün Node.js (npx) lazımdır. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 77093bb26..b581c9097 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6,6 +6,7 @@ (filtered) (gefiltert) + Add Stop + Zwischenziel hinzufügen + Input + Eingabe ++ New expression + Neuer Ausdruck + Output + Ausgabe /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh 3D-Mesh A 3D globe seen from space. Ein 3D-Globus, aus dem All gesehen. A blank map with a basemap Eine leere Karte mit Grundkarte +A call for the rustgis Python package Ein Aufruf für das Python-Paket rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. Die Adresse eines Katalogs (CSW 2.0.2): seine Einträge nach Wörtern und, wenn Sie möchten, nach dem Kartenbereich durchsuchen, dann die verknüpften Dienste und Dateien hinzufügen. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. Ein Nutzungslimit des ChatGPT-Abos oder der App wurde erreicht. Prüfen Sie die wöchentliche Nutzung vor einem erneuten Versuch. A ChatGPT sign-in can only be used with the OpenAI API. Eine ChatGPT-Anmeldung kann nur mit der OpenAI-API verwendet werden. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). Die Dat A globe scene. Eine Globusszene. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Breite und Länge (40.7, -74.0 oder 40°42'N 74°0'W), ein Layer, ein Lesezeichen oder ein Wert in der Tabelle eines Layers. Enter wählt die hervorgehobene Zeile; Orte suchen sucht den Text als Ort A layer the model reads Ein Layer, den das Modell liest +A line for the Script pane Eine Zeile für den Skriptbereich A magnifying lens that follows the pointer. Eine Lupe, die dem Mauszeiger folgt. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). Die Adresse eines Karten-, Objekt- oder Coverage-Dienstes: WMS, WMTS, WFS, WCS, OGC API Features oder Tiles oder ein ArcGIS FeatureServer, MapServer, ImageServer oder VectorTileServer (oder ein Dienstverzeichnis zum Durchsuchen). a name, or owner/name such as giswqs/geospatial ein Name oder Besitzer/Name, etwa giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Ein benannter Endpunkt zur Wiederverwendung. Passwörter und Schlüssel werden aufbewahrt, bis RustGIS geschlossen wird, und nie gespeichert. A node cannot connect to itself. Ein Knoten kann nicht mit sich selbst verbunden werden. A notebook of text and command cells. Ein Notizbuch aus Text- und Befehlszellen. +A password or token in the address was left out: connections never keep secrets. Ein Passwort oder Token in der Adresse wurde weggelassen: Verbindungen speichern nie Geheimnisse. A place or an address Ein Ort oder eine Adresse A project keeps at least one map Ein Projekt behält mindestens eine Karte A projected coordinate system. Ein projiziertes Koordinatensystem. A report listing the selected layer's records, page after page. Ein Bericht, der die Datensätze des ausgewählten Layers Seite für Seite auflistet. A result that only feeds other tools is removed once the model has run; add an output to keep it. Ein Ergebnis, das nur andere Werkzeuge speist, wird nach dem Modelllauf entfernt; fügen Sie eine Ausgabe hinzu, um es zu behalten. A result the model keeps, under a name Ein Ergebnis, das das Modell unter einem Namen behält +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) Ein rustgis-cli-Befehl für eine POSIX-Shell (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Ein Dienstimport oder eine Aktualisierung läuft bereits. +A service's layers stay in their service group. Die Layer eines Dienstes bleiben in ihrer Dienstgruppe. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Eine Aufgabe umfasst höchstens 200 Schritte: Beenden Sie die Aufzeichnung, um sie zu erstellen, und zeichnen Sie den Rest als weitere Aufgabe auf. A template with the first selected feature's values Eine Vorlage mit den Werten des ersten ausgewählten Objekts +A tile layer per grid tile, each following the time slider through its dates. Ein Kachel-Layer pro Gitterkachel, der jeweils dem Zeitschieberegler durch seine Daten folgt. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. Die Adresse eines Kacheldienstes: Rasterkacheln mit {z}/{x}/{y} (oder {-y}, {q}, {bbox-epsg-3857} für WMS), Vektorkacheln (.pbf) oder TileJSON, ein MapLibre-Stil, ein PMTiles-Archiv (eine Webadresse oder eine Datei auf diesem Computer) oder eine MBTiles-Datei auf diesem Computer. A token is set. Ein Token ist gesetzt. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Ein Wert, ein Feld oder zwei davon, verknüpft mit + − × ÷. Gilt für die ausgewählten Zeilen oder alle Zeilen, wenn keine ausgewählt ist. a vector a pixel ein Vektor pro Pixel a vector an image patch ein Vektor pro Bildausschnitt A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Ein Zarr-Speicher (Format 2 oder 3) über seine Webadresse, mit konsolidierten Metadaten, oder ein Ordner auf diesem Computer. Die Karte einer Variablen wird für die gewählten Schritte ihrer anderen Dimensionen gelesen, etwa der Zeit. +A Zarr variable's values through time at points clicked on the map. Die Werte einer Zarr-Variable im Zeitverlauf an Punkten, die in der Karte angeklickt werden. +A2 landscape sheet A2-Blatt quer +A2 portrait sheet A2-Blatt hoch +A3 landscape sheet A3-Blatt quer +A3 portrait sheet A3-Blatt hoch +A4 landscape sheet A4-Blatt quer +A4 portrait sheet A4-Blatt hoch +A5 Grid A5-Gitter +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. Die flächentreuen fünfeckigen Zellen von A5 in einer Auflösung, über die Kartenausdehnung, einen Layer oder die ganze Welt, als Polygone mit ihren IDs. About Über About plugins Über Plugins About RustGIS Über RustGIS @@ -65,6 +83,7 @@ Add Hinzufügen Add\nData Daten\nhinzufügen Add ({n}) Hinzufügen ({n}) Add a bar chart of a layer's numeric field. Ein Balkendiagramm eines numerischen Layer-Feldes hinzufügen. +Add a circle. Einen Kreis hinzufügen. Add a colorbar of a raster layer's colour scale in the map frame. Eine Farbleiste mit der Farbskala eines Raster-Layers im Kartenrahmen hinzufügen. Add a description. Eine Beschreibung hinzufügen. Add a field. Ein Feld hinzufügen. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Ein Seitenlayout zum Druck Add a page to the presentation. Der Präsentation eine Seite hinzufügen. Add a PNG or JPEG picture to the page. Ein PNG- oder JPEG-Bild zur Seite hinzufügen. Add a presentation file. Eine Präsentationsdatei hinzufügen. +Add a rectangle, to frame or highlight part of the page. Ein Rechteck hinzufügen, um einen Teil der Seite einzurahmen oder hervorzuheben. Add a report file, or a report template for the selected layer. Eine Berichtsdatei oder eine Berichtsvorlage für den ausgewählten Layer hinzufügen. Add a scale bar for the map frame. Eine Maßstabsleiste für den Kartenrahmen hinzufügen. add a second map first (Data › New Map) zuerst eine zweite Karte hinzufügen (Daten › Neue Karte) Add a second map frame first Zuerst einen zweiten Kartenrahmen hinzufügen +Add a straight line. Eine gerade Linie hinzufügen. Add a text element. Ein Textelement hinzufügen. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Eine Web-Grundkarte über die anderen legen, statt sie zu ersetzen, um sie mit ihnen zu überblenden. Klicken Sie auf eine angezeigte, um sie zu entfernen. +Add a Zarr variable first (Add Data › Zarr…). Fügen Sie zuerst eine Zarr-Variable hinzu (Daten hinzufügen › Zarr…). Add Administrative Boundaries Verwaltungsgrenzen hinzufügen +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Einen Pfeil hinzufügen. Drehen Sie ihn mit dem runden Griff darüber oder legen Sie seinen Winkel in seinen Eigenschaften fest. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. Fügen Sie ein Earthdata-Login-Token hinzu (Earthdata Login, oben), um NASA-GeoTIFFs auf die Karte zu bringen. Add an elevation raster first. Zuerst ein Höhenraster hinzufügen. Add an elevation raster to the map first Zuerst ein Höhenraster zur Karte hinzufügen +Add an ellipse. Eine Ellipse hinzufügen. Add an empty layer with a ready-made schema, to draw into. Einen leeren Layer mit fertigem Schema zum Zeichnen hinzufügen. Add an input for each layer the model reads. Fügen Sie für jeden Layer, den das Modell liest, eine Eingabe hinzu. Add an output to keep a result under a name. Fügen Sie eine Ausgabe hinzu, um ein Ergebnis unter einem Namen zu behalten. Add and Open Hinzufügen und öffnen Add and Open Notebook Notizbuch hinzufügen und öffnen Add Archive Archiv hinzufügen +Add as Time Series Als Zeitreihe hinzufügen add at least two keyframes mindestens zwei Keyframes hinzufügen Add Cell Zelle hinzufügen Add Color Ramp File… Farbverlaufsdatei hinzufügen… @@ -132,6 +157,7 @@ Add Graphics Layer Grafik-Layer hinzufügen Add Input Eingabe hinzufügen Add Job Auftrag hinzufügen Add Keyframe Keyframe hinzufügen +Add Label Class Beschriftungsklasse hinzufügen Add Layer File Layer-Datei hinzufügen Add Open Data Offene Daten hinzufügen Add Output Ausgabe hinzufügen @@ -147,9 +173,11 @@ Add Rule Regel hinzufügen Add sample countries Beispielländer hinzufügen Add Sample Data Beispieldaten hinzufügen Add Satellite Embeddings Satelliten-Embeddings hinzufügen +Add Selected ({n}) Ausgewählte hinzufügen ({n}) Add Service Layer Dienst-Layer hinzufügen Add skill… Skill hinzufügen… Add STAC Asset STAC-Asset hinzufügen +Add STAC Data Cube STAC-Datenwürfel hinzufügen Add STAC Footprints STAC-Footprints hinzufügen Add Step Schritt hinzufügen Add Stroke Strich hinzufügen @@ -171,6 +199,7 @@ Add to Favourites Zu Favoriten hinzufügen Add to Map Zur Karte hinzufügen Add vector data. Vektordaten hinzufügen. Add view Ansicht hinzufügen +Add With Options… Mit Optionen hinzufügen… Add Zarr Variable Zarr-Variable hinzufügen Add Zarr Variable… Zarr-Variable hinzufügen… Add ▾ Hinzufügen ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Agenten haben vollen Zugriff: Sie dürfen Skripte, Aufgaben, Modelle und Plugins ausführen. Klicken, um das MCP-Relay zu stoppen Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Agenten auf diesem Computer, die sich verbinden (Claude Code, Codex, Cursor und andere), arbeiten mit den Werkzeugen von RustGIS an der hier geöffneten Karte: Sie können Dateien öffnen und speichern und das Web nutzen. Skripte und die Plugin-Verwaltung bleiben aus. Agents then run commands, change files and use the web on this computer without asking you. Agenten führen dann ohne Nachfrage Befehle aus, ändern Dateien und nutzen das Web auf diesem Computer. +Aggregate Points to A5 Punkte zu A5 aggregieren Aggregation Aggregation Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Landwirtschaftliche Feldgrenzen, die das Fields-of-the-World-Modell weltweit in Sentinel-2-Bildern gefunden hat, jeweils für ein Jahr (2nd Edition, CC BY 4.0). Dies sind Modellvorhersagen, keine vermessenen Flurstücke. AI KI @@ -221,15 +251,18 @@ All steps done. Alle Schritte erledigt. All tables with geometry Alle Tabellen mit Geometrie Allow Zulassen Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Erlauben Sie bei der Anmeldung die Nutzung Ihres ChatGPT-Abos. Wenn Ihr Workspace das nicht zulässt, wenden Sie sich an dessen Admin oder wählen Sie einen Anbieter mit API-Schlüssel. +Allow Feature Selection Objektauswahl zulassen Allow only certain combinations of values across fields. Nur bestimmte Wertekombinationen über Felder hinweg zulassen. Allow such actions for this conversation Solche Aktionen für diese Unterhaltung zulassen Allow this action? Diese Aktion zulassen? Allow this agent's RustGIS action? Diese RustGIS-Aktion des Agenten erlauben? Along Selected Line Entlang ausgewählter Linie +Alt+ Alt+ Alternating bar Wechselnde Leiste Altitude Höhe Always allow Immer erlauben -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3 oder ein S3-kompatibler Dienst (MinIO, Cloudflare R2, Wasabi…) über seinen Endpunkt. Schlüssel werden aufbewahrt, bis RustGIS geschlossen wird; eine gemerkte Verbindung behält Namen, Endpunkt und Region, nie den geheimen Schlüssel. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3 oder ein S3-kompatibler Dienst (MinIO, Cloudflare R2, Wasabi…) über seinen Endpunkt. Schlüssel werden aufbewahrt, bis RustGIS geschlossen wird; eine gespeicherte Verbindung behält Namen, Endpunkt und Region, nie den geheimen Schlüssel. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Ein Agent auf diesem Computer möchte: An AWS profile Ein AWS-Profil @@ -248,6 +281,7 @@ API key API-Schlüssel Apply Anwenden Apply a function to a raster Eine Funktion auf ein Raster anwenden Apply Control Points Passpunkte anwenden +Apply elevation Höhe anwenden Apply Style Stil anwenden Apply to layers Auf Layer anwenden Apply to the selected layer Auf den ausgewählten Layer anwenden @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Anwendungsbereich: innerhalb von { Area {area} · Perimeter {perimeter} Fläche {area} · Umfang {perimeter} Areas (name, land use, area) Flächen (Name, Landnutzung, Fläche) Arrange Anordnen +Arrow Pfeil +Arrowheads Pfeilspitzen Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Stellen Sie eine Frage zu Ihren Daten, oder beschreiben Sie, was mit der Karte geschehen soll. Der Assistent führt RustGIS-Befehle für Sie aus; jede Änderung kann rückgängig gemacht werden. Ask about the map, or tell it what to do… Fragen Sie zur Karte, oder sagen Sie, was zu tun ist… Ask about your data, or tell the assistant what to do with the map. Fragen Sie zu Ihren Daten, oder sagen Sie dem Assistenten, was mit der Karte geschehen soll. @@ -275,12 +311,13 @@ Asking NASA's CMR… NASA-CMR wird abgefragt… Asking the catalog… Katalog wird abgefragt… Asking the NLDI… NLDI wird abgefragt… Assistant settings Assistent-Einstellungen +At the end Am Ende +At the start Am Anfang Atlas Atlas Attachments Anhänge Attribute\nTable Attribut-\ntabelle Attribute Rules Attributregeln Attribute Table Attributtabelle -Attribute table Attributtabelle Attributes Attribute Attribution Quellenangabe Aurora Aurora @@ -295,11 +332,9 @@ Automatic (system language) Automatisch (Systemsprache) Automatic (system time zone and region) Automatisch (Zeitzone und Region des Systems) Automatic ({kind}) Automatisch ({kind}) Avoid Vermeiden -AWS AWS AWS, anonymous AWS, anonym Azimuth Azimut Back Zurück -Back to Explore; cancel a sketch Zurück zu „Erkunden“; Skizze abbrechen Back to the Map Zurück zur Karte Back to the map Zurück zur Karte Back to tools Zurück zu den Werkzeugen @@ -314,8 +349,6 @@ Basemap Grundkarte Basemaps Grundkarten Basin from hydrolocation Einzugsgebiet ab Hydrolokation Bearing Richtung -Bearing {b}° · Pitch {p}° Richtung {b}° · Neigung {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Richtung: wohin der obere Kartenrand zeigt, im Uhrzeigersinn ab Norden. Neigung: die Kippung gegenüber dem senkrechten Blick nach unten. Klicken, um beides zurückzusetzen. Before the event Vor dem Ereignis Below Unten Below 1:5,000,000 show everything; beyond it only what matches: Unter 1:5.000.000 alles anzeigen; darüber nur, was übereinstimmt: @@ -328,10 +361,12 @@ Bilinear Bilinear Bind To Time Slider An Zeitschieberegler binden Bind to Time Slider An Zeitschieberegler binden Blank Leer +Blank for Amazon S3 Leer für Amazon S3 Blank Letter page Leere Letter-Seite Blank Map Leere Karte Blank Page Leere Seite Blend: {mode} Überblendung: {mode} +Block Feature Selection Objektauswahl sperren Blue Blau Bold Fett Bold label text. Beschriftungstext fett. @@ -339,6 +374,7 @@ Bookmark Description Lesezeichenbeschreibung Bookmarks Lesezeichen Bookmarks Pane Lesezeichenbereich Boolean Boolesch +Both ends Beide Enden Bottom Unten Boundaries… Grenzen… Bounding\nGeometry Umgebende\nGeometrie @@ -346,6 +382,7 @@ Brightness Helligkeit Brightness, contrast, saturation and sepia for the whole map. Helligkeit, Kontrast, Sättigung und Sepia für die ganze Karte. Bring this bookmark's map into view to update it Die Karte dieses Lesezeichens anzeigen, um es zu aktualisieren Browse Durchsuchen +Browse Connection Verbindung durchsuchen Browse it in the Browser Im Browser durchsuchen Browse Plugins Plugins durchsuchen Browse the geoprocessing toolbox. Die Geoverarbeitungs-Toolbox durchsuchen. @@ -357,6 +394,7 @@ Browser Browser Browser\nPane Browser-\nbereich Browser View Browseransicht Bucket Bucket +Buckets Buckets Buffer Puffer Buffer A Point Punkt puffern Buffer the cities by 200 km Die Städte mit 200 km puffern @@ -381,7 +419,9 @@ Calculate Field Feld berechnen Calculate field Feld berechnen Calculate field values. Feldwerte berechnen. Calculate fields and enforce conditions on every edit. Felder berechnen und Bedingungen bei jeder Bearbeitung durchsetzen. +Calculate Geometry Geometrie berechnen Calculate Geometry Attributes Geometrieattribute berechnen +Calculated {rows} features {rows} Objekte berechnet Calculation Berechnung Camera Kamera Camera Effect Kameraeffekt @@ -396,12 +436,14 @@ Case Field Gruppierungsfeld Cast Shadow Schatten werfen Catalog Katalog catalog is a static STAC catalog's catalog.json or collection.json catalog ist die catalog.json oder collection.json eines statischen STAC-Katalogs +Catalogue (CSW) Katalog (CSW) Catalogue search (CSW) Katalogsuche (CSW) Celestial Bodies Himmelskörper Celestial Body Himmelskörper Celestial body: {body} Himmelskörper: {body} Cell size Zellengröße cell size ° Zellengröße ° +Cells Zellen Center Mitte Center Map Here Karte hier zentrieren Centered Zentriert @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. Die Nutzung eines ChatGPT sign-in failed. Check the connection and local settings, then try again. ChatGPT-Anmeldung fehlgeschlagen. Prüfen Sie die Verbindung und die lokalen Einstellungen und versuchen Sie es erneut. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. Die ChatGPT-Nutzung ist vorübergehend nicht verfügbar. Bleiben Sie angemeldet und versuchen Sie es später erneut. CHE CHE +Check Prüfen Check Again Erneut prüfen Check for\nUpdates Nach Updates\nsuchen Check for Updates Nach Updates suchen @@ -428,6 +471,7 @@ Check for Updates… Nach Updates suchen… Check the address and the service type. Prüfen Sie die Adresse und den Diensttyp. Check the address and your connection. Prüfen Sie die Adresse und Ihre Verbindung. Check the address. Prüfen Sie die Adresse. +Check the clause, and count the features it takes. Die Klausel prüfen und die erfassten Objekte zählen. Check the integration's client, permissions, workspace policy and route configuration. Prüfen Sie Client, Berechtigungen, Arbeitsbereichsrichtlinien und Routenkonfiguration der Integration. Checking for updates… Suche nach Updates… China China @@ -455,6 +499,7 @@ Choose which layers a click identifies. Auswählen, welche Layer ein Klick ident Choose which tabs the ribbon shows. Wählen, welche Registerkarten das Menüband zeigt. Circle Kreis Circle Radius (km) Kreisradius (km) +Class Klasse Class color Klassenfarbe Class Style Klassenstil Classes Klassen @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Klicken, um Stützpunkt Click to edit the description Klicken, um die Beschreibung zu bearbeiten Click to place a point graphic. Klicken, um eine Punktgrafik zu platzieren. Click to select the node Klicken, um den Knoten auszuwählen +Click to select, double-click to add, right-click for more Klicken zum Auswählen, Doppelklick zum Hinzufügen, Rechtsklick für mehr Click to switch between decimal degrees and DMS Klicken, um zwischen Dezimalgrad und GMS zu wechseln Clip Zuschneiden Clip a raster to the view, or resample it. Ein Raster auf die Ansicht zuschneiden oder neu abtasten. @@ -532,7 +578,7 @@ Close thread Thread schließen Close {name} {name} schließen Closest Nächste Coded values, one per line: code=description Codierte Werte, einer pro Zeile: Code=Beschreibung -Collapse or expand the ribbon Menüband ein- oder ausblenden +Collapse or Expand the Ribbon Menüband reduzieren oder erweitern Collapse the ribbon (Ctrl+F1) Menüband minimieren (Strg+F1) Collection Sammlung Collection {id}: choose dates and Find Items Sammlung {id}: Zeitraum wählen und Elemente finden @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Kombinationen aus dem Feld oben, eine pro Zeile COMID {comid} COMID {comid} Comma Komma +Comma-separated (optional) Kommagetrennt (optional) Command\nSearch Befehls-\nsuche command is required command ist erforderlich Command Search Befehlssuche @@ -572,10 +619,13 @@ Connect Verbinden Connect a folder to the Browser. Einen Ordner mit dem Browser verbinden. Connect to S3 Mit S3 verbinden Connect, or pick a layer Verbinden oder einen Layer wählen +Connected to {kind} {title} Verbunden mit {kind} {title} Connecting to {url}… Verbindung mit {url} wird hergestellt… Connection Verbindung +connection.new returned no id connection.new lieferte keine ID Connections Verbindungen -Connect… Verbinden… +Connections you add are listed here Hinzugefügte Verbindungen werden hier aufgeführt +Connections… Verbindungen… Constant Konstante Constant height Konstante Höhe Constant… Konstante… @@ -583,11 +633,14 @@ Contingent Values Bedingte Werte Continue Weiter Continue with ChatGPT Mit ChatGPT fortfahren Contrast Kontrast +Control Point Fit Passpunkt-Anpassung Conversion Konvertierung Convert Konvertieren Convert a layer to another format Einen Layer in ein anderes Format konvertieren Convert the selected layer's labels into an annotation layer. Die Beschriftungen des ausgewählten Layers in einen Annotation-Layer konvertieren. +Convert this layer's labels into an annotation layer. Die Beschriftungen dieses Layers in einen Annotation-Layer konvertieren. Convert To Annotation In Annotation konvertieren +Convert to Annotation In Annotation konvertieren Converted data Konvertierte Daten Convert… Konvertieren… Coordinate Koordinate @@ -599,11 +652,15 @@ Copied the map to the clipboard Karte in die Zwischenablage kopiert Copied {path} {path} kopiert Copies the (selected) features of a layer to a new layer. Kopiert die (ausgewählten) Objekte eines Layers in einen neuen Layer. Copy Kopieren +Copy / Cut / Paste features Objekte kopieren / ausschneiden / einfügen Copy a model.run command that runs this model, for the Script pane or Python Einen model.run-Befehl kopieren, der dieses Modell ausführt, für den Skriptbereich oder Python Copy Address Adresse kopieren Copy address Adresse kopieren Copy an image of the map to the clipboard. Ein Bild der Karte in die Zwischenablage kopieren. +Copy as CLI Command Als CLI-Befehl kopieren +Copy as Command Als Befehl kopieren Copy As GeoJSON Als GeoJSON kopieren +Copy as Python Als Python kopieren Copy Command Befehl kopieren Copy coordinates to the clipboard Koordinaten in die Zwischenablage kopieren Copy details Details kopieren @@ -611,11 +668,13 @@ Copy Group Gruppe kopieren Copy Layer Layer kopieren Copy Layers Layer kopieren Copy Path Pfad kopieren +Copy Run As Code Ausführung als Code kopieren Copy sign-in link Anmeldelink kopieren Copy the selected features (also as GeoJSON text). Die ausgewählten Objekte kopieren (auch als GeoJSON-Text). Copy the selected features and delete them. Die ausgewählten Objekte kopieren und löschen. Copy the selected layer's data source path. Den Datenquellenpfad des ausgewählten Layers kopieren. Copy this element (Ctrl+D, ⌘D on a Mac) Dieses Element kopieren (Ctrl+D, ⌘D auf dem Mac) +Copy this run as code, or open it in its tool Diese Ausführung als Code kopieren oder in ihrem Werkzeug öffnen Corrections Korrekturen Cost Kosten could not draw the map die Karte konnte nicht gezeichnet werden @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Das Plugin-Verzeichnis konnte nicht g Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Anzahl, Summe, Mittelwert, Minimum, Maximum und Standardabweichung eines Feldes, optional je Gruppierungsfeld. Countries, cities, rivers and lakes Länder, Städte, Flüsse und Seen Country Land +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Zählt die Punkte eines Layers nach der A5-Zelle, in der sie liegen (Zellen gleicher Fläche, die Anzahl ist also eine Dichte), mit Summe und Mittelwert eines Feldes. Counts the points that fall inside each polygon. Zählt die Punkte, die in jedes Polygon fallen. Counts, totals and top categories of what's in view. Anzahlen, Summen und häufigste Kategorien im sichtbaren Bereich. CPU CPU @@ -647,12 +707,8 @@ Cross Section Querschnitt Cross Section (2 clicks) Querschnitt (2 Klicks) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Strg+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Kumulativ Current date Aktuelles Datum Current Project Aktuelles Projekt @@ -680,13 +736,16 @@ Data type Datentyp Data you add is listed here Hinzugefügte Daten werden hier aufgeführt data.gov and CKAN catalogues data.gov- und CKAN-Kataloge Database Datenbank -Databases Datenbanken +Database connections are used by the desktop app Datenbankverbindungen werden von der Desktop-App verwendet +databases are read through DuckDB, a program the browser can't run Datenbanken werden über DuckDB gelesen, ein Programm, das der Browser nicht ausführen kann Database… Datenbank… Dataset Datensatz Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Datensätze auf Hugging Face nach Name oder Besitzer/Name; die Dateien eines Datensatzes durchsuchen und GeoTIFFs, GeoParquet, PMTiles, LiDAR und andere Dateien hinzufügen. Data… Daten… Date Datum Dates Zeitraum +Dates a cell needs Benötigte Daten pro Zelle +Dates a cell needs is a whole number, or empty for half Benötigte Daten pro Zelle ist eine ganze Zahl, oder leer für die Hälfte Day Tag Day and night shading for a time. Tag-und-Nacht-Schattierung für einen Zeitpunkt. Decimal Dezimal @@ -715,7 +774,6 @@ Delete Notebook Notizbuch löschen Delete Page Seite löschen Delete Presentation Präsentation löschen Delete Report Bericht löschen -Delete selected features Ausgewählte Objekte löschen Delete selected rows Ausgewählte Zeilen löschen Delete Style Stil löschen Delete Task Aufgabe löschen @@ -736,11 +794,15 @@ Desktop and command line only Nur Desktop-App und Befehlszeile Details Details Details of the selected layer. Details zum ausgewählten Layer. Deviations Abweichungen +DGGS DGGS Diagnostic\nMonitor Diagnose-\nmonitor Diagnostic Monitor Diagnosemonitor Diagnostics Diagnose +Diagonal ↗ Diagonal ↗ +Diagonal ↘ Diagonal ↘ Diamond Raute Difference, ratio or percent change between two rasters. Differenz, Verhältnis oder prozentuale Änderung zwischen zwei Rastern. +Digit Digit Directions Route Directions From Here Route von hier Directions To Here Route hierher @@ -758,6 +820,7 @@ Dissolve Field Zusammenführungsfeld Distance Entfernung Distance (km) Entfernung (km) Distance Coefficient P Distanzkoeffizient P +Distance units Entfernungseinheiten Distances along the surface, heights, areas and volumes. Entfernungen entlang der Oberfläche, Höhen, Flächen und Volumen. DISTINCT DISTINCT Distinct Eindeutig @@ -804,18 +867,23 @@ Drag to draw Zum Zeichnen ziehen Drag to draw a freehand line. Ziehen, um eine Freihandlinie zu zeichnen. Drag to draw a freehand polygon. Ziehen, um ein Freihandpolygon zu zeichnen. Drag to draw a rectangle. Ziehen, um ein Rechteck zu zeichnen. +Drag to resize the message box Ziehen, um die Größe des Nachrichtenfelds zu ändern Drag tools from the palette onto the canvas. Ziehen Sie Werkzeuge aus der Palette auf die Zeichenfläche. Drag vertices on the map; click an edge to add one Stützpunkte auf der Karte ziehen; auf eine Kante klicken, um einen hinzuzufügen Draw a halo around label text. Einen Halo um den Beschriftungstext zeichnen. Draw a labelled latitude/longitude grid over the map frame. Ein beschriftetes Längen-/Breitengitter über den Kartenrahmen zeichnen. Draw a model: chain tools from input layers to the results to keep Ein Modell zeichnen: Werkzeuge von Eingabe-Layern bis zu den zu behaltenden Ergebnissen verketten Draw a model: chain tools from input layers to the results to keep. Ein Modell zeichnen: Werkzeuge von Eingabe-Layern bis zu den zu behaltenden Ergebnissen verketten. +Draw at its place in the stack, the bottom stroke first An seiner Stelle im Stapel zeichnen, den untersten Strich zuerst +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Die Striche aller Objekte einer Ebene vor der nächsten zeichnen, damit Umrandungen dort, wo sich Linien treffen, unter den Kernen verbunden werden Draw labels at every scale and distance. Beschriftungen in jedem Maßstab und jeder Entfernung zeichnen. Draw new features in the selected layer. Double-click to finish lines and polygons. Neue Objekte im ausgewählten Layer zeichnen. Doppelklick beendet Linien und Polygone. Draw new single-band rasters with the inverted grey stretch again. Neue einkanalige Raster wieder mit der invertierten Graustufen-Streckung zeichnen. +Draw only polygon outlines, keeping their colors and widths. Nur Polygonumrisse zeichnen, mit ihren Farben und Breiten. Draw points as bins coloured by how many fall in each. Punkte als Klassen darstellen, eingefärbt nach ihrer Anzahl je Klasse. Draw polygons as outlines only. Polygone nur als Umrisse zeichnen. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Ab jetzt hinzugefügte einkanalige Raster mit diesem Farbschema, dieser Streckung und diesem Resampling zeichnen. +Draw strokes in levels Striche in Ebenen zeichnen Draw the layer at every scale. Den Layer in jedem Maßstab zeichnen. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Die Karte auf der GPU zeichnen (Beta; Globus, Ansichtseffekte und Farbsehsimulation bleiben beim CPU-Renderer). Draw the selected layer above others. Den ausgewählten Layer über den anderen zeichnen. @@ -828,6 +896,7 @@ drawn by the server vom Server gezeichnet Drive Auto Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. Drohnen- und Luftbilder, die auf OpenAerialMap geteilt wurden, neueste zuerst, als Cloud-optimierte GeoTIFFs hinzugefügt. Duplicate Duplizieren +Duplicate Connection Verbindung duplizieren Duplicate Element Element duplizieren Duplicate Layout Layout duplizieren Duplicate Map Karte duplizieren @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name z. B. pop_est / 1000000 o e.g. 0, 255, -9999 z. B. 0, 255, -9999 e.g. continent = 'Europe' z. B. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' z. B. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 z. B. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Jede Geoverarbeitung wird ein Schritt, verkettet über ihre Ausgaben Earth Index has no points in the view Earth Index hat keine Punkte in der Ansicht Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Stützpunkte\nbearbeiten Edit and run the notebook. Das Notizbuch bearbeiten und ausführen. Edit Attribute Attribut bearbeiten Edit Cell Zelle bearbeiten +Edit Connection Verbindung bearbeiten Edit Description Beschreibung bearbeiten Edit Graphic Grafik bearbeiten Edit Status Bearbeitungsstatus @@ -882,6 +953,7 @@ Editor\nTools Editor-\nwerkzeuge Editor Tools Editor-Werkzeuge Edits since the project was saved. Bearbeitungen seit dem Speichern des Projekts. Edits the project Bearbeitet das Projekt +Edit… Bearbeiten… Effects Effekte Element Element Element Properties Elementeigenschaften @@ -898,21 +970,25 @@ Enable Lighting Beleuchtung aktivieren Enable Plugin Plugin aktivieren Enabled Aktiviert Encloses each feature, or all features, in its convex hull. Umschließt jedes Objekt oder alle Objekte mit ihrer konvexen Hülle. +Encoded polyline Kodierte Polylinie End Ende End date Enddatum Endpoint Endpunkt Enhancement Verbesserung Enter Enter Enter an API key. Geben Sie einen API-Schlüssel ein. +Enter an EPSG code as a number, such as 32650 Geben Sie einen EPSG-Code als Zahl ein, z. B. 32650 Enter the provider's address. Geben Sie die Adresse des Anbieters ein. Enter to send, Shift+Enter for a new line Enter zum Senden, Shift+Enter für eine neue Zeile EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Umgebungen EPSG EPSG +EPSG code EPSG-Code EPSG or ESRI code EPSG- oder ESRI-Code EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Gleiches Intervall Equirectangular (Plate Carrée) Plattkarte (abstandstreu zylindrisch) @@ -922,14 +998,18 @@ Error Fehler Error Inspector Fehlerinspektor Error Inspector (validate) Fehlerinspektor (validieren) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Europa flächentreu (ETRS89 LAEA) Every band's value at a pixel. Der Wert jedes Kanals an einem Pixel. Every editing tool. Alle Bearbeitungswerkzeuge. +Every label setting of this layer, in the Labeling pane. Alle Beschriftungseinstellungen dieses Layers im Beschriftungsbereich. Every layer, as FEMA draws them Alle Layer, wie FEMA sie zeichnet +Every run, in order, as a script that opens the project Jede Ausführung der Reihe nach, als Skript, das das Projekt öffnet every Socrata site, or one such as data.cityofnewyork.us jede Socrata-Website oder eine wie data.cityofnewyork.us Everywhere Überall +Existing field Vorhandenes Feld Existing Template Vorhandene Vorlage exit rotary Kreisverkehr verlassen exit roundabout Kreisverkehr verlassen @@ -948,6 +1028,7 @@ Export\nRaster Raster\nexportieren Export Animation Animation exportieren Export Bookmarks Lesezeichen exportieren Export Bookmarks… Lesezeichen exportieren… +Export CSV… CSV exportieren… Export Features Objekte exportieren Export Features (tool) Objekte exportieren (Werkzeug) Export Features needs a vector layer (Data › Export Raster writes a raster) Objekte exportieren benötigt einen Vektorlayer (Daten › Raster exportieren schreibt ein Raster) @@ -996,6 +1077,7 @@ Face Culling Rückseitenentfernung Faces Flächen Factor Faktor Failed Fehlgeschlagen +FALSE FALSE Farthest Weiteste Faster drawing: no antialiasing. Schnelleres Zeichnen: kein Antialiasing. Favourite Data Source Favorisierte Datenquelle @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Feldgrenzen Field density Felddichte Field name Feldname +Field Prefix Feldpräfix +Field to Sum and Average Feld für Summe und Mittelwert Fields Felder Fields are drawn from zoom 9: zoom in to see them. Felder werden ab Zoomstufe 9 gezeichnet: Zoomen Sie hinein, um sie zu sehen. Fields colored by score, drawn from zoom 9 Felder nach Bewertung eingefärbt, ab Zoomstufe 9 gezeichnet @@ -1079,6 +1163,8 @@ Folder Connections Ordnerverbindungen folder connections need the desktop app Ordnerverbindungen erfordern die Desktop-App Folders Ordner Folder… Ordner… +Follow the time slider Dem Zeitschieberegler folgen +Font Schriftart for a store that doesn't say für einen Speicher ohne Angabe Forest Wald Forget Earthdata Login Token Earthdata-Login-Token vergessen @@ -1090,9 +1176,11 @@ France Frankreich Free the prepared geometry. Die vorbereitete Geometrie freigeben. Freehand Freihand FROM FROM +From Von From Selection Aus Auswahl From Service… Aus Dienst… From symbol Aus Symbol +From the address Aus der Adresse from the address aus der Adresse from the service vom Dienst From URL… Aus URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Objekte weit entfernt vom Ansichtszent Hide Group Gruppe ausblenden Hide labels when the viewer is closer than this. Beschriftungen ausblenden, wenn der Betrachter näher ist. Hide labels when the viewer is farther than this. Beschriftungen ausblenden, wenn der Betrachter weiter entfernt ist. +Hide Layers Layer ausblenden Hide other values Andere Werte ausblenden Hide Steps Schritte ausblenden Hide the description Die Beschreibung ausblenden @@ -1213,6 +1302,8 @@ Hold Zurückstellen HOME HOME Home Start Home › Add Start › Hinzufügen +Home › Add Data Start › Daten hinzufügen +Home › Inspect › Measure Start › Untersuchen › Messen Home › Navigate Start › Navigieren Home › Navigate › Bookmarks Start › Navigieren › Lesezeichen Home › Web Data Start › Webdaten @@ -1220,7 +1311,7 @@ Home › Web Data › United States Start › Webdaten › Vereinigte Staaten Homepage Website Horizontal Horizontal host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=… oder eine .duckdb-Datei +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Gehostete Bilddaten Hour Stunde How the layer's colours combine with the tile layers and basemap beneath. Wie sich die Farben des Layers mit den darunterliegenden Kachel-Layern und der Grundkarte verbinden. @@ -1248,6 +1339,7 @@ Imagery Sharing Bilddaten teilen Imagery Tools Bilddaten-Werkzeuge ImageServer ImageServer Image… Bild… +Imperial Imperial Import Importieren Import\nProject Projekt\nimportieren Import All Alle importieren @@ -1267,6 +1359,7 @@ Import Task Aufgabe importieren Import Task… Aufgabe importieren… Import Thumbnail Miniaturansicht importieren Import… Importieren… +IN IN In Beyond Ausblenden unter In memory Im Speicher In Progress In Bearbeitung @@ -1281,6 +1374,8 @@ Information Informationen INPUT EINGABE Input Eingabe Input Features Eingabeobjekte +Input Layer Eingabe-Layer +Input Points Eingabepunkte Input Polygons Eingabepolygone Input Rows Eingabezeilen Input Summary Features Eingabe-Zusammenfassungsobjekte @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Installierte Plugins werden hier gespeich Installed with Homebrew, winget, the AUR or COPR? Update it there. Mit Homebrew, winget, dem AUR oder COPR installiert? Dann dort aktualisieren. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} installiert. Sie finden es auf der Registerkarte „Plugins“; seine Werkzeuge sind auch im Werkzeugbereich. Installing it downloads Python {version} and the packages it needs from the internet. Bei der Installation werden Python {version} und die benötigten Pakete aus dem Internet heruntergeladen. +Integer field: values are rounded. Ganzzahlfeld: Werte werden gerundet. Interface font: {font} Schriftart der Oberfläche: {font} Interpolate a surface from points (IDW) Eine Oberfläche aus Punkten interpolieren (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Interpoliert eine Rasteroberfläche mit 2 Kanälen (Vorhersage und Varianz) aus Punktobjekten mit Ordinary Kriging. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. Interpoliert eine Rasteroberfläche aus Objektwerten durch inverse Distanzgewichtung, wie QGIS. Interpolation Interpolation Interpolation Attribute Interpolationsattribut @@ -1316,10 +1413,13 @@ Invalid: {error} Ungültig: {error} Invert Umkehren Invert color scheme Farbschema umkehren Invert the selection. Die Auswahl umkehren. +IS IS IS NULL IS NULL it connects to an address on the internet es verbindet sich mit einer Adresse im Internet It has no code: nothing in it runs. Es enthält keinen Code: Nichts darin wird ausgeführt. +It is locked, or one of its surrounds is: unlock it first Es ist gesperrt, oder eines seiner Kartenelemente: Entsperren Sie es zuerst it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) es ist Ihnen vorbehalten (Plugins, Skripte, Aufgaben, Modelle und der GeoLibre-Runner) +it needs input: open it from its pane or button, or run it from a script or the assistant Es braucht Eingaben: Öffnen Sie es über seinen Bereich oder seine Schaltfläche, oder führen Sie es aus einem Skript oder dem Assistenten aus it reads or writes files on this computer es liest oder schreibt Dateien auf diesem Computer it replaces the open project es ersetzt das geöffnete Projekt it runs a plugin's command es führt einen Befehl eines Plugins aus @@ -1332,20 +1432,30 @@ Italic Kursiv Item Pop-ups Element-Pop-ups Item {id} Element {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. Seine Firewall ({firewall}) hat RustGIS blockiert. Der Dienst lässt möglicherweise nur bestimmte Anwendungen zu: Wenden Sie sich an den Administrator und senden Sie ihm die Details unten. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Sein Werkzeug ist gerade nicht verfügbar (von einem nicht geladenen Plugin oder einer nicht installierten Suite) Jobs Aufträge JOIN JOIN Join Linienverbindung +Join Features Join-Objekte +Join Operation Join-Vorgang +Joins attributes from a join layer to a target layer based on their spatial relationship. Verbindet Attribute aus einem Join-Layer mit einem Ziel-Layer anhand ihrer räumlichen Beziehung. +Journal figure, 1 column (90 mm) Zeitschriftenabbildung, 1 Spalte (90 mm) +Journal figure, 1.5 columns (140 mm) Zeitschriftenabbildung, 1,5 Spalten (140 mm) +Journal figure, 2 columns (190 mm) Zeitschriftenabbildung, 2 Spalten (190 mm) Keep a history of every edit to the layer, to look back or restore. Einen Verlauf aller Bearbeitungen des Layers führen, um zurückzublicken oder wiederherzustellen. Keep an archive of edits Ein Archiv der Bearbeitungen führen keep current aktuellen Wert beibehalten +Keep it from being moved, resized, deleted or edited by mistake Verhindern, dass es versehentlich verschoben, in der Größe geändert, gelöscht oder bearbeitet wird Keep one value of each point's vector as its value field Einen Wert aus dem Vektor jedes Punktes als dessen Wertfeld behalten Keep Result Ergebnis behalten Keep the labelled features fixed as the map moves. Die beschrifteten Objekte beim Verschieben der Karte beibehalten. Keep the last drawing tool active after each graphic. Das letzte Zeichenwerkzeug nach jeder Grafik aktiv lassen. Keep the last tool active Letztes Werkzeug aktiv lassen +Keep this endpoint under Data Sources › Connections, without its password or keys Diesen Endpunkt unter Datenquellen › Verbindungen behalten, ohne Passwort oder Schlüssel Keep this result Dieses Ergebnis behalten Keep Tool Werkzeug beibehalten Keep value Wert behalten +Key Key Keyboard Shortcuts Tastenkürzel Keyboard shortcuts. Tastenkürzel. Keys from Schlüssel aus @@ -1356,13 +1466,20 @@ KMZ KMZ Label Beschriftung Label Class Beschriftungsklasse Label Distance Range Beschriftungs-Entfernungsbereich +Label features in this layer Objekte in diesem Layer beschriften Label Field Beschriftungsfeld +Label only the features that match. Nur die passenden Objekte beschriften. Label Placement Beschriftungsplatzierung Label Range Beschriftungsbereich Label the cities with their names Die Städte mit ihren Namen beschriften Label Visibility Range Beschriftungs-Sichtbarkeitsbereich Labeling Beschriftung +Labeling Pane Beschriftungsbereich +Labeling Properties… Beschriftungseigenschaften… Labels Beschriftungen +labels are drawn for feature layers, not rasters Beschriftungen werden für Objekt-Layer gezeichnet, nicht für Raster +Labels are drawn for feature layers, not rasters. Beschriftungen werden für Objekt-Layer gezeichnet, nicht für Raster. +Labels are off for this layer: the clause applies once they are on. Beschriftungen sind für diesen Layer aus: Die Klausel gilt, sobald sie eingeschaltet sind. Lagoon Lagune Lambert Conformal Conic Lambertsche winkeltreue Kegelprojektion Lambert Cylindrical Equal Area Lambert flächentreue Zylinderprojektion @@ -1383,12 +1500,14 @@ Layer Blend Layer-Überblendung Layer Extrusion Layer-Extrusion Layer File Layer-Datei Layer From\nSelection Layer aus\nAuswahl +Layer not found Layer nicht gefunden Layer Template Layer-Vorlage Layer Templates Layer-Vorlagen layer {id} Layer {id} Layers Layer Layers ({n}) Layer ({n}) Layers and Table Views Layer und Tabellenansichten +Layers, a date each Layer, jeweils ein Datum Layout Layout Layout\nFile Layout-\ndatei Layout after: {name} Layout danach: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Lern-\nressourcen Left Links Legend Legende Legend label. Clear it to show the value again. Legendenbeschriftung. Leeren, um wieder den Wert anzuzeigen. -Length {length} · gain {gain} m · loss {loss} m Länge {length} · Anstieg {gain} m · Abstieg {loss} m +Length {length} · gain {gain} · loss {loss} Länge {length} · Anstieg {gain} · Abstieg {loss} Let the assistant hand tasks to agents on this computer Den Assistenten Aufgaben an Agenten auf diesem Computer übergeben lassen Let your Agents Control RustGIS Ihre Agenten RustGIS steuern lassen Letter spacing Zeichenabstand @@ -1439,6 +1558,7 @@ List by drawing order Nach Zeichenreihenfolge auflisten List by selection Nach Auswahl auflisten List Columns in File Spalten in Datei auflisten List Commands Befehle auflisten +List Connections Verbindungen auflisten List Data Sources Datenquellen auflisten List Database Columns Datenbankspalten auflisten List Database Tables Datenbanktabellen auflisten @@ -1475,6 +1595,11 @@ Locate… Suchen… Location Lage Locator Lokator Lock Sperren +Locked Gesperrt +Locked: unlock it in the Format pane to move or change it Gesperrt: Entsperren Sie es im Formatbereich, um es zu verschieben oder zu ändern +Log In to Connection Bei Verbindung anmelden +Log In to {name}… Bei {name} anmelden… +Log In… Anmelden… Long Long (Ganzzahl) Longitude Länge Longitude (X) Länge (X) @@ -1493,6 +1618,7 @@ Manage Verwalten Manage Bookmarks Lesezeichen verwalten Manage Edits Bearbeitungen verwalten Manage Templates Vorlagen verwalten +Mann–Kendall Mann–Kendall Map Karte Map\nFrame Karten-\nrahmen Map (the current view) Karte (die aktuelle Ansicht) @@ -1504,6 +1630,7 @@ Map File Kartendatei Map Frame Kartenrahmen Map Frames Kartenrahmen Map frames per second (live while the map moves) Kartenbilder pro Sekunde (live, während sich die Karte bewegt) +Map Labeling Kartenbeschriftung Map Link Kartenlink Map name Kartenname Map Page Kartenseite @@ -1515,6 +1642,7 @@ Map style (JSON) Kartenstil (JSON) Map Surrounds Kartenelemente Map Thumbnail Kartenminiatur Map tiles of the whole world Kartenkacheln der ganzen Welt +Map Tool: {tool} Kartenwerkzeug: {tool} Map View Kartenansicht map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Karte: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Marker Mask Values Werte maskieren Masking Maskierung +Match Option Abgleichsoption Matching places Passende Orte Max Max Max % Max % +Max Neighbors Max. Nachbarn Maximum zoom Maximale Zoomstufe MCP Relay MCP-Relay MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCP-Relay hört bereits auf 127.0.0.1:{port}; stoppen Sie es zuerst, um Port {asked} zu nutzen @@ -1542,6 +1672,8 @@ Measure Distance Entfernung messen Measure geodesic distances and areas on the map. Geodätische Entfernungen und Flächen auf der Karte messen. Measure on this body, with one of its basemaps. Auf diesem Himmelskörper messen, mit einer seiner Grundkarten. Measure with an elevation raster Mit einem Höhenraster messen +Measurement units Maßeinheiten +Measurement units must be metric or imperial Maßeinheiten müssen metrisch oder imperial sein Measurements so far. Bisherige Messungen. Mensuration Mensuration Mensuration Tools Mensurationswerkzeuge @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Führt Poly Messages appear here. Hier erscheinen Meldungen. Meters Meter Method Methode +Metric Metrisch Midnight Mitternacht Miles Meilen Miller Cylindrical Miller-Zylinderprojektion @@ -1560,21 +1693,43 @@ Min must be less than Max Min muss kleiner als Max sein Min must not be more than Max Min darf nicht größer als Max sein Minimum Bounding Geometry Minimale umgebende Geometrie Minimum Maximum Minimum Maximum +Minus Minus Minutes Minuten minutes ago vor Minuten Miter Spitz Mobile Map Mobile Karte Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Modell Model\nBuilder Model\nBuilder model id Modell-ID Model output Modellausgabe ModelBuilder ModelBuilder Models Modelle +Modified Mann–Kendall (Hamed & Rao) Modifizierter Mann–Kendall (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Modifizierter Mann–Kendall (Yue & Wang) Modify Ändern Modify Selected Features Ausgewählte Objekte ändern Mollweide (equal-area world) Mollweide (flächentreue Weltkarte) Month Monat +Months Monate +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Monate sind Zahlen von 1 bis 12 (12, 1, 2 für Dezember bis Februar) More Mehr More basemaps: Weitere Grundkarten: Mosaic images into an orthomosaic Bilder zu einem Orthomosaik zusammensetzen @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Die sichtbaren Raster zu einem O Most cloud cover (%) Höchste Wolkenbedeckung (%) Mouse wheel Mausrad Move Verschieben -Move (m) Verschieben (m) Move Bookmark Lesezeichen verschieben Move Down Nach unten Move down Nach unten Move Group Gruppe verschieben +Move Label Class Beschriftungsklasse verschieben Move Task Step Aufgabenschritt verschieben +Move To Bottom Ganz nach unten Move To Group In Gruppe verschieben +Move To Top Ganz nach oben Move To {group} Nach {group} verschieben Move Up Nach oben Move up Nach oben @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Meine Buckets My OGC Layer Mein OGC-Layer +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Name name the plugin whose panel to open: {"id": plugin id} das Plugin angeben, dessen Panel geöffnet werden soll: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Neues\nNotizbuch New\nPresentation Neue\nPräsentation New\nReport Neuer\nBericht New\nWorkspace Neuer\nArbeitsbereich -New / Open / Save project Projekt neu / öffnen / speichern New Bookmark Neues Lesezeichen New Bookmark… Neues Lesezeichen… +New Connection Neue Verbindung +New Connection ▾ Neue Verbindung ▾ New conversation Neue Unterhaltung New Custom Template Neue benutzerdefinierte Vorlage New Custom Template… Neue benutzerdefinierte Vorlage… New features use this template's values Neue Objekte verwenden die Werte dieser Vorlage +New field Neues Feld New Group Neue Gruppe New job name Name des neuen Auftrags New Layout Neues Layout @@ -1649,6 +1809,7 @@ New Notebook Neues Notizbuch New Presentation Neue Präsentation New Project Neues Projekt New project Neues Projekt +New Publication Layout Neues Publikationslayout New Relationship… Neue Beziehung… New Report Neuer Bericht New Selection Neue Auswahl @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Noch keine Les No catalogs below this one: Find Items lists its items Keine Kataloge unterhalb dieses: Elemente finden listet seine Elemente auf No COMID was returned for this point. Für diesen Punkt wurde keine COMID geliefert. no command kein Befehl -No connections this session Keine Verbindungen in dieser Sitzung +No data at the points picked. Keine Daten an den gewählten Punkten. No datasets match. Keine passenden Datensätze. No datasets with data RustGIS can add Keine Datensätze mit Daten, die RustGIS hinzufügen kann +no date in the name kein Datum im Namen No edits since the project was saved Keine Bearbeitungen seit dem Speichern des Projekts No field of this layer holds dates. Kein Feld dieses Layers enthält Datumsangaben. +No Fill Keine Füllung +No geometry to calculate Keine Geometrie zu berechnen No granules in this view and date range. Keine Granulen in dieser Ansicht und diesem Zeitraum. no home folder kein Home-Ordner no layer kein Layer @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) Keine Beziehungen (Datenentwurf No relationships yet Noch keine Beziehungen No reports yet. Publish › New Report lists the selected layer's records. Noch keine Berichte. Veröffentlichen › Neuer Bericht listet die Datensätze des ausgewählten Layers auf. no row keine Zeile +No serif font is installed, so this text is drawn in the sans-serif Es ist keine Serifenschrift installiert, daher wird dieser Text serifenlos gezeichnet no service or file links keine Dienst- oder Dateilinks No services match your search. Keine Dienste entsprechen Ihrer Suche. No standalone tables Keine eigenständigen Tabellen @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Keine None (public buckets) Keine (öffentliche Buckets) none, a .duckdb file, or host=… dbname=… user=… keine, eine .duckdb-Datei oder host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD keines, oder aus PGPASSWORD oder MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD keins, oder aus PGPASSWORD, MYSQL_PWD oder SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. Keine. Fügen Sie einen über eine Adresse oder aus einem Grundkarten-Plugin hinzu. North Nord North\nArrow Nord-\npfeil North Polar Stereographic Polare stereografische Projektion (Nord) +NOT NOT not a picture kein Bild not a RustGIS layout kein RustGIS-Layout not a RustGIS project kein RustGIS-Projekt +Not applied yet. Noch nicht angewendet. Not for texts: a text is edited in the Format pane. Nicht für Texte: Ein Text wird im Formatbereich bearbeitet. not needed nicht erforderlich Not sandboxed Nicht in der Sandbox @@ -1739,9 +1906,12 @@ Nothing found where the map is Am Kartenort wurde nichts gefunden Nothing here Hier ist nichts Nothing named {name} to rename Nichts mit dem Namen {name} zum Umbenennen Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Noch nichts geteilt. Veröffentlichen › Teilen als erstellt Webkarten, Layer, Stile, Szenen und mehr. +Nothing to add here Hier gibt es nichts hinzuzufügen +Nothing to test Nichts zu testen Nothing was found at this address. Unter dieser Adresse wurde nichts gefunden. Nothing was recorded, so no task was made. Es wurde nichts aufgezeichnet, daher wurde keine Aufgabe erstellt. Now Jetzt +Nugget Nugget NULL NULL Nulls Nullwerte Observations (observer, date, species, count) Beobachtungen (Beobachter, Datum, Art, Anzahl) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. Auf dem Desktop kann der Schl On-screen compass, zoom slider and pan pad. Bildschirmkompass, Zoomregler und Verschiebe-Steuerkreuz. One command per line: id {json}. Lines starting with # are comments. Ein Befehl pro Zeile: id {json}. Zeilen, die mit # beginnen, sind Kommentare. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Eine DuckDB-SELECT-Abfrage: die ST_-Funktionen von DuckDB Spatial, Dateien und Webadressen (read_parquet('…')) sowie die Tabellen der Datenbank unten. Ctrl+Enter führt sie aus. +One layer: a Zarr variable or a raster's bands Ein Layer: eine Zarr-Variable oder die Kanäle eines Rasters One page per feature of an index layer. Eine Seite pro Objekt eines Indexlayers. One per line: code=name Einer pro Zeile: Code=Name One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Ein Strich, aus dem Symbol. Fügen Sie Striche für Umrandungen, parallele Linien und Versätze hinzu. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Ordner öffnen Open In Geoprocessing… In Geoverarbeitung öffnen… Open in ModelBuilder In ModelBuilder öffnen +Open in Tool Im Werkzeug öffnen Open Layout Layout öffnen Open ModelBuilder ModelBuilder öffnen Open Notebook Notizbuch öffnen @@ -1797,6 +1969,7 @@ Open Presentation Präsentation öffnen Open Project Projekt öffnen Open project Projekt öffnen Open Project (from text) Projekt öffnen (aus Text) +Open Project… Projekt öffnen… Open Report Bericht öffnen Open the attribute table of the selected layer. Die Attributtabelle des ausgewählten Layers öffnen. Open the Basemaps tab Registerkarte „Grundkarten“ öffnen @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Oder in einer POSIX-Shell aus Or run in PowerShell, for Claude Code or Codex: Oder in PowerShell ausführen, für Claude Code oder Codex: Or run, for Claude Code or Codex: Oder führen Sie für Claude Code oder Codex aus: ORDER BY ORDER BY +Ordinary Kriging Ordinary Kriging Orientation Ausrichtung Ortho Mapping Ortho-Kartierung Other Sonstiges Other Data Weitere Daten +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Weitere Beschriftungsklassen in diesem Layer (mit Befehlen erstellt): {n}. Jede beschriftet die Objekte, auf die ihre eigene Klausel passt, sodass ein Objekt Beschriftungen von mehreren erhalten kann. Out Beyond Ausblenden über Outline Umriss Outline another frame's extent in this map frame. Die Ausdehnung eines anderen Rahmens in diesem Kartenrahmen umranden. @@ -1851,6 +2026,7 @@ Output Ausgabe Output Name Ausgabename Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Über 1.000 Raster-, Vektor-, LiDAR-, Hydrologie- und Geländewerkzeuge, die im Browser laufen, nach einem einmaligen Download von etwa 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Über 1.000 Raster-, Vektor-, LiDAR-, Hydrologie- und Geländewerkzeuge als optionaler Download von etwa 20 MB. +Overlapping Überlappend Overlay Überlagerung overlay Überlagerung Overlay (a transparent layer kept on top, such as labels or roads) Überlagerung (ein transparenter Layer, der oben bleibt, etwa Beschriftungen oder Straßen) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture hat keine {ty}-Daten im Kart Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Die offenen Daten von Overture Maps: Gebäude, Orte, Verkehr, Verwaltungseinheiten, Basis-Layer und Adressen. Im Kartenausschnitt werden die Objekte direkt aus den GeoParquet-Dateien von Overture gelesen (auf eine Stadt oder kleiner zoomen); die ganze Welt kommt als Kartenkacheln. Overture Maps… Overture Maps… +Overwrite existing values Vorhandene Werte überschreiben Package Paket Package a trained deep learning model. Ein trainiertes Deep-Learning-Modell packen. Package Map Karte packen @@ -1907,8 +2084,12 @@ Performance Leistung Phase Phase Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Einen dynamischen Wert wie Maßstab oder Datum wählen, dann in die Karte klicken, um ihn zu platzieren. +Pick a Point Punkt wählen +Pick a point on the map to see the variable's values through time there. Wählen Sie einen Punkt in der Karte, um dort die Werte der Variable im Zeitverlauf zu sehen. Pick a route in the list, or click a grey route on the map. Eine Route in der Liste wählen oder auf eine graue Route in der Karte klicken. +Pick a single layer. Wählen Sie einen einzelnen Layer. Pick it on the map In der Karte wählen +Pick one… Eines wählen… Picture Bild Pictures Bilder Pictures, attachments and chart Bilder, Anhänge und Diagramm @@ -1966,8 +2147,11 @@ Portal Portal Portolan Portolan Portolan… Portolan… Portrait Hochformat +Position Position +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Nachbearbeitung power 2 Potenz 2 +Precision 6 (OSRM, Valhalla) Genauigkeit 6 (OSRM, Valhalla) Prepare Vorbereiten Prepare every layer's geometry for this projection. Die Geometrie jedes Layers für diese Projektion vorbereiten. Prepare Plugin Environment Plugin-Umgebung vorbereiten @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Projektvorlagen: Projekte project's path is required der Pfad des Projekts ist erforderlich Projection Projektion Projections Projektionen +Properties Eigenschaften +Property Eigenschaft Proportional Symbols Proportionale Symbole Provider Anbieter +Provinces: Provinzen: Proximity Nachbarschaft Public GIS portals of US counties and cities, by state. Öffentliche GIS-Portale von US-Countys und -Städten, nach Bundesstaat. Public GIS portals of US federal agencies, by department. Öffentliche GIS-Portale von US-Bundesbehörden, nach Ministerium. Public GIS portals of US states and the District of Columbia. Öffentliche GIS-Portale der US-Bundesstaaten und des District of Columbia. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. Öffentliche Layer auf ArcGIS Online oder einem anderen ArcGIS-Portal: Objekt-, Karten-, Bild- und Vektorkacheldienste, Webkarten und GeoJSON-Dateien. +Publication Layout Publikationslayout Publish Veröffentlichen Publish › Output Veröffentlichen › Ausgabe Put the layer back as it was then Den Layer in den damaligen Zustand zurückversetzen Python environment failed: {error} Python-Umgebung fehlgeschlagen: {error} Python environment not prepared Python-Umgebung nicht vorbereitet Python environment ready Python-Umgebung bereit +Python script Python-Skript Qualitative Qualitativ Quantile Quantil Query files, web addresses and databases with DuckDB Spatial; add results to the map. Dateien, Webadressen und Datenbanken mit DuckDB Spatial abfragen; Ergebnisse zur Karte hinzufügen. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Polygone um eine Höhe oder ein Feld anhe Raise Priority Priorität erhöhen Random points in polygons Zufallspunkte in Polygonen Range Bereich +Range (Degrees) Reichweite (Grad) +range: 0, 0.9 Bereich: 0, 0.9 Raster Raster Raster\nAnalysis Raster-\nanalyse Raster\nInformation Raster-\ninformationen @@ -2075,6 +2266,7 @@ Redo {action} {action} wiederholen Red–Blue Rot–Blau Refine the search to see {n} more Suche verfeinern, um {n} weitere zu sehen Refresh Aktualisieren +Refresh every connection's lists Die Listen aller Verbindungen aktualisieren Refresh Map Series Kartenserie aktualisieren Refresh the map series from its index layer. Die Kartenserie aus ihrem Indexlayer aktualisieren. Region Region @@ -2091,11 +2283,10 @@ Relationships Beziehungen Release Release Reload Neu laden Reload Web Layers Web-Layer neu laden -Remember (without the secret) Merken (ohne geheimen Schlüssel) Remember the key on this computer Schlüssel auf diesem Computer speichern -Remembered: Gemerkt: Remove Entfernen Remove All Alle entfernen +Remove All Label Classes Alle Beschriftungsklassen entfernen Remove an offline area. Einen Offline-Bereich entfernen. Remove Bookmark Lesezeichen entfernen Remove Connection Verbindung entfernen @@ -2108,11 +2299,14 @@ Remove from Favourites Aus Favoriten entfernen Remove From Group Aus Gruppe entfernen Remove Group Gruppe entfernen Remove Keyframe Keyframe entfernen +Remove Label Class Beschriftungsklasse entfernen Remove Section Abschnitt entfernen Remove Table Tabelle entfernen +Remove the clause. Die Klausel entfernen. Remove the last keyframe. Den letzten Keyframe entfernen. Remove the layer from the map. Den Layer aus der Karte entfernen. Remove the strokes and draw the symbol's line alone Die Striche entfernen und nur die Linie des Symbols zeichnen +Remove this class's own strokes and draw it with the layer's Die eigenen Striche dieser Klasse entfernen und sie mit denen des Layers zeichnen Remove this stop Dieses Zwischenziel entfernen Remove Tile Layer Kachel-Layer entfernen Remove token Token entfernen @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Entfern Rename Umbenennen Rename Bookmark Lesezeichen umbenennen Rename Group Gruppe umbenennen +Rename it to save it: a connection has this name Benennen Sie sie um, um sie zu speichern: Eine Verbindung hat bereits diesen Namen +Rename Label Class Beschriftungsklasse umbenennen Rename Layer Layer umbenennen Rename Layout Layout umbenennen Rename Map Karte umbenennen @@ -2149,6 +2345,7 @@ Reset Default Standard zurücksetzen Reset Effects Effekte zurücksetzen Reset North Nach Norden ausrichten Reset north Nach Norden ausrichten +Resolution (0–30) Auflösung (0–30) Response Antwort Restore Wiederherstellen Restore From Archive Aus Archiv wiederherstellen @@ -2162,6 +2359,7 @@ RGB RGB Ribbon Menüband Right Rechts Right-click a source to add it here Rechtsklick auf eine Quelle fügt sie hier hinzu +Right-click for more Rechtsklick für mehr Robinson Robinson Rotate Drehen Rotate (°) Drehen (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Beispieldaten (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Beispieldaten © Natural Earth (gemeinfrei). RustGIS steht unter der Apache-2.0-Lizenz. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Beispieldaten: Natural Earth (gemeinfrei). Lizenziert unter Apache-2.0. +Sans-serif Serifenlos Satellite Embedding Datasets Satelliten-Embedding-Datensätze Satellite Embeddings Satelliten-Embeddings Satellite Embeddings… Satelliten-Embeddings… Saturation Sättigung Save Speichern Save As Speichern unter +Save as Connection Als Verbindung speichern Save as Default Als Standard speichern Save as Default Raster Style Als Standard-Rasterstil speichern Save as Layer Als Layer speichern Save As Presentation File… Als Präsentationsdatei speichern… +Save as Python Script… Als Python-Skript speichern… Save As Report File… Als Berichtsdatei speichern… Save As Report Template… Als Berichtsvorlage speichern… Save as {name} Als {name} speichern Save As… Speichern unter… Save Color Ramps Farbverläufe speichern Save Data Source Datenquelle speichern +Save History As Python Script Verlauf als Python-Skript speichern Save Layer File Layer-Datei speichern Save Layout File Layout-Datei speichern Save Map File Kartendatei speichern @@ -2254,6 +2456,7 @@ save or load a pane set by name einen Bereichssatz nach Namen speichern oder lad Save Project Projekt speichern Save project Projekt speichern Save Project As Projekt speichern unter +Save Project As… Projekt speichern unter… Save Project Template Projektvorlage speichern Save Style Items Stilelemente speichern Save Task Item Aufgabenelement speichern @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Das Modell als Datei speichern (*.model. Save the model in the project Das Modell im Projekt speichern Save the open layout as a layout file. Das geöffnete Layout als Layout-Datei speichern. Save the panes you have open, and switch back to them. Die geöffneten Bereiche speichern und später wieder zu ihnen wechseln. +Save the project too: the script opens its saved file, without changes made since. Speichern Sie auch das Projekt: Das Skript öffnet dessen gespeicherte Datei, ohne seither vorgenommene Änderungen. Save the project under a new name. Das Projekt unter einem neuen Namen speichern. Save the project with your edits. Das Projekt mit Ihren Bearbeitungen speichern. Save the project's custom color ramps to a color ramp file. Die benutzerdefinierten Farbverläufe des Projekts in einer Farbverlaufsdatei speichern. @@ -2275,13 +2479,16 @@ Save Web Link Chart Web-Link-Diagramm speichern Save Web Map Webkarte speichern Save Web Scene Webszene speichern Saved Gespeichert +Saved as the connection {name} Als Verbindung {name} gespeichert Saved as the default style for new single-band rasters. Als Standardstil für neue einkanalige Raster gespeichert. +Saved connection Gespeicherte Verbindung Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Gespeicherte Schlüssel werden unverschlüsselt in den Einstellungen der App abgelegt (im Web im Speicher des Browsers). Saved models Gespeicherte Modelle Saved symbology: apply it to the selected layer, or save the layer's. Gespeicherte Symbologie: auf den ausgewählten Layer anwenden oder die des Layers speichern. Saved View Gespeicherte Ansicht Saved {name} {name} gespeichert Saved {name}. {name} gespeichert. +Saved {path} {path} gespeichert saving failed unexpectedly Speichern ist unerwartet fehlgeschlagen Scale Maßstab Scale\nBar Maßstabs-\nleiste @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Suchen (dieses Portal braucht Suchbegriffe) Search Catalogue Katalog durchsuchen Search commands — e.g. buffer, basemap, labels Befehle suchen — z. B. Puffer, Grundkarte, Beschriftungen Search datasets, e.g. GEDI biomass Datensätze suchen, z. B. GEDI biomass -Search Distance (km) Suchentfernung (km) +Search Distance Suchentfernung Search every command and its parameters Alle Befehle und ihre Parameter durchsuchen Search layers (e.g. temperature) Layer suchen (z. B. Temperatur) Search layers, places, lat/lon… Layer, Orte, Breite/Länge suchen… @@ -2329,6 +2536,8 @@ Searching the folders… Ordner werden durchsucht… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. Die Suche funktioniert hier, aber die Datenserver der NASA erlauben Webseiten nicht, ihre Dateien zu lesen (kein CORS), daher brauchen GeoTIFFs und Downloads geschützter Daten die Desktop-App. Herunterladen öffnet eine Datei in einem neuen Tab, wo Earthdata Login Sie zur Anmeldung auffordert. Searching… Suche läuft… Searching… try again in a moment. Suche läuft… versuchen Sie es gleich noch einmal. +Seasonal Mann–Kendall Saisonaler Mann–Kendall +Seasons a year Jahreszeiten pro Jahr Second standard parallel Zweiter Standardbreitenkreis Seconds Sekunden Secret access key Geheimer Zugriffsschlüssel @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Trennzeichen Sepia Sepia Sequential Sequenziell +Serif Serif Server Server Service Group Dienstgruppe Service group data exceeds 64 MB. Die Daten der Dienstgruppe überschreiten 64 MB. Service Import Requests Dienstimportanfragen Service Layers Dienst-Layer -Services Dienste +Session token Sitzungstoken Set Festlegen Set Cell Size Zellengröße festlegen Set Domain Domäne festlegen @@ -2411,6 +2621,7 @@ Shaded Schattiert Shaded Relief Schummerung Shape Form Shapefile Shapefile +Shapes Formen Share Teilen Share As Teilen als Share every layer and table as OGC API Features collections. Alle Layer und Tabellen als OGC API Features-Sammlungen teilen. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Details anzeigen, wenn der Show different features at different scales. In verschiedenen Maßstäben verschiedene Objekte anzeigen. Show grid Gitter anzeigen Show Group Gruppe anzeigen +Show labels Beschriftungen anzeigen Show latitude/longitude lines on the basemap. Längen- und Breitengrade auf der Grundkarte anzeigen. +Show Layers Layer anzeigen Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Layer im Zeitverlauf zeigen: nur die Objekte, deren Datum im Fenster des Schiebereglers liegt, und datierte Bilddaten für sein Datum. Show Map Karte anzeigen Show Me Zeigen @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Werkzeugbereich ein- oder ausblenden. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Lineale anzeigen. Aus einem Lineal ziehen, um eine Hilfslinie hinzuzufügen; eine Hilfslinie zurück auf das Lineal ziehen, um sie zu entfernen. Show the bands picked, read in place as the map needs them Die gewählten Kanäle zeigen, direkt gelesen, wie die Karte sie braucht Show the description Die Beschreibung anzeigen +Show the Labeling pane for the selected layer. Den Beschriftungsbereich für den ausgewählten Layer anzeigen. Show the labels that don't fit, in red. Nicht platzierbare Beschriftungen rot anzeigen. Show the layer only inside another layer's polygons. Den Layer nur innerhalb der Polygone eines anderen Layers anzeigen. Show the layer only left of a divider, to compare it with what's beneath. Den Layer nur links einer Trennlinie anzeigen, um ihn mit dem Darunterliegenden zu vergleichen. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Anmeldung abgebrochen. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. Zeitüberschreitung bei der Anmeldung. Starten Sie erneut und erlauben Sie dem Browser den Zugriff auf die Loopback-Adresse dieses Computers. signed in angemeldet Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Auf diesem Computer abgemeldet, aber OpenAI hat es nicht bestätigt. Sie können RustGIS in den ChatGPT-Einstellungen trennen. +Significance level Signifikanzniveau Signing in with ChatGPT works in the desktop app. Die Anmeldung mit ChatGPT funktioniert in der Desktop-App. Signing the asset (Planetary Computer)… Asset wird signiert (Planetary Computer)… +Sill Sill Simplify Vereinfachen Simulate the drive, or save the route as a layer. Die Fahrt simulieren oder die Route als Layer speichern. Simulation Simulation @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} Skills für den KI-Assistenten: {names} Slideshow, PDF or PNG Diashow, PDF oder PNG slight left leicht links slight right leicht rechts +Slope per Steigung pro Slope, aspect, hillshade, NDVI, band math, thresholds. Neigung, Exposition, Schummerung, NDVI, Kanalberechnung, Schwellenwerte. Slope, hillshade, NDVI and more. Neigung, Schummerung, NDVI und mehr. Small scales Kleine Maßstäbe @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Süd South Polar Stereographic Polare stereografische Projektion (Süd) +Spatial Join Räumliche Verknüpfung Speaker notes Sprechernotizen Spectral Analysis Spektralanalyse Spectral indices Spektralindizes @@ -2530,6 +2748,7 @@ Split (click the line) Aufteilen (Linie anklicken) SQL SQL SQL Pane SQL-Bereich SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL-Abfragen laufen über DuckDB, ein Programm, das die Desktop-App aufruft: Öffnen Sie dieses Projekt in der Desktop-App, um es abzufragen. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL, das wahr sein muss, z. B. pop > 0 Square Quadrat Squares Quadrate @@ -2588,6 +2807,7 @@ Start over at the end while playing. Beim Abspielen am Ende von vorn beginnen. Start With Starten mit Start-up Start starting with a project file needs the desktop app der Start mit einer Projektdatei erfordert die Desktop-App +Statistic Statistik Statistics Statistik Statistics Field Statistikfeld Status Status @@ -2613,8 +2833,10 @@ Stretch\nType Streckungs-\ntyp Stretch it over the map view, or give control points (one per line: column row lon lat). Über die Kartenansicht strecken oder Passpunkte angeben (einer pro Zeile: Spalte Zeile Länge Breite). Stretch Type Streckungstyp Stretch type Streckungstyp +Stroke Levels Strichebenen Stroke {n} Strich {n} Strokes Striche +Strokes for Striche für Style Stil Style Items Stilelemente Style › Drawing Stil › Zeichnen @@ -2624,7 +2846,10 @@ Subreport: {name} Unterbericht: {name} Subtypes Subtypen Subtypes, one per line: code=name Subtypen, einer pro Zeile: Code=Name Suitability Modeler Eignungsmodellierer +Summarise Zusammenfassen +Summarise over time Über die Zeit zusammenfassen Summarise the conversation so far, so a long chat stays within the model's context Den bisherigen Verlauf zusammenfassen, damit ein langer Chat in den Kontext des Modells passt +Summarise Zarr Over Time Zarr über die Zeit zusammenfassen Summarising the earlier conversation to keep it within the model's context… Der bisherige Verlauf wird zusammengefasst, damit er in den Kontext des Modells passt… Summarize Zusammenfassen Summarize\nWithin Zusammenfassen\ninnerhalb @@ -2658,6 +2883,8 @@ Table or layer Tabelle oder Layer Tables Tabellen Tags Tags Take the data in view offline, to edit and sync back later. Die sichtbaren Daten offline nehmen, um sie zu bearbeiten und später zurück zu synchronisieren. +Target Features Zielobjekte +Target Records Zieldatensätze Task Aufgabe Task Item Aufgabenelement Task name Aufgabenname @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Öffentliche GIS-Daten von Tennessee (Tennessee GeoData, geodata.tn.gov): Grundstücksgrenzen, Verkehr, Gesundheit, Hydrologie und mehr. Terrain Gelände Test Test +Test Connection Verbindung testen +Testing… Wird getestet… Text Text Text Symbol Textsymbol That connection would create a loop. Diese Verbindung würde eine Schleife erzeugen. The address fields are empty in every row. Die Adressfelder sind in jeder Zeile leer. +The answer isn't UTF-8 text Die Antwort ist kein UTF-8-Text the answer isn't UTF-8 text die Antwort ist kein UTF-8-Text The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. Der Assistent folgt den Anweisungen eines Skills, wenn er ihn verwendet. Was immer sie sagen: Befehle, die Ihre Zustimmung brauchen, fragen weiterhin nach. The assistant is busy; stop it or wait for it to finish. Der Assistent ist beschäftigt; stoppen Sie ihn oder warten Sie, bis er fertig ist. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. Die Registerkarte des Grundkarten- the capture failed unexpectedly die Aufnahme ist unerwartet fehlgeschlagen The connection failed; trying again in {seconds} s. Die Verbindung ist fehlgeschlagen; neuer Versuch in {seconds} s. The country list couldn't be read ({e}): type a country's code Die Länderliste konnte nicht gelesen werden ({e}): Ländercode eingeben +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. Die aktuelle Höhe bleibt wirksam, während Sie die Eingaben bearbeiten. Klicken Sie auf „Höhe anwenden“, um Wert und Einheiten zu übernehmen. The dataset has nothing RustGIS can add Der Datensatz enthält nichts, was RustGIS hinzufügen kann The environment (AWS_ACCESS_KEY_ID…) Die Umgebung (AWS_ACCESS_KEY_ID…) The features where the map is Die Objekte im Kartenausschnitt @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. Die The installed GeoLibre runner lists no tools. Der installierte GeoLibre-Runner listet keine Werkzeuge auf. The job covers the map view Der Auftrag umfasst die Kartenansicht The key is kept for this session only. Der Schlüssel wird nur für diese Sitzung aufbewahrt. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. Der Layer zeigt den Schritt, auf dem der Zeitschieberegler steht, und schaltet den Regler über die Daten der Variable ein. The layer's file couldn't be read: {error} Die Datei des Layers konnte nicht gelesen werden: {error} +The layer's strokes Die Striche des Layers the layout export failed unexpectedly der Layout-Export ist unerwartet fehlgeschlagen The link doesn't say its STAC API: Find Items searches {api} Der Link nennt keine STAC-API: Elemente finden durchsucht {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. Die Liste der Plugins zum Durchsuchen. Leer lassen für das RustGIS-Plugin-Verzeichnis. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. Die Miniaturansicht des Projekts, au The projection the map opens in. Die Projektion, in der die Karte startet. The provider's content filter stopped the reply. Der Inhaltsfilter des Anbieters hat die Antwort gestoppt. The public server is free and shared by many: it answers light use, and may ask you to wait. Der öffentliche Server ist kostenlos und wird von vielen geteilt: Er bedient leichte Nutzung und kann Sie bitten zu warten. +The range is two numbers, low and high (0, 0.9) Der Bereich besteht aus zwei Zahlen, Minimum und Maximum (0, 0.9) The raster's data couldn't be read. Die Daten des Rasters konnten nicht gelesen werden. The raster's data couldn't be read: {path} Die Daten des Rasters konnten nicht gelesen werden: {path} the release isn't known das Release ist unbekannt @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Die Dateien des Shapefiles werden als The share of each cell covered by fields, for the zoomed-out view Der Anteil jeder Zelle, der von Feldern bedeckt ist, für die herausgezoomte Ansicht The sign-in expired or was revoked. Sign in again. Die Anmeldung ist abgelaufen oder wurde widerrufen. Melden Sie sich erneut an. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. Die Anmeldung wird im Einstellungsordner der App gespeichert, nur für Ihr Benutzerkonto und nie in einer Projektdatei. +The significance level is a number, such as 0.05 Das Signifikanzniveau ist eine Zahl, z. B. 0.05 The start-up project {path} didn't open: {error} Das Startprojekt {path} ließ sich nicht öffnen: {error} The start-up project {path} isn't a RustGIS project file. Das Startprojekt {path} ist keine RustGIS-Projektdatei. The step's layer {name} isn't in the map. Der Layer {name} des Schritts ist nicht in der Karte. The store has no variables with two or more dimensions. Der Speicher hat keine Variablen mit zwei oder mehr Dimensionen. the store is gone der Speicher ist nicht mehr vorhanden +The summary couldn't start: {error} Die Zusammenfassung konnte nicht starten: {error} +The summary failed unexpectedly Die Zusammenfassung ist unerwartet fehlgeschlagen +The summary finished after another project was opened, so it wasn't added Die Zusammenfassung wurde erst nach dem Öffnen eines anderen Projekts fertig und daher nicht hinzugefügt The task's steps changed, so the step being edited was closed: edit it again. Die Schritte der Aufgabe haben sich geändert, daher wurde der bearbeitete Schritt geschlossen: Bearbeiten Sie ihn erneut. the time slider is off Der Zeitschieberegler ist aus The token expires on {date}. Das Token läuft am {date} ab. +The trend couldn't start: {error} Der Trend konnte nicht starten: {error} +The trend failed unexpectedly Der Trend ist unerwartet fehlgeschlagen +The trend finished after another project was opened, so it wasn't added Der Trend wurde erst nach dem Öffnen eines anderen Projekts fertig und daher nicht hinzugefügt +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. Die von Schwarz bis Weiß dargestellten Werte, gleich für jedes Datum und jede Kachel (in den eigenen Einheiten des Kanals). Leer: die eigene Streckung jeder Datei. the view has no extent die Ansicht hat keine Ausdehnung The view is larger than one read: its centre was read (zoom in for the rest) Die Ansicht ist größer als ein Lesevorgang: Ihre Mitte wurde gelesen (für den Rest hineinzoomen) The web app always runs the latest release: reload the page to get it. Die Web-App läuft immer mit dem neuesten Release: Laden Sie die Seite neu, um es zu erhalten. the window's screenshot didn't come back in time, so the map was drawn offscreen Der Screenshot des Fensters kam nicht rechtzeitig zurück, daher wurde die Karte außerhalb des Bildschirms gezeichnet the window's screenshot didn't hold the map, so it was drawn offscreen Der Screenshot des Fensters enthielt die Karte nicht, daher wurde sie außerhalb des Bildschirms gezeichnet Theme Thema +There is no expression defined. Es ist kein Ausdruck definiert. Thermal Thermisch +Thesis figure, full page (160 × 225 mm) Abbildung für Abschlussarbeit, ganze Seite (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Abbildung für Abschlussarbeit, halbe Seite (160 × 110 mm) Thinking… Denkt nach… +Third-party licences Lizenzen von Drittanbietern +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins dieser Build kann keine Plugins herunterladen this build can't reach AI providers dieser Build kann keine KI-Anbieter erreichen this build saves projects with Save As diese Version speichert Projekte mit „Speichern unter“ This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Dieses ChatGPT-Konto war bereits gespeichert. Wählen Sie es nächstes Mal in der Liste, statt es erneut hinzuzufügen. +This class is drawn with the layer's strokes. Changing them here gives it its own. Diese Klasse wird mit den Strichen des Layers gezeichnet. Wenn Sie sie hier ändern, erhält sie eigene. +This class is hidden, so it labels nothing. Diese Klasse ist ausgeblendet und beschriftet daher nichts. +This clause no longer reads, so no feature is labelled: {error} Diese Klausel ist nicht mehr lesbar, daher wird kein Objekt beschriftet: {error} This map has no layers yet. Diese Karte hat noch keine Layer. This map's thumbnail, from an image. Die Miniaturansicht dieser Karte, aus einem Bild. This map's thumbnail, from the view. Die Miniaturansicht dieser Karte, aus der Ansicht. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Diese Anfrage hat keine Option zum einmaligen Erlauben. This request uses an unsupported capability. Check the parameter and change the request. Diese Anfrage verwendet eine nicht unterstützte Funktion. Prüfen Sie den Parameter und ändern Sie die Anfrage. This result is kept Dieses Ergebnis wird behalten +This run has no tool form to open Für diese Ausführung gibt es kein Werkzeugformular zum Öffnen +This session only (optional) Nur diese Sitzung (optional) This step Dieser Schritt This step names no tool to show. Dieser Schritt nennt kein Werkzeug zum Anzeigen. Thumbnail Miniaturansicht @@ -2789,15 +3041,21 @@ Tilt Map Karte neigen Tilt-shift Tilt-Shift Tilt-shift blur and film grain. Tilt-Shift-Unschärfe und Filmkorn. Time Zeit +Time\nSeries Zeit-\nreihe Time\nSlider Zeit-\nregler Time Fields Zeitfelder +Time Series Zeitreihe +Time series Zeitreihe +Time Series at a Point Zeitreihe an einem Punkt Time Slider Zeitschieberegler Time Slider Settings Zeitschieberegler-Einstellungen Tint by elevation Nach Höhe einfärben Tissot's Indicatrix Tissotsche Indikatrix Title Titel +To Bis Toggle Dark Mode Dunkelmodus umschalten Toggle GPU Rendering GPU-Darstellung umschalten +Toggle Imperial Measurements Imperiale Maßeinheiten umschalten Tolerance (km) Toleranz (km) Tolls Mautstraßen Tool Gallery Werkzeuggalerie @@ -2828,7 +3086,10 @@ Train Deep Learning Model Deep-Learning-Modell trainieren Trained models Trainierte Modelle Transparency Transparenz Traverse Polygonzug +Trend Analysis Trendanalyse +Trend over a series of rasters Trend über eine Rasterreihe Triangle Dreieck +TRUE TRUE Try again in a moment. Versuchen Sie es gleich noch einmal. Try again later. Versuchen Sie es später erneut. Turn editing on or off for the map. Die Bearbeitung für die Karte ein- oder ausschalten. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map Geben Sie Länge und Breite ein Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Geben Sie einen Maßstab ein, etwa 1:12,345, 12345 oder 25k, und drücken Sie Enter Type an address or coordinates and press Enter, or click the map. Eine Adresse oder Koordinaten eingeben und Enter drücken oder in die Karte klicken. Type numbers separated by commas Durch Kommas getrennte Zahlen eingeben +Type the keys to log in for this session, then Save. Geben Sie die Schlüssel ein, um sich für diese Sitzung anzumelden, dann Speichern. +Type the password or keys to log in for this session, then Save. Geben Sie das Passwort oder die Schlüssel ein, um sich für diese Sitzung anzumelden, dann Speichern. Typed below Unten eingegeben UM UM Unavailable: {reason} Nicht verfügbar: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Vom Zeitschieberegler lösen Unbind from Time Slider Vom Zeitschieberegler lösen Undo Rückgängig Undo (Ctrl+Z) Rückgängig (Ctrl+Z) -Undo / Redo Rückgängig / Wiederholen Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Rückgängig entfernt den aufgezeichneten Schritt nicht: Löschen Sie ihn unter Schritte bearbeiten, sobald Sie die Aufzeichnung beenden. Undo the last change. Die letzte Änderung rückgängig machen. Undo {action} {action} rückgängig machen @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools GeoLibre-Werkzeuge deinstallieren Uninstall Plugin Plugin deinstallieren Uninstalled {id}. {id} deinstalliert. Unique Values Eindeutige Werte +Unit Einheit United States Vereinigte Staaten Units Einheiten Units per step Einheiten pro Schritt @@ -2877,6 +3140,7 @@ Up a level Eine Ebene nach oben Update Aktualisieren Update Bookmark Lesezeichen aktualisieren Update Job Auftrag aktualisieren +Update Label Class Beschriftungsklasse aktualisieren Update to {version} Auf {version} aktualisieren Upload File Datei hochladen Upload File… Datei hochladen… @@ -2895,6 +3159,7 @@ Use another account Anderes Konto verwenden Use Current View Aktuelle Ansicht verwenden Use each feature's line color Die Linienfarbe jedes Objekts verwenden Use each feature's line width Die Linienbreite jedes Objekts verwenden +Use the Layer's Strokes Striche des Layers verwenden USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan ChatGPT-Tarif wird verwendet Utilities Versorgung Valid combinations, one per line: a, b Gültige Kombinationen, eine pro Zeile: a, b Valid expression · {n} matching rows Gültiger Ausdruck · {n} passende Zeilen +Valid: {n} of {of} features match Gültig: {n} von {of} Objekten passen Validate Validieren Validate Model Modell validieren value (empty: no-data) Wert (leer: NoData) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Vantors offene Bilddaten von Katastrophen: ein Ereignis und eine Phase (davor oder danach) wählen, dann Elemente finden. Vantor… Vantor… Variable Variable +Variogram Model Variogrammmodell Vector and raster data Vektor- und Rasterdaten Vector Layer Vektor-Layer +vector tiles Vektorkacheln Vector tiles (MVT) Vektorkacheln (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Vektoren, die ein Geo-Foundation-Model für jedes Pixel oder jeden Bildausschnitt erzeugt hat, am Kartenort gefunden und als Farben dargestellt. Version {version} Version {version} @@ -2933,6 +3201,9 @@ Video file or URL Videodatei oder URL Video Page Videoseite Videos Videos Video… Video… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Vietnamesisches Landeskoordinatensystem (VN-2000) mit 3-Grad-Transverse-Mercator und einer 7-Parameter-Helmert-Transformation nach WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Vietnamesisches Landeskoordinatensystem (VN-2000) UTM-Zone 48N (102°E–108°E, Mittelmeridian 105°E). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Vietnamesisches Landeskoordinatensystem (VN-2000) UTM-Zone 49N (108°E–114°E, Mittelmeridian 111°E). View Ansicht View Clipping Ansichtsbeschneidung View Effects Ansichtseffekte @@ -2952,6 +3223,9 @@ Visibility Range Sichtbarkeitsbereich Visible Sichtbar Visible Layers Sichtbare Layer Visual Effect Visueller Effekt +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vietnam) +VN-2000 / UTM zone 48N VN-2000 / UTM-Zone 48N +VN-2000 / UTM zone 49N VN-2000 / UTM-Zone 49N Volume Volumen Waiting for the browser… Warte auf den Browser… Waiting for your answer Wartet auf Ihre Antwort @@ -2970,6 +3244,7 @@ Web Link Chart Web-Link-Diagramm Web maps Webkarten Web Mercator Web Mercator Web Scene Webszene +Web service (detected) Webdienst (erkannt) Web Style Webstil Web Tile Layers Web-Kachel-Layer Web Tool Web-Werkzeug @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Wo Beschriftungen relativ zu ihren Where new features get their elevation (the z field). Woher neue Objekte ihre Höhe beziehen (das z-Feld). Where the map opens: the full extent or a view you saved. Wo die Karte startet: in voller Ausdehnung oder in einer gespeicherten Ansicht. Where tools work Wo Werkzeuge arbeiten +Whether the Select tool picks these layers' features on the map Ob das Auswahlwerkzeug die Objekte dieser Layer in der Karte auswählt Which 5 countries have the largest GDP? Welche 5 Länder haben das größte BIP? Whitebox and GeoLibre tools Whitebox- und GeoLibre-Werkzeuge Whole map Ganze Karte @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Welt, flächentreu: wahre Grö World, equal-area: true sizes, shapes bend towards the edges. Welt, flächentreu: wahre Größen, Formen biegen sich zu den Rändern hin. Worldview Grenzen Write a plugin Ein Plugin schreiben +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Schreibt die Sen-Steigung, Z und p-Wert des Tests sowie die Steigung dort, wo der Trend signifikant ist. X (mm) X (mm) X column X-Spalte X Field X-Feld @@ -3050,7 +3327,6 @@ Yours Eigene YYYY-MM-DD JJJJ-MM-TT YYYY-MM-DD, for the layers that change with time JJJJ-MM-TT, für die Layer, die sich mit der Zeit ändern YYYY-MM-DD, for time layers JJJJ-MM-TT, für Zeit-Layer -z (m) z (m) Z factor Z-Faktor Zarr Zarr Zarr Variables Zarr-Variablen @@ -3061,7 +3337,6 @@ Zoom Box Rechteckzoom Zoom Frame Zoom-Rahmen Zoom In Vergrößern Zoom in Vergrößern -Zoom in / out Vergrößern / Verkleinern Zoom In Here Hier vergrößern Zoom in on the centre of the map. Auf die Kartenmitte hineinzoomen. Zoom in · finish a sketch Vergrößern · Skizze beenden @@ -3073,6 +3348,7 @@ Zoom To\nLayer Auf Layer\nzoomen Zoom to a box (Explore) Auf Rechteck zoomen (Erkunden) Zoom To Extent Auf Ausdehnung zoomen Zoom To Layer Auf Layer zoomen +Zoom To Layers Auf Layer zoomen Zoom To Map View Auf Kartenansicht zoomen Zoom To Selection Auf Auswahl zoomen Zoom to selection Auf Auswahl zoomen @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Auf die Ausdehnung des Layers zoomen. Zoom to the selected features. Auf die ausgewählten Objekte zoomen. Zoom to the whole world. Auf die ganze Welt zoomen. Zoom to this bookmark Auf dieses Lesezeichen zoomen +Zoom To Tile Layer Auf Kachel-Layer zoomen Zoom to Tokyo Auf Tokio zoomen Zoom To World Auf Welt zoomen +Zoom {z} · Bearing {b}° · Pitch {p}° Zoom {z} · Richtung {b}° · Neigung {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Zoom: die Zoomstufe der Karte, wie Webkarten sie zählen. Richtung: wohin der obere Kartenrand zeigt, im Uhrzeigersinn ab Norden. Neigung: die Kippung gegenüber dem senkrechten Blick nach unten. Klicken, um Richtung und Neigung zurückzusetzen. {agent} was stopped by you. {agent} wurde von Ihnen gestoppt. {id} changed since it was installed; install it again from the Plugin Manager. {id} wurde seit der Installation geändert; installieren Sie es erneut über den Plugin-Manager. {names}: needs Node.js (npx) for its ACP adapter. {names}: braucht Node.js (npx) für seinen ACP-Adapter. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f9e403c07..0ea8cb59b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6,6 +6,7 @@ (filtered) (filtrada) + Add Stop + Añadir parada + Input + Entrada ++ New expression + Nueva expresión + Output + Salida /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh Malla 3D A 3D globe seen from space. Un globo 3D visto desde el espacio. A blank map with a basemap Un mapa en blanco con un mapa base +A call for the rustgis Python package Una llamada para el paquete de Python rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. La dirección de un catálogo (CSW 2.0.2): busque en sus registros por palabras y, si lo desea, por el área del mapa, y añada los servicios y archivos a los que enlazan. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. Se alcanzó un límite de uso del plan de ChatGPT o de la aplicación. Revisa el uso semanal antes de intentarlo de nuevo. A ChatGPT sign-in can only be used with the OpenAI API. Un inicio de sesión de ChatGPT solo puede usarse con la API de OpenAI. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). Los con A globe scene. Una escena de globo. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Una latitud y longitud (40.7, -74.0 o 40°42'N 74°0'W), una capa, un marcador o un valor de la tabla de una capa. Enter elige la fila resaltada; Buscar lugares busca el texto como un lugar A layer the model reads Una capa que lee el modelo +A line for the Script pane Una línea para el panel Script A magnifying lens that follows the pointer. Una lupa que sigue al puntero. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). La dirección de un servicio de mapas, entidades o coberturas: WMS, WMTS, WFS, WCS, OGC API Features o Tiles, o un ArcGIS FeatureServer, MapServer, ImageServer o VectorTileServer (o un directorio de servicios, para explorar). a name, or owner/name such as giswqs/geospatial un nombre, o propietario/nombre como giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Un punto de conexión con nombre que puede reutilizar. Las contraseñas y claves se conservan hasta que se cierra RustGIS, nunca se guardan. A node cannot connect to itself. Un nodo no puede conectarse consigo mismo. A notebook of text and command cells. Un cuaderno de celdas de texto y de comandos. +A password or token in the address was left out: connections never keep secrets. Se omitió una contraseña o un token de la dirección: las conexiones nunca guardan secretos. A place or an address Un lugar o una dirección A project keeps at least one map Un proyecto conserva al menos un mapa A projected coordinate system. Un sistema de coordenadas proyectadas. A report listing the selected layer's records, page after page. Un informe que enumera los registros de la capa seleccionada, página tras página. A result that only feeds other tools is removed once the model has run; add an output to keep it. Un resultado que solo alimenta otras herramientas se elimina una vez ejecutado el modelo; añada una salida para conservarlo. A result the model keeps, under a name Un resultado que el modelo conserva, con un nombre +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) Un comando de rustgis-cli, para un shell POSIX (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Ya hay una importación o actualización de servicio en curso. +A service's layers stay in their service group. Las capas de un servicio permanecen en su grupo de servicio. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Una tarea admite como máximo 200 pasos: detenga la grabación para crearla y luego grabe el resto como otra tarea. A template with the first selected feature's values Una plantilla con los valores de la primera entidad seleccionada +A tile layer per grid tile, each following the time slider through its dates. Una capa de teselas por cada tesela de la cuadrícula, cada una siguiendo el control deslizante de tiempo por sus fechas. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. La dirección de un servicio de teselas: teselas ráster con {z}/{x}/{y} (o {-y}, {q}, {bbox-epsg-3857} para WMS), teselas vectoriales (.pbf) o TileJSON, un estilo de MapLibre, un archivo PMTiles (una dirección web o un archivo de este equipo) o un archivo MBTiles de este equipo. A token is set. Hay un token establecido. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Un valor, un campo o dos de ellos unidos por + − × ÷. Se aplica a las filas seleccionadas, o a todas las filas si no hay ninguna seleccionada. a vector a pixel un vector por píxel a vector an image patch un vector por parche de imagen A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Un almacén Zarr (formato 2 o 3) por su dirección web, con metadatos consolidados, o una carpeta de este equipo. El mapa de una variable se lee para los pasos elegidos de sus otras dimensiones, como el tiempo. +A Zarr variable's values through time at points clicked on the map. Los valores de una variable Zarr a lo largo del tiempo en puntos en los que se hace clic en el mapa. +A2 landscape sheet Hoja A2 horizontal +A2 portrait sheet Hoja A2 vertical +A3 landscape sheet Hoja A3 horizontal +A3 portrait sheet Hoja A3 vertical +A4 landscape sheet Hoja A4 horizontal +A4 portrait sheet Hoja A4 vertical +A5 Grid Cuadrícula A5 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. Las celdas pentagonales de áreas iguales de A5 a una resolución, sobre la extensión del mapa, una capa o el mundo, como polígonos con sus identificadores. About Acerca de About plugins Acerca de los complementos About RustGIS Acerca de RustGIS @@ -65,6 +83,7 @@ Add Añadir Add\nData Añadir\ndatos Add ({n}) Añadir ({n}) Add a bar chart of a layer's numeric field. Añadir un gráfico de barras de un campo numérico de una capa. +Add a circle. Añadir un círculo. Add a colorbar of a raster layer's colour scale in the map frame. Añadir una barra de colores con la escala de colores de una capa ráster en el marco de mapa. Add a description. Añadir una descripción. Add a field. Añadir un campo. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Añadir un diseño de pág Add a page to the presentation. Añadir una página a la presentación. Add a PNG or JPEG picture to the page. Añadir una imagen PNG o JPEG a la página. Add a presentation file. Añadir un archivo de presentación. +Add a rectangle, to frame or highlight part of the page. Añadir un rectángulo, para enmarcar o resaltar parte de la página. Add a report file, or a report template for the selected layer. Añadir un archivo de informe, o una plantilla de informe para la capa seleccionada. Add a scale bar for the map frame. Añadir una barra de escala para el marco de mapa. add a second map first (Data › New Map) añada primero un segundo mapa (Datos › Nuevo mapa) Add a second map frame first Añada primero un segundo marco de mapa +Add a straight line. Añadir una línea recta. Add a text element. Añadir un elemento de texto. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Añadir un mapa base web sobre los demás en lugar de reemplazarlos, para fusionarlo con ellos. Haga clic en uno visible para quitarlo. +Add a Zarr variable first (Add Data › Zarr…). Añada primero una variable Zarr (Añadir datos › Zarr…). Add Administrative Boundaries Añadir límites administrativos +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Añadir una flecha. Gírela con el tirador redondo situado encima o defina su ángulo en sus propiedades. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. Añada un token de Earthdata Login (Earthdata Login, arriba) para poner GeoTIFF de la NASA en el mapa. Add an elevation raster first. Añada primero un ráster de elevación. Add an elevation raster to the map first Añada primero un ráster de elevación al mapa +Add an ellipse. Añadir una elipse. Add an empty layer with a ready-made schema, to draw into. Añadir una capa vacía con un esquema predefinido, para dibujar en ella. Add an input for each layer the model reads. Añada una entrada por cada capa que lee el modelo. Add an output to keep a result under a name. Añada una salida para conservar un resultado con un nombre. Add and Open Añadir y abrir Add and Open Notebook Añadir y abrir cuaderno Add Archive Añadir archivo histórico +Add as Time Series Añadir como serie temporal add at least two keyframes añada al menos dos fotogramas clave Add Cell Añadir celda Add Color Ramp File… Añadir archivo de rampa de color… @@ -132,6 +157,7 @@ Add Graphics Layer Añadir capa de gráficos Add Input Añadir entrada Add Job Añadir trabajo Add Keyframe Añadir fotograma clave +Add Label Class Añadir clase de etiqueta Add Layer File Añadir archivo de capa Add Open Data Añadir datos abiertos Add Output Añadir salida @@ -147,9 +173,11 @@ Add Rule Añadir regla Add sample countries Añadir países de ejemplo Add Sample Data Añadir datos de ejemplo Add Satellite Embeddings Añadir incrustaciones satelitales +Add Selected ({n}) Añadir seleccionados ({n}) Add Service Layer Añadir capa de servicio Add skill… Añadir habilidad… Add STAC Asset Añadir recurso STAC +Add STAC Data Cube Añadir cubo de datos STAC Add STAC Footprints Añadir huellas STAC Add Step Añadir paso Add Stroke Añadir trazo @@ -171,6 +199,7 @@ Add to Favourites Añadir a favoritos Add to Map Añadir al mapa Add vector data. Añadir datos vectoriales. Add view Añadir vista +Add With Options… Añadir con opciones… Add Zarr Variable Añadir variable Zarr Add Zarr Variable… Añadir variable Zarr… Add ▾ Añadir ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Los agentes tienen acceso total: pueden ejecutar scripts, tareas, modelos y complementos. Haga clic para detener el relé MCP Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Los agentes de este equipo que se conecten (Claude Code, Codex, Cursor y otros) trabajan en el mapa abierto aquí con las herramientas de RustGIS: pueden abrir y guardar archivos y usar la web. Los scripts y la gestión de complementos siguen desactivados. Agents then run commands, change files and use the web on this computer without asking you. Los agentes ejecutarán comandos, cambiarán archivos y usarán la web en este equipo sin preguntarte. +Aggregate Points to A5 Agregar puntos en A5 Aggregation Agregación Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Límites de campos agrícolas que el modelo Fields of the World encontró en imágenes Sentinel-2, en todo el mundo, año por año (2.ª edición, CC BY 4.0). Son predicciones del modelo, no parcelas catastrales. AI IA @@ -221,15 +251,18 @@ All steps done. Todos los pasos completados. All tables with geometry Todas las tablas con geometría Allow Permitir Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Permite el uso del plan de ChatGPT al iniciar sesión. Si tu espacio de trabajo no lo permite, pide permiso a su administrador o elige un proveedor con clave de API. +Allow Feature Selection Permitir la selección de entidades Allow only certain combinations of values across fields. Permitir solo ciertas combinaciones de valores entre campos. Allow such actions for this conversation Permitir este tipo de acciones en esta conversación Allow this action? ¿Permitir esta acción? Allow this agent's RustGIS action? ¿Permitir esta acción de RustGIS del agente? Along Selected Line A lo largo de la línea seleccionada +Alt+ Alt+ Alternating bar Barra alterna Altitude Altitud Always allow Permitir siempre -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3, o un servicio compatible con S3 (MinIO, Cloudflare R2, Wasabi…) por su punto de conexión. Las claves se conservan hasta que se cierra RustGIS; una conexión recordada conserva su nombre, punto de conexión y región, nunca su secreto. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3, o un servicio compatible con S3 (MinIO, Cloudflare R2, Wasabi…) por su punto de conexión. Las claves se conservan hasta que se cierra RustGIS; una conexión guardada conserva su nombre, punto de conexión y región, nunca su secreto. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Un agente de este equipo pide: An AWS profile Un perfil de AWS @@ -248,6 +281,7 @@ API key Clave de API Apply Aplicar Apply a function to a raster Aplicar una función a un ráster Apply Control Points Aplicar puntos de control +Apply elevation Aplicar elevación Apply Style Aplicar estilo Apply to layers Aplicar a capas Apply to the selected layer Aplicar a la capa seleccionada @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Área de uso: a menos de {r}° de Area {area} · Perimeter {perimeter} Área {area} · Perímetro {perimeter} Areas (name, land use, area) Áreas (nombre, uso del suelo, área) Arrange Organizar +Arrow Flecha +Arrowheads Puntas de flecha Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Haga una pregunta sobre sus datos, o describa qué hacer con el mapa. El asistente ejecuta comandos de RustGIS por usted; cada cambio se puede deshacer. Ask about the map, or tell it what to do… Pregunte sobre el mapa, o dígale qué hacer… Ask about your data, or tell the assistant what to do with the map. Pregunte sobre sus datos, o diga al asistente qué hacer con el mapa. @@ -275,12 +311,13 @@ Asking NASA's CMR… Consultando el CMR de la NASA… Asking the catalog… Consultando el catálogo… Asking the NLDI… Consultando el NLDI… Assistant settings Configuración del asistente +At the end Al final +At the start Al principio Atlas Atlas Attachments Adjuntos Attribute\nTable Tabla de\natributos Attribute Rules Reglas de atributos Attribute Table Tabla de atributos -Attribute table Tabla de atributos Attributes Atributos Attribution Atribución Aurora Aurora @@ -295,11 +332,9 @@ Automatic (system language) Automático (idioma del sistema) Automatic (system time zone and region) Automático (zona horaria y región del sistema) Automatic ({kind}) Automático ({kind}) Avoid Evitar -AWS AWS AWS, anonymous AWS, anónimo Azimuth Acimut Back Atrás -Back to Explore; cancel a sketch Volver a Explorar; cancelar un boceto Back to the Map Volver al mapa Back to the map Volver al mapa Back to tools Volver a las herramientas @@ -314,8 +349,6 @@ Basemap Mapa base Basemaps Mapas base Basin from hydrolocation Cuenca desde hidrolocalización Bearing Orientación -Bearing {b}° · Pitch {p}° Rumbo {b}° · Inclinación {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Rumbo: la dirección hacia la que apunta la parte superior del mapa, en sentido horario desde el norte. Inclinación: la desviación respecto a mirar en vertical hacia abajo. Haga clic para restablecer ambos. Before the event Antes del evento Below Abajo Below 1:5,000,000 show everything; beyond it only what matches: Por debajo de 1:5.000.000 se muestra todo; por encima, solo lo que cumpla: @@ -328,10 +361,12 @@ Bilinear Bilineal Bind To Time Slider Vincular al control deslizante de tiempo Bind to Time Slider Vincular al control deslizante de tiempo Blank En blanco +Blank for Amazon S3 En blanco para Amazon S3 Blank Letter page Página Carta en blanco Blank Map Mapa en blanco Blank Page Página en blanco Blend: {mode} Fusión: {mode} +Block Feature Selection Bloquear la selección de entidades Blue Azul Bold Negrita Bold label text. Texto de etiqueta en negrita. @@ -339,6 +374,7 @@ Bookmark Description Descripción del marcador Bookmarks Marcadores Bookmarks Pane Panel Marcadores Boolean Booleano +Both ends Ambos extremos Bottom Abajo Boundaries… Límites… Bounding\nGeometry Geometría\nenvolvente @@ -346,6 +382,7 @@ Brightness Brillo Brightness, contrast, saturation and sepia for the whole map. Brillo, contraste, saturación y sepia para todo el mapa. Bring this bookmark's map into view to update it Muestre el mapa de este marcador para actualizarlo Browse Explorar +Browse Connection Explorar conexión Browse it in the Browser Explorarlo en el Navegador Browse Plugins Explorar complementos Browse the geoprocessing toolbox. Explorar la caja de herramientas de geoprocesamiento. @@ -357,6 +394,7 @@ Browser Navegador Browser\nPane Panel\nNavegador Browser View Vista del navegador Bucket Bucket +Buckets Buckets Buffer Área de influencia Buffer A Point Área de influencia de un punto Buffer the cities by 200 km Crear un área de influencia de 200 km alrededor de las ciudades @@ -381,7 +419,9 @@ Calculate Field Calcular campo Calculate field Calcular campo Calculate field values. Calcular los valores de un campo. Calculate fields and enforce conditions on every edit. Calcular campos y aplicar condiciones en cada edición. +Calculate Geometry Calcular geometría Calculate Geometry Attributes Calcular atributos de geometría +Calculated {rows} features Se calcularon {rows} entidades Calculation Cálculo Camera Cámara Camera Effect Efecto de cámara @@ -396,12 +436,14 @@ Case Field Campo de caso Cast Shadow Proyectar sombra Catalog Catálogo catalog is a static STAC catalog's catalog.json or collection.json catalog es el catalog.json o collection.json de un catálogo STAC estático +Catalogue (CSW) Catálogo (CSW) Catalogue search (CSW) Búsqueda en catálogo (CSW) Celestial Bodies Cuerpos celestes Celestial Body Cuerpo celeste Celestial body: {body} Cuerpo celeste: {body} Cell size Tamaño de celda cell size ° tamaño de celda ° +Cells Celdas Center Centro Center Map Here Centrar el mapa aquí Centered Centrado @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. El uso del plan d ChatGPT sign-in failed. Check the connection and local settings, then try again. No se pudo iniciar sesión en ChatGPT. Comprueba la conexión y los ajustes locales e inténtalo de nuevo. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. El uso de ChatGPT no está disponible temporalmente. Conserva tu sesión e inténtalo más tarde. CHE CHE +Check Comprobar Check Again Volver a comprobar Check for\nUpdates Buscar\nactualizaciones Check for Updates Buscar actualizaciones @@ -428,6 +471,7 @@ Check for Updates… Buscar actualizaciones… Check the address and the service type. Compruebe la dirección y el tipo de servicio. Check the address and your connection. Compruebe la dirección y su conexión. Check the address. Compruebe la dirección. +Check the clause, and count the features it takes. Comprobar la cláusula y contar las entidades que abarca. Check the integration's client, permissions, workspace policy and route configuration. Comprueba el cliente, los permisos, la política del espacio de trabajo y la configuración de rutas de la integración. Checking for updates… Buscando actualizaciones… China China @@ -455,6 +499,7 @@ Choose which layers a click identifies. Elegir qué capas identifica un clic. Choose which tabs the ribbon shows. Elegir qué pestañas muestra la cinta. Circle Círculo Circle Radius (km) Radio del círculo (km) +Class Clase Class color Color de la clase Class Style Estilo de clase Classes Clases @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Haga clic para añadir Click to edit the description Haga clic para editar la descripción Click to place a point graphic. Haga clic para colocar un gráfico de punto. Click to select the node Haga clic para seleccionar el nodo +Click to select, double-click to add, right-click for more Clic para seleccionar, doble clic para añadir, clic derecho para más opciones Click to switch between decimal degrees and DMS Haga clic para alternar entre grados decimales y GMS Clip Recortar Clip a raster to the view, or resample it. Recortar un ráster a la vista, o remuestrearlo. @@ -532,7 +578,7 @@ Close thread Cerrar hilo Close {name} Cerrar {name} Closest Más cercana Coded values, one per line: code=description Valores codificados, uno por línea: código=descripción -Collapse or expand the ribbon Contraer o expandir la cinta +Collapse or Expand the Ribbon Contraer o expandir la cinta Collapse the ribbon (Ctrl+F1) Contraer la cinta (Ctrl+F1) Collection Colección Collection {id}: choose dates and Find Items Colección {id}: elija fechas y Buscar ítems @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Combinaciones del cuadro de arriba, una por línea COMID {comid} COMID {comid} Comma Coma +Comma-separated (optional) Separados por comas (opcional) Command\nSearch Buscar\ncomandos command is required se requiere un comando Command Search Búsqueda de comandos @@ -572,10 +619,13 @@ Connect Conectar Connect a folder to the Browser. Conectar una carpeta al Navegador. Connect to S3 Conectar a S3 Connect, or pick a layer Conecte o elija una capa +Connected to {kind} {title} Conectado a {kind} {title} Connecting to {url}… Conectando a {url}… Connection Conexión +connection.new returned no id connection.new no devolvió ningún id Connections Conexiones -Connect… Conectar… +Connections you add are listed here Las conexiones que añada aparecen aquí +Connections… Conexiones… Constant Constante Constant height Altura constante Constant… Constante… @@ -583,11 +633,14 @@ Contingent Values Valores contingentes Continue Continuar Continue with ChatGPT Continuar con ChatGPT Contrast Contraste +Control Point Fit Ajuste por puntos de control Conversion Conversión Convert Convertir Convert a layer to another format Convertir una capa a otro formato Convert the selected layer's labels into an annotation layer. Convertir las etiquetas de la capa seleccionada en una capa de anotación. +Convert this layer's labels into an annotation layer. Convertir las etiquetas de esta capa en una capa de anotación. Convert To Annotation Convertir a anotación +Convert to Annotation Convertir a anotación Converted data Datos convertidos Convert… Convertir… Coordinate Coordenada @@ -599,11 +652,15 @@ Copied the map to the clipboard Mapa copiado al portapapeles Copied {path} Copiado {path} Copies the (selected) features of a layer to a new layer. Copia las entidades (seleccionadas) de una capa a una capa nueva. Copy Copiar +Copy / Cut / Paste features Copiar / Cortar / Pegar entidades Copy a model.run command that runs this model, for the Script pane or Python Copiar un comando model.run que ejecuta este modelo, para el panel Script o Python Copy Address Copiar dirección Copy address Copiar dirección Copy an image of the map to the clipboard. Copiar una imagen del mapa al portapapeles. +Copy as CLI Command Copiar como comando de CLI +Copy as Command Copiar como comando Copy As GeoJSON Copiar como GeoJSON +Copy as Python Copiar como Python Copy Command Copiar comando Copy coordinates to the clipboard Copiar las coordenadas al portapapeles Copy details Copiar detalles @@ -611,11 +668,13 @@ Copy Group Copiar grupo Copy Layer Copiar capa Copy Layers Copiar capas Copy Path Copiar ruta +Copy Run As Code Copiar ejecución como código Copy sign-in link Copiar enlace de inicio de sesión Copy the selected features (also as GeoJSON text). Copiar las entidades seleccionadas (también como texto GeoJSON). Copy the selected features and delete them. Copiar las entidades seleccionadas y eliminarlas. Copy the selected layer's data source path. Copiar la ruta de la fuente de datos de la capa seleccionada. Copy this element (Ctrl+D, ⌘D on a Mac) Copiar este elemento (Ctrl+D, ⌘D en un Mac) +Copy this run as code, or open it in its tool Copiar esta ejecución como código, o abrirla en su herramienta Corrections Correcciones Cost Coste could not draw the map no se pudo dibujar el mapa @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} No se pudo cargar el registro de comp Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Recuento, suma, media, mínimo, máximo y desviación estándar de un campo, opcionalmente por campo de caso. Countries, cities, rivers and lakes Países, ciudades, ríos y lagos Country País +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Cuenta los puntos de una capa según la celda A5 que los contiene (celdas de áreas iguales, por lo que el recuento es una densidad), con la suma y la media de un campo. Counts the points that fall inside each polygon. Cuenta los puntos que caen dentro de cada polígono. Counts, totals and top categories of what's in view. Recuentos, totales y categorías principales de lo que está a la vista. CPU CPU @@ -647,12 +707,8 @@ Cross Section Sección transversal Cross Section (2 clicks) Sección transversal (2 clics) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Acumulativo Current date Fecha actual Current Project Proyecto actual @@ -680,13 +736,16 @@ Data type Tipo de datos Data you add is listed here Los datos que añada aparecen aquí data.gov and CKAN catalogues Catálogos de data.gov y CKAN Database Base de datos -Databases Bases de datos +Database connections are used by the desktop app Las conexiones a bases de datos las usa la aplicación de escritorio +databases are read through DuckDB, a program the browser can't run las bases de datos se leen a través de DuckDB, un programa que el navegador no puede ejecutar Database… Base de datos… Dataset Conjunto de datos Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Conjuntos de datos en Hugging Face por nombre o propietario/nombre; explore los archivos de un conjunto de datos y añada GeoTIFF, GeoParquet, PMTiles, LiDAR y otros archivos. Data… Datos… Date Fecha Dates Fechas +Dates a cell needs Fechas que necesita una celda +Dates a cell needs is a whole number, or empty for half Fechas que necesita una celda es un número entero, o vacío para la mitad Day Día Day and night shading for a time. Sombreado de día y noche en un momento dado. Decimal Decimal @@ -715,7 +774,6 @@ Delete Notebook Eliminar cuaderno Delete Page Eliminar página Delete Presentation Eliminar presentación Delete Report Eliminar informe -Delete selected features Eliminar las entidades seleccionadas Delete selected rows Eliminar las filas seleccionadas Delete Style Eliminar estilo Delete Task Eliminar tarea @@ -736,11 +794,15 @@ Desktop and command line only Solo en la aplicación de escritorio y la línea d Details Detalles Details of the selected layer. Detalles de la capa seleccionada. Deviations Desviaciones +DGGS DGGS Diagnostic\nMonitor Monitor de\ndiagnóstico Diagnostic Monitor Monitor de diagnóstico Diagnostics Diagnóstico +Diagonal ↗ Diagonal ↗ +Diagonal ↘ Diagonal ↘ Diamond Rombo Difference, ratio or percent change between two rasters. Diferencia, razón o cambio porcentual entre dos rásteres. +Digit Digit Directions Indicaciones Directions From Here Indicaciones desde aquí Directions To Here Indicaciones hasta aquí @@ -758,6 +820,7 @@ Dissolve Field Campo de disolución Distance Distancia Distance (km) Distancia (km) Distance Coefficient P Coeficiente de distancia P +Distance units Unidades de distancia Distances along the surface, heights, areas and volumes. Distancias sobre la superficie, alturas, áreas y volúmenes. DISTINCT DISTINCT Distinct Distintos @@ -804,18 +867,23 @@ Drag to draw Arrastre para dibujar Drag to draw a freehand line. Arrastre para dibujar una línea a mano alzada. Drag to draw a freehand polygon. Arrastre para dibujar un polígono a mano alzada. Drag to draw a rectangle. Arrastre para dibujar un rectángulo. +Drag to resize the message box Arrastre para cambiar el tamaño del cuadro de mensaje Drag tools from the palette onto the canvas. Arrastre herramientas desde la paleta al lienzo. Drag vertices on the map; click an edge to add one Arrastre vértices en el mapa; haga clic en un borde para añadir uno Draw a halo around label text. Dibujar un halo alrededor del texto de la etiqueta. Draw a labelled latitude/longitude grid over the map frame. Dibujar una cuadrícula de latitud/longitud etiquetada sobre el marco de mapa. Draw a model: chain tools from input layers to the results to keep Dibujar un modelo: encadenar herramientas desde las capas de entrada hasta los resultados que se conservan Draw a model: chain tools from input layers to the results to keep. Dibujar un modelo: encadenar herramientas desde las capas de entrada hasta los resultados que se conservan. +Draw at its place in the stack, the bottom stroke first Dibujar en su lugar de la pila, primero el trazo inferior +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Dibujar los trazos de un nivel de todas las entidades antes del siguiente, para que los bordes se unan bajo los núcleos donde se cruzan las líneas Draw labels at every scale and distance. Dibujar etiquetas a cualquier escala y distancia. Draw new features in the selected layer. Double-click to finish lines and polygons. Dibujar entidades nuevas en la capa seleccionada. Doble clic para terminar líneas y polígonos. Draw new single-band rasters with the inverted grey stretch again. Volver a dibujar los nuevos rásteres de una banda con el estiramiento en grises invertido. +Draw only polygon outlines, keeping their colors and widths. Dibujar solo los contornos de los polígonos, conservando sus colores y anchos. Draw points as bins coloured by how many fall in each. Dibujar los puntos como celdas coloreadas según cuántos caen en cada una. Draw polygons as outlines only. Dibujar los polígonos solo como contornos. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Dibujar los rásteres de una banda que se añadan a partir de ahora con este esquema de color, estiramiento y remuestreo. +Draw strokes in levels Dibujar trazos por niveles Draw the layer at every scale. Dibujar la capa en todas las escalas. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Dibujar el mapa en la GPU (beta; el globo, los efectos de vista y la simulación de visión del color siguen en el renderizador de CPU). Draw the selected layer above others. Dibujar la capa seleccionada por encima de las demás. @@ -828,6 +896,7 @@ drawn by the server dibujado por el servidor Drive Coche Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. Imágenes de drones y aéreas compartidas en OpenAerialMap, las más recientes primero, añadidas como GeoTIFF optimizados para la nube. Duplicate Duplicar +Duplicate Connection Duplicar conexión Duplicate Element Duplicar elemento Duplicate Layout Duplicar diseño Duplicate Map Duplicar mapa @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name p. ej. pop_est / 1000000 e.g. 0, 255, -9999 p. ej. 0, 255, -9999 e.g. continent = 'Europe' p. ej. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' p. ej. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 p. ej. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Cada ejecución de geoprocesamiento se convierte en un paso, encadenado por sus salidas Earth Index has no points in the view Earth Index no tiene puntos en la vista Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Editar\nvértices Edit and run the notebook. Editar y ejecutar el cuaderno. Edit Attribute Editar atributo Edit Cell Editar celda +Edit Connection Editar conexión Edit Description Editar descripción Edit Graphic Editar gráfico Edit Status Estado de edición @@ -882,6 +953,7 @@ Editor\nTools Herramientas\nde edición Editor Tools Herramientas de edición Edits since the project was saved. Ediciones desde que se guardó el proyecto. Edits the project Edita el proyecto +Edit… Editar… Effects Efectos Element Elemento Element Properties Propiedades del elemento @@ -898,21 +970,25 @@ Enable Lighting Activar iluminación Enable Plugin Activar complemento Enabled Activado Encloses each feature, or all features, in its convex hull. Encierra cada entidad, o todas las entidades, en su envolvente convexa. +Encoded polyline Polilínea codificada End Fin End date Fecha de fin Endpoint Punto de conexión Enhancement Mejora Enter Enter Enter an API key. Introduzca una clave de API. +Enter an EPSG code as a number, such as 32650 Introduzca un código EPSG como número, por ejemplo 32650 Enter the provider's address. Introduzca la dirección del proveedor. Enter to send, Shift+Enter for a new line Enter para enviar, Shift+Enter para una nueva línea EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Entornos EPSG EPSG +EPSG code Código EPSG EPSG or ESRI code Código EPSG o ESRI EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Intervalo igual Equirectangular (Plate Carrée) Equirrectangular (Plate Carrée) @@ -922,14 +998,18 @@ Error Error Error Inspector Inspector de errores Error Inspector (validate) Inspector de errores (validar) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Europa de áreas iguales (ETRS89 LAEA) Every band's value at a pixel. El valor de cada banda en un píxel. Every editing tool. Todas las herramientas de edición. +Every label setting of this layer, in the Labeling pane. Todos los ajustes de etiquetas de esta capa, en el panel Etiquetado. Every layer, as FEMA draws them Todas las capas, tal como las dibuja FEMA +Every run, in order, as a script that opens the project Todas las ejecuciones, en orden, como un script que abre el proyecto every Socrata site, or one such as data.cityofnewyork.us todos los sitios de Socrata, o uno como data.cityofnewyork.us Everywhere En todas partes +Existing field Campo existente Existing Template Plantilla existente exit rotary salga de la rotonda exit roundabout salga de la rotonda @@ -948,6 +1028,7 @@ Export\nRaster Exportar\nráster Export Animation Exportar animación Export Bookmarks Exportar marcadores Export Bookmarks… Exportar marcadores… +Export CSV… Exportar CSV… Export Features Exportar entidades Export Features (tool) Exportar entidades (herramienta) Export Features needs a vector layer (Data › Export Raster writes a raster) Exportar entidades necesita una capa vectorial (Datos › Exportar ráster escribe un ráster) @@ -996,6 +1077,7 @@ Face Culling Ocultación de caras Faces Caras Factor Factor Failed Error +FALSE FALSE Farthest Más lejana Faster drawing: no antialiasing. Dibujo más rápido: sin suavizado. Favourite Data Source Origen de datos favorito @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Límites de campos de cultivo Field density Densidad de campos de cultivo Field name Nombre del campo +Field Prefix Prefijo de campo +Field to Sum and Average Campo para sumar y promediar Fields Campos Fields are drawn from zoom 9: zoom in to see them. Los campos de cultivo se dibujan a partir del zoom 9: acerque para verlos. Fields colored by score, drawn from zoom 9 Campos de cultivo coloreados por puntuación, dibujados a partir del zoom 9 @@ -1079,6 +1163,8 @@ Folder Connections Conexiones a carpetas folder connections need the desktop app las conexiones a carpetas requieren la aplicación de escritorio Folders Carpetas Folder… Carpeta… +Follow the time slider Seguir el control deslizante de tiempo +Font Fuente for a store that doesn't say para un almacén que no lo indica Forest Bosque Forget Earthdata Login Token Olvidar token de Earthdata Login @@ -1090,9 +1176,11 @@ France Francia Free the prepared geometry. Liberar la geometría preparada. Freehand A mano alzada FROM FROM +From Desde From Selection Desde la selección From Service… Desde servicio… From symbol Del símbolo +From the address Desde la dirección from the address de la dirección from the service del servicio From URL… Desde URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Ocultar las entidades alejadas del cen Hide Group Ocultar grupo Hide labels when the viewer is closer than this. Ocultar las etiquetas cuando el observador esté más cerca que este valor. Hide labels when the viewer is farther than this. Ocultar las etiquetas cuando el observador esté más lejos que este valor. +Hide Layers Ocultar capas Hide other values Ocultar otros valores Hide Steps Ocultar pasos Hide the description Ocultar la descripción @@ -1213,6 +1302,8 @@ Hold Retener HOME HOME Home Inicio Home › Add Inicio › Añadir +Home › Add Data Inicio › Añadir datos +Home › Inspect › Measure Inicio › Inspeccionar › Medir Home › Navigate Inicio › Navegar Home › Navigate › Bookmarks Inicio › Navegar › Marcadores Home › Web Data Inicio › Datos web @@ -1220,7 +1311,7 @@ Home › Web Data › United States Inicio › Datos web › Estados Unidos Homepage Página principal Horizontal Horizontal host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=…, o un archivo .duckdb +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Imágenes alojadas Hour Hora How the layer's colours combine with the tile layers and basemap beneath. Cómo se combinan los colores de la capa con las capas de teselas y el mapa base de debajo. @@ -1248,6 +1339,7 @@ Imagery Sharing Compartir imágenes Imagery Tools Herramientas de imágenes ImageServer ImageServer Image… Imagen… +Imperial Imperial Import Importar Import\nProject Importar\nproyecto Import All Importar todo @@ -1267,6 +1359,7 @@ Import Task Importar tarea Import Task… Importar tarea… Import Thumbnail Importar miniatura Import… Importar… +IN IN In Beyond Límite al acercar In memory En memoria In Progress En curso @@ -1281,6 +1374,8 @@ Information Información INPUT ENTRADA Input Entrada Input Features Entidades de entrada +Input Layer Capa de entrada +Input Points Puntos de entrada Input Polygons Polígonos de entrada Input Rows Filas de entrada Input Summary Features Entidades de resumen de entrada @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Los complementos instalados se guardan en Installed with Homebrew, winget, the AUR or COPR? Update it there. ¿Lo instaló con Homebrew, winget, AUR o COPR? Actualícelo desde ahí. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. Se ha instalado {name} {version}. Lo encontrará en la pestaña Complementos; sus herramientas también están en el panel Herramientas. Installing it downloads Python {version} and the packages it needs from the internet. Al instalarlo se descargan de internet Python {version} y los paquetes que necesita. +Integer field: values are rounded. Campo entero: los valores se redondean. Interface font: {font} Fuente de la interfaz: {font} Interpolate a surface from points (IDW) Interpolar una superficie a partir de puntos (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Interpola una superficie ráster de 2 bandas (Predicción y Varianza) a partir de entidades de punto mediante kriging ordinario. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. Interpola una superficie ráster a partir de valores de entidades por ponderación de distancia inversa, como QGIS. Interpolation Interpolación Interpolation Attribute Atributo de interpolación @@ -1316,10 +1413,13 @@ Invalid: {error} No válida: {error} Invert Invertir Invert color scheme Invertir esquema de color Invert the selection. Invertir la selección. +IS IS IS NULL IS NULL it connects to an address on the internet se conecta a una dirección de internet It has no code: nothing in it runs. No tiene código: nada en él se ejecuta. +It is locked, or one of its surrounds is: unlock it first Está bloqueado, o lo está uno de sus elementos de contorno: desbloquéelo primero it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) está reservado para usted (complementos, scripts, tareas, modelos y el ejecutor de GeoLibre) +it needs input: open it from its pane or button, or run it from a script or the assistant necesita datos de entrada: ábralo desde su panel o botón, o ejecútelo desde un script o el asistente it reads or writes files on this computer lee o escribe archivos en este equipo it replaces the open project reemplaza el proyecto abierto it runs a plugin's command ejecuta un comando de un complemento @@ -1332,20 +1432,30 @@ Italic Cursiva Item Pop-ups Ventanas emergentes de elementos Item {id} Ítem {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. Su cortafuegos ({firewall}) bloqueó RustGIS. Es posible que el servicio solo permita algunas aplicaciones: contacte con su administrador y envíele los detalles de abajo. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Su herramienta no está disponible ahora (de un complemento no cargado o de un conjunto no instalado) Jobs Trabajos JOIN JOIN Join Unión +Join Features Entidades de unión +Join Operation Operación de unión +Joins attributes from a join layer to a target layer based on their spatial relationship. Une atributos de una capa de unión a una capa de destino según su relación espacial. +Journal figure, 1 column (90 mm) Figura de revista, 1 columna (90 mm) +Journal figure, 1.5 columns (140 mm) Figura de revista, 1,5 columnas (140 mm) +Journal figure, 2 columns (190 mm) Figura de revista, 2 columnas (190 mm) Keep a history of every edit to the layer, to look back or restore. Conservar un historial de cada edición de la capa, para consultarlo o restaurarlo. Keep an archive of edits Conservar un archivo histórico de ediciones keep current mantener actual +Keep it from being moved, resized, deleted or edited by mistake Evitar que se mueva, cambie de tamaño, elimine o edite por error Keep one value of each point's vector as its value field Conservar un valor del vector de cada punto como su campo de valor Keep Result Conservar resultado Keep the labelled features fixed as the map moves. Mantener fijas las entidades etiquetadas al mover el mapa. Keep the last drawing tool active after each graphic. Mantener activa la última herramienta de dibujo después de cada gráfico. Keep the last tool active Mantener activa la última herramienta +Keep this endpoint under Data Sources › Connections, without its password or keys Conservar este punto de conexión en Orígenes de datos › Conexiones, sin su contraseña ni claves Keep this result Conservar este resultado Keep Tool Mantener herramienta Keep value Conservar valor +Key Key Keyboard Shortcuts Atajos de teclado Keyboard shortcuts. Atajos de teclado. Keys from Claves desde @@ -1356,13 +1466,20 @@ KMZ KMZ Label Etiqueta Label Class Clase de etiqueta Label Distance Range Rango de distancia de etiquetas +Label features in this layer Etiquetar las entidades de esta capa Label Field Campo de etiqueta +Label only the features that match. Etiquetar solo las entidades que coinciden. Label Placement Ubicación de etiquetas Label Range Rango de etiquetas Label the cities with their names Etiquetar las ciudades con sus nombres Label Visibility Range Rango de visibilidad de etiquetas Labeling Etiquetado +Labeling Pane Panel Etiquetado +Labeling Properties… Propiedades de etiquetado… Labels Etiquetas +labels are drawn for feature layers, not rasters las etiquetas se dibujan para capas de entidades, no para rásteres +Labels are drawn for feature layers, not rasters. Las etiquetas se dibujan para capas de entidades, no para rásteres. +Labels are off for this layer: the clause applies once they are on. Las etiquetas están desactivadas en esta capa: la cláusula se aplica cuando se activen. Lagoon Laguna Lambert Conformal Conic Cónica conforme de Lambert Lambert Cylindrical Equal Area Cilíndrica de áreas iguales de Lambert @@ -1383,12 +1500,14 @@ Layer Blend Fusión de capa Layer Extrusion Extrusión de capa Layer File Archivo de capa Layer From\nSelection Capa desde\nselección +Layer not found No se encontró la capa Layer Template Plantilla de capa Layer Templates Plantillas de capa layer {id} capa {id} Layers Capas Layers ({n}) Capas ({n}) Layers and Table Views Capas y vistas de tabla +Layers, a date each Capas, una por fecha Layout Diseño Layout\nFile Archivo\nde diseño Layout after: {name} Diseño después de: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Recursos de\naprendizaje Left Izquierda Legend Leyenda Legend label. Clear it to show the value again. Etiqueta de la leyenda. Bórrela para volver a mostrar el valor. -Length {length} · gain {gain} m · loss {loss} m Longitud {length} · ascenso {gain} m · descenso {loss} m +Length {length} · gain {gain} · loss {loss} Longitud {length} · ascenso {gain} · descenso {loss} Let the assistant hand tasks to agents on this computer Permitir que el asistente encargue tareas a agentes de este equipo Let your Agents Control RustGIS Deja que tus agentes controlen RustGIS Letter spacing Espaciado entre letras @@ -1439,6 +1558,7 @@ List by drawing order Listar por orden de dibujo List by selection Listar por selección List Columns in File Listar columnas del archivo List Commands Listar comandos +List Connections Listar conexiones List Data Sources Listar orígenes de datos List Database Columns Listar columnas de la base de datos List Database Tables Listar tablas de la base de datos @@ -1475,6 +1595,11 @@ Locate… Localizar… Location Ubicación Locator Localizador Lock Bloquear +Locked Bloqueado +Locked: unlock it in the Format pane to move or change it Bloqueado: desbloquéelo en el panel Formato para moverlo o cambiarlo +Log In to Connection Iniciar sesión en la conexión +Log In to {name}… Iniciar sesión en {name}… +Log In… Iniciar sesión… Long Entero largo Longitude Longitud Longitude (X) Longitud (X) @@ -1493,6 +1618,7 @@ Manage Administrar Manage Bookmarks Administrar marcadores Manage Edits Administrar ediciones Manage Templates Administrar plantillas +Mann–Kendall Mann–Kendall Map Mapa Map\nFrame Marco\nde mapa Map (the current view) Mapa (la vista actual) @@ -1504,6 +1630,7 @@ Map File Archivo de mapa Map Frame Marco de mapa Map Frames Marcos de mapa Map frames per second (live while the map moves) Fotogramas por segundo del mapa (en directo mientras el mapa se mueve) +Map Labeling Etiquetado del mapa Map Link Enlace de mapa Map name Nombre del mapa Map Page Página de mapa @@ -1515,6 +1642,7 @@ Map style (JSON) Estilo de mapa (JSON) Map Surrounds Elementos de contorno del mapa Map Thumbnail Miniatura del mapa Map tiles of the whole world Teselas de mapa de todo el mundo +Map Tool: {tool} Herramienta de mapa: {tool} Map View Vista del mapa map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Mapa: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Marcador Mask Values Enmascarar valores Masking Enmascaramiento +Match Option Opción de coincidencia Matching places Lugares coincidentes Max Máx Max % Máx % +Max Neighbors Máximo de vecinos Maximum zoom Zoom máximo MCP Relay Relé MCP MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} El relé MCP ya está escuchando en 127.0.0.1:{port}; deténgalo primero para usar el puerto {asked} @@ -1542,6 +1672,8 @@ Measure Distance Medir distancia Measure geodesic distances and areas on the map. Medir distancias y áreas geodésicas en el mapa. Measure on this body, with one of its basemaps. Medir sobre este cuerpo, con uno de sus mapas base. Measure with an elevation raster Medir con un ráster de elevación +Measurement units Unidades de medida +Measurement units must be metric or imperial Las unidades de medida deben ser métricas o imperiales Measurements so far. Mediciones hasta ahora. Mensuration Mensuración Mensuration Tools Herramientas de mensuración @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Combina los Messages appear here. Los mensajes aparecen aquí. Meters Metros Method Método +Metric Métrico Midnight Medianoche Miles Millas Miller Cylindrical Cilíndrica de Miller @@ -1560,21 +1693,43 @@ Min must be less than Max Mín debe ser menor que Máx Min must not be more than Max Mín no debe ser mayor que Máx Minimum Bounding Geometry Geometría envolvente mínima Minimum Maximum Mínimo Máximo +Minus Minus Minutes Minutos minutes ago minutos atrás Miter Inglete Mobile Map Mapa móvil Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Modelo Model\nBuilder Model\nBuilder model id id. del modelo Model output Salida del modelo ModelBuilder ModelBuilder Models Modelos +Modified Mann–Kendall (Hamed & Rao) Mann–Kendall modificado (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Mann–Kendall modificado (Yue & Wang) Modify Modificar Modify Selected Features Modificar entidades seleccionadas Mollweide (equal-area world) Mollweide (mundo de áreas iguales) Month Mes +Months Meses +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Los meses son números del 1 al 12 (12, 1, 2 para diciembre a febrero) More Más More basemaps: Más mapas base: Mosaic images into an orthomosaic Unir imágenes en un ortomosaico @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Unir los rásteres visibles en u Most cloud cover (%) Cobertura de nubes máxima (%) Mouse wheel Rueda del ratón Move Mover -Move (m) Mover (m) Move Bookmark Mover marcador Move Down Bajar Move down Bajar Move Group Mover grupo +Move Label Class Mover clase de etiqueta Move Task Step Mover paso de tarea +Move To Bottom Mover al final Move To Group Mover a un grupo +Move To Top Mover al principio Move To {group} Mover a {group} Move Up Subir Move up Subir @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Mis buckets My OGC Layer Mi capa OGC +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Nombre name the plugin whose panel to open: {"id": plugin id} indique el complemento cuyo panel abrir: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Nuevo\ncuaderno New\nPresentation Nueva\npresentación New\nReport Nuevo\ninforme New\nWorkspace Nuevo espacio\nde trabajo -New / Open / Save project Nuevo / Abrir / Guardar proyecto New Bookmark Nuevo marcador New Bookmark… Nuevo marcador… +New Connection Nueva conexión +New Connection ▾ Nueva conexión ▾ New conversation Nueva conversación New Custom Template Nueva plantilla personalizada New Custom Template… Nueva plantilla personalizada… New features use this template's values Las entidades nuevas usan los valores de esta plantilla +New field Campo nuevo New Group Nuevo grupo New job name Nombre del nuevo trabajo New Layout Nuevo diseño @@ -1649,6 +1809,7 @@ New Notebook Nuevo cuaderno New Presentation Nueva presentación New Project Nuevo proyecto New project Nuevo proyecto +New Publication Layout Nuevo diseño de publicación New Relationship… Nueva relación… New Report Nuevo informe New Selection Nueva selección @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Aún no hay ma No catalogs below this one: Find Items lists its items No hay catálogos por debajo de este: Buscar ítems lista sus ítems No COMID was returned for this point. No se devolvió ningún COMID para este punto. no command ningún comando -No connections this session No hay conexiones en esta sesión +No data at the points picked. No hay datos en los puntos elegidos. No datasets match. Ningún conjunto de datos coincide. No datasets with data RustGIS can add No hay conjuntos de datos con datos que RustGIS pueda añadir +no date in the name sin fecha en el nombre No edits since the project was saved No hay ediciones desde que se guardó el proyecto No field of this layer holds dates. Ningún campo de esta capa contiene fechas. +No Fill Sin relleno +No geometry to calculate No hay geometría que calcular No granules in this view and date range. No hay gránulos en esta vista y rango de fechas. no home folder no hay carpeta personal no layer ninguna capa @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) No hay relaciones (Diseño de d No relationships yet Aún no hay relaciones No reports yet. Publish › New Report lists the selected layer's records. Aún no hay informes. Publicar › Nuevo informe enumera los registros de la capa seleccionada. no row ninguna fila +No serif font is installed, so this text is drawn in the sans-serif No hay ninguna fuente serif instalada, así que este texto se dibuja en sans-serif no service or file links ningún enlace a servicios o archivos No services match your search. Ningún servicio coincide con su búsqueda. No standalone tables No hay tablas independientes @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Ninguno None (public buckets) Ninguna (buckets públicos) none, a .duckdb file, or host=… dbname=… user=… ninguno, un archivo .duckdb o host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD ninguna, o desde PGPASSWORD o MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD ninguna, o desde PGPASSWORD, MYSQL_PWD o SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. Ninguna. Añada una por dirección o desde un complemento de mapas base. North Norte North\nArrow Flecha\nde norte North Polar Stereographic Estereográfica polar norte +NOT NOT not a picture no es una imagen not a RustGIS layout no es un diseño de RustGIS not a RustGIS project no es un proyecto de RustGIS +Not applied yet. Aún no se ha aplicado. Not for texts: a text is edited in the Format pane. No para textos: un texto se edita en el panel Formato. not needed no es necesaria Not sandboxed Sin aislamiento @@ -1739,9 +1906,12 @@ Nothing found where the map is No se encontró nada donde está el mapa Nothing here No hay nada aquí Nothing named {name} to rename No hay nada llamado {name} para cambiar de nombre Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Aún no se ha compartido nada. Publicar › Compartir como crea mapas web, capas, estilos, escenas y más. +Nothing to add here No hay nada que añadir aquí +Nothing to test No hay nada que probar Nothing was found at this address. No se encontró nada en esta dirección. Nothing was recorded, so no task was made. No se grabó nada, así que no se creó ninguna tarea. Now Ahora +Nugget Pepita (nugget) NULL NULL Nulls Nulos Observations (observer, date, species, count) Observaciones (observador, fecha, especie, recuento) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. En el escritorio, la clave ta On-screen compass, zoom slider and pan pad. Brújula en pantalla, control deslizante de zoom y panel de desplazamiento. One command per line: id {json}. Lines starting with # are comments. Un comando por línea: id {json}. Las líneas que empiezan por # son comentarios. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Una sola SELECT de DuckDB: las funciones ST_ de DuckDB Spatial, archivos y direcciones web (read_parquet('…')) y las tablas de la base de datos de abajo. Ctrl+Enter la ejecuta. +One layer: a Zarr variable or a raster's bands Una capa: una variable Zarr o las bandas de un ráster One page per feature of an index layer. Una página por entidad de una capa de índice. One per line: code=name Uno por línea: código=nombre One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Un trazo, del símbolo. Añada trazos para bordes, líneas paralelas y desplazamientos. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Abrir carpeta Open In Geoprocessing… Abrir en Geoprocesamiento… Open in ModelBuilder Abrir en ModelBuilder +Open in Tool Abrir en la herramienta Open Layout Abrir diseño Open ModelBuilder Abrir ModelBuilder Open Notebook Abrir cuaderno @@ -1797,6 +1969,7 @@ Open Presentation Abrir presentación Open Project Abrir proyecto Open project Abrir proyecto Open Project (from text) Abrir proyecto (desde texto) +Open Project… Abrir proyecto… Open Report Abrir informe Open the attribute table of the selected layer. Abrir la tabla de atributos de la capa seleccionada. Open the Basemaps tab Abrir la pestaña Mapas base @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: O ejecute en un shell POSIX, Or run in PowerShell, for Claude Code or Codex: O ejecute en PowerShell, para Claude Code o Codex: Or run, for Claude Code or Codex: O ejecuta, para Claude Code o Codex: ORDER BY ORDER BY +Ordinary Kriging Kriging ordinario Orientation Orientación Ortho Mapping Ortocartografía Other Otro Other Data Otros datos +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Otras clases de etiqueta en esta capa (creadas con comandos): {n}. Cada una etiqueta las entidades que coinciden con su propia cláusula, así que una entidad puede recibir etiquetas de más de una. Out Beyond Límite al alejar Outline Contorno Outline another frame's extent in this map frame. Delinear la extensión de otro marco en este marco de mapa. @@ -1851,6 +2026,7 @@ Output Salida Output Name Nombre de salida Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Más de 1.000 herramientas ráster, vectoriales, LiDAR, hidrológicas y de terreno que se ejecutan en el navegador, tras una única descarga de unos 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Más de 1.000 herramientas ráster, vectoriales, LiDAR, hidrológicas y de terreno, en una descarga opcional de unos 20 MB. +Overlapping Superpuestas Overlay Superposición overlay superposición Overlay (a transparent layer kept on top, such as labels or roads) Superposición (una capa transparente que se mantiene encima, como etiquetas o carreteras) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture no tiene datos de {ty} donde Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Los datos abiertos de Overture Maps: edificios, lugares, transporte, divisiones administrativas, capas base y direcciones. Donde está el mapa, sus entidades se leen en su ubicación desde los archivos GeoParquet de Overture (acerque a una ciudad o menos); el mundo entero llega como teselas de mapa. Overture Maps… Overture Maps… +Overwrite existing values Sobrescribir los valores existentes Package Paquete Package a trained deep learning model. Empaquetar un modelo de aprendizaje profundo entrenado. Package Map Empaquetar mapa @@ -1907,8 +2084,12 @@ Performance Rendimiento Phase Fase Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Elija un valor dinámico, como la escala o la fecha, y haga clic en el mapa para colocarlo. +Pick a Point Elegir un punto +Pick a point on the map to see the variable's values through time there. Elija un punto en el mapa para ver allí los valores de la variable a lo largo del tiempo. Pick a route in the list, or click a grey route on the map. Elija una ruta de la lista o haga clic en una ruta gris del mapa. +Pick a single layer. Elija una sola capa. Pick it on the map Elegirlo en el mapa +Pick one… Elija uno… Picture Imagen Pictures Imágenes Pictures, attachments and chart Imágenes, adjuntos y gráfico @@ -1966,8 +2147,11 @@ Portal Portal Portolan Portolan Portolan… Portolan… Portrait Vertical +Position Posición +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Posprocesamiento power 2 potencia 2 +Precision 6 (OSRM, Valhalla) Precisión 6 (OSRM, Valhalla) Prepare Preparar Prepare every layer's geometry for this projection. Preparar la geometría de cada capa para esta proyección. Prepare Plugin Environment Preparar el entorno de complementos @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Plantillas de proyecto: in project's path is required se requiere la ruta del proyecto Projection Proyección Projections Proyecciones +Properties Propiedades +Property Propiedad Proportional Symbols Símbolos proporcionales Provider Proveedor +Provinces: Provincias: Proximity Proximidad Public GIS portals of US counties and cities, by state. Portales SIG públicos de condados y ciudades de EE. UU., por estado. Public GIS portals of US federal agencies, by department. Portales SIG públicos de agencias federales de EE. UU., por departamento. Public GIS portals of US states and the District of Columbia. Portales SIG públicos de los estados de EE. UU. y el Distrito de Columbia. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. Capas públicas en ArcGIS Online u otro portal de ArcGIS: servicios de entidades, mapas, imágenes y teselas vectoriales, mapas web y archivos GeoJSON. +Publication Layout Diseño de publicación Publish Publicar Publish › Output Publicar › Salida Put the layer back as it was then Devolver la capa al estado que tenía entonces Python environment failed: {error} Error en el entorno de Python: {error} Python environment not prepared Entorno de Python sin preparar Python environment ready Entorno de Python listo +Python script Script de Python Qualitative Cualitativa Quantile Cuantil Query files, web addresses and databases with DuckDB Spatial; add results to the map. Consultar archivos, direcciones web y bases de datos con DuckDB Spatial; añadir los resultados al mapa. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Elevar polígonos según una altura o un Raise Priority Subir prioridad Random points in polygons Puntos aleatorios en polígonos Range Rango +Range (Degrees) Alcance (grados) +range: 0, 0.9 rango: 0, 0.9 Raster Ráster Raster\nAnalysis Análisis\nráster Raster\nInformation Información\nráster @@ -2075,6 +2266,7 @@ Redo {action} Rehacer {action} Red–Blue Rojo–Azul Refine the search to see {n} more Refine la búsqueda para ver {n} más Refresh Actualizar +Refresh every connection's lists Actualizar las listas de todas las conexiones Refresh Map Series Actualizar serie de mapas Refresh the map series from its index layer. Actualizar la serie de mapas a partir de su capa de índice. Region Región @@ -2091,11 +2283,10 @@ Relationships Relaciones Release Versión Reload Recargar Reload Web Layers Recargar capas web -Remember (without the secret) Recordar (sin el secreto) Remember the key on this computer Recordar la clave en este equipo -Remembered: Recordadas: Remove Quitar Remove All Quitar todas +Remove All Label Classes Quitar todas las clases de etiqueta Remove an offline area. Quitar un área sin conexión. Remove Bookmark Quitar marcador Remove Connection Quitar conexión @@ -2108,11 +2299,14 @@ Remove from Favourites Quitar de favoritos Remove From Group Quitar del grupo Remove Group Quitar grupo Remove Keyframe Quitar fotograma clave +Remove Label Class Quitar clase de etiqueta Remove Section Quitar sección Remove Table Quitar tabla +Remove the clause. Quitar la cláusula. Remove the last keyframe. Quitar el último fotograma clave. Remove the layer from the map. Quitar la capa del mapa. Remove the strokes and draw the symbol's line alone Quitar los trazos y dibujar solo la línea del símbolo +Remove this class's own strokes and draw it with the layer's Quitar los trazos propios de esta clase y dibujarla con los de la capa Remove this stop Quitar esta parada Remove Tile Layer Quitar capa de teselas Remove token Quitar token @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Elimina Rename Cambiar nombre Rename Bookmark Cambiar nombre del marcador Rename Group Cambiar nombre del grupo +Rename it to save it: a connection has this name Cámbiele el nombre para guardarla: ya hay una conexión con este nombre +Rename Label Class Cambiar nombre de clase de etiqueta Rename Layer Cambiar nombre de capa Rename Layout Cambiar nombre del diseño Rename Map Cambiar nombre de mapa @@ -2149,6 +2345,7 @@ Reset Default Restablecer predeterminado Reset Effects Restablecer efectos Reset North Orientar al norte Reset north Orientar al norte +Resolution (0–30) Resolución (0–30) Response Respuesta Restore Restaurar Restore From Archive Restaurar desde el archivo histórico @@ -2162,6 +2359,7 @@ RGB RGB Ribbon Cinta Right Derecha Right-click a source to add it here Haga clic derecho en un origen para añadirlo aquí +Right-click for more Clic derecho para más opciones Robinson Robinson Rotate Rotar Rotate (°) Rotar (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Datos de ejemplo (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Datos de ejemplo © Natural Earth (dominio público). RustGIS se distribuye bajo la licencia Apache-2.0. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Datos de ejemplo: Natural Earth (dominio público). Con licencia Apache-2.0. +Sans-serif Sans-serif Satellite Embedding Datasets Conjuntos de datos de incrustaciones satelitales Satellite Embeddings Incrustaciones satelitales Satellite Embeddings… Incrustaciones satelitales… Saturation Saturación Save Guardar Save As Guardar como +Save as Connection Guardar como conexión Save as Default Guardar como predeterminado Save as Default Raster Style Guardar como estilo ráster predeterminado Save as Layer Guardar como capa Save As Presentation File… Guardar como archivo de presentación… +Save as Python Script… Guardar como script de Python… Save As Report File… Guardar como archivo de informe… Save As Report Template… Guardar como plantilla de informe… Save as {name} Guardar como {name} Save As… Guardar como… Save Color Ramps Guardar rampas de color Save Data Source Guardar origen de datos +Save History As Python Script Guardar historial como script de Python Save Layer File Guardar archivo de capa Save Layout File Guardar archivo de diseño Save Map File Guardar archivo de mapa @@ -2254,6 +2456,7 @@ save or load a pane set by name guardar o cargar un conjunto de paneles por nomb Save Project Guardar proyecto Save project Guardar proyecto Save Project As Guardar proyecto como +Save Project As… Guardar proyecto como… Save Project Template Guardar plantilla de proyecto Save Style Items Guardar elementos de estilo Save Task Item Guardar elemento de tarea @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Guardar el modelo como archivo (*.model. Save the model in the project Guardar el modelo en el proyecto Save the open layout as a layout file. Guardar el diseño abierto como archivo de diseño. Save the panes you have open, and switch back to them. Guardar los paneles que tiene abiertos y volver a ellos. +Save the project too: the script opens its saved file, without changes made since. Guarde también el proyecto: el script abre su archivo guardado, sin los cambios hechos desde entonces. Save the project under a new name. Guardar el proyecto con otro nombre. Save the project with your edits. Guardar el proyecto con sus ediciones. Save the project's custom color ramps to a color ramp file. Guardar las rampas de color personalizadas del proyecto en un archivo de rampas de color. @@ -2275,13 +2479,16 @@ Save Web Link Chart Guardar gráfico de vínculos web Save Web Map Guardar mapa web Save Web Scene Guardar escena web Saved Guardado +Saved as the connection {name} Guardado como la conexión {name} Saved as the default style for new single-band rasters. Guardado como estilo predeterminado para los nuevos rásteres de una banda. +Saved connection Conexión guardada Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Las claves guardadas se almacenan sin cifrar en las preferencias de la aplicación (en el almacenamiento del navegador en la web). Saved models Modelos guardados Saved symbology: apply it to the selected layer, or save the layer's. Simbología guardada: aplicarla a la capa seleccionada, o guardar la de la capa. Saved View Vista guardada Saved {name} {name} guardado Saved {name}. {name} guardado. +Saved {path} Guardado {path} saving failed unexpectedly el guardado falló inesperadamente Scale Escala Scale\nBar Barra\nde escala @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Buscar (este portal necesita palabras)… Search Catalogue Buscar en catálogo Search commands — e.g. buffer, basemap, labels Buscar comandos — p. ej. área de influencia, mapa base, etiquetas Search datasets, e.g. GEDI biomass Buscar conjuntos de datos, p. ej. GEDI biomass -Search Distance (km) Distancia de búsqueda (km) +Search Distance Distancia de búsqueda Search every command and its parameters Buscar en todos los comandos y sus parámetros Search layers (e.g. temperature) Buscar capas (p. ej. temperature) Search layers, places, lat/lon… Buscar capas, lugares, lat/lon… @@ -2329,6 +2536,8 @@ Searching the folders… Buscando en las carpetas… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. La búsqueda funciona aquí, pero los servidores de datos de la NASA no permiten que las páginas web lean sus archivos (sin CORS), así que los GeoTIFF y las descargas de datos protegidos requieren la aplicación de escritorio. Descargar abre un archivo en una pestaña nueva, donde Earthdata Login le pide que inicie sesión. Searching… Buscando… Searching… try again in a moment. Buscando… inténtelo de nuevo en un momento. +Seasonal Mann–Kendall Mann–Kendall estacional +Seasons a year Estaciones por año Second standard parallel Segundo paralelo estándar Seconds Segundos Secret access key Clave de acceso secreta @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Separador Sepia Sepia Sequential Secuencial +Serif Serif Server Servidor Service Group Grupo de servicio Service group data exceeds 64 MB. Los datos del grupo de servicio superan los 64 MB. Service Import Requests Solicitudes de importación del servicio Service Layers Capas de servicio -Services Servicios +Session token Token de sesión Set Establecer Set Cell Size Establecer tamaño de celda Set Domain Establecer dominio @@ -2411,6 +2621,7 @@ Shaded Sombreado Shaded Relief Relieve sombreado Shape Forma Shapefile Shapefile +Shapes Formas Share Compartir Share As Compartir como Share every layer and table as OGC API Features collections. Compartir todas las capas y tablas como colecciones OGC API Features. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Mostrar detalles al pasar Show different features at different scales. Mostrar distintas entidades a distintas escalas. Show grid Mostrar cuadrícula Show Group Mostrar grupo +Show labels Mostrar etiquetas Show latitude/longitude lines on the basemap. Mostrar líneas de latitud/longitud en el mapa base. +Show Layers Mostrar capas Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Mostrar las capas a lo largo del tiempo: solo las entidades cuya fecha cae en la ventana del control deslizante, y las imágenes fechadas de su fecha. Show Map Mostrar mapa Show Me Muéstremelo @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Mostrar u ocultar el panel Herramientas. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Mostrar reglas. Arrastre desde una regla para añadir una guía; arrastre una guía de vuelta a la regla para quitarla. Show the bands picked, read in place as the map needs them Mostrar las bandas elegidas, leídas in situ según las necesite el mapa Show the description Mostrar la descripción +Show the Labeling pane for the selected layer. Mostrar el panel Etiquetado de la capa seleccionada. Show the labels that don't fit, in red. Mostrar en rojo las etiquetas que no caben. Show the layer only inside another layer's polygons. Mostrar la capa solo dentro de los polígonos de otra capa. Show the layer only left of a divider, to compare it with what's beneath. Mostrar la capa solo a la izquierda de un divisor, para compararla con lo que hay debajo. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Inicio de sesión cancelado. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. Se agotó el tiempo de inicio de sesión. Vuelve a empezar y permite que el navegador acceda a la dirección de bucle local de este equipo. signed in sesión iniciada Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Sesión cerrada en este equipo, pero OpenAI no lo confirmó. Puedes desconectar RustGIS en la configuración de ChatGPT. +Significance level Nivel de significancia Signing in with ChatGPT works in the desktop app. El inicio de sesión con ChatGPT funciona en la aplicación de escritorio. Signing the asset (Planetary Computer)… Firmando el recurso (Planetary Computer)… +Sill Meseta (sill) Simplify Simplificar Simulate the drive, or save the route as a layer. Simular el trayecto o guardar la ruta como capa. Simulation Simulación @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} Habilidades para el Asistente de IA: {names Slideshow, PDF or PNG Diapositivas, PDF o PNG slight left gire ligeramente a la izquierda slight right gire ligeramente a la derecha +Slope per Pendiente por Slope, aspect, hillshade, NDVI, band math, thresholds. Pendiente, orientación, sombreado, NDVI, álgebra de bandas, umbrales. Slope, hillshade, NDVI and more. Pendiente, sombreado, NDVI y más. Small scales Escalas pequeñas @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Sur South Polar Stereographic Estereográfica polar sur +Spatial Join Unión espacial Speaker notes Notas del orador Spectral Analysis Análisis espectral Spectral indices Índices espectrales @@ -2530,6 +2748,7 @@ Split (click the line) Dividir (hacer clic en la línea) SQL SQL SQL Pane Panel SQL SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. Las consultas SQL se ejecutan con DuckDB, un programa al que llama la aplicación de escritorio: abra este proyecto en la aplicación de escritorio para consultarlo. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL que debe cumplirse, p. ej. pop > 0 Square Cuadrado Squares Cuadrados @@ -2588,6 +2807,7 @@ Start over at the end while playing. Volver a empezar al llegar al final durante Start With Empezar con Start-up Inicio starting with a project file needs the desktop app iniciar con un archivo de proyecto requiere la aplicación de escritorio +Statistic Estadístico Statistics Estadísticas Statistics Field Campo de estadísticas Status Estado @@ -2613,8 +2833,10 @@ Stretch\nType Tipo de\nestiramiento Stretch it over the map view, or give control points (one per line: column row lon lat). Estirarlo sobre la vista del mapa, o indicar puntos de control (uno por línea: columna fila lon lat). Stretch Type Tipo de estiramiento Stretch type Tipo de estiramiento +Stroke Levels Niveles de trazo Stroke {n} Trazo {n} Strokes Trazos +Strokes for Trazos de Style Estilo Style Items Elementos de estilo Style › Drawing Estilo › Dibujo @@ -2624,7 +2846,10 @@ Subreport: {name} Subinforme: {name} Subtypes Subtipos Subtypes, one per line: code=name Subtipos, uno por línea: código=nombre Suitability Modeler Modelador de idoneidad +Summarise Resumir +Summarise over time Resumir a lo largo del tiempo Summarise the conversation so far, so a long chat stays within the model's context Resumir la conversación hasta ahora, para que un chat largo quepa en el contexto del modelo +Summarise Zarr Over Time Resumir Zarr a lo largo del tiempo Summarising the earlier conversation to keep it within the model's context… Resumiendo la conversación anterior para que quepa en el contexto del modelo… Summarize Resumir Summarize\nWithin Resumir\ndentro @@ -2658,6 +2883,8 @@ Table or layer Tabla o capa Tables Tablas Tags Etiquetas Take the data in view offline, to edit and sync back later. Llevar sin conexión los datos a la vista, para editarlos y sincronizarlos después. +Target Features Entidades de destino +Target Records Registros de destino Task Tarea Task Item Elemento de tarea Task name Nombre de la tarea @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Datos SIG públicos de Tennessee (Tennessee GeoData, geodata.tn.gov): límites de propiedad, transporte, salud, hidrología y más. Terrain Terreno Test Test +Test Connection Probar conexión +Testing… Probando… Text Texto Text Symbol Símbolo de texto That connection would create a loop. Esa conexión crearía un bucle. The address fields are empty in every row. Los campos de dirección están vacíos en todas las filas. +The answer isn't UTF-8 text La respuesta no es texto UTF-8 the answer isn't UTF-8 text la respuesta no es texto UTF-8 The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. El asistente sigue las instrucciones de una habilidad cuando la usa. Digan lo que digan, sus comandos que necesitan tu aprobación siguen preguntando. The assistant is busy; stop it or wait for it to finish. El asistente está ocupado; deténgalo o espere a que termine. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. La pestaña de mapas base público the capture failed unexpectedly la captura falló inesperadamente The connection failed; trying again in {seconds} s. La conexión falló; se reintentará en {seconds} s. The country list couldn't be read ({e}): type a country's code No se pudo leer la lista de países ({e}): escriba el código de un país +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. La elevación actual sigue vigente mientras edita los valores. Haga clic en Aplicar elevación para aplicar el valor y las unidades. The dataset has nothing RustGIS can add El conjunto de datos no tiene nada que RustGIS pueda añadir The environment (AWS_ACCESS_KEY_ID…) El entorno (AWS_ACCESS_KEY_ID…) The features where the map is Las entidades donde está el mapa @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. El f The installed GeoLibre runner lists no tools. El ejecutor de GeoLibre instalado no lista ninguna herramienta. The job covers the map view El trabajo cubre la vista del mapa The key is kept for this session only. La clave se conserva solo durante esta sesión. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. La capa muestra el paso en el que está el control deslizante de tiempo y activa el control sobre las fechas de la variable. The layer's file couldn't be read: {error} No se pudo leer el archivo de la capa: {error} +The layer's strokes Los trazos de la capa the layout export failed unexpectedly la exportación del diseño falló inesperadamente The link doesn't say its STAC API: Find Items searches {api} El enlace no indica su STAC API: Buscar ítems busca en {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. La lista de complementos que explorar. Déjela vacía para usar el registro de complementos de RustGIS. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. La miniatura del proyecto, a partir The projection the map opens in. La proyección con la que se abre el mapa. The provider's content filter stopped the reply. El filtro de contenido del proveedor detuvo la respuesta. The public server is free and shared by many: it answers light use, and may ask you to wait. El servidor público es gratuito y lo comparten muchos: atiende un uso ligero y puede pedirle que espere. +The range is two numbers, low and high (0, 0.9) El rango son dos números, mínimo y máximo (0, 0.9) The raster's data couldn't be read. No se pudieron leer los datos del ráster. The raster's data couldn't be read: {path} No se pudieron leer los datos del ráster: {path} the release isn't known la versión no es conocida @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Los archivos del Shapefile se descarg The share of each cell covered by fields, for the zoomed-out view La proporción de cada celda cubierta por campos de cultivo, para la vista alejada The sign-in expired or was revoked. Sign in again. La sesión caducó o fue revocada. Inicia sesión de nuevo. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. La sesión se guarda en la carpeta de configuración de la aplicación, solo para su cuenta de usuario, y nunca en un archivo de proyecto. +The significance level is a number, such as 0.05 El nivel de significancia es un número, por ejemplo 0.05 The start-up project {path} didn't open: {error} El proyecto de inicio {path} no se abrió: {error} The start-up project {path} isn't a RustGIS project file. El proyecto de inicio {path} no es un archivo de proyecto de RustGIS. The step's layer {name} isn't in the map. La capa {name} del paso no está en el mapa. The store has no variables with two or more dimensions. El almacén no tiene variables con dos o más dimensiones. the store is gone el almacén ya no existe +The summary couldn't start: {error} No se pudo iniciar el resumen: {error} +The summary failed unexpectedly El resumen falló de forma inesperada +The summary finished after another project was opened, so it wasn't added El resumen terminó después de abrir otro proyecto, así que no se añadió The task's steps changed, so the step being edited was closed: edit it again. Los pasos de la tarea cambiaron, así que se cerró el paso que se estaba editando: edítelo de nuevo. the time slider is off el control deslizante de tiempo está desactivado The token expires on {date}. El token caduca el {date}. +The trend couldn't start: {error} No se pudo iniciar la tendencia: {error} +The trend failed unexpectedly La tendencia falló de forma inesperada +The trend finished after another project was opened, so it wasn't added La tendencia terminó después de abrir otro proyecto, así que no se añadió +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. Los valores que se muestran de negro a blanco, iguales para cada fecha y tesela (en las unidades de la banda). Vacío: el estiramiento propio de cada archivo. the view has no extent la vista no tiene extensión The view is larger than one read: its centre was read (zoom in for the rest) La vista es mayor que una lectura: se leyó su centro (acerque para el resto) The web app always runs the latest release: reload the page to get it. La aplicación web siempre ejecuta la última versión: recargue la página para obtenerla. the window's screenshot didn't come back in time, so the map was drawn offscreen la captura de pantalla de la ventana no llegó a tiempo, así que el mapa se dibujó fuera de pantalla the window's screenshot didn't hold the map, so it was drawn offscreen la captura de pantalla de la ventana no contenía el mapa, así que se dibujó fuera de pantalla Theme Tema +There is no expression defined. No hay ninguna expresión definida. Thermal Térmica +Thesis figure, full page (160 × 225 mm) Figura de tesis, página completa (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Figura de tesis, media página (160 × 110 mm) Thinking… Pensando… +Third-party licences Licencias de terceros +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins esta compilación no puede descargar complementos this build can't reach AI providers esta compilación no puede conectarse a proveedores de IA this build saves projects with Save As esta compilación guarda los proyectos con Guardar como This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Esta cuenta de ChatGPT ya estaba guardada. La próxima vez, elígela en la lista en lugar de añadirla de nuevo. +This class is drawn with the layer's strokes. Changing them here gives it its own. Esta clase se dibuja con los trazos de la capa. Cambiarlos aquí le da los suyos propios. +This class is hidden, so it labels nothing. Esta clase está oculta, así que no etiqueta nada. +This clause no longer reads, so no feature is labelled: {error} Esta cláusula ya no se puede leer, así que no se etiqueta ninguna entidad: {error} This map has no layers yet. Este mapa aún no tiene capas. This map's thumbnail, from an image. La miniatura de este mapa, a partir de una imagen. This map's thumbnail, from the view. La miniatura de este mapa, a partir de la vista. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Esta solicitud no tiene opción de permitir una sola vez. This request uses an unsupported capability. Check the parameter and change the request. Esta solicitud usa una función no compatible. Comprueba el parámetro y modifica la solicitud. This result is kept Este resultado se conserva +This run has no tool form to open Esta ejecución no tiene un formulario de herramienta que abrir +This session only (optional) Solo esta sesión (opcional) This step Este paso This step names no tool to show. Este paso no indica ninguna herramienta que mostrar. Thumbnail Miniatura @@ -2789,15 +3041,21 @@ Tilt Map Inclinar mapa Tilt-shift Tilt-shift Tilt-shift blur and film grain. Desenfoque tilt-shift y grano de película. Time Tiempo +Time\nSeries Serie\ntemporal Time\nSlider Control deslizante\nde tiempo Time Fields Campos de tiempo +Time Series Serie temporal +Time series Serie temporal +Time Series at a Point Serie temporal en un punto Time Slider Control deslizante de tiempo Time Slider Settings Configuración del control deslizante de tiempo Tint by elevation Teñir según la elevación Tissot's Indicatrix Indicatriz de Tissot Title Título +To Hasta Toggle Dark Mode Alternar modo oscuro Toggle GPU Rendering Alternar representación en GPU +Toggle Imperial Measurements Alternar medidas imperiales Tolerance (km) Tolerancia (km) Tolls Peajes Tool Gallery Galería de herramientas @@ -2828,7 +3086,10 @@ Train Deep Learning Model Entrenar modelo de aprendizaje profundo Trained models Modelos entrenados Transparency Transparencia Traverse Poligonal +Trend Analysis Análisis de tendencias +Trend over a series of rasters Tendencia sobre una serie de rásteres Triangle Triángulo +TRUE TRUE Try again in a moment. Inténtelo de nuevo en un momento. Try again later. Inténtelo de nuevo más tarde. Turn editing on or off for the map. Activar o desactivar la edición del mapa. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map escriba una longitud y una latit Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Escriba una escala, como 1:12,345, 12345 o 25k, y pulse Enter Type an address or coordinates and press Enter, or click the map. Escriba una dirección o unas coordenadas y pulse Intro, o haga clic en el mapa. Type numbers separated by commas Escriba números separados por comas +Type the keys to log in for this session, then Save. Escriba las claves para iniciar sesión en esta sesión y luego Guardar. +Type the password or keys to log in for this session, then Save. Escriba la contraseña o las claves para iniciar sesión en esta sesión y luego Guardar. Typed below Escritas abajo UM UM Unavailable: {reason} No disponible: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Desvincular del control deslizante de tiempo Unbind from Time Slider Desvincular del control deslizante de tiempo Undo Deshacer Undo (Ctrl+Z) Deshacer (Ctrl+Z) -Undo / Redo Deshacer / Rehacer Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Deshacer no quita el paso grabado: elimínelo en Editar pasos cuando detenga la grabación. Undo the last change. Deshacer el último cambio. Undo {action} Deshacer {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools Desinstalar herramientas de GeoLibre Uninstall Plugin Desinstalar complemento Uninstalled {id}. Se ha desinstalado {id}. Unique Values Valores únicos +Unit Unidad United States Estados Unidos Units Unidades Units per step Unidades por paso @@ -2877,6 +3140,7 @@ Up a level Subir un nivel Update Actualizar Update Bookmark Actualizar marcador Update Job Actualizar trabajo +Update Label Class Actualizar clase de etiqueta Update to {version} Actualizar a {version} Upload File Cargar archivo Upload File… Cargar archivo… @@ -2895,6 +3159,7 @@ Use another account Usar otra cuenta Use Current View Usar la vista actual Use each feature's line color Usar el color de línea de cada entidad Use each feature's line width Usar el ancho de línea de cada entidad +Use the Layer's Strokes Usar los trazos de la capa USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan Usando el plan de ChatGPT Utilities Utilidades Valid combinations, one per line: a, b Combinaciones válidas, una por línea: a, b Valid expression · {n} matching rows Expresión válida · {n} filas coincidentes +Valid: {n} of {of} features match Válida: {n} de {of} entidades coinciden Validate Validar Validate Model Validar modelo value (empty: no-data) valor (vacío: sin datos) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Imágenes abiertas de desastres de Vantor: elija un evento y una fase (antes o después), luego Buscar ítems. Vantor… Vantor… Variable Variable +Variogram Model Modelo de variograma Vector and raster data Datos vectoriales y ráster Vector Layer Capa vectorial +vector tiles teselas vectoriales Vector tiles (MVT) Teselas vectoriales (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Vectores que un modelo fundacional geoespacial creó para cada píxel o parche de imagen, encontrados donde está el mapa y mostrados como colores. Version {version} Versión {version} @@ -2933,6 +3201,9 @@ Video file or URL Archivo o URL de vídeo Video Page Página de vídeo Videos Vídeos Video… Vídeo… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Sistema de coordenadas nacional de Vietnam (VN-2000) con Transversa de Mercator de 3 grados y una transformación de Helmert de 7 parámetros a WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Sistema de coordenadas nacional de Vietnam (VN-2000), zona UTM 48N (102°E–108°E, meridiano central 105°E). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Sistema de coordenadas nacional de Vietnam (VN-2000), zona UTM 49N (108°E–114°E, meridiano central 111°E). View Vista View Clipping Recorte de la vista View Effects Efectos de vista @@ -2952,6 +3223,9 @@ Visibility Range Rango de visibilidad Visible Visible Visible Layers Capas visibles Visual Effect Efecto visual +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vietnam) +VN-2000 / UTM zone 48N VN-2000 / UTM zona 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zona 49N Volume Volumen Waiting for the browser… Esperando al navegador… Waiting for your answer Esperando tu respuesta @@ -2970,6 +3244,7 @@ Web Link Chart Gráfico de vínculos web Web maps Mapas web Web Mercator Web Mercator Web Scene Escena web +Web service (detected) Servicio web (detectado) Web Style Estilo web Web Tile Layers Capas de teselas web Web Tool Herramienta web @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Dónde se sitúan las etiquetas res Where new features get their elevation (the z field). De dónde obtienen su elevación las entidades nuevas (el campo z). Where the map opens: the full extent or a view you saved. Dónde se abre el mapa: la extensión completa o una vista guardada. Where tools work Dónde trabajan las herramientas +Whether the Select tool picks these layers' features on the map Si la herramienta Seleccionar elige las entidades de estas capas en el mapa Which 5 countries have the largest GDP? ¿Qué 5 países tienen el mayor PIB? Whitebox and GeoLibre tools Herramientas Whitebox y GeoLibre Whole map Todo el mapa @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Mundo, de áreas iguales: tama World, equal-area: true sizes, shapes bend towards the edges. Mundo, de áreas iguales: tamaños reales, las formas se curvan hacia los bordes. Worldview Fronteras Write a plugin Escribir un complemento +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Escribe la pendiente de Sen, el Z y el valor p de la prueba, y la pendiente donde la tendencia es significativa. X (mm) X (mm) X column Columna X X Field Campo X @@ -3050,7 +3327,6 @@ Yours Tuyas YYYY-MM-DD AAAA-MM-DD YYYY-MM-DD, for the layers that change with time AAAA-MM-DD, para las capas que cambian con el tiempo YYYY-MM-DD, for time layers AAAA-MM-DD, para capas temporales -z (m) z (m) Z factor Factor Z Zarr Zarr Zarr Variables Variables Zarr @@ -3061,7 +3337,6 @@ Zoom Box Zoom a rectángulo Zoom Frame Zoom en el marco Zoom In Acercar Zoom in Acercar -Zoom in / out Acercar / alejar Zoom In Here Acercar aquí Zoom in on the centre of the map. Acercar al centro del mapa. Zoom in · finish a sketch Acercar · terminar un boceto @@ -3073,6 +3348,7 @@ Zoom To\nLayer Zoom a\nla capa Zoom to a box (Explore) Zoom a un rectángulo (Explorar) Zoom To Extent Zoom a la extensión Zoom To Layer Zoom a la capa +Zoom To Layers Zoom a las capas Zoom To Map View Zoom a la vista del mapa Zoom To Selection Zoom a la selección Zoom to selection Zoom a la selección @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Zoom a la extensión de la capa. Zoom to the selected features. Zoom a las entidades seleccionadas. Zoom to the whole world. Zoom a todo el mundo. Zoom to this bookmark Hacer zoom a este marcador +Zoom To Tile Layer Zoom a la capa de teselas Zoom to Tokyo Hacer zoom a Tokio Zoom To World Zoom al mundo +Zoom {z} · Bearing {b}° · Pitch {p}° Zoom {z} · Rumbo {b}° · Inclinación {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Zoom: el nivel de zoom del mapa, tal como lo cuentan los mapas web. Rumbo: la dirección hacia la que apunta la parte superior del mapa, en sentido horario desde el norte. Inclinación: la desviación respecto a mirar en vertical hacia abajo. Haga clic para restablecer el rumbo y la inclinación. {agent} was stopped by you. Detuviste {agent}. {id} changed since it was installed; install it again from the Plugin Manager. {id} ha cambiado desde que se instaló; vuelva a instalarlo desde el Administrador de complementos. {names}: needs Node.js (npx) for its ACP adapter. {names}: necesita Node.js (npx) para su adaptador ACP. diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv index 0294da596..9685a71a0 100644 --- a/crates/ui-egui/src/i18n/fa.tsv +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -6,6 +6,7 @@ (filtered) (فیلتر شده) + Add Stop + افزودن توقف + Input + ورودی ++ New expression + عبارت جدید + Output + خروجی /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh مش سه‌بعدی <هیچ> A 3D globe seen from space. یک کره سه‌بعدی که از فضای دیده می‌شود. A blank map with a basemap یک نقشه خالی با نقشه پایه +A call for the rustgis Python package یک فراخوانی برای بسته Python به نام rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. آدرس یک کاتالوگ (CSW 2.0.2): رکوردهایش را با کلمات و، در صورت تمایل، با محدودهٔ نقشه جستجو کنید؛ سپس سرویس‌ها و فایل‌های پیوندیافته به آن‌ها را اضافه کنید. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. سقف مصرف طرح ChatGPT یا برنامه پر شده است. پیش از تلاش دوباره، «مصرف هفتگی» را ببینید. A ChatGPT sign-in can only be used with the OpenAI API. ورود به ChatGPT فقط می‌تواند با API OpenAI استفاده شود. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). داد A globe scene. یک صحنه کره‌ای. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place عرض و طول جغرافیایی (۴۰.۷، -۷۴.۰ یا ۴۰°۴۲'ش ۷۴°۰'غ)، یک لایه، یک نشانک یا یک مقدار در جدول لایه. Enter سطر برجسته را انتخاب می‌کند؛ جستجوی مکان‌ها متن را به‌عنوان یک مکان جستجو می‌کند. A layer the model reads یک لایه که مدل آن را می‌خواند +A line for the Script pane یک خط برای پنل Script A magnifying lens that follows the pointer. یک لنز بزرگنمایی که نشانگر را دنبال می‌کند. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). آدرس یک سرویس نقشه، عنصر یا پوشش: WMS، WMTS، WFS، WCS، OGC API Features or Tiles، یا یک FeatureServer، MapServer، ImageServer یا VectorTileServer ArcGIS (یا یک فهرست سرویس، برای مرور). a name, or owner/name such as giswqs/geospatial یک نام، یا مالک/نامی مانند giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. یک نقطه پایانی نام‌دار که می‌توانید دوباره از آن استفاده کنید. رمزها و کلیدها تا بسته‌شدن RustGIS نگه داشته می‌شوند و هرگز ذخیره نمی‌شوند. A node cannot connect to itself. یک گره نمی‌تواند به خودش متصل شود. A notebook of text and command cells. یک دفترچه متنی از سلول‌های متن و دستور. +A password or token in the address was left out: connections never keep secrets. رمز یا توکن موجود در نشانی کنار گذاشته شد: اتصال‌ها هرگز اطلاعات محرمانه را نگه نمی‌دارند. A place or an address یک مکان یا آدرس A project keeps at least one map یک پروژه حداقل یک نقشه را نگه می‌دارد A projected coordinate system. سیستم مختصات تصویری A report listing the selected layer's records, page after page. گزارشی که رکوردهای لایه‌ی انتخاب‌شده را صفحه به صفحه فهرست می‌کند A result that only feeds other tools is removed once the model has run; add an output to keep it. نتیجه‌ای که فقط سایر ابزارها را تغذیه می‌کند، پس از اجرای مدل حذف می‌شود؛ یک خروجی اضافه کنید تا آن را حفظ کنید A result the model keeps, under a name نتیجه‌ای که مدل آن را حفظ می‌کند، تحت یک نام +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) یک دستور rustgis-cli برای پوسته POSIX (macOS، Linux، WSL، Git Bash) A service import or refresh is already running. واردسازی یا تازه‌سازی سرویس در حال اجراست +A service's layers stay in their service group. لایه‌های یک سرویس در گروه سرویس خود می‌مانند. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. یک وظیفه حداکثر ۲۰۰ مرحله دارد: ضبط را متوقف کنید تا آن را ایجاد کنید، سپس بقیه را به عنوان وظیفه‌ی دیگری ضبط کنید A template with the first selected feature's values قالبی با مقادیر اولین عنصر انتخاب‌شده +A tile layer per grid tile, each following the time slider through its dates. یک لایه کاشی برای هر کاشی شبکه، که هر کدام لغزنده زمان را در تاریخ‌هایش دنبال می‌کند. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. آدرس سرویس تایل: تایل‌های رستری با {z}/{x}/{y} (یا {-y}، {q}، {bbox-epsg-3857} برای WMS)، تایل‌های برداری (.pbf) یا TileJSON، استایل MapLibre، بایگانی PMTiles (آدرس وب، یا فایلی روی این کامپیوتر)، یا فایل MBTiles روی این کامپیوتر A token is set. توکن تنظیم شده است A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. یک مقدار، یک فیلد، یا دو مورد با + − × ÷ به هم پیوسته. بر روی ردیف‌های انتخاب‌شده اعمال می‌شود، یا بر روی هر ردیفی زمانی که هیچ‌کدام انتخاب نشده باشند a vector a pixel یک برداری یک پیکسل a vector an image patch یک برداری یک تصویر کوچک A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. یک ذخیره‌سازی Zarr (فرمت ۲ یا ۳) با آدرس وب، با متادیتای ادغام‌شده، یا یک پوشه روی این کامپیوتر. نقشه‌ی یک متغیر برای مراحل انتخاب‌شده از ابعاد دیگر آن، مانند زمان، خوانده می‌شود +A Zarr variable's values through time at points clicked on the map. مقادیر یک متغیر Zarr در گذر زمان در نقاطی که روی نقشه کلیک می‌شوند. +A2 landscape sheet برگه A2 افقی +A2 portrait sheet برگه A2 عمودی +A3 landscape sheet برگه A3 افقی +A3 portrait sheet برگه A3 عمودی +A4 landscape sheet برگه A4 افقی +A4 portrait sheet برگه A4 عمودی +A5 Grid شبکه A5 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. سلول‌های پنج‌ضلعی هم‌مساحت A5 در یک تفکیک‌پذیری، روی گستره نقشه، یک لایه یا کل جهان، به‌صورت چندضلعی همراه با شناسه‌هایشان. About درباره About plugins درباره‌ی افزونه‌ها About RustGIS درباره‌ی RustGIS @@ -65,6 +83,7 @@ Add افزودن Add\nData افزودن\nداده Add ({n}) افزودن ({n}) Add a bar chart of a layer's numeric field. افزودن نمودار میله‌ای از یک فیلد عددی لایه +Add a circle. افزودن دایره. Add a colorbar of a raster layer's colour scale in the map frame. افزودن نوار رنگی از مقیاس رنگی یک لایه‌ی رستری در قاب نقشه Add a description. افزودن توضیح Add a field. افزودن فیلد @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. افزودن چیدمان Add a page to the presentation. افزودن صفحه به ارائه. Add a PNG or JPEG picture to the page. افزودن تصویر PNG یا JPEG به صفحه. Add a presentation file. افزودن فایل ارائه. +Add a rectangle, to frame or highlight part of the page. افزودن مستطیل، برای قاب‌گرفتن یا برجسته‌کردن بخشی از صفحه. Add a report file, or a report template for the selected layer. افزودن فایل گزارش یا الگوی گزارش برای لایه‌ی انتخاب شده. Add a scale bar for the map frame. افزودن نوار مقیاس برای قاب نقشه. add a second map first (Data › New Map) ابتدا نقشه‌ی دوم را اضافه کنید (داده › نقشه‌ی جدید) Add a second map frame first ابتدا قاب نقشه‌ی دوم را اضافه کنید +Add a straight line. افزودن خط راست. Add a text element. افزودن عنصر متنی. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. یک نقشه پایه وب را به جای جایگزینی آن‌ها، روی بقیه اضافه کنید تا با آن‌ها ترکیب شود. روی یکی که نمایش داده شده کلیک کنید تا آن را حذف کنید. +Add a Zarr variable first (Add Data › Zarr…). ابتدا یک متغیر Zarr اضافه کنید (افزودن داده › Zarr…). Add Administrative Boundaries افزودن مرزهای اداری +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. افزودن فلش. آن را با دستگیره گرد بالایش بچرخانید، یا زاویه‌اش را در ویژگی‌هایش تنظیم کنید. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. توکن ورود به Earthdata Login (بالای صفحه) را اضافه کنید تا GeoTIFFهای NASA را روی نقشه قرار دهید. Add an elevation raster first. ابتدا یک رستر ارتفاعی اضافه کنید. Add an elevation raster to the map first ابتدا یک رستر ارتفاعی را به نقشه اضافه کنید +Add an ellipse. افزودن بیضی. Add an empty layer with a ready-made schema, to draw into. یک لایه خالی با اسکیمای آماده برای رسم در آن اضافه کنید. Add an input for each layer the model reads. برای هر لایه‌ای که مدل می‌خواند، یک ورودی اضافه کنید. Add an output to keep a result under a name. یک خروجی برای نگهداری نتیجه تحت یک نام اضافه کنید. Add and Open افزودن و باز کردن Add and Open Notebook افزودن و باز کردن دفترچه یادداشت Add Archive افزودning بایگانی +Add as Time Series افزودن به‌عنوان سری زمانی add at least two keyframes حداقل دو کلیدفریم اضافه کنید Add Cell افزودن سلول Add Color Ramp File… افزودن فایل رام رنگی… @@ -132,6 +157,7 @@ Add Graphics Layer افزودن لایه گرافیک Add Input افزودن ورودی Add Job افزودن وظیفه Add Keyframe افزودن کلیدفریم +Add Label Class افزودن کلاس برچسب Add Layer File افزودن پرونده لایه Add Open Data افزودن داده باز Add Output افزودن خروجی @@ -147,9 +173,11 @@ Add Rule افزودن قانون Add sample countries افزودن کشورهای نمونه Add Sample Data افزودن داده‌های نمونه Add Satellite Embeddings افزودن Embeddings ماهواره‌ای +Add Selected ({n}) افزودن انتخاب‌شده‌ها ({n}) Add Service Layer افزودن لایه سرویس Add skill… افزودن مهارت… Add STAC Asset افزودن دارایی STAC +Add STAC Data Cube افزودن مکعب داده STAC Add STAC Footprints افزودن ردپاهای STAC Add Step افزودن مرحله Add Stroke افزودن خطوط @@ -171,6 +199,7 @@ Add to Favourites افزودن به علاقه‌مندی‌ها Add to Map افزودن به نقشه Add vector data. افزودن داده برداری. Add view افزودن نما +Add With Options… افزودن با گزینه‌ها… Add Zarr Variable افزودن متغیر Zarr Add Zarr Variable… افزودن متغیر Zarr… Add ▾ افزودن ▾ @@ -203,6 +232,7 @@ Agent work کار ایجنت Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay ایجنت‌ها دسترسی کامل دارند: آن‌ها می‌توانند اسکریپت، وظیفه، مدل و افزونه اجرا کنند. برای متوقف کردن رله MCP کلیک کنید Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. ایجنت‌های روی این رایانه که متصل می‌شوند (Claude Code، Codex، Cursor و دیگران) روی نقشه باز این‌جا با ابزارهای RustGIS کار می‌کنند: می‌توانند فایل‌ها را باز و ذخیره کنند و از وب استفاده کنند. اسکریپت‌ها و مدیریت افزونه‌ها خاموش می‌مانند. Agents then run commands, change files and use the web on this computer without asking you. آنگاه ایجنت‌ها بدون پرسیدن از شما، روی این رایانه دستور اجرا می‌کنند، فایل‌ها را تغییر می‌دهند و از وب استفاده می‌کنند. +Aggregate Points to A5 تجمیع نقاط در A5 Aggregation تراکم Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. مرزهای مزارع کشاورزی که مدل Fields of World در تصاویر Sentinel-2 در سراسر جهان، یک سال در یک بار (ویرایش دوم، CC BY 4.0) پیدا کرده است. این‌ها پیش‌بینی مدل هستند، نه قطعات نقشه‌برداری شده. AI هوش مصنوعی @@ -221,15 +251,18 @@ All steps done. تمام مراحل انجام شد. All tables with geometry همه جداول با هندسه Allow اجازه Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. هنگام ورود، استفاده از طرح ChatGPT را مجاز کنید. اگر فضای کاری شما اجازه نمی‌دهد، از مدیر آن بپرسید یا ارائه‌دهنده کلید API را انتخاب کنید. +Allow Feature Selection اجازه انتخاب عنصر Allow only certain combinations of values across fields. فقط ترکیب‌های خاصی از مقادیر در بین فیلدها را مجاز کن. Allow such actions for this conversation اجازه چنین اقداماتی را برای این گفت‌وگو بده Allow this action? این عمل مجاز است؟ Allow this agent's RustGIS action? اجازه عمل RustGIS این ایجنت را بدهید؟ Along Selected Line در امتاز خط انتخاب شده +Alt+ Alt+ Alternating bar نوار متناوب Altitude ارتفاع Always allow همیشه اجازه بده -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. سرویس Amazon S3 یا یک سرویس سازگار با S3 (MinIO، Cloudflare R2، Wasabi…) با استفاده از endpoint. کلیدها تا زمان بسته شدن RustGIS نگهداری می‌شوند؛ یک اتصال به‌یادسپرده‌شده نام، endpoint و منطقه را حفظ می‌کند، نه secret آن را. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3، یا یک سرویس سازگار با S3 (MinIO، Cloudflare R2، Wasabi…) از طریق نقطه پایانی آن. کلیدها تا بسته‌شدن RustGIS نگه داشته می‌شوند؛ یک اتصال ذخیره‌شده نام، نقطه پایانی و منطقه خود را نگه می‌دارد، هرگز اطلاعات محرمانه‌اش را. amenity=cafe, shop امنیت=کافه، فروشگاه An agent on this computer asks to: ایجنتی روی این رایانه درخواست می‌کند: An AWS profile پروفایل AWS @@ -248,6 +281,7 @@ API key کلید API Apply اعمال Apply a function to a raster اعمال یک تابع روی یک رستر Apply Control Points اعمال نقاط کنترل +Apply elevation اعمال ارتفاع Apply Style اعمال سبک Apply to layers اعمال بر روی لایه‌ها Apply to the selected layer اعمال بر روی لایه‌ی انتخاب شده @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° محدوده استفاده: در Area {area} · Perimeter {perimeter} مساحت {area} · محیط {perimeter} Areas (name, land use, area) مساحت‌ها (نام، کاربری اراضی، مساحت) Arrange چینش +Arrow فلش +Arrowheads نوک فلش‌ها Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. در مورد داده‌های خود سوال بپرسید یا توضیح دهید که می‌خواهید با نقشه چه کنید. دستیار RustGIS دستورات را برای شما اجرا می‌کند؛ هر تغییری قابل بازگشت است. Ask about the map, or tell it what to do… در مورد نقشه بپرسید یا بگویید که می‌خواهید چه کاری انجام دهد… Ask about your data, or tell the assistant what to do with the map. در مورد داده‌های خود سوال بپرسید یا به دستیار بگویید که می‌خواهید با نقشه چه کاری انجام دهد. @@ -275,12 +311,13 @@ Asking NASA's CMR… در حال پرسیدن از CMR ناسا… Asking the catalog… در حال پرسیدن از فهرست… Asking the NLDI… در حال پرسیدن از NLDI… Assistant settings تنظیمات دستیار +At the end در انتها +At the start در ابتدا Atlas اطلس Attachments پیوست‌ها Attribute\nTable جدول\nویژگی‌ها Attribute Rules قوانین ویژگی Attribute Table جدول ویژگی‌ها -Attribute table جدول ویژگی‌ها Attributes ویژگی‌ها Attribution ارجاع Aurora نور قطبی @@ -295,11 +332,9 @@ Automatic (system language) خودکار (زبان سیستم) Automatic (system time zone and region) خودکار (منطقه و منطقه زمانی سیستم) Automatic ({kind}) خودکار ({kind}) Avoid اجتناب -AWS AWS AWS, anonymous دسترسی ناشناس به AWS Azimuth زاویه افق Back بازگشت -Back to Explore; cancel a sketch بازگشت به کاوش؛ لغو طرح‌ریزی Back to the Map بازگشت به نقشه Back to the map بازگشت به نقشه Back to tools بازگشت به ابزارها @@ -314,8 +349,6 @@ Basemap نقشه‌ی پایه Basemaps نقشه‌های پایه Basin from hydrolocation حوضه آبریز از موقعیت هیدرولوژیک Bearing جهت -Bearing {b}° · Pitch {p}° جهت {b}° · شیب {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. جهت: جهت روی نقشه که رو به شمال است، در جهت عقربه‌های ساعت. شیب: زاویه انحراف از دید مستقیم به پایین. کلیک برای بازنشانی هر دو. Before the event پیش از رویداد Below زیرین Below 1:5,000,000 show everything; beyond it only what matches: زیر ۱:۵,۰۰۰,۰۰۰ همه را نشان بده؛ فراتر از آن فقط مواردی که مطابقت دارند: @@ -328,10 +361,12 @@ Bilinear دوخطی Bind To Time Slider پیوند به لغزنده زمان Bind to Time Slider پیوند به لغزنده زمان Blank خالی +Blank for Amazon S3 برای Amazon S3 خالی بگذارید Blank Letter page صفحه نامه خالی Blank Map نقشه خالی Blank Page صفحه خالی Blend: {mode} ترکیب: {mode} +Block Feature Selection جلوگیری از انتخاب عنصر Blue آبی Bold توپر Bold label text. متن برچسب را ضخیم کن. @@ -339,6 +374,7 @@ Bookmark Description توضیحات نشانک Bookmarks نشانک‌ها Bookmarks Pane پنل نشانک‌ها Boolean بولی +Both ends هر دو سر Bottom پایین Boundaries… مرزها… Bounding\nGeometry هندسه\nمحدودکننده @@ -346,6 +382,7 @@ Brightness روشنایی Brightness, contrast, saturation and sepia for the whole map. روشنایی، کنتراست، اشباع و سپیای کل نقشه. Bring this bookmark's map into view to update it این نشانک را به دیدن بیاورید تا نقشه آن را به‌روز کنید Browse مرور +Browse Connection مرور اتصال Browse it in the Browser در مرورگر مرور کنید Browse Plugins افزونه‌ها را مرور کنید Browse the geoprocessing toolbox. جعبه ابزار پردازش فضایی را مرور کنید. @@ -357,6 +394,7 @@ Browser مرورگر Browser\nPane پنل\nمرورگر Browser View نمای مرورگر Bucket باکت +Buckets باکت‌ها Buffer بافر Buffer A Point بافر نقطه Buffer the cities by 200 km بافر شهرها به فاصله 200 کیلومتر @@ -381,7 +419,9 @@ Calculate Field محاسبه ویژگی Calculate field محاسبه ویژگی Calculate field values. محاسبه مقادیر ویژگی. Calculate fields and enforce conditions on every edit. محاسبه ویژگی‌ها و اعمال شرایط روی هر ویرایش. +Calculate Geometry محاسبه هندسه Calculate Geometry Attributes محاسبه ویژگی‌های هندسی +Calculated {rows} features {rows} عنصر محاسبه شد Calculation محاسبه Camera دوربین Camera Effect افکت دوربین @@ -396,12 +436,14 @@ Case Field فیلد مورد نظر Cast Shadow انداختن سایه Catalog کاتالوگ catalog is a static STAC catalog's catalog.json or collection.json فهرست، فهرست استاتیک STAC از catalog.json یا collection.json است +Catalogue (CSW) فهرست (CSW) Catalogue search (CSW) جستجوی فهرست (CSW) Celestial Bodies اجسام آسمانی Celestial Body جسم آسمانی Celestial body: {body} جسم آسمانی: {body} Cell size اندازه سلول cell size ° اندازه سلول ° +Cells سلول‌ها Center مرکز Center Map Here مرکز نقشه در این‌جا Centered مرکز قرارگرفته @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. استفاده ا ChatGPT sign-in failed. Check the connection and local settings, then try again. ورود به ChatGPT ناموفق بود. اتصال و تنظیمات محلی را بررسی کنید و دوباره تلاش کنید. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. استفاده از ChatGPT موقتاً در دسترس نیست. ورود خود را نگه دارید و بعداً دوباره تلاش کنید. CHE CHE +Check بررسی Check Again دوباره بررسی کن Check for\nUpdates بررسی\nبه‌روزرسانی‌ها Check for Updates بررسی به‌روزرسانی‌ها @@ -428,6 +471,7 @@ Check for Updates… بررسی به‌روزرسانی‌ها… Check the address and the service type. نشانی و نوع سرویس را بررسی کنید. Check the address and your connection. نشانی و اتصال خود را بررسی کنید. Check the address. نشانی را بررسی کنید. +Check the clause, and count the features it takes. بند را بررسی کنید و عناصری را که در بر می‌گیرد بشمارید. Check the integration's client, permissions, workspace policy and route configuration. کلاینت یکپارچه‌سازی، مجوزها، خط‌مشی فضای کاری و پیکربندی مسیر را بررسی کنید. Checking for updates… در حال بررسی به‌روزرسانی‌ها… China چین @@ -455,6 +499,7 @@ Choose which layers a click identifies. لایه‌هایی که با کلیک Choose which tabs the ribbon shows. تب‌هایی که در نوار نمایش داده می‌شوند را انتخاب کنید. Circle دایره Circle Radius (km) شعاع دایره (کیلومتر) +Class کلاس Class color رنگ کلاس Class Style سبک کلاس Classes کلاس‌ها @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line کلیک برای اف Click to edit the description کلیک برای ویرایش توضیحات Click to place a point graphic. کلیک برای قرار دادن یک گرافیک نقطه‌ای. Click to select the node کلیک برای انتخاب گره +Click to select, double-click to add, right-click for more کلیک برای انتخاب، دوبار کلیک برای افزودن، کلیک راست برای گزینه‌های بیشتر Click to switch between decimal degrees and DMS کلیک برای سوئیچ بین درجه اعشاری و DMS Clip برش Clip a raster to the view, or resample it. برش رستر به نما، یا بازنمونه‌برداری آن. @@ -532,7 +578,7 @@ Close thread بستن رشته گفت‌وگو Close {name} بستن {name} Closest نزدیک‌ترین Coded values, one per line: code=description مقادیر کدشده، هر خط یکی: code=description -Collapse or expand the ribbon جمع‌کردن یا بازکردن نوار (Ribbon) +Collapse or Expand the Ribbon جمع‌کردن یا بازکردن نوار (Ribbon) Collapse the ribbon (Ctrl+F1) جمع‌کردن نوار (Ribbon) (Ctrl+F1) Collection مجموعه Collection {id}: choose dates and Find Items مجموعه {id}: تاریخ‌ها را انتخاب کنید و Find Items را بزنید @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line ترکیب‌های داخل کادر بالا، هر خط یکی COMID {comid} COMID {comid} Comma ویرگول +Comma-separated (optional) جداشده با کاما (اختیاری) Command\nSearch جستجوی\nدستور command is required دستور الزامی است Command Search جستجوی دستور @@ -572,10 +619,13 @@ Connect اتصال Connect a folder to the Browser. یک پوشه را به Browser متصل کنید. Connect to S3 اتصال به S3 Connect, or pick a layer اتصال، یا انتخاب یک لایه +Connected to {kind} {title} به {kind} {title} متصل شد Connecting to {url}… در حال اتصال به {url}… Connection اتصال +connection.new returned no id connection.new هیچ شناسه‌ای برنگرداند Connections اتصال‌ها -Connect… اتصال… +Connections you add are listed here اتصال‌هایی که اضافه می‌کنید اینجا فهرست می‌شوند +Connections… اتصال‌ها… Constant ثابت Constant height ارتفاع ثابت Constant… ثابت… @@ -583,11 +633,14 @@ Contingent Values مقادیر وابسته Continue ادامه Continue with ChatGPT ادامه با ChatGPT Contrast کنتراست +Control Point Fit برازش نقاط کنترل Conversion تبدیل Convert تبدیل Convert a layer to another format تبدیل یک لایه به فرمت دیگر Convert the selected layer's labels into an annotation layer. برچسب‌های لایه انتخابی را به یک لایه annotation تبدیل کن. +Convert this layer's labels into an annotation layer. برچسب‌های این لایه را به یک لایه annotation تبدیل کن. Convert To Annotation تبدیل به Annotation +Convert to Annotation تبدیل به Annotation Converted data داده تبدیل‌شده Convert… تبدیل… Coordinate مختصات @@ -599,11 +652,15 @@ Copied the map to the clipboard نقشه در کلیپ‌برد کپی شد Copied {path} کپی شد: {path} Copies the (selected) features of a layer to a new layer. عناصر (انتخاب‌شده) یک لایه را به یک لایه جدید کپی می‌کند. Copy کپی +Copy / Cut / Paste features کپی / برش / چسباندن عناصر Copy a model.run command that runs this model, for the Script pane or Python یک دستور model.run که این مدل را اجرا می‌کند، برای پنل Script یا Python کپی کن Copy Address کپی آدرس Copy address کپی آدرس Copy an image of the map to the clipboard. یک تصویر از نقشه را در کلیپ‌برد کپی کن. +Copy as CLI Command کپی به‌عنوان دستور CLI +Copy as Command کپی به‌عنوان دستور Copy As GeoJSON کپی به عنوان GeoJSON +Copy as Python کپی به‌عنوان Python Copy Command کپی دستور Copy coordinates to the clipboard کپی مختصات در کلیپ‌برد Copy details کپی جزئیات @@ -611,11 +668,13 @@ Copy Group کپی گروه Copy Layer کپی لایه Copy Layers کپی لایه‌ها Copy Path کپی مسیر +Copy Run As Code کپی اجرا به‌عنوان کد Copy sign-in link کپی پیوند ورود Copy the selected features (also as GeoJSON text). کپی عناصر انتخاب‌شده (هم به صورت متن GeoJSON). Copy the selected features and delete them. کپی عناصر انتخاب‌شده و حذف آن‌ها. Copy the selected layer's data source path. کپی مسیر منبع داده لایه انتخاب‌شده. Copy this element (Ctrl+D, ⌘D on a Mac) کپی این عنصر (Ctrl+D, ⌘D در مک) +Copy this run as code, or open it in its tool این اجرا را به‌عنوان کد کپی کنید، یا آن را در ابزارش باز کنید Corrections اصلاحات Cost هزینه could not draw the map نمیتوانست نقشه را رسم کند @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} نمی‌توان رجیستر افز Count, sum, mean, min, max and standard deviation of a field, optionally per case field. تعداد، مجموع، میانگین، حداقل، حداکثر و انحراف معیار یک فیلد، اختیاریاً بر اساس فیلد مورد. Countries, cities, rivers and lakes کشورها، شهرها، رودها و دریاچه‌ها Country کشور +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. نقاط یک لایه را بر اساس سلول A5 دربرگیرنده‌شان می‌شمارد (سلول‌ها هم‌مساحت‌اند، پس شمارش همان تراکم است)، همراه با مجموع و میانگین یک فیلد. Counts the points that fall inside each polygon. نقاطی را که درون هر چندضلعی قرار می‌گیرند، شمارش می‌کند. Counts, totals and top categories of what's in view. تعداد، مجموع و برترین دسته‌بندی‌های محتوای قابل مشاهده. CPU CPU @@ -647,12 +707,8 @@ Cross Section برش عرضی Cross Section (2 clicks) برش عرضی (۲ کلیک) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative تراکمی Current date تاریخ فعلی Current Project پروژه فعلی @@ -680,13 +736,16 @@ Data type نوع داده Data you add is listed here داده‌های اضافه‌شده در اینجا لیست می‌شوند data.gov and CKAN catalogues فهرست‌های data.gov و CKAN Database پایگاه داده -Databases پایگاه‌های داده +Database connections are used by the desktop app اتصال‌های پایگاه داده در برنامه دسکتاپ استفاده می‌شوند +databases are read through DuckDB, a program the browser can't run پایگاه‌های داده از طریق DuckDB خوانده می‌شوند، برنامه‌ای که مرورگر نمی‌تواند اجرا کند Database… پایگاه داده… Dataset مجموعه داده Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. مجموعه داده‌ها در Hugging Face بر اساس نام یا مالک/نام; فایل‌های یک مجموعه داده را مرور کرده و فایل‌های GeoTIFF، GeoParquet، PMTiles، LiDAR و سایر فایل‌ها را اضافه کنید. Data… داده… Date تاریخ Dates تاریخ‌ها +Dates a cell needs تاریخ‌های لازم برای هر سلول +Dates a cell needs is a whole number, or empty for half تاریخ‌های لازم برای هر سلول یک عدد صحیح است، یا برای نصف خالی بگذارید Day روز Day and night shading for a time. سایه‌روزی روز و شب برای یک زمان Decimal ده‌دهی @@ -715,7 +774,6 @@ Delete Notebook حذف Notebook Delete Page حذف صفحه Delete Presentation حذف Presentation Delete Report حذف Report -Delete selected features حذف اشکال انتخاب‌شده Delete selected rows حذف ردیف‌های انتخاب‌شده Delete Style حذف Style Delete Task حذف تسک @@ -736,11 +794,15 @@ Desktop and command line only فقط دسکتاپ و خط فرمان Details جزئیات Details of the selected layer. جزئیات لایه انتخاب‌شده. Deviations انحراف‌ها +DGGS DGGS Diagnostic\nMonitor پایش\nعیب‌یابی Diagnostic Monitor پایش عیب‌یابی Diagnostics عیب‌یابی +Diagonal ↗ مورب ↗ +Diagonal ↘ مورب ↘ Diamond لوزی Difference, ratio or percent change between two rasters. اختلاف، نسبت یا درصد تغییر بین دو رستر. +Digit Digit Directions مسیریابی Directions From Here مسیریابی از این‌جا Directions To Here مسیریابی به این‌جا @@ -758,6 +820,7 @@ Dissolve Field فیلد حل مرزها Distance فاصله Distance (km) مسافت (km) Distance Coefficient P ضریب مسافت P +Distance units واحدهای فاصله Distances along the surface, heights, areas and volumes. مسافت‌ها روی سطح، ارتفاع‌ها، مساحت‌ها و حجم‌ها. DISTINCT DISTINCT Distinct یکتا @@ -804,18 +867,23 @@ Drag to draw برای رسم بکشید Drag to draw a freehand line. برای کشیدن خط دستی بکشید. Drag to draw a freehand polygon. برای کشیدن چندضلعی دستی بکشید. Drag to draw a rectangle. برای کشیدن مستطیل بکشید. +Drag to resize the message box برای تغییر اندازه کادر پیام بکشید Drag tools from the palette onto the canvas. ابزارها را از پالت روی بوم بکشید. Drag vertices on the map; click an edge to add one رأس‌ها را روی نقشه بکشید؛ روی یک لبه کلیک کنید تا یک رأس جدید اضافه شود Draw a halo around label text. هاله‌ای اطراف متن برچسب بکشید. Draw a labelled latitude/longitude grid over the map frame. یک شبکه عرض/طول جغرافیایی برچسب‌دار روی قاب نقشه بکشید. Draw a model: chain tools from input layers to the results to keep یک مدل بکشید: ابزارها را از لایه‌های ورودی به نتایجی که باید نگه دارید زنجیره‌وار کنید. Draw a model: chain tools from input layers to the results to keep. یک مدل بکشید: ابزارها را از لایه‌های ورودی به نتایجی که باید نگه دارید زنجیره‌وار کنید. +Draw at its place in the stack, the bottom stroke first ترسیم در جای خود در پشته، با ضربه پایینی در ابتدا +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet ضربه‌های یک سطح از همه عناصر پیش از سطح بعدی ترسیم شوند، تا پوسته‌ها در محل برخورد خط‌ها زیر هسته‌ها به هم بپیوندند Draw labels at every scale and distance. برچسب‌ها را در هر مقیاس و فاصله بکشید. Draw new features in the selected layer. Double-click to finish lines and polygons. عناصر جدید را در لایه انتخاب‌شده بکشید. برای خطوط و چندضلعی‌ها دوبار کلیک کنید تا پایان دهید. Draw new single-band rasters with the inverted grey stretch again. رسترهای تک‌بانده جدید را با کشش خاکستری معکوس دوباره بکشید. +Draw only polygon outlines, keeping their colors and widths. فقط خط دور چندضلعی‌ها ترسیم شود، با حفظ رنگ‌ها و پهنای آن‌ها. Draw points as bins coloured by how many fall in each. نقاط را به‌صورت خانه‌های رنگ‌آمیزی‌شده بر اساس تعداد نقاطِ هر خانه رسم کنید. Draw polygons as outlines only. چندضلعی‌ها را فقط به صورت خطوط بیرونی بکشید. Draw single-band rasters added from now on with this color scheme, stretch and resampling. رسترهای تک‌بانده که از این به بعد اضافه می‌شوند را با این طرح رنگ، کشش و بازنمونه‌گیری مجدد بکشید. +Draw strokes in levels ترسیم ضربه‌ها در سطوح Draw the layer at every scale. این لایه را در هر مقیاس بکشید. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). نقشه را روی GPU بکشید (بتا؛ کره، جلوه‌های دید و شبیه‌سازی بینایی رنگی در رندر CPU باقی می‌مانند). Draw the selected layer above others. لایه انتخاب‌شده را بالای لایه‌های دیگر بکشید. @@ -828,6 +896,7 @@ drawn by the server توسط سرور کشیده شده Drive درایو Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. تصاویر پهپادی و هوایی که در OpenAerialMap به اشتراک گذاشته شده‌اند، جدیدترین اول، به عنوان GeoTIFFهای بهینه‌شده در ابر اضافه می‌شوند. Duplicate تکثیر +Duplicate Connection تکثیر اتصال Duplicate Element تکثیر عنصر Duplicate Layout تکثیر صفحه‌آرایی Duplicate Map تکثیر نقشه @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name مثلاً: pop_est / 10000 e.g. 0, 255, -9999 مثلاً 0، 255، -9999 e.g. continent = 'Europe' مثلاً continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' مثلاً pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 مثلاً pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs هر اجرای زمین‌پردازش یک گام می‌شود که با خروجی‌هایش به هم زنجیر می‌شوند Earth Index has no points in the view در این نما هیچ نقطه‌ای از Earth Index نیست Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices ویرایش\nرأس‌ها Edit and run the notebook. ویرایش و اجرای notebook. Edit Attribute ویرایش ویژگی Edit Cell ویرایش سلول +Edit Connection ویرایش اتصال Edit Description ویرایش توضیحات Edit Graphic ویرایش گرافیک Edit Status ویرایش وضعیت @@ -882,6 +953,7 @@ Editor\nTools ابزارهای\nویرایشگر Editor Tools ابزارهای ویرایشگر Edits since the project was saved. ویرایش‌های پس از آخرین ذخیره پروژه. Edits the project پروژه را ویرایش می‌کند +Edit… ویرایش… Effects جلوه‌ها Element عنصر Element Properties ویژگی‌های عنصر @@ -898,21 +970,25 @@ Enable Lighting روشن‌کردن نورپردازی Enable Plugin فعال‌کردن افزونه Enabled فعال Encloses each feature, or all features, in its convex hull. هر عنصر، یا همه عناصر، را در پوسته محدب خود می‌پیچد. +Encoded polyline چندخطه کدگذاری‌شده End پایان End date تاریخ پایان Endpoint نقطه پایانی Enhancement بهبود Enter Enter Enter an API key. یک کلید API وارد کنید. +Enter an EPSG code as a number, such as 32650 یک کد EPSG را به‌صورت عدد وارد کنید، مانند 32650 Enter the provider's address. نشانی ارائه‌دهنده را وارد کنید. Enter to send, Shift+Enter for a new line با Enter بفرستید، با Shift+Enter خط جدید EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments محیط‌ها EPSG EPSG +EPSG code کد EPSG EPSG or ESRI code کد EPSG یا ESRI EPSG: کد EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth زمین مساوی Equal Interval فاصله مساوی Equirectangular (Plate Carrée) مستطیل (پلات کاره) @@ -922,14 +998,18 @@ Error خطا Error Inspector بررسی‌کننده خطا Error Inspector (validate) بررسی‌کننده خطا (اعتبارسنجی) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) اروپا مساحت برابر (ETRS89 LAEA) Every band's value at a pixel. مقدار هر باندر در یک پیکسل Every editing tool. هر ابزار ویرایش +Every label setting of this layer, in the Labeling pane. همه تنظیمات برچسب این لایه، در پنل برچسب‌گذاری. Every layer, as FEMA draws them هر لایه، همان‌طور که FEMA ترسیم می‌کند +Every run, in order, as a script that opens the project همه اجراها، به ترتیب، به‌صورت اسکریپتی که پروژه را باز می‌کند every Socrata site, or one such as data.cityofnewyork.us هر سایت سقراطا، یا سایتی مانند data.cityofnewyork.us Everywhere هرwhere +Existing field فیلد موجود Existing Template قالب موجود exit rotary خروج از چرخ‌بر exit roundabout خروج از دوربرگ @@ -948,6 +1028,7 @@ Export\nRaster برون‌بری\nرستر Export Animation برون‌بری انیمیشن Export Bookmarks برون‌بری نشانک‌ها Export Bookmarks… برون‌بری نشانک‌ها… +Export CSV… خروجی CSV… Export Features برون‌بری عناصر Export Features (tool) برون‌بری عناصر (ابزار) Export Features needs a vector layer (Data › Export Raster writes a raster) برون‌بری عناصر به یک لایه برداری نیاز دارد (داده › برون‌بری رستر یک رستر می‌نویسد) @@ -996,6 +1077,7 @@ Face Culling حذف رویه‌های پنهان Faces رویه‌ها Factor ضریب Failed ناموفق +FALSE FALSE Farthest دورترین Faster drawing: no antialiasing. ترسیم سریع‌تر: بدون antialiasing. Favourite Data Source منبع داده مورد علاقه @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries مرزهای مزارع Field density تراکم مزارع Field name نام فیلد +Field Prefix پیشوند فیلد +Field to Sum and Average فیلد برای مجموع و میانگین Fields فیلدها Fields are drawn from zoom 9: zoom in to see them. مزارع از بزرگ‌نمایی 9 ترسیم می‌شوند: برای دیدنشان بزرگ‌نمایی کنید. Fields colored by score, drawn from zoom 9 مزارع رنگ‌شده بر اساس امتیاز، از بزرگ‌نمایی 9 ترسیم می‌شوند @@ -1079,6 +1163,8 @@ Folder Connections اتصال‌های پوشه folder connections need the desktop app اتصال‌های پوشه به برنامه دسکتاپ نیاز دارند Folders پوشه‌ها Folder… پوشه… +Follow the time slider دنبال‌کردن لغزنده زمان +Font فونت for a store that doesn't say برای فروشگاهی که نامش را ننوشته Forest جنگل Forget Earthdata Login Token فراموش‌کردن توکن ورود Earthdata @@ -1090,9 +1176,11 @@ France فرانسه Free the prepared geometry. آزادسازی هندسه آماده‌شده. Freehand آزاد‌دست FROM FROM +From از From Selection از انتخاب From Service… از سرویس… From symbol از نماد +From the address از نشانی from the address از نشانی from the service از سرویس From URL… از URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. پنهان‌کردن عناصر دو Hide Group پنهان‌کردن گروه Hide labels when the viewer is closer than this. پنهان‌کردن برچسب‌ها وقتی بیننده نزدیک‌تر از این است. Hide labels when the viewer is farther than this. پنهان‌کردن برچسب‌ها وقتی بیننده دورتر از این است. +Hide Layers پنهان‌کردن لایه‌ها Hide other values پنهان‌کردن مقادیر دیگر Hide Steps پنهان‌کردن گام‌ها Hide the description پنهان‌کردن توضیحات @@ -1213,6 +1302,8 @@ Hold نگه‌داشتن HOME HOME Home خانه Home › Add خانه › افزودن +Home › Add Data خانه › افزودن داده +Home › Inspect › Measure خانه › بازرسی › اندازه گیری Home › Navigate خانه › ناوبری Home › Navigate › Bookmarks خانه › ناوبری › نشانک‌ها Home › Web Data خانه › داده وب @@ -1220,7 +1311,7 @@ Home › Web Data › United States خانه › داده وب › ایالات Homepage صفحه خانگی Horizontal افقی host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file رشته‌ای مانند host=… dbname=… user=…، یا یک فایل .duckdb +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery تصاویر میزبانی‌شده Hour ساعت How the layer's colours combine with the tile layers and basemap beneath. نحوه ترکیب رنگ‌های لایه با لایه‌های کاشی و نقشه پایه زیر آن. @@ -1248,6 +1339,7 @@ Imagery Sharing اشتراک تصاویر Imagery Tools ابزارهای تصویر ImageServer ImageServer Image… تصویر… +Imperial امپریال Import واردی Import\nProject درون‌بری\nپروژه Import All درون‌بری همه @@ -1267,6 +1359,7 @@ Import Task درون‌بری تسک Import Task… درون‌بری تسک… Import Thumbnail درون‌بری تصویر بندانگشتی Import… درون‌بری… +IN IN In Beyond نزدیک‌تر از In memory در حافظه In Progress در حال انجام @@ -1281,6 +1374,8 @@ Information اطلاعات INPUT INPUT Input ورودی Input Features عناصر ورودی +Input Layer لایه ورودی +Input Points نقاط ورودی Input Polygons چندضلعی‌های ورودی Input Rows ردیف‌های ورودی Input Summary Features عناصر خلاصه ورودی @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} افزونه‌های نصب‌شده د Installed with Homebrew, winget, the AUR or COPR? Update it there. با Homebrew، winget، AUR یا COPR نصبش کرده‌اید؟ همان‌جا به‌روزرسانی‌اش کنید. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. نصب {name} {version} انجام شد. آن را در برگهٔ Plugins پیدا کنید؛ ابزارهایش هم در پنل Tools هستند. Installing it downloads Python {version} and the packages it needs from the internet. نصب آن، Python {version} و بسته‌های مورد نیازش را از اینترنت دانلود می‌کند. +Integer field: values are rounded. فیلد عدد صحیح: مقادیر گرد می‌شوند. Interface font: {font} فونت رابط کاربری: {font} Interpolate a surface from points (IDW) درون‌یابی سطح از نقاط (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. یک سطح رستری دوباندی (پیش‌بینی و واریانس) را با کریجینگ معمولی از عناصر نقطه‌ای درون‌یابی می‌کند. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. سطح رستری را از مقادیر عناصر با وزن‌دهی معکوس فاصله درون‌یابی می‌کند، مثل QGIS. Interpolation درون‌یابی Interpolation Attribute ویژگی درون‌یابی @@ -1316,10 +1413,13 @@ Invalid: {error} نامعتبر: {error} Invert وارونه‌کردن Invert color scheme وارونه‌کردن طرح رنگ Invert the selection. وارونه‌کردن انتخاب. +IS IS IS NULL IS NULL it connects to an address on the internet به نشانی‌ای در اینترنت وصل می‌شود It has no code: nothing in it runs. کدی ندارد: هیچ بخشی از آن اجرا نمی‌شود. +It is locked, or one of its surrounds is: unlock it first خودش یا یکی از عناصر پیرامونی‌اش قفل است: ابتدا قفل آن را باز کنید it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) مخصوص خود شماست (افزونه‌ها، اسکریپت‌ها، تسک‌ها، مدل‌ها و اجراکننده GeoLibre) +it needs input: open it from its pane or button, or run it from a script or the assistant به ورودی نیاز دارد: آن را از پنل یا دکمه‌اش باز کنید، یا از یک اسکریپت یا دستیار اجرا کنید it reads or writes files on this computer روی این رایانه فایل می‌خواند یا می‌نویسد it replaces the open project پروژه باز را جایگزین می‌کند it runs a plugin's command دستور یک افزونه را اجرا می‌کند @@ -1332,20 +1432,30 @@ Italic ایتالیک Item Pop-ups پاپ‌آپ‌های آیتم Item {id} آیتم {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. فایروال آن ({firewall}) RustGIS را مسدود کرد. ممکن است سرویس فقط به بعضی برنامه‌ها اجازه بدهد: با مدیر آن تماس بگیرید و جزئیات زیر را برایش بفرستید. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) ابزار آن اکنون در دسترس نیست (ابزار یک افزونه که بارگذاری نشده، یا یک مجموعه که نصب نشده) Jobs کارها JOIN JOIN Join اتصال +Join Features عناصر اتصال +Join Operation عملیات اتصال +Joins attributes from a join layer to a target layer based on their spatial relationship. ویژگی‌های یک لایه اتصال را بر اساس رابطه مکانی‌شان به یک لایه هدف متصل می‌کند. +Journal figure, 1 column (90 mm) شکل مجله، 1 ستون (90 mm) +Journal figure, 1.5 columns (140 mm) شکل مجله، 1.5 ستون (140 mm) +Journal figure, 2 columns (190 mm) شکل مجله، 2 ستون (190 mm) Keep a history of every edit to the layer, to look back or restore. تاریخچه هر ویرایش روی لایه نگه می‌دارد، برای مرور یا بازگردانی. Keep an archive of edits بایگانی ویرایش‌ها را نگه دار keep current فعلی بماند +Keep it from being moved, resized, deleted or edited by mistake جلوگیری از جابه‌جایی، تغییر اندازه، حذف یا ویرایش اشتباهی آن Keep one value of each point's vector as its value field یکی از مقادیر بردار هر نقطه را به‌عنوان فیلد مقدارش نگه دار Keep Result نگه‌داشتن نتیجه Keep the labelled features fixed as the map moves. عنصرهای برچسب‌دار هنگام حرکت نقشه ثابت می‌مانند. Keep the last drawing tool active after each graphic. بعد از هر گرافیک، آخرین ابزار رسم فعال بماند. Keep the last tool active آخرین ابزار فعال بماند +Keep this endpoint under Data Sources › Connections, without its password or keys این نقطه پایانی را زیر منابع داده › اتصال‌ها نگه دار، بدون رمز یا کلیدهایش Keep this result این نتیجه نگه داشته شود Keep Tool نگه‌داشتن ابزار Keep value نگه‌داشتن مقدار +Key Key Keyboard Shortcuts میان‌برهای صفحه‌کلید Keyboard shortcuts. میان‌برهای صفحه‌کلید. Keys from کلیدها از @@ -1356,13 +1466,20 @@ KMZ KMZ Label برچسب Label Class کلاس برچسب Label Distance Range گستره فاصله برچسب +Label features in this layer برچسب‌گذاری عناصر این لایه Label Field فیلد برچسب +Label only the features that match. فقط عناصری که مطابقت دارند برچسب بخورند. Label Placement جای‌گذاری برچسب Label Range گستره برچسب Label the cities with their names شهرها را با نامشان برچسب بزن Label Visibility Range گستره دید برچسب Labeling برچسب‌گذاری +Labeling Pane پنل برچسب‌گذاری +Labeling Properties… ویژگی‌های برچسب‌گذاری… Labels برچسب‌ها +labels are drawn for feature layers, not rasters برچسب‌ها برای لایه‌های عناصر ترسیم می‌شوند، نه رسترها +Labels are drawn for feature layers, not rasters. برچسب‌ها برای لایه‌های عناصر ترسیم می‌شوند، نه رسترها. +Labels are off for this layer: the clause applies once they are on. برچسب‌های این لایه خاموش است: بند پس از روشن‌شدن آن‌ها اعمال می‌شود. Lagoon تالاب Lambert Conformal Conic لامبرت هم‌شکل مخروطی Lambert Cylindrical Equal Area لامبرت استوانه‌ای هم‌مساحت @@ -1383,12 +1500,14 @@ Layer Blend ترکیب لایه Layer Extrusion برجسته‌سازی لایه Layer File فایل لایه Layer From\nSelection لایه از\nانتخاب +Layer not found لایه پیدا نشد Layer Template قالب لایه Layer Templates قالب‌های لایه layer {id} لایه {id} Layers لایه‌ها Layers ({n}) لایه‌ها ({n}) Layers and Table Views لایه‌ها و نمای جدول‌ها +Layers, a date each لایه‌ها، هر کدام یک تاریخ Layout صفحه‌آرایی Layout\nFile فایل\nصفحه‌آرایی Layout after: {name} صفحه‌آرایی پس از: {name} @@ -1402,7 +1521,7 @@ Learning\nResources منابع\nیادگیری Left چپ Legend شرح Legend label. Clear it to show the value again. برچسب راهنما. برای نمایش دوباره مقدار، آن را پاک کنید. -Length {length} · gain {gain} m · loss {loss} m طول {length} · صعود {gain} m · نزول {loss} m +Length {length} · gain {gain} · loss {loss} طول {length} · افزایش {gain} · کاهش {loss} Let the assistant hand tasks to agents on this computer اجازه بده دستیار کارها را به ایجنت‌های این رایانه بسپارد Let your Agents Control RustGIS اجازه دهید ایجنت‌های شما RustGIS را کنترل کنند Letter spacing فاصله حروف @@ -1439,6 +1558,7 @@ List by drawing order فهرست بر اساس ترتیب ترسیم List by selection فهرست بر اساس انتخاب List Columns in File فهرست ستون‌های فایل List Commands فهرست دستورها +List Connections فهرست اتصال‌ها List Data Sources فهرست منابع داده List Database Columns فهرست ستون‌های پایگاه داده List Database Tables فهرست جدول‌های پایگاه داده @@ -1475,6 +1595,11 @@ Locate… مکان‌یابی… Location مکان Locator مکان‌یاب Lock قفل +Locked قفل‌شده +Locked: unlock it in the Format pane to move or change it قفل‌شده: برای جابه‌جایی یا تغییر، قفل آن را در پنل Format باز کنید +Log In to Connection ورود به اتصال +Log In to {name}… ورود به {name}… +Log In… ورود… Long بلند Longitude طول جغرافیایی Longitude (X) طول جغرافیایی (X) @@ -1493,6 +1618,7 @@ Manage مدیریت Manage Bookmarks مدیریت نشانک‌ها Manage Edits مدیریت ویرایش‌ها Manage Templates مدیریت قالب‌ها +Mann–Kendall Mann–Kendall Map نقشه Map\nFrame قاب\nنقشه Map (the current view) نقشه (نمای فعلی) @@ -1504,6 +1630,7 @@ Map File فایل نقشه Map Frame قاب نقشه Map Frames قاب‌های نقشه Map frames per second (live while the map moves) فریم در ثانیه نقشه (زنده هنگام حرکت نقشه) +Map Labeling برچسب‌گذاری نقشه Map Link پیوند نقشه Map name نام نقشه Map Page صفحه نقشه @@ -1515,6 +1642,7 @@ Map style (JSON) استایل نقشه (JSON) Map Surrounds عناصر پیرامونی نقشه Map Thumbnail تصویر بندانگشتی نقشه Map tiles of the whole world کاشی‌های نقشه سراسر جهان +Map Tool: {tool} ابزار نقشه: {tool} Map View نمای نقشه map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} نقشه: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker نشانه Mask Values ماسک مقادیر Masking ماسک‌کردن +Match Option گزینه تطابق Matching places مکان‌های منطبق Max بیشینه Max % بیشینه % +Max Neighbors حداکثر همسایه‌ها Maximum zoom بیشینه بزرگ‌نمایی MCP Relay رله MCP MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} رله MCP همین حالا روی 127.0.0.1:{port} گوش می‌دهد؛ برای استفاده از درگاه {asked} اول آن را متوقف کنید @@ -1542,6 +1672,8 @@ Measure Distance اندازه‌گیری مسافت Measure geodesic distances and areas on the map. اندازه‌گیری مسافت‌ها و مساحت‌های ژئودزیک روی نقشه. Measure on this body, with one of its basemaps. اندازه‌گیری روی این جرم آسمانی، با یکی از نقشه‌های پایه‌اش. Measure with an elevation raster اندازه‌گیری با رستر ارتفاعی +Measurement units واحدهای اندازه‌گیری +Measurement units must be metric or imperial واحدهای اندازه‌گیری باید متریک یا امپریال باشند Measurements so far. اندازه‌گیری‌های تا این‌جا. Mensuration اندازه‌نگاری Mensuration Tools ابزارهای اندازه‌نگاری @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. چندضل Messages appear here. پیام‌ها این‌جا ظاهر می‌شوند. Meters متر Method روش +Metric متریک Midnight نیمه‌شب Miles مایل Miller Cylindrical میلر استوانه‌ای @@ -1560,21 +1693,43 @@ Min must be less than Max کمینه باید از بیشینه کمتر باش Min must not be more than Max کمینه نباید از بیشینه بیشتر باشد Minimum Bounding Geometry کمینه هندسه دربرگیرنده Minimum Maximum کمینه–بیشینه +Minus Minus Minutes دقیقه minutes ago دقیقه پیش Miter میتر Mobile Map نقشه موبایل Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model مدل Model\nBuilder Model\nBuilder model id شناسه مدل Model output خروجی مدل ModelBuilder ModelBuilder Models مدل‌ها +Modified Mann–Kendall (Hamed & Rao) Mann–Kendall اصلاح‌شده (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Mann–Kendall اصلاح‌شده (Yue & Wang) Modify اصلاح Modify Selected Features اصلاح عناصر انتخاب‌شده Mollweide (equal-area world) مولواید (جهت هم‌مساحت) Month ماه +Months ماه‌ها +Months are numbers from 1 to 12 (12, 1, 2 for December to February) ماه‌ها اعداد 1 تا 12 هستند (12، 1، 2 برای دسامبر تا فوریه) More بیشتر More basemaps: نقشه‌های پایه بیشتر: Mosaic images into an orthomosaic موزاییک تصاویر در یک ارتو موزاییک @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. موزاییک رسترهای ن Most cloud cover (%) بیشترین پوشش ابر (%) Mouse wheel چرخ موس Move جابجایی -Move (m) جابه‌جایی (m) Move Bookmark جابه‌جایی نشانک Move Down انتقال به پایین Move down انتقال به پایین Move Group جابه‌جایی گروه +Move Label Class جابه‌جایی کلاس برچسب Move Task Step جابه‌جایی گام تسک +Move To Bottom انتقال به پایین‌ترین Move To Group انتقال به گروه +Move To Top انتقال به بالاترین Move To {group} انتقال به {group} Move Up انتقال به بالا Move up انتقال به بالا @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets باکت‌های من My OGC Layer لایه OGC من +MySQL/MariaDB MySQL/MariaDB N/S/E/W ش/ج/خ/غ Name نام name the plugin whose panel to open: {"id": plugin id} افزونه‌ای که پنلش باز شود را نام ببرید: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook نوت‌بوک\nجدید New\nPresentation ارائه\nجدید New\nReport گزارش\nجدید New\nWorkspace فضای کاری\nجدید -New / Open / Save project پروژه جدید / بازکردن / ذخیره New Bookmark نشانک جدید New Bookmark… نشانک جدید… +New Connection اتصال جدید +New Connection ▾ اتصال جدید ▾ New conversation گفت‌وگوی جدید New Custom Template قالب سفارشی جدید New Custom Template… قالب سفارشی جدید… New features use this template's values عناصر جدید از مقادیر این قالب استفاده می‌کنند +New field فیلد جدید New Group گروه جدید New job name نام کار جدید New Layout صفحه‌آرایی جدید @@ -1649,6 +1809,7 @@ New Notebook نوت‌بوک جدید New Presentation ارائهٔ جدید New Project پروژه جدید New project پروژه جدید +New Publication Layout صفحه‌آرایی انتشار جدید New Relationship… رابطه جدید… New Report گزارش جدید New Selection انتخاب جدید @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. هنوز نش No catalogs below this one: Find Items lists its items زیر این فهرست، فهرستی نیست: Find Items آیتم‌هایش را فهرست می‌کند No COMID was returned for this point. برای این نقطه هیچ COMIDای برگردانده نشد. no command بدون دستور -No connections this session در این نشست اتصالی نبود +No data at the points picked. در نقاط انتخاب‌شده داده‌ای نیست. No datasets match. هیچ مجموعه داده‌ای مطابقت ندارد. No datasets with data RustGIS can add هیچ مجموعه داده‌ای با داده قابل افزودن به RustGIS نیست +no date in the name تاریخی در نام نیست No edits since the project was saved از ذخیره پروژه ویرایشی نبوده است No field of this layer holds dates. هیچ فیلدی از این لایه تاریخ نگه نمی‌دارد. +No Fill بدون پرکننده +No geometry to calculate هندسه‌ای برای محاسبه نیست No granules in this view and date range. در این نما و بازه تاریخی گرانولی نیست. no home folder بدون پوشه خانه no layer بدون لایه @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) بدون رابطه (طراحی No relationships yet هنوز رابطه‌ای نیست No reports yet. Publish › New Report lists the selected layer's records. هنوز Reportی نیست. انتشار › Report جدید رکوردهای لایه انتخاب‌شده را فهرست می‌کند. no row بدون ردیف +No serif font is installed, so this text is drawn in the sans-serif هیچ فونت سریف‌داری نصب نیست، پس این متن با فونت بدون سریف ترسیم می‌شود no service or file links بدون پیوند سرویس یا فایل No services match your search. هیچ سرویسی با جستجوی شما مطابقت ندارد. No standalone tables بدون جدول مستقل @@ -1718,14 +1883,16 @@ Nominatim Nominatim None هیچ None (public buckets) هیچ (باکت‌های عمومی) none, a .duckdb file, or host=… dbname=… user=… هیچ، یک فایل .duckdb، یا host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD هیچ، یا از PGPASSWORD یا MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD هیچ، یا از PGPASSWORD، MYSQL_PWD یا SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. هیچ. یکی با نشانی اضافه کنید، یا از افزونه نقشه پایه. North شمال North\nArrow فلش\nشمال North Polar Stereographic قطبی استریوگرافیک شمالی +NOT NOT not a picture تصویر نیست not a RustGIS layout صفحه‌آرایی RustGIS نیست not a RustGIS project پروژه RustGIS نیست +Not applied yet. هنوز اعمال نشده است. Not for texts: a text is edited in the Format pane. برای متن‌ها نیست: متن در پنل Format ویرایش می‌شود. not needed لازم نیست Not sandboxed بدون سندباکس @@ -1739,9 +1906,12 @@ Nothing found where the map is در محل نقشه چیزی پیدا نشد Nothing here اینجا چیزی نیست Nothing named {name} to rename چیزی به نام {name} برای تغییر نام نیست Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. هنوز چیزی به اشتراک گذاشته نشده. انتشار › اشتراک به‌عنوان، نقشه وب، لایه، استایل، صحنه و بیشتر می‌سازد. +Nothing to add here چیزی برای افزودن اینجا نیست +Nothing to test چیزی برای آزمودن نیست Nothing was found at this address. در این نشانی چیزی پیدا نشد. Nothing was recorded, so no task was made. چیزی ثبت نشد، پس تسکی ساخته نشد. Now اکنون +Nugget اثر قطعه‌ای (Nugget) NULL NULL Nulls تهی‌ها Observations (observer, date, species, count) مشاهدات (ناظر، تاریخ، گونه، تعداد) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. در دسکتاپ، کلید On-screen compass, zoom slider and pan pad. قطب‌نمای روی صفحه، لغزنده بزرگ‌نمایی و پد جابه‌جایی. One command per line: id {json}. Lines starting with # are comments. هر خط یک دستور: id {json}. خط‌هایی که با # شروع می‌شوند توضیح‌اند. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. یک SELECT از DuckDB: توابع ST_ در DuckDB Spatial، فایل‌ها و نشانی‌های وب (read_parquet('…')) و جدول‌های پایگاه داده زیر. با Ctrl+Enter اجرا می‌شود. +One layer: a Zarr variable or a raster's bands یک لایه: یک متغیر Zarr یا باندهای یک رستر One page per feature of an index layer. برای هر عنصر لایه ایندکس، یک صفحه. One per line: code=name هر خط یکی: code=name One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. یک ضربه، از نماد. برای پوسته، خط‌های موازی و آفست‌ها ضربه اضافه کنید. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder بازکردن پوشه Open In Geoprocessing… بازکردن در زمین‌پردازش… Open in ModelBuilder بازکردن در ModelBuilder +Open in Tool بازکردن در ابزار Open Layout بازکردن صفحه‌آرایی Open ModelBuilder بازکردن ModelBuilder Open Notebook بازکردن Notebook @@ -1797,6 +1969,7 @@ Open Presentation بازکردن Presentation Open Project بازکردن پروژه Open project بازکردن پروژه Open Project (from text) بازکردن پروژه (از متن) +Open Project… بازکردن پروژه… Open Report بازکردن Report Open the attribute table of the selected layer. بازکردن جدول ویژگی لایه انتخاب‌شده. Open the Basemaps tab بازکردن برگه Basemaps @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: یا در یک شل POSIX ا Or run in PowerShell, for Claude Code or Codex: یا در PowerShell اجرا کنید، برای Claude Code یا Codex: Or run, for Claude Code or Codex: یا برای Claude Code یا Codex اجرا کنید: ORDER BY ORDER BY +Ordinary Kriging کریجینگ معمولی Orientation جهت‌گیری Ortho Mapping ارثومپینگ Other سایر Other Data داده‌های دیگر +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. کلاس‌های برچسب دیگر این لایه (ساخته‌شده با دستورها): {n}. هر کدام عناصری را برچسب می‌زند که با بند خودش مطابقت دارند، پس یک عنصر ممکن است از بیش از یک کلاس برچسب بگیرد. Out Beyond دورتر از Outline خط دور Outline another frame's extent in this map frame. گستره قاب دیگر را در این قاب نقشه خط‌بندی می‌کند. @@ -1851,6 +2026,7 @@ Output خروجی Output Name نام خروجی Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. بیش از 1000 ابزار رستر، بردار، لیدار، هیدرولوژی و زمین که در مرورگر اجرا می‌شوند، با یک دانلود یک‌باره حدود 25 مگابایتی. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. بیش از 1000 ابزار رستر، بردار، لیدار، هیدرولوژی و زمین، با دانلود اختیاری حدود 20 مگابایتی. +Overlapping هم‌پوشان Overlay رویه‌ی overlay روی‌گذاری Overlay (a transparent layer kept on top, such as labels or roads) روی‌گذاری (لایه شفافی که بالا نگه داشته می‌شود، مثل برچسب‌ها یا جاده‌ها) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is داده {ty} در محل نقشه ا Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. داده باز Overture Maps: ساختمان‌ها، مکان‌ها، حمل‌ونقل، بخش‌های اداری، لایه‌های پایه و نشانی‌ها. در محل نقشه، عناصرش مستقیم از فایل‌های GeoParquet اوورت خوانده می‌شوند (به اندازه یک شهر یا کوچک‌تر بزرگ‌نمایی کنید)؛ کل جهان هم به‌صورت کاشی نقشه می‌آید. Overture Maps… Overture Maps… +Overwrite existing values بازنویسی مقادیر موجود Package بسته‌بندی Package a trained deep learning model. بسته‌بندی یک مدل یادگیری عمیق آموزش‌دیده. Package Map بسته‌بندی نقشه @@ -1907,8 +2084,12 @@ Performance کارایی Phase فاز Photon Photon Pick a live value, such as the scale or date, then click the map to place it. یک مقدار زنده مثل مقیاس یا تاریخ انتخاب کنید، سپس برای جای‌گذاری روی نقشه کلیک کنید. +Pick a Point انتخاب یک نقطه +Pick a point on the map to see the variable's values through time there. نقطه‌ای را روی نقشه انتخاب کنید تا مقادیر متغیر را در گذر زمان در آنجا ببینید. Pick a route in the list, or click a grey route on the map. یک مسیر از فهرست برگزینید، یا روی مسیر خاکستری روی نقشه کلیک کنید. +Pick a single layer. یک لایه را انتخاب کنید. Pick it on the map انتخابش روی نقشه +Pick one… یکی را انتخاب کنید… Picture تصویر Pictures تصاویر Pictures, attachments and chart تصاویر، پیوست‌ها و نمودار @@ -1966,8 +2147,11 @@ Portal پورتال Portolan Portolan Portolan… Portolan… Portrait عمودی (Portrait) +Position موقعیت +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing پس‌پردازش power 2 توان 2 +Precision 6 (OSRM, Valhalla) دقت 6 (OSRM، Valhalla) Prepare آماده‌سازی Prepare every layer's geometry for this projection. آماده‌سازی هندسه همه لایه‌ها برای این تصویر. Prepare Plugin Environment آماده‌سازی محیط افزونه @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. قالب‌های پروژ project's path is required مسیر پروژه الزامی است Projection تصویر نقشه Projections تصویرها +Properties ویژگی‌ها +Property ویژگی Proportional Symbols نمادهای تناسبی Provider ارائه‌دهنده +Provinces: استان‌ها: Proximity نزدیکی Public GIS portals of US counties and cities, by state. پورتال‌های عمومی GIS شهرستان‌ها و شهرهای آمریکا، بر اساس ایالت. Public GIS portals of US federal agencies, by department. پورتال‌های عمومی GIS نهادهای فدرال آمریکا، بر اساس وزارتخانه. Public GIS portals of US states and the District of Columbia. پورتال‌های عمومی GIS ایالت‌های آمریکا و ناحیه کلمبیا. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. لایه‌های عمومی روی ArcGIS Online یا پورتال دیگر ArcGIS: سرویس‌های عنصری، نقشه، تصویر و کاشی برداری، نقشه‌های وب و فایل‌های GeoJSON. +Publication Layout صفحه‌آرایی انتشار Publish انتشار Publish › Output انتشار › خروجی Put the layer back as it was then بازگرداندن لایه به حالت آن زمان Python environment failed: {error} محیط Python ناموفق بود: {error} Python environment not prepared محیط Python آماده نشده است Python environment ready محیط Python آماده است +Python script اسکریپت Python Qualitative کیفی Quantile چندک Query files, web addresses and databases with DuckDB Spatial; add results to the map. پرس‌وجوی فایل‌ها، نشانی‌های وب و پایگاه‌های داده با DuckDB Spatial؛ افزودن نتایج به نقشه. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. برجسته‌کردن چندضلعی‌ Raise Priority افزایش اولویت Random points in polygons نقاط تصادفی در چندضلعی‌ها Range بازه +Range (Degrees) برد (درجه) +range: 0, 0.9 بازه: 0, 0.9 Raster رستر Raster\nAnalysis تحلیل\nرستر Raster\nInformation اطلاعات\nرستر @@ -2075,6 +2266,7 @@ Redo {action} بازانجام {action} Red–Blue قرمز–آبی Refine the search to see {n} more جستجو را دقیق‌تر کنید تا {n} مورد دیگر را ببینید Refresh تازه‌سازی +Refresh every connection's lists تازه‌سازی فهرست‌های همه اتصال‌ها Refresh Map Series به‌روزرسانی سری نقشه‌ها Refresh the map series from its index layer. به‌روزرسانی سری نقشه‌ها از لایه ایندکس آن. Region منطقه @@ -2091,11 +2283,10 @@ Relationships رابطه‌ها Release انتشار Reload بارگذاری دوباره Reload Web Layers بارگذاری دوباره لایه‌های وب -Remember (without the secret) به یادگار بماند (بدون بخش محرمانه) Remember the key on this computer کلید روی این رایانه به خاطر سپرده شود -Remembered: به خاطر سپرده شد: Remove حذف‌کردن Remove All حذف همه +Remove All Label Classes حذف همه کلاس‌های برچسب Remove an offline area. حذف یک پهنه آفلاین. Remove Bookmark حذف نشانک Remove Connection حذف اتصال @@ -2108,11 +2299,14 @@ Remove from Favourites حذف از علاقه‌مندی‌ها Remove From Group حذف از گروه Remove Group حذف گروه Remove Keyframe حذف فریم کلیدی +Remove Label Class حذف کلاس برچسب Remove Section حذف بخش Remove Table حذف جدول +Remove the clause. حذف بند. Remove the last keyframe. حذف آخرین فریم کلیدی. Remove the layer from the map. حذف لایه از نقشه. Remove the strokes and draw the symbol's line alone حذف ضربه‌ها و ترسیم تنها خط نماد +Remove this class's own strokes and draw it with the layer's حذف ضربه‌های خود این کلاس و ترسیم آن با ضربه‌های لایه Remove this stop حذف این مقصد میانی Remove Tile Layer حذف لایه کاشی Remove token حذف توکن @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). رأس Rename تغییر نام Rename Bookmark تغییر نام نشانک Rename Group تغییر نام گروه +Rename it to save it: a connection has this name برای ذخیره، نامش را تغییر دهید: اتصالی با این نام وجود دارد +Rename Label Class تغییر نام کلاس برچسب Rename Layer تغییر نام لایه Rename Layout تغییر نام صفحه‌آرایی Rename Map تغییر نام نقشه @@ -2149,6 +2345,7 @@ Reset Default بازنشانی پیش‌فرض Reset Effects بازنشانی جلوه‌ها Reset North بازنشانی شمال Reset north بازنشانی شمال +Resolution (0–30) تفکیک‌پذیری (0–30) Response پاسخ Restore بازگردانی Restore From Archive بازگردانی از بایگانی @@ -2162,6 +2359,7 @@ RGB RGB Ribbon نوار (Ribbon) Right راست Right-click a source to add it here برای افزودن منبع به این‌جا، روی آن راست‌کلیک کنید +Right-click for more کلیک راست برای گزینه‌های بیشتر Robinson Robinson Rotate چرخش Rotate (°) چرخش (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) داده نمونه (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. داده نمونه © Natural Earth (مالکیت عمومی). RustGIS با مجوز Apache-2.0 منتشر شده است. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. داده نمونه: Natural Earth (مالکیت عمومی). با مجوز Apache-2.0. +Sans-serif بدون سریف Satellite Embedding Datasets مجموعه داده Embedding ماهواره‌ای Satellite Embeddings جاسازی‌های ماهواره‌ای Satellite Embeddings… جاسازی‌های ماهواره‌ای… Saturation اشباع رنگ Save ذخیره Save As ذخیره به‌عنوان +Save as Connection ذخیره به‌عنوان اتصال Save as Default ذخیره به‌عنوان پیش‌فرض Save as Default Raster Style ذخیره به‌عنوان استایل پیش‌فرض رستر Save as Layer ذخیره به‌عنوان لایه Save As Presentation File… ذخیره به‌عنوان فایل Presentation… +Save as Python Script… ذخیره به‌عنوان اسکریپت Python… Save As Report File… ذخیره به‌عنوان فایل Report… Save As Report Template… ذخیره به‌عنوان قالب Report… Save as {name} ذخیره به‌عنوان {name} Save As… ذخیره به‌عنوان… Save Color Ramps ذخیره طیف‌های رنگی Save Data Source ذخیره منبع داده +Save History As Python Script ذخیره تاریخچه به‌عنوان اسکریپت Python Save Layer File ذخیره فایل لایه Save Layout File ذخیره فایل صفحه‌آرایی Save Map File ذخیره فایل نقشه @@ -2254,6 +2456,7 @@ save or load a pane set by name ذخیره یا بارگذاری یک مجموع Save Project ذخیره پروژه Save project ذخیره پروژه Save Project As ذخیره پروژه به‌عنوان +Save Project As… ذخیره پروژه به‌عنوان… Save Project Template ذخیره قالب پروژه Save Style Items ذخیره آیتم‌های استایل Save Task Item ذخیره آیتم تسک @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) ذخیره مدل به‌عنوان فا Save the model in the project ذخیره مدل در پروژه Save the open layout as a layout file. ذخیره صفحه‌آرایی باز به‌عنوان فایل صفحه‌آرایی. Save the panes you have open, and switch back to them. ذخیره پنل‌های باز و بازگشت به آن‌ها. +Save the project too: the script opens its saved file, without changes made since. پروژه را هم ذخیره کنید: اسکریپت فایل ذخیره‌شده آن را باز می‌کند، بدون تغییراتی که پس از آن داده شده‌اند. Save the project under a new name. ذخیره پروژه با نامی جدید. Save the project with your edits. ذخیره پروژه همراه با ویرایش‌های شما. Save the project's custom color ramps to a color ramp file. ذخیره طیف‌های رنگی سفارشی پروژه در یک فایل طیف رنگی. @@ -2275,13 +2479,16 @@ Save Web Link Chart ذخیره نمودار پیوند وب Save Web Map ذخیره نقشه وب Save Web Scene ذخیره صحنه وب Saved ذخیره شد +Saved as the connection {name} به‌عنوان اتصال {name} ذخیره شد Saved as the default style for new single-band rasters. به‌عنوان استایل پیش‌فرض رسترهای جدید تک‌باندی ذخیره شد. +Saved connection اتصال ذخیره‌شده Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). کلیدهای ذخیره‌شده، رمزنگاری‌نشده در ترجیحات برنامه نگه‌داری می‌شوند (روی وب، در حافظه مرورگر). Saved models مدل‌های ذخیره‌شده Saved symbology: apply it to the selected layer, or save the layer's. نمادگذاری ذخیره‌شده: اعمال آن روی لایه انتخاب‌شده، یا ذخیره نمادگذاری لایه. Saved View نمای ذخیره‌شده Saved {name} ذخیره شد: {name} Saved {name}. ذخیره شد: {name}. +Saved {path} {path} ذخیره شد saving failed unexpectedly ذخیره به‌طور غیرمنتظره‌ای ناموفق شد Scale مقیاس Scale\nBar خط\nمقیاس @@ -2304,7 +2511,7 @@ Search (this portal needs words)… جستجو (این پورتال به واژ Search Catalogue جستجوی فهرست Search commands — e.g. buffer, basemap, labels جستجوی دستورها — مثلاً بافر، نقشه پایه، برچسب‌ها Search datasets, e.g. GEDI biomass جستجوی مجموعه داده‌ها، مثلاً زیست‌توده GEDI -Search Distance (km) فاصله جستجو (km) +Search Distance فاصله جستجو Search every command and its parameters جستجو در همه دستورها و پارامترهایشان Search layers (e.g. temperature) جستجوی لایه‌ها (مثلاً temperature) Search layers, places, lat/lon… جستجوی لایه‌ها، مکان‌ها، طول/عرض… @@ -2329,6 +2536,8 @@ Searching the folders… در حال جستجوی پوشه‌ها… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. جستجو در این‌جا کار می‌کند، ولی سرورهای داده ناسا به صفحه‌های وب اجازه خواندن فایل‌هایشان را نمی‌دهند (بدون CORS)، پس GeoTIFFها و دانلود داده‌های محافظت‌شده به برنامه دسکتاپ نیاز دارند. دانلود، فایل را در برگه‌ای جدید باز می‌کند که Earthdata Login از شما ورود می‌خواهد. Searching… در حال جستجو… Searching… try again in a moment. در حال جستجو… لحظه‌ای بعد دوباره تلاش کنید. +Seasonal Mann–Kendall Mann–Kendall فصلی +Seasons a year فصل‌ها در سال Second standard parallel موازی استاندارد دوم Seconds ثانیه Secret access key کلید دسترسی محرمانه @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator جداکننده Sepia سپیا Sequential ترتیبی +Serif سریف‌دار Server سرور Service Group گروه سرویس Service group data exceeds 64 MB. داده گروه سرویس از 64 MB فراتر رفته است. Service Import Requests درخواست‌های درون‌بری سرویس Service Layers لایه‌های سرویس -Services سرویس‌ها +Session token توکن نشست Set تنظیم Set Cell Size تنظیم اندازه سلول Set Domain تنظیم دامنه @@ -2411,6 +2621,7 @@ Shaded سایه‌زده Shaded Relief سایه‌روشن Shape شکل Shapefile Shapefile +Shapes شکل‌ها Share به‌اشتراک‌گذاری Share As اشتراک به‌عنوان Share every layer and table as OGC API Features collections. اشتراک همه لایه‌ها و جدول‌ها به‌عنوان مجموعه‌های OGC API Features. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. نمایش جزئیات ه Show different features at different scales. نمایش عناصر متفاوت در مقیاس‌های متفاوت. Show grid نمایش شبکه Show Group نمایش گروه +Show labels نمایش برچسب‌ها Show latitude/longitude lines on the basemap. نمایش خطوط طول/عرض روی نقشه پایه. +Show Layers نمایش لایه‌ها Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. نمایش لایه‌ها در گذر زمان: فقط عنصرهایی که تاریخشان در پنجره لغزنده می‌افتد، و تصاویر تاریخ‌دار مربوط به آن تاریخ. Show Map نمایش نقشه Show Me نشانم بده @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. نمایش یا پنهان‌کردن پنل Tools. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. نمایش خط‌کش‌ها. برای افزودن راهنما از خط‌کش بکشید؛ برای حذف، راهنما را روی آن برگردانید. Show the bands picked, read in place as the map needs them نمایش باند انتخاب‌شده، خوانده‌شده در جا وقتی نقشه لازمشان دارد Show the description نمایش توضیحات +Show the Labeling pane for the selected layer. نمایش پنل برچسب‌گذاری برای لایه انتخاب‌شده. Show the labels that don't fit, in red. نمایش برچسب‌هایی که جا نشدند، به رنگ قرمز. Show the layer only inside another layer's polygons. نمایش لایه فقط داخل چندضلعی‌های لایه دیگری. Show the layer only left of a divider, to compare it with what's beneath. نمایش لایه فقط سمت چپ یک جداکننده، برای مقایسه با چیزی که زیرش است. @@ -2491,8 +2705,10 @@ Sign-in cancelled. ورود لغو شد. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. ورود با وقفه زمانی مواجه شد. دوباره شروع کنید و اجازه دهید مرورگر به نشانی loopback این رایانه دسترسی داشته باشد. signed in وارد شده Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. روی این رایانه خارج شدید، ولی OpenAI آن را تأیید نکرد. می‌توانید RustGIS را در تنظیمات ChatGPT قطع کنید. +Significance level سطح معناداری Signing in with ChatGPT works in the desktop app. ورود با ChatGPT در برنامه دسکتاپ کار می‌کند. Signing the asset (Planetary Computer)… در حال امضای asset (Planetary Computer)… +Sill آستانه (Sill) Simplify ساده‌سازی Simulate the drive, or save the route as a layer. شبیه‌سازی رانندگی، یا ذخیره مسیر به‌عنوان لایه. Simulation شبیه‌سازی @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} مهارت‌های دستیار هوش م Slideshow, PDF or PNG اسلایدشو، PDF یا PNG slight left پیچ ملایم به چپ slight right پیچ ملایم به راست +Slope per شیب بر حسب Slope, aspect, hillshade, NDVI, band math, thresholds. شیب، جهت شیب، سایه‌روشن، NDVI، ریاضیات باند و آستانه‌ها. Slope, hillshade, NDVI and more. شیب، سایه‌روشن، NDVI و بیشتر. Small scales مقیاس‌های کوچک @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South جنوب South Polar Stereographic قطبی استریوگرافیک جنوبی +Spatial Join اتصال مکانی Speaker notes یادداشت‌های گوینده Spectral Analysis تحلیل طیفی Spectral indices شاخص‌های طیفی @@ -2530,6 +2748,7 @@ Split (click the line) شکستن (روی خط کلیک کنید) SQL SQL SQL Pane پنل SQL SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. پرس‌وجوهای SQL از طریق DuckDB اجرا می‌شوند، برنامه‌ای که دسکتاپ صدا می‌زند: برای پرس‌وجو، این پروژه را در برنامه دسکتاپ باز کنید. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 عبارت SQL که باید درست باشد، مثلاً pop > 0 Square مربعی Squares مربع‌ها @@ -2588,6 +2807,7 @@ Start over at the end while playing. هنگام پخش، در انتها از ن Start With شروع با Start-up آغازین starting with a project file needs the desktop app شروع با یک فایل پروژه به برنامه دسکتاپ نیاز دارد +Statistic آماره Statistics آمار Statistics Field فیلد آمار Status وضعیت @@ -2613,8 +2833,10 @@ Stretch\nType نوع\nکشش Stretch it over the map view, or give control points (one per line: column row lon lat). آن را روی نمای نقشه بکشید، یا نقاط کنترل بدهید (هر خط یکی: ستون ردیف طول عرض). Stretch Type نوع کشش Stretch type نوع کشش +Stroke Levels سطوح ضربه Stroke {n} ضربه {n} Strokes ضربه‌ها +Strokes for ضربه‌ها برای Style سبک Style Items آیتم‌های استایل Style › Drawing استایل › ترسیم @@ -2624,7 +2846,10 @@ Subreport: {name} زیرReport: {name} Subtypes زیرگروه‌ها Subtypes, one per line: code=name زیرگروه‌ها، هر خط یکی: code=name Suitability Modeler مدل‌ساز تناسب‌سنجی +Summarise خلاصه‌سازی +Summarise over time خلاصه‌سازی در گذر زمان Summarise the conversation so far, so a long chat stays within the model's context خلاصه‌کردن گفت‌وگو تا این لحظه، تا گفت‌وگوی طولانی در بافت مدل جا شود +Summarise Zarr Over Time خلاصه‌سازی Zarr در گذر زمان Summarising the earlier conversation to keep it within the model's context… خلاصه‌کردن گفت‌وگوی پیشین تا در بافت مدل جا شود… Summarize خلاصه‌سازی Summarize\nWithin خلاصه‌سازی\nدرون @@ -2658,6 +2883,8 @@ Table or layer جدول یا لایه Tables جدول‌ها Tags تگ‌ها Take the data in view offline, to edit and sync back later. آفلاین‌کردن داده داخل نما، برای ویرایش و همگام‌سازی بعدی. +Target Features عناصر هدف +Target Records رکوردهای هدف Task تسک Task Item آیتم تسک Task name نام تسک @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. داده عمومی GIS تنسی (Tennessee GeoData، geodata.tn.gov): مرزهای املاک، حمل‌ونقل، سلامت، هیدرولوژی و بیشتر. Terrain زمین Test آزمون +Test Connection آزمودن اتصال +Testing… در حال آزمودن… Text متن Text Symbol نماد متن That connection would create a loop. آن اتصال یک حلقه می‌سازد. The address fields are empty in every row. فیلدهای نشانی در همه ردیف‌ها خالی‌اند. +The answer isn't UTF-8 text پاسخ متن UTF-8 نیست the answer isn't UTF-8 text پاسخ، متن UTF-8 نیست The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. دستیار هنگام استفاده از یک مهارت، دستورالعمل‌های همان مهارت را دنبال می‌کند. با هر محتوایی که داشته باشد، دستورهای نیازمند تأیید شما همچنان سؤال می‌پرسند. The assistant is busy; stop it or wait for it to finish. دستیار مشغول است؛ متوقفش کنید یا صبر کنید تا تمام شود. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. برگه نقشه‌های پای the capture failed unexpectedly گرفتن تصویر به‌طور غیرمنتظره‌ای ناموفق شد The connection failed; trying again in {seconds} s. اتصال ناموفق بود؛ تلاش دوباره در {seconds} ثانیه. The country list couldn't be read ({e}): type a country's code فهرست کشورها خوانده نشد ({e}): کد یک کشور را بنویسید +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. ارتفاع فعلی هنگام ویرایش ورودی‌ها برقرار می‌ماند. برای اعمال مقدار و واحدها روی «اعمال ارتفاع» کلیک کنید. The dataset has nothing RustGIS can add این مجموعه داده چیزی برای افزودن به RustGIS ندارد The environment (AWS_ACCESS_KEY_ID…) محیط (AWS_ACCESS_KEY_ID…) The features where the map is عنصرهای موجود در محل نقشه @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. پا The installed GeoLibre runner lists no tools. اجراکننده نصب‌شده GeoLibre هیچ ابزاری فهرست نمی‌کند. The job covers the map view کار، نمای نقشه را پوشش می‌دهد The key is kept for this session only. کلید فقط برای همین نشست نگه داشته می‌شود. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. لایه گامی را نشان می‌دهد که لغزنده زمان روی آن است، و لغزنده را روی تاریخ‌های متغیر روشن می‌کند. The layer's file couldn't be read: {error} فایل لایه خوانده نشد: {error} +The layer's strokes ضربه‌های لایه the layout export failed unexpectedly برون‌بری صفحه‌آرایی به‌طور غیرمنتظره‌ای ناموفق شد The link doesn't say its STAC API: Find Items searches {api} پیوند، API STAC خودش را نگفته: Find Items در {api} جستجو می‌کند The list of plugins to browse. Leave it empty for the RustGIS plugin registry. فهرست افزونه‌ها برای مرور. برای فهرست افزونه RustGIS، خالی بگذارید. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. تصویر بندانگشتی پرو The projection the map opens in. تصویری که نقشه با آن باز می‌شود. The provider's content filter stopped the reply. فیلتر محتوای ارائه‌دهنده، پاسخ را متوقف کرد. The public server is free and shared by many: it answers light use, and may ask you to wait. سرور عمومی رایگان است و میان خیلی‌ها مشترک: پاسخ استفاده سبک است و ممکن است از شما بخواهد صبر کنید. +The range is two numbers, low and high (0, 0.9) بازه دو عدد است، کمینه و بیشینه (0, 0.9) The raster's data couldn't be read. داده رستر خوانده نشد. The raster's data couldn't be read: {path} داده رستر خوانده نشد: {path} the release isn't known انتشار ناشناخته است @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. فایل‌های Shapefile به‌ص The share of each cell covered by fields, for the zoomed-out view سهم پوشش هر سلول از مزارع، برای نمای دور The sign-in expired or was revoked. Sign in again. ورود منقضی یا باطل شده است. دوباره وارد شوید. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. اطلاعات ورود در پوشه تنظیمات برنامه نگه‌داری می‌شود، فقط برای حساب کاربری شما، و هرگز در فایل پروژه ذخیره نمی‌شود. +The significance level is a number, such as 0.05 سطح معناداری یک عدد است، مانند 0.05 The start-up project {path} didn't open: {error} پروژه آغازین {path} باز نشد: {error} The start-up project {path} isn't a RustGIS project file. پروژه آغازین {path} یک فایل پروژه RustGIS نیست. The step's layer {name} isn't in the map. لایه گام، یعنی {name}، در نقشه نیست. The store has no variables with two or more dimensions. مخزن هیچ متغیری با دو بعد یا بیشتر ندارد. the store is gone مخزن از دست رفته است +The summary couldn't start: {error} خلاصه‌سازی شروع نشد: {error} +The summary failed unexpectedly خلاصه‌سازی به‌طور غیرمنتظره ناموفق بود +The summary finished after another project was opened, so it wasn't added خلاصه‌سازی پس از بازشدن پروژه دیگری تمام شد، پس اضافه نشد The task's steps changed, so the step being edited was closed: edit it again. گام‌های تسک تغییر کردند، پس گامِ در حال ویرایش بسته شد: دوباره ویرایشش کنید. the time slider is off لغزنده زمان خاموش است The token expires on {date}. توکن در {date} منقضی می‌شود. +The trend couldn't start: {error} تحلیل روند شروع نشد: {error} +The trend failed unexpectedly تحلیل روند به‌طور غیرمنتظره ناموفق بود +The trend finished after another project was opened, so it wasn't added تحلیل روند پس از بازشدن پروژه دیگری تمام شد، پس اضافه نشد +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. مقادیری که از سیاه تا سفید نمایش داده می‌شوند، برای همه تاریخ‌ها و کاشی‌ها یکسان (بر حسب واحدهای خود باند). خالی: کشش خود هر فایل. the view has no extent نما گستره‌ای ندارد The view is larger than one read: its centre was read (zoom in for the rest) نما از یک بار خواندن بزرگ‌تر است: مرکزش خوانده شد (برای بقیه بزرگ‌نمایی کنید) The web app always runs the latest release: reload the page to get it. برنامه وب همیشه آخرین انتشار را اجرا می‌کند: برای دریافتش، صفحه را دوباره بارگذاری کنید. the window's screenshot didn't come back in time, so the map was drawn offscreen عکس فوری پنجره به‌موقع برنگشت، پس نقشه بیرون از دید ترسیم شد the window's screenshot didn't hold the map, so it was drawn offscreen عکس فوری پنجره نقشه را در بر نگرفت، پس نقشه بیرون از دید ترسیم شد Theme پوسته +There is no expression defined. هیچ عبارتی تعریف نشده است. Thermal حرارتی +Thesis figure, full page (160 × 225 mm) شکل پایان‌نامه، تمام‌صفحه (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) شکل پایان‌نامه، نیم‌صفحه (160 × 110 mm) Thinking… در حال فکرکردن… +Third-party licences مجوزهای اشخاص ثالث +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins این بیلد نمی‌تواند افزونه دانلود کند this build can't reach AI providers این بیلد نمی‌تواند به ارائه‌دهندگان هوش مصنوعی دسترسی پیدا کند this build saves projects with Save As این بیلد پروژه‌ها را با «ذخیره به‌عنوان» ذخیره می‌کند This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. این حساب ChatGPT قبلاً ذخیره شده است. دفعه بعد، به‌جای افزودن دوباره، آن را از فهرست انتخاب کنید. +This class is drawn with the layer's strokes. Changing them here gives it its own. این کلاس با ضربه‌های لایه ترسیم می‌شود. تغییر آن‌ها در اینجا، ضربه‌های مختص خودش را به آن می‌دهد. +This class is hidden, so it labels nothing. این کلاس پنهان است، پس هیچ چیزی را برچسب نمی‌زند. +This clause no longer reads, so no feature is labelled: {error} این بند دیگر خوانا نیست، پس هیچ عنصری برچسب نمی‌خورد: {error} This map has no layers yet. این نقشه هنوز لایه‌ای ندارد. This map's thumbnail, from an image. تصویر بندانگشتی این نقشه، از یک تصویر. This map's thumbnail, from the view. تصویر بندانگشتی این نقشه، از نما. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. این درخواست گزینه اجازه یک‌باره ندارد. This request uses an unsupported capability. Check the parameter and change the request. این درخواست از قابلیتی پشتیبانی‌نشده استفاده می‌کند. پارامتر را بررسی کنید و درخواست را تغییر دهید. This result is kept این نتیجه نگه داشته می‌شود +This run has no tool form to open این اجرا فرم ابزاری برای بازکردن ندارد +This session only (optional) فقط همین نشست (اختیاری) This step این گام This step names no tool to show. این گام ابزاری برای نمایش نام نبرده است. Thumbnail تصویر بندانگشتی @@ -2789,15 +3041,21 @@ Tilt Map شیب‌دادن نقشه Tilt-shift تیلت‌شیفت Tilt-shift blur and film grain. تاری تیلت‌شیفت و دانه‌های فیلم. Time زمان +Time\nSeries سری\nزمانی Time\nSlider لغزنده\nزمان Time Fields فیلدهای زمان +Time Series سری زمانی +Time series سری زمانی +Time Series at a Point سری زمانی در یک نقطه Time Slider لغزنده زمان Time Slider Settings تنظیمات لغزنده زمان Tint by elevation رنگ‌آمیزی بر اساس ارتفاع Tissot's Indicatrix بیضی نشان تیسو Title عنوان +To تا Toggle Dark Mode تغییر حالت تیره Toggle GPU Rendering تغییر ترسیم GPU +Toggle Imperial Measurements تغییر اندازه‌گیری امپریال Tolerance (km) رواداری (km) Tolls عوارض Tool Gallery گالری ابزارها @@ -2828,7 +3086,10 @@ Train Deep Learning Model آموزش مدل یادگیری عمیق Trained models مدل‌های آموزش‌دیده Transparency شفافیت Traverse طی‌مسیر +Trend Analysis تحلیل روند +Trend over a series of rasters روند در یک سری از رسترها Triangle مثلث +TRUE TRUE Try again in a moment. لحظه‌ای بعد دوباره تلاش کنید. Try again later. بعداً دوباره تلاش کنید. Turn editing on or off for the map. روشن یا خاموش‌کردن ویرایش برای نقشه. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map طول و عرض جغرافیا Type a scale, such as 1:12,345, 12345 or 25k, and press Enter یک مقیاس بنویسید، مثل 1:12,345 یا 12345 یا 25k، و Enter را بزنید Type an address or coordinates and press Enter, or click the map. نشانی یا مختصات بنویسید و Enter را بزنید، یا روی نقشه کلیک کنید. Type numbers separated by commas اعداد را با ویرگول جدا کنید و بنویسید +Type the keys to log in for this session, then Save. کلیدها را برای ورود در این نشست وارد کنید، سپس ذخیره کنید. +Type the password or keys to log in for this session, then Save. رمز یا کلیدها را برای ورود در این نشست وارد کنید، سپس ذخیره کنید. Typed below نوشته‌شده در پایین UM UM Unavailable: {reason} در دسترس نیست: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider جدا کردن از لغزنده زمان Unbind from Time Slider جدا کردن از لغزنده زمان Undo واگرد Undo (Ctrl+Z) واگرد (Ctrl+Z) -Undo / Redo واگرد / بازانجام Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. واگرد، گام ثبت‌شده را حذف نمی‌کند: وقتی ضبط را قطع کردید، آن را در ویرایش گام‌ها حذف کنید. Undo the last change. واگرد آخرین تغییر. Undo {action} واگرد {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools حذف نصب ابزارهای GeoLibre Uninstall Plugin حذف نصب افزونه Uninstalled {id}. حذف نصب شد: {id} Unique Values مقادیر یکتا +Unit واحد United States ایالات متحده Units واحدها Units per step واحد در هر گام @@ -2877,6 +3140,7 @@ Up a level یک سطح بالا Update به‌روزرسانی Update Bookmark به‌روزرسانی نشانک Update Job به‌روزرسانی کار +Update Label Class به‌روزرسانی کلاس برچسب Update to {version} به‌روزرسانی به {version} Upload File بارگذاری فایل Upload File… بارگذاری فایل… @@ -2895,6 +3159,7 @@ Use another account استفاده از حساب دیگر Use Current View استفاده از نمای فعلی Use each feature's line color استفاده از رنگ خط هر عنصر Use each feature's line width استفاده از ضخامت خط هر عنصر +Use the Layer's Strokes استفاده از ضربه‌های لایه USERPROFILE USERPROFILE USGS 3DEP DEM DEM USGS 3DEP USGS 3DEP DEM… DEM USGS 3DEP… @@ -2912,6 +3177,7 @@ Using ChatGPT plan در حال استفاده از طرح ChatGPT Utilities ابزارهای کمکی Valid combinations, one per line: a, b ترکیب‌های معتبر، هر خط یکی: a، b Valid expression · {n} matching rows عبارت معتبر · {n} ردیف منطبق +Valid: {n} of {of} features match معتبر: {n} از {of} عنصر مطابقت دارند Validate اعتبارسنجی Validate Model اعتبارسنجی مدل value (empty: no-data) مقدار (خالی: بدون داده) @@ -2920,8 +3186,10 @@ Vantor Open Data داده باز Vantor Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. تصاویر باز Vantor از بلایا: یک رویداد و یک فاز (پیش یا پس از آن) برگزینید، سپس Find Items. Vantor… Vantor… Variable متغیر +Variogram Model مدل واریوگرام Vector and raster data داده برداری و رستری Vector Layer لایه برداری +vector tiles کاشی‌های برداری Vector tiles (MVT) کاشی‌های برداری (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. مدل پایهٔ جیومکانی Vectors برای هر پیکسل یا لکهٔ تصویر ساخته شده، در محل نقشه پیدا می‌شود و به‌صورت رنگ‌ها نمایش داده می‌شود. Version {version} نسخه {version} @@ -2933,6 +3201,9 @@ Video file or URL فایل ویدیو یا URL Video Page صفحه ویدیو Videos ویدیوها Video… ویدیو… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. سیستم مختصات ملی ویتنام (VN-2000) با Transverse Mercator سه‌درجه‌ای و جابه‌جایی هلمرت 7 پارامتری به WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). سیستم مختصات ملی ویتنام (VN-2000)، UTM منطقه 48N (102°E–108°E، نصف‌النهار مرکزی 105°E). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). سیستم مختصات ملی ویتنام (VN-2000)، UTM منطقه 49N (108°E–114°E، نصف‌النهار مرکزی 111°E). View نمایش View Clipping برش نما View Effects جلوه‌های نما @@ -2952,6 +3223,9 @@ Visibility Range گستره دید Visible نمایان Visible Layers لایه‌های نمایان Visual Effect جلوه بصری +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (ویتنام) +VN-2000 / UTM zone 48N VN-2000 / UTM zone 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zone 49N Volume حجم Waiting for the browser… در انتظار مرورگر… Waiting for your answer در انتظار پاسخ شما @@ -2970,6 +3244,7 @@ Web Link Chart نمودار پیوند وب Web maps نقشه‌های وب Web Mercator وب مرکاتور Web Scene صحنه وب +Web service (detected) سرویس وب (شناسایی‌شده) Web Style استایل وب Web Tile Layers لایه‌های کاشی وب Web Tool ابزار وب @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. جای برچسب‌ها نسبت Where new features get their elevation (the z field). جایی که عناصر جدید ارتفاعشان را می‌گیرند (فیلد z). Where the map opens: the full extent or a view you saved. جایی که نقشه باز می‌شود: گستره کامل یا نمای ذخیره‌شده شما. Where tools work محل کار ابزارها +Whether the Select tool picks these layers' features on the map اینکه ابزار Select عناصر این لایه‌ها را روی نقشه انتخاب کند یا نه Which 5 countries have the largest GDP? کدام 5 کشور بیشترین GDP را دارند؟ Whitebox and GeoLibre tools ابزارهای Whitebox و GeoLibre Whole map کل نقشه @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. جهانی، هم‌مساحت World, equal-area: true sizes, shapes bend towards the edges. جهانی، هم‌مساحت: اندازه‌های درست، شکل‌ها به سمت لبه‌ها خم می‌شوند. Worldview Worldview Write a plugin نوشتن افزونه +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. شیب Sen، مقدار Z و p-value آزمون، و شیب در جاهایی که روند معنادار است را می‌نویسد. X (mm) X (mm) X column ستون X X Field فیلد X @@ -3050,7 +3327,6 @@ Yours متعلق به شما YYYY-MM-DD YYYY-MM-DD YYYY-MM-DD, for the layers that change with time قالب YYYY-MM-DD؛ برای لایه‌هایی که با زمان تغییر می‌کنند YYYY-MM-DD, for time layers قالب YYYY-MM-DD؛ برای لایه‌های زمانی -z (m) z (m) Z factor ضریب Z Zarr Zarr Zarr Variables متغیرهای Zarr @@ -3061,7 +3337,6 @@ Zoom Box کادر بزرگ‌نمایی Zoom Frame بزرگ‌نمایی قاب Zoom In بزرگ‌نمایی Zoom in بزرگ‌نمایی -Zoom in / out بزرگ‌نمایی / کوچک‌نمایی Zoom In Here بزرگ‌نمایی از این‌جا Zoom in on the centre of the map. بزرگ‌نمایی روی مرکز نقشه. Zoom in · finish a sketch بزرگ‌نمایی · پایان یک طرح دستی @@ -3073,6 +3348,7 @@ Zoom To\nLayer بزرگ‌نمایی به\nلایه Zoom to a box (Explore) بزرگ‌نمایی به یک کادر (کاوش) Zoom To Extent بزرگ‌نمایی به گستره Zoom To Layer بزرگ‌نمایی به لایه +Zoom To Layers بزرگ‌نمایی به لایه‌ها Zoom To Map View بزرگ‌نمایی به نمای نقشه Zoom To Selection بزرگ‌نمایی به انتخاب Zoom to selection بزرگ‌نمایی به انتخاب @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. بزرگ‌نمایی به گستره لایه. Zoom to the selected features. بزرگ‌نمایی به عناصر انتخاب‌شده. Zoom to the whole world. بزرگ‌نمایی به کل جهان. Zoom to this bookmark بزرگ‌نمایی به این نشانک +Zoom To Tile Layer بزرگ‌نمایی به لایه کاشی Zoom to Tokyo بزرگ‌نمایی به توکیو Zoom To World بزرگ‌نمایی به جهان +Zoom {z} · Bearing {b}° · Pitch {p}° زوم {z} · جهت {b}° · شیب {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. زوم: سطح بزرگ‌نمایی نقشه، به شیوه شمارش نقشه‌های وب. جهت: سمتی که بالای نقشه رو به آن است، در جهت عقربه‌های ساعت از شمال. شیب: میزان کج‌شدن نسبت به نگاه مستقیم به پایین. برای بازنشانی جهت و شیب کلیک کنید. {agent} was stopped by you. عامل {agent} توسط شما متوقف شد. {id} changed since it was installed; install it again from the Plugin Manager. افزونهٔ {id} از زمان نصب تغییر کرده است؛ از Plugin Manager دوباره نصبش کنید. {names}: needs Node.js (npx) for its ACP adapter. افزونهٔ {names} برای آداپتور ACP خود به Node.js (npx) نیاز دارد. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9f6375e05..ed886260a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6,6 +6,7 @@ (filtered) (filtrée) + Add Stop + Ajouter un arrêt + Input + Entrée ++ New expression + Nouvelle expression + Output + Sortie /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh Maillage 3D A 3D globe seen from space. Un globe 3D vu de l'espace. A blank map with a basemap Une carte vierge avec un fond de carte +A call for the rustgis Python package Un appel pour le paquet Python rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. L'adresse d'un catalogue (CSW 2.0.2) : recherchez ses fiches par mots et, si vous le souhaitez, par l'étendue de la carte, puis ajoutez les services et fichiers liés. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. Une limite d’utilisation du forfait ChatGPT ou de l’application a été atteinte. Consultez l’utilisation hebdomadaire avant de réessayer. A ChatGPT sign-in can only be used with the OpenAI API. Une connexion ChatGPT ne peut être utilisée qu'avec l'API OpenAI. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). Les jeu A globe scene. Une scène globe. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Une latitude et une longitude (40.7, -74.0 ou 40°42'N 74°0'W), une couche, un signet ou une valeur de la table d'une couche. Enter choisit la ligne en surbrillance ; Rechercher des lieux cherche le texte comme lieu A layer the model reads Une couche que le modèle lit +A line for the Script pane Une ligne pour le volet Script A magnifying lens that follows the pointer. Une loupe qui suit le pointeur. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). L'adresse d'un service de cartes, d'entités ou de couvertures : WMS, WMTS, WFS, WCS, OGC API Features ou Tiles, ou un FeatureServer, MapServer, ImageServer ou VectorTileServer ArcGIS (ou un répertoire de services, à parcourir). a name, or owner/name such as giswqs/geospatial un nom, ou propriétaire/nom comme giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Un point de terminaison nommé que vous pouvez réutiliser. Les mots de passe et les clés sont conservés jusqu'à la fermeture de RustGIS, jamais enregistrés. A node cannot connect to itself. Un nœud ne peut pas se connecter à lui-même. A notebook of text and command cells. Un bloc-notes de cellules de texte et de commandes. +A password or token in the address was left out: connections never keep secrets. Un mot de passe ou un jeton présent dans l'adresse a été omis : les connexions ne conservent jamais de secrets. A place or an address Un lieu ou une adresse A project keeps at least one map Un projet conserve au moins une carte A projected coordinate system. Un système de coordonnées projetées. A report listing the selected layer's records, page after page. Un rapport listant les enregistrements de la couche sélectionnée, page après page. A result that only feeds other tools is removed once the model has run; add an output to keep it. Un résultat qui ne fait qu'alimenter d'autres outils est supprimé une fois le modèle exécuté ; ajoutez une sortie pour le conserver. A result the model keeps, under a name Un résultat que le modèle conserve, sous un nom +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) Une commande rustgis-cli, pour un shell POSIX (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Une importation ou une actualisation de service est déjà en cours. +A service's layers stay in their service group. Les couches d'un service restent dans le groupe de leur service. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Une tâche contient au plus 200 étapes : arrêtez l'enregistrement pour la créer, puis enregistrez le reste comme une autre tâche. A template with the first selected feature's values Un modèle avec les valeurs de la première entité sélectionnée +A tile layer per grid tile, each following the time slider through its dates. Une couche de tuiles par tuile de la grille, chacune suivant le curseur temporel au fil de ses dates. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. L'adresse d'un service de tuiles : tuiles raster avec {z}/{x}/{y} (ou {-y}, {q}, {bbox-epsg-3857} pour le WMS), tuiles vectorielles (.pbf) ou TileJSON, un style MapLibre, une archive PMTiles (une adresse web ou un fichier de cet ordinateur) ou un fichier MBTiles de cet ordinateur. A token is set. Un jeton est défini. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Une valeur, un champ, ou deux d'entre eux reliés par + − × ÷. S'applique aux lignes sélectionnées, ou à toutes les lignes si aucune n'est sélectionnée. a vector a pixel un vecteur par pixel a vector an image patch un vecteur par imagette A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Un magasin Zarr (format 2 ou 3) par son adresse web, avec métadonnées consolidées, ou un dossier de cet ordinateur. La carte d'une variable est lue pour les pas choisis de ses autres dimensions, comme le temps. +A Zarr variable's values through time at points clicked on the map. Les valeurs d'une variable Zarr au fil du temps aux points cliqués sur la carte. +A2 landscape sheet Feuille A2 paysage +A2 portrait sheet Feuille A2 portrait +A3 landscape sheet Feuille A3 paysage +A3 portrait sheet Feuille A3 portrait +A4 landscape sheet Feuille A4 paysage +A4 portrait sheet Feuille A4 portrait +A5 Grid Grille A5 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. Les cellules pentagonales équivalentes d'A5 à une résolution donnée, sur l'étendue de la carte, une couche ou le monde, sous forme de polygones avec leurs identifiants. About À propos About plugins À propos des extensions About RustGIS À propos de RustGIS @@ -65,6 +83,7 @@ Add Ajouter Add\nData Ajouter\ndes données Add ({n}) Ajouter ({n}) Add a bar chart of a layer's numeric field. Ajouter un diagramme en barres d'un champ numérique d'une couche. +Add a circle. Ajouter un cercle. Add a colorbar of a raster layer's colour scale in the map frame. Ajouter une barre de couleurs de l'échelle de couleurs d'une couche raster dans le bloc de carte. Add a description. Ajouter une description. Add a field. Ajouter un champ. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Ajouter une mise en page p Add a page to the presentation. Ajouter une page à la présentation. Add a PNG or JPEG picture to the page. Ajouter une image PNG ou JPEG à la page. Add a presentation file. Ajouter un fichier de présentation. +Add a rectangle, to frame or highlight part of the page. Ajouter un rectangle, pour encadrer ou mettre en évidence une partie de la page. Add a report file, or a report template for the selected layer. Ajouter un fichier de rapport, ou un modèle de rapport pour la couche sélectionnée. Add a scale bar for the map frame. Ajouter une barre d'échelle pour le bloc de carte. add a second map first (Data › New Map) ajoutez d'abord une deuxième carte (Données › Nouvelle carte) Add a second map frame first Ajoutez d'abord un deuxième bloc de carte +Add a straight line. Ajouter une ligne droite. Add a text element. Ajouter un élément texte. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Ajouter un fond de carte web par-dessus les autres au lieu de les remplacer, pour le fusionner avec eux. Cliquez sur un fond affiché pour le retirer. +Add a Zarr variable first (Add Data › Zarr…). Ajoutez d'abord une variable Zarr (Ajouter des données › Zarr…). Add Administrative Boundaries Ajouter des limites administratives +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Ajouter une flèche. Faites-la pivoter avec la poignée ronde au-dessus, ou définissez son angle dans ses propriétés. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. Ajoutez un jeton Earthdata Login (Earthdata Login, ci-dessus) pour placer des GeoTIFF de la NASA sur la carte. Add an elevation raster first. Ajoutez d'abord un raster d'altitude. Add an elevation raster to the map first Ajoutez d'abord un raster d'altitude à la carte +Add an ellipse. Ajouter une ellipse. Add an empty layer with a ready-made schema, to draw into. Ajouter une couche vide avec un schéma prêt à l'emploi, pour y dessiner. Add an input for each layer the model reads. Ajoutez une entrée pour chaque couche que le modèle lit. Add an output to keep a result under a name. Ajoutez une sortie pour conserver un résultat sous un nom. Add and Open Ajouter et ouvrir Add and Open Notebook Ajouter et ouvrir un bloc-notes Add Archive Ajouter une archive +Add as Time Series Ajouter comme série temporelle add at least two keyframes ajoutez au moins deux images clés Add Cell Ajouter une cellule Add Color Ramp File… Ajouter un fichier de dégradés de couleurs… @@ -132,6 +157,7 @@ Add Graphics Layer Ajouter une couche graphique Add Input Ajouter une entrée Add Job Ajouter un travail Add Keyframe Ajouter une image clé +Add Label Class Ajouter une classe d'étiquettes Add Layer File Ajouter un fichier de couche Add Open Data Ajouter des données ouvertes Add Output Ajouter une sortie @@ -147,9 +173,11 @@ Add Rule Ajouter une règle Add sample countries Ajouter les pays d'exemple Add Sample Data Ajouter des données d'exemple Add Satellite Embeddings Ajouter des plongements satellitaires +Add Selected ({n}) Ajouter la sélection ({n}) Add Service Layer Ajouter une couche de service Add skill… Ajouter une compétence… Add STAC Asset Ajouter un asset STAC +Add STAC Data Cube Ajouter un cube de données STAC Add STAC Footprints Ajouter les emprises STAC Add Step Ajouter une étape Add Stroke Ajouter un trait @@ -171,6 +199,7 @@ Add to Favourites Ajouter aux favoris Add to Map Ajouter à la carte Add vector data. Ajouter des données vecteur. Add view Ajouter la vue +Add With Options… Ajouter avec des options… Add Zarr Variable Ajouter une variable Zarr Add Zarr Variable… Ajouter une variable Zarr… Add ▾ Ajouter ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Les agents ont un accès complet : ils peuvent exécuter des scripts, tâches, modèles et extensions. Cliquez pour arrêter le relais MCP Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Les agents de cet ordinateur qui se connectent (Claude Code, Codex, Cursor et d'autres) travaillent sur la carte ouverte ici avec les outils de RustGIS : ils peuvent ouvrir et enregistrer des fichiers et utiliser le web. Les scripts et la gestion des extensions restent désactivés. Agents then run commands, change files and use the web on this computer without asking you. Les agents exécutent alors des commandes, modifient des fichiers et utilisent le web sur cet ordinateur sans vous demander. +Aggregate Points to A5 Agréger les points en A5 Aggregation Agrégation Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Limites de parcelles agricoles trouvées par le modèle Fields of the World dans l'imagerie Sentinel-2, dans le monde entier, une année à la fois (2nd Edition, CC BY 4.0). Ce sont des prédictions du modèle, pas des parcelles levées. AI IA @@ -221,15 +251,18 @@ All steps done. Toutes les étapes sont terminées. All tables with geometry Toutes les tables avec géométrie Allow Autoriser Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Autorisez l'utilisation du forfait ChatGPT lors de la connexion. Si votre espace de travail ne le permet pas, demandez à son administrateur ou choisissez un fournisseur avec clé d'API. +Allow Feature Selection Autoriser la sélection d'entités Allow only certain combinations of values across fields. N'autoriser que certaines combinaisons de valeurs entre champs. Allow such actions for this conversation Autoriser ce type d'action pour cette conversation Allow this action? Autoriser cette action ? Allow this agent's RustGIS action? Autoriser cette action RustGIS de l'agent ? Along Selected Line Le long de la ligne sélectionnée +Alt+ Alt+ Alternating bar Barre alternée Altitude Altitude Always allow Toujours autoriser -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3, ou un service compatible S3 (MinIO, Cloudflare R2, Wasabi…) par son point de terminaison. Les clés sont conservées jusqu'à la fermeture de RustGIS ; une connexion mémorisée garde son nom, son point de terminaison et sa région, jamais sa clé secrète. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3, ou un service compatible S3 (MinIO, Cloudflare R2, Wasabi…) par son point de terminaison. Les clés sont conservées jusqu'à la fermeture de RustGIS ; une connexion enregistrée garde son nom, son point de terminaison et sa région, jamais sa clé secrète. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Un agent de cet ordinateur demande à : An AWS profile Un profil AWS @@ -248,6 +281,7 @@ API key Clé API Apply Appliquer Apply a function to a raster Appliquer une fonction à un raster Apply Control Points Appliquer les points de contrôle +Apply elevation Appliquer l'altitude Apply Style Appliquer le style Apply to layers Appliquer aux couches Apply to the selected layer Appliquer à la couche sélectionnée @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Domaine d'emploi : à moins de {r} Area {area} · Perimeter {perimeter} Surface {area} · Périmètre {perimeter} Areas (name, land use, area) Zones (nom, occupation du sol, surface) Arrange Disposer +Arrow Flèche +Arrowheads Pointes de flèche Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Posez une question sur vos données, ou décrivez ce qu'il faut faire avec la carte. L'assistant exécute des commandes RustGIS pour vous ; chaque modification peut être annulée. Ask about the map, or tell it what to do… Posez une question sur la carte, ou dites-lui quoi faire… Ask about your data, or tell the assistant what to do with the map. Posez une question sur vos données, ou dites à l'assistant quoi faire avec la carte. @@ -275,12 +311,13 @@ Asking NASA's CMR… Interrogation du CMR de la NASA… Asking the catalog… Interrogation du catalogue… Asking the NLDI… Interrogation du NLDI… Assistant settings Paramètres de l'assistant +At the end À la fin +At the start Au début Atlas Atlas Attachments Pièces jointes Attribute\nTable Table\nattributaire Attribute Rules Règles attributaires Attribute Table Table attributaire -Attribute table Table attributaire Attributes Attributs Attribution Attribution Aurora Aurore @@ -295,11 +332,9 @@ Automatic (system language) Automatique (langue du système) Automatic (system time zone and region) Automatique (fuseau horaire et région du système) Automatic ({kind}) Automatique ({kind}) Avoid Éviter -AWS AWS AWS, anonymous AWS, anonyme Azimuth Azimut Back Retour -Back to Explore; cancel a sketch Retour à « Explorer » ; annuler un croquis Back to the Map Retour à la carte Back to the map Retour à la carte Back to tools Retour aux outils @@ -314,8 +349,6 @@ Basemap Fond de carte Basemaps Fonds de carte Basin from hydrolocation Bassin depuis une hydrolocalisation Bearing Orientation -Bearing {b}° · Pitch {p}° Orientation {b}° · Inclinaison {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Orientation : la direction vers laquelle pointe le haut de la carte, dans le sens horaire depuis le nord. Inclinaison : l'écart par rapport à une vue verticale. Cliquez pour réinitialiser les deux. Before the event Avant l'événement Below En dessous Below 1:5,000,000 show everything; beyond it only what matches: En dessous de 1:5 000 000, tout afficher ; au-delà, seulement ce qui correspond : @@ -328,10 +361,12 @@ Bilinear Bilinéaire Bind To Time Slider Lier au curseur temporel Bind to Time Slider Lier au curseur temporel Blank Vierge +Blank for Amazon S3 Vide pour Amazon S3 Blank Letter page Page Lettre vierge Blank Map Carte vierge Blank Page Page vierge Blend: {mode} Fusion : {mode} +Block Feature Selection Bloquer la sélection d'entités Blue Bleu Bold Gras Bold label text. Texte d'étiquette en gras. @@ -339,6 +374,7 @@ Bookmark Description Description du signet Bookmarks Signets Bookmarks Pane Volet Signets Boolean Booléen +Both ends Aux deux extrémités Bottom Bas Boundaries… Limites… Bounding\nGeometry Géométrie\nenglobante @@ -346,6 +382,7 @@ Brightness Luminosité Brightness, contrast, saturation and sepia for the whole map. Luminosité, contraste, saturation et sépia pour toute la carte. Bring this bookmark's map into view to update it Affichez la carte de ce signet pour le mettre à jour Browse Parcourir +Browse Connection Parcourir la connexion Browse it in the Browser Le parcourir dans l'Explorateur Browse Plugins Parcourir les extensions Browse the geoprocessing toolbox. Parcourir la boîte à outils de géotraitement. @@ -357,6 +394,7 @@ Browser Explorateur Browser\nPane Volet\nExplorateur Browser View Vue Explorateur Bucket Compartiment +Buckets Compartiments Buffer Tampon Buffer A Point Tampon autour d'un point Buffer the cities by 200 km Créer une zone tampon de 200 km autour des villes @@ -381,7 +419,9 @@ Calculate Field Calculer un champ Calculate field Calculer un champ Calculate field values. Calculer les valeurs d'un champ. Calculate fields and enforce conditions on every edit. Calculer des champs et imposer des conditions à chaque modification. +Calculate Geometry Calculer la géométrie Calculate Geometry Attributes Calculer les attributs de géométrie +Calculated {rows} features {rows} entités calculées Calculation Calcul Camera Caméra Camera Effect Effet de caméra @@ -396,12 +436,14 @@ Case Field Champ de regroupement Cast Shadow Projeter les ombres Catalog Catalogue catalog is a static STAC catalog's catalog.json or collection.json catalog est le catalog.json ou collection.json d'un catalogue STAC statique +Catalogue (CSW) Catalogue (CSW) Catalogue search (CSW) Recherche de catalogue (CSW) Celestial Bodies Corps célestes Celestial Body Corps céleste Celestial body: {body} Corps céleste : {body} Cell size Taille de cellule cell size ° taille de cellule ° +Cells Cellules Center Centre Center Map Here Centrer la carte ici Centered Centré @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. L’utilisation d ChatGPT sign-in failed. Check the connection and local settings, then try again. Échec de la connexion à ChatGPT. Vérifiez la connexion réseau et les paramètres locaux, puis réessayez. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. L’utilisation de ChatGPT est temporairement indisponible. Conservez votre connexion et réessayez plus tard. CHE CHE +Check Vérifier Check Again Vérifier à nouveau Check for\nUpdates Mises à\njour Check for Updates Rechercher des mises à jour @@ -428,6 +471,7 @@ Check for Updates… Rechercher des mises à jour… Check the address and the service type. Vérifiez l'adresse et le type de service. Check the address and your connection. Vérifiez l'adresse et votre connexion. Check the address. Vérifiez l'adresse. +Check the clause, and count the features it takes. Vérifier la clause et compter les entités qu'elle retient. Check the integration's client, permissions, workspace policy and route configuration. Vérifiez le client, les autorisations, la politique de l’espace de travail et la configuration des routes de l’intégration. Checking for updates… Recherche de mises à jour… China Chine @@ -455,6 +499,7 @@ Choose which layers a click identifies. Choisir les couches identifiées par un Choose which tabs the ribbon shows. Choisir les onglets affichés par le ruban. Circle Cercle Circle Radius (km) Rayon du cercle (km) +Class Classe Class color Couleur de la classe Class Style Style de classe Classes Classes @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Cliquez pour ajouter de Click to edit the description Cliquez pour modifier la description Click to place a point graphic. Cliquez pour placer un graphique ponctuel. Click to select the node Cliquez pour sélectionner le nœud +Click to select, double-click to add, right-click for more Cliquez pour sélectionner, double-cliquez pour ajouter, clic droit pour plus d'options Click to switch between decimal degrees and DMS Cliquez pour basculer entre degrés décimaux et DMS Clip Découper Clip a raster to the view, or resample it. Découper un raster selon la vue, ou le rééchantillonner. @@ -532,7 +578,7 @@ Close thread Fermer le fil Close {name} Fermer {name} Closest Plus proche Coded values, one per line: code=description Valeurs codées, une par ligne : code=description -Collapse or expand the ribbon Réduire ou développer le ruban +Collapse or Expand the Ribbon Réduire ou développer le ruban Collapse the ribbon (Ctrl+F1) Réduire le ruban (Ctrl+F1) Collection Collection Collection {id}: choose dates and Find Items Collection {id} : choisissez les dates puis Rechercher des éléments @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Combinaisons de la zone ci-dessus, une par ligne COMID {comid} COMID {comid} Comma Virgule +Comma-separated (optional) Séparés par des virgules (facultatif) Command\nSearch Rechercher\nune commande command is required command est requis Command Search Recherche de commandes @@ -572,10 +619,13 @@ Connect Connecter Connect a folder to the Browser. Connecter un dossier à l'Explorateur. Connect to S3 Se connecter à S3 Connect, or pick a layer Connectez, ou choisissez une couche +Connected to {kind} {title} Connecté à {kind} {title} Connecting to {url}… Connexion à {url}… Connection Connexion +connection.new returned no id connection.new n'a renvoyé aucun identifiant Connections Connexions -Connect… Connecter… +Connections you add are listed here Les connexions que vous ajoutez sont listées ici +Connections… Connexions… Constant Constante Constant height Hauteur constante Constant… Constante… @@ -583,11 +633,14 @@ Contingent Values Valeurs conditionnelles Continue Continuer Continue with ChatGPT Continuer avec ChatGPT Contrast Contraste +Control Point Fit Ajustement des points de contrôle Conversion Conversion Convert Convertir Convert a layer to another format Convertir une couche dans un autre format Convert the selected layer's labels into an annotation layer. Convertir les étiquettes de la couche sélectionnée en couche d'annotations. +Convert this layer's labels into an annotation layer. Convertir les étiquettes de cette couche en couche d'annotations. Convert To Annotation Convertir en annotations +Convert to Annotation Convertir en annotations Converted data Données converties Convert… Convertir… Coordinate Coordonnée @@ -599,11 +652,15 @@ Copied the map to the clipboard Carte copiée dans le presse-papiers Copied {path} {path} copié Copies the (selected) features of a layer to a new layer. Copie les entités (sélectionnées) d'une couche dans une nouvelle couche. Copy Copier +Copy / Cut / Paste features Copier / Couper / Coller des entités Copy a model.run command that runs this model, for the Script pane or Python Copier une commande model.run qui exécute ce modèle, pour le volet Script ou Python Copy Address Copier l'adresse Copy address Copier l'adresse Copy an image of the map to the clipboard. Copier une image de la carte dans le presse-papiers. +Copy as CLI Command Copier comme commande CLI +Copy as Command Copier comme commande Copy As GeoJSON Copier en GeoJSON +Copy as Python Copier en Python Copy Command Copier la commande Copy coordinates to the clipboard Copier les coordonnées dans le presse-papiers Copy details Copier les détails @@ -611,11 +668,13 @@ Copy Group Copier le groupe Copy Layer Copier la couche Copy Layers Copier les couches Copy Path Copier le chemin +Copy Run As Code Copier l'exécution en code Copy sign-in link Copier le lien de connexion Copy the selected features (also as GeoJSON text). Copier les entités sélectionnées (également en texte GeoJSON). Copy the selected features and delete them. Copier les entités sélectionnées et les supprimer. Copy the selected layer's data source path. Copier le chemin de la source de données de la couche sélectionnée. Copy this element (Ctrl+D, ⌘D on a Mac) Copier cet élément (Ctrl+D, ⌘D sur Mac) +Copy this run as code, or open it in its tool Copier cette exécution en code, ou l'ouvrir dans son outil Corrections Corrections Cost Coût could not draw the map impossible de dessiner la carte @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Impossible de charger le registre des Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Nombre, somme, moyenne, min, max et écart type d'un champ, éventuellement par champ de regroupement. Countries, cities, rivers and lakes Pays, villes, rivières et lacs Country Pays +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Compte les points d'une couche par la cellule A5 qui les contient (des cellules de surface égale, donc le nombre est une densité), avec la somme et la moyenne d'un champ. Counts the points that fall inside each polygon. Compte les points situés dans chaque polygone. Counts, totals and top categories of what's in view. Nombres, totaux et principales catégories de ce qui est visible. CPU CPU @@ -647,12 +707,8 @@ Cross Section Coupe transversale Cross Section (2 clicks) Coupe transversale (2 clics) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Cumulatif Current date Date actuelle Current Project Projet actuel @@ -680,13 +736,16 @@ Data type Type de données Data you add is listed here Les données ajoutées sont listées ici data.gov and CKAN catalogues Catalogues data.gov et CKAN Database Base de données -Databases Bases de données +Database connections are used by the desktop app Les connexions aux bases de données sont utilisées par l'application de bureau +databases are read through DuckDB, a program the browser can't run les bases de données sont lues via DuckDB, un programme que le navigateur ne peut pas exécuter Database… Base de données… Dataset Jeu de données Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Jeux de données sur Hugging Face par nom ou propriétaire/nom ; parcourez les fichiers d'un jeu de données et ajoutez des GeoTIFF, GeoParquet, PMTiles, LiDAR et d'autres fichiers. Data… Données… Date Date Dates Dates +Dates a cell needs Dates requises par cellule +Dates a cell needs is a whole number, or empty for half Les dates requises par cellule sont un nombre entier, ou vide pour la moitié Day Jour Day and night shading for a time. Ombrage jour et nuit pour une heure donnée. Decimal Décimal @@ -715,7 +774,6 @@ Delete Notebook Supprimer le bloc-notes Delete Page Supprimer la page Delete Presentation Supprimer la présentation Delete Report Supprimer le rapport -Delete selected features Supprimer les entités sélectionnées Delete selected rows Supprimer les lignes sélectionnées Delete Style Supprimer le style Delete Task Supprimer la tâche @@ -736,11 +794,15 @@ Desktop and command line only Application de bureau et ligne de commande uniquem Details Détails Details of the selected layer. Détails de la couche sélectionnée. Deviations Écarts +DGGS DGGS Diagnostic\nMonitor Moniteur de\ndiagnostic Diagnostic Monitor Moniteur de diagnostic Diagnostics Diagnostic +Diagonal ↗ Diagonale ↗ +Diagonal ↘ Diagonale ↘ Diamond Losange Difference, ratio or percent change between two rasters. Différence, rapport ou variation en pourcentage entre deux rasters. +Digit Digit Directions Itinéraire Directions From Here Itinéraire depuis ici Directions To Here Itinéraire jusqu'ici @@ -758,6 +820,7 @@ Dissolve Field Champ de regroupement Distance Distance Distance (km) Distance (km) Distance Coefficient P Coefficient de distance P +Distance units Unités de distance Distances along the surface, heights, areas and volumes. Distances le long de la surface, hauteurs, surfaces et volumes. DISTINCT DISTINCT Distinct Distinct @@ -804,18 +867,23 @@ Drag to draw Glisser pour dessiner Drag to draw a freehand line. Faites glisser pour dessiner une ligne à main levée. Drag to draw a freehand polygon. Faites glisser pour dessiner un polygone à main levée. Drag to draw a rectangle. Faites glisser pour dessiner un rectangle. +Drag to resize the message box Faites glisser pour redimensionner la zone de message Drag tools from the palette onto the canvas. Faites glisser des outils de la palette vers le canevas. Drag vertices on the map; click an edge to add one Faites glisser les sommets sur la carte ; cliquez sur un segment pour en ajouter un Draw a halo around label text. Dessiner un halo autour du texte d'étiquette. Draw a labelled latitude/longitude grid over the map frame. Dessiner un carroyage latitude/longitude annoté sur le bloc de carte. Draw a model: chain tools from input layers to the results to keep Dessiner un modèle : enchaîner des outils depuis les couches en entrée jusqu'aux résultats à conserver Draw a model: chain tools from input layers to the results to keep. Dessiner un modèle : enchaîner des outils depuis les couches en entrée jusqu'aux résultats à conserver. +Draw at its place in the stack, the bottom stroke first Dessiner à sa place dans la pile, le trait du bas d'abord +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Dessiner les traits d'un niveau pour toutes les entités avant le suivant, pour que les enveloppes se rejoignent sous les traits centraux là où les lignes se croisent Draw labels at every scale and distance. Dessiner les étiquettes à toutes les échelles et distances. Draw new features in the selected layer. Double-click to finish lines and polygons. Dessiner de nouvelles entités dans la couche sélectionnée. Double-cliquez pour terminer les lignes et polygones. Draw new single-band rasters with the inverted grey stretch again. Afficher de nouveau les nouveaux rasters monobandes avec l'étirement en niveaux de gris inversé. +Draw only polygon outlines, keeping their colors and widths. Dessiner uniquement les contours des polygones, en conservant leurs couleurs et épaisseurs. Draw points as bins coloured by how many fall in each. Dessiner les points en cellules colorées selon leur nombre dans chacune. Draw polygons as outlines only. Dessiner les polygones en contours uniquement. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Afficher les rasters monobandes ajoutés désormais avec cette palette de couleurs, cet étirement et ce rééchantillonnage. +Draw strokes in levels Dessiner les traits par niveaux Draw the layer at every scale. Dessiner la couche à toutes les échelles. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Dessiner la carte sur le GPU (bêta ; le globe, les effets de vue et la simulation de la vision des couleurs restent sur le moteur de rendu CPU). Draw the selected layer above others. Dessiner la couche sélectionnée au-dessus des autres. @@ -828,6 +896,7 @@ drawn by the server dessiné par le serveur Drive Voiture Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. Imagerie de drone et aérienne partagée sur OpenAerialMap, la plus récente d'abord, ajoutée en GeoTIFF optimisés pour le cloud. Duplicate Dupliquer +Duplicate Connection Dupliquer la connexion Duplicate Element Dupliquer l'élément Duplicate Layout Dupliquer la mise en page Duplicate Map Dupliquer la carte @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name p. ex. pop_est / 1000000 e.g. 0, 255, -9999 p. ex. 0, 255, -9999 e.g. continent = 'Europe' p. ex. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' p. ex. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 p. ex. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Chaque exécution de géotraitement devient une étape, enchaînée par ses sorties Earth Index has no points in the view Earth Index n'a aucun point dans la vue Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Modifier\nles sommets Edit and run the notebook. Modifier et exécuter le bloc-notes. Edit Attribute Modifier l'attribut Edit Cell Modifier la cellule +Edit Connection Modifier la connexion Edit Description Modifier la description Edit Graphic Modifier le graphique Edit Status État de l'édition @@ -882,6 +953,7 @@ Editor\nTools Outils\nd'édition Editor Tools Outils d'édition Edits since the project was saved. Modifications depuis l'enregistrement du projet. Edits the project Modifie le projet +Edit… Modifier… Effects Effets Element Élément Element Properties Propriétés de l'élément @@ -898,21 +970,25 @@ Enable Lighting Activer l'éclairage Enable Plugin Activer une extension Enabled Activée Encloses each feature, or all features, in its convex hull. Englobe chaque entité, ou toutes les entités, dans son enveloppe convexe. +Encoded polyline Polyligne encodée End Fin End date Date de fin Endpoint Point de terminaison Enhancement Amélioration Enter Enter Enter an API key. Saisissez une clé API. +Enter an EPSG code as a number, such as 32650 Saisissez un code EPSG sous forme de nombre, par exemple 32650 Enter the provider's address. Saisissez l'adresse du fournisseur. Enter to send, Shift+Enter for a new line Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Environnements EPSG EPSG +EPSG code Code EPSG EPSG or ESRI code Code EPSG ou ESRI EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Intervalles égaux Equirectangular (Plate Carrée) Équirectangulaire (Plate Carrée) @@ -922,14 +998,18 @@ Error Erreur Error Inspector Inspecteur d'erreurs Error Inspector (validate) Inspecteur d'erreurs (valider) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Europe équivalente (ETRS89 LAEA) Every band's value at a pixel. La valeur de chaque bande en un pixel. Every editing tool. Tous les outils d'édition. +Every label setting of this layer, in the Labeling pane. Tous les paramètres d'étiquetage de cette couche, dans le volet Étiquetage. Every layer, as FEMA draws them Toutes les couches, telles que la FEMA les dessine +Every run, in order, as a script that opens the project Chaque exécution, dans l'ordre, sous forme de script qui ouvre le projet every Socrata site, or one such as data.cityofnewyork.us tous les sites Socrata, ou un seul comme data.cityofnewyork.us Everywhere Partout +Existing field Champ existant Existing Template Modèle existant exit rotary sortez du rond-point exit roundabout sortez du rond-point @@ -948,6 +1028,7 @@ Export\nRaster Exporter\nle raster Export Animation Exporter l'animation Export Bookmarks Exporter les signets Export Bookmarks… Exporter les signets… +Export CSV… Exporter en CSV… Export Features Exporter des entités Export Features (tool) Exporter des entités (outil) Export Features needs a vector layer (Data › Export Raster writes a raster) Exporter des entités nécessite une couche vecteur (Données › Exporter le raster écrit un raster) @@ -996,6 +1077,7 @@ Face Culling Élimination des faces Faces Faces Factor Facteur Failed Échec +FALSE FALSE Farthest Plus éloignée Faster drawing: no antialiasing. Dessin plus rapide : sans anticrénelage. Favourite Data Source Source de données favorite @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Limites des parcelles Field density Densité des parcelles Field name Nom du champ +Field Prefix Préfixe de champ +Field to Sum and Average Champ à sommer et moyenner Fields Champs Fields are drawn from zoom 9: zoom in to see them. Les parcelles sont dessinées à partir du zoom 9 : zoomez pour les voir. Fields colored by score, drawn from zoom 9 Parcelles colorées selon le score, dessinées à partir du zoom 9 @@ -1079,6 +1163,8 @@ Folder Connections Connexions aux dossiers folder connections need the desktop app les connexions aux dossiers nécessitent l'application de bureau Folders Dossiers Folder… Dossier… +Follow the time slider Suivre le curseur temporel +Font Police for a store that doesn't say pour un magasin qui ne le précise pas Forest Forêt Forget Earthdata Login Token Oublier le jeton Earthdata Login @@ -1090,9 +1176,11 @@ France France Free the prepared geometry. Libérer la géométrie préparée. Freehand Main levée FROM FROM +From Du From Selection À partir de la sélection From Service… Depuis un service… From symbol Depuis le symbole +From the address D'après l'adresse from the address d'après l'adresse from the service d'après le service From URL… Depuis une URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Masquer les entités éloignées du ce Hide Group Masquer le groupe Hide labels when the viewer is closer than this. Masquer les étiquettes quand l'observateur est plus proche que cette valeur. Hide labels when the viewer is farther than this. Masquer les étiquettes quand l'observateur est plus éloigné que cette valeur. +Hide Layers Masquer les couches Hide other values Masquer les autres valeurs Hide Steps Masquer les étapes Hide the description Masquer la description @@ -1213,6 +1302,8 @@ Hold En attente HOME HOME Home Accueil Home › Add Accueil › Ajouter +Home › Add Data Accueil › Ajouter des données +Home › Inspect › Measure Accueil › Inspecter › Mesurer Home › Navigate Accueil › Naviguer Home › Navigate › Bookmarks Accueil › Naviguer › Signets Home › Web Data Accueil › Données web @@ -1220,7 +1311,7 @@ Home › Web Data › United States Accueil › Données web › États-Unis Homepage Page d'accueil Horizontal Horizontal host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=…, ou un fichier .duckdb +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Imagerie hébergée Hour Heure How the layer's colours combine with the tile layers and basemap beneath. Comment les couleurs de la couche se combinent avec les couches de tuiles et le fond de carte en dessous. @@ -1248,6 +1339,7 @@ Imagery Sharing Partage d'imagerie Imagery Tools Outils d'imagerie ImageServer ImageServer Image… Image… +Imperial Impérial Import Importer Import\nProject Importer\nun projet Import All Tout importer @@ -1267,6 +1359,7 @@ Import Task Importer une tâche Import Task… Importer une tâche… Import Thumbnail Importer une miniature Import… Importer… +IN IN In Beyond Masquer au-delà de In memory En mémoire In Progress En cours @@ -1281,6 +1374,8 @@ Information Informations INPUT ENTRÉE Input Entrée Input Features Entités en entrée +Input Layer Couche en entrée +Input Points Points en entrée Input Polygons Polygones en entrée Input Rows Lignes en entrée Input Summary Features Entités de synthèse en entrée @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Les extensions installées sont conservé Installed with Homebrew, winget, the AUR or COPR? Update it there. Installé avec Homebrew, winget, l'AUR ou COPR ? Mettez-le à jour par ce biais. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} installée. Retrouvez-la dans l'onglet Extensions ; ses outils sont aussi dans le volet Outils. Installing it downloads Python {version} and the packages it needs from the internet. L'installer télécharge depuis Internet Python {version} et les paquets dont elle a besoin. +Integer field: values are rounded. Champ entier : les valeurs sont arrondies. Interface font: {font} Police de l'interface : {font} Interpolate a surface from points (IDW) Interpoler une surface à partir de points (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Interpole une surface raster à 2 bandes (Prédiction et Variance) à partir d'entités ponctuelles par krigeage ordinaire. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. Interpole une surface raster à partir des valeurs des entités par pondération inverse à la distance, comme QGIS. Interpolation Interpolation Interpolation Attribute Attribut d'interpolation @@ -1316,10 +1413,13 @@ Invalid: {error} Non valide : {error} Invert Inverser Invert color scheme Inverser la palette de couleurs Invert the selection. Inverser la sélection. +IS IS IS NULL IS NULL it connects to an address on the internet il se connecte à une adresse sur Internet It has no code: nothing in it runs. Il ne contient aucun code : rien ne s'y exécute. +It is locked, or one of its surrounds is: unlock it first Il est verrouillé, ou l'un de ses éléments d'habillage l'est : déverrouillez-le d'abord it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) il vous est réservé (extensions, scripts, tâches, modèles et exécuteur GeoLibre) +it needs input: open it from its pane or button, or run it from a script or the assistant il a besoin de paramètres : ouvrez-le depuis son volet ou son bouton, ou exécutez-le depuis un script ou l'assistant it reads or writes files on this computer il lit ou écrit des fichiers sur cet ordinateur it replaces the open project il remplace le projet ouvert it runs a plugin's command il exécute une commande d'extension @@ -1332,20 +1432,30 @@ Italic Italique Item Pop-ups Fenêtres contextuelles des éléments Item {id} Élément {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. Son pare-feu ({firewall}) a bloqué RustGIS. Le service n'autorise peut-être que certaines applications : contactez son administrateur et envoyez-lui les détails ci-dessous. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Son outil n'est pas disponible pour l'instant (celui d'une extension non chargée, ou d'une suite non installée) Jobs Travaux JOIN JOIN Join Jointure +Join Features Entités à joindre +Join Operation Opération de jointure +Joins attributes from a join layer to a target layer based on their spatial relationship. Joint les attributs d'une couche de jointure à une couche cible selon leur relation spatiale. +Journal figure, 1 column (90 mm) Figure de revue, 1 colonne (90 mm) +Journal figure, 1.5 columns (140 mm) Figure de revue, 1,5 colonne (140 mm) +Journal figure, 2 columns (190 mm) Figure de revue, 2 colonnes (190 mm) Keep a history of every edit to the layer, to look back or restore. Conserver l'historique de chaque modification de la couche, pour la consulter ou la restaurer. Keep an archive of edits Conserver une archive des modifications keep current conserver l'actuel +Keep it from being moved, resized, deleted or edited by mistake Éviter qu'il soit déplacé, redimensionné, supprimé ou modifié par erreur Keep one value of each point's vector as its value field Conserver une valeur du vecteur de chaque point comme champ de valeur Keep Result Conserver le résultat Keep the labelled features fixed as the map moves. Garder fixes les entités étiquetées lorsque la carte se déplace. Keep the last drawing tool active after each graphic. Garder le dernier outil de dessin actif après chaque graphique. Keep the last tool active Garder le dernier outil actif +Keep this endpoint under Data Sources › Connections, without its password or keys Conserver ce point de terminaison sous Sources de données › Connexions, sans son mot de passe ni ses clés Keep this result Conserver ce résultat Keep Tool Garder l'outil Keep value Conserver la valeur +Key Key Keyboard Shortcuts Raccourcis clavier Keyboard shortcuts. Raccourcis clavier. Keys from Clés depuis @@ -1356,13 +1466,20 @@ KMZ KMZ Label Étiquette Label Class Classe d'étiquettes Label Distance Range Plage de distance des étiquettes +Label features in this layer Étiqueter les entités de cette couche Label Field Champ d'étiquette +Label only the features that match. N'étiqueter que les entités correspondantes. Label Placement Placement des étiquettes Label Range Plage d'étiquettes Label the cities with their names Étiqueter les villes avec leur nom Label Visibility Range Plage de visibilité des étiquettes Labeling Étiquetage +Labeling Pane Volet Étiquetage +Labeling Properties… Propriétés d'étiquetage… Labels Étiquettes +labels are drawn for feature layers, not rasters les étiquettes sont dessinées pour les couches d'entités, pas pour les rasters +Labels are drawn for feature layers, not rasters. Les étiquettes sont dessinées pour les couches d'entités, pas pour les rasters. +Labels are off for this layer: the clause applies once they are on. Les étiquettes sont désactivées pour cette couche : la clause s'appliquera une fois qu'elles seront activées. Lagoon Lagon Lambert Conformal Conic Conique conforme de Lambert Lambert Cylindrical Equal Area Cylindrique équivalente de Lambert @@ -1383,12 +1500,14 @@ Layer Blend Fusion de couche Layer Extrusion Extrusion de couche Layer File Fichier de couche Layer From\nSelection Couche depuis\nla sélection +Layer not found Couche introuvable Layer Template Modèle de couche Layer Templates Modèles de couche layer {id} couche {id} Layers Couches Layers ({n}) Couches ({n}) Layers and Table Views Couches et vues de table +Layers, a date each Couches, une date chacune Layout Mise en page Layout\nFile Fichier de\nmise en page Layout after: {name} Mise en page après : {name} @@ -1402,7 +1521,7 @@ Learning\nResources Ressources\nd'apprentissage Left Gauche Legend Légende Legend label. Clear it to show the value again. Étiquette de légende. Effacez-la pour afficher à nouveau la valeur. -Length {length} · gain {gain} m · loss {loss} m Longueur {length} · montée {gain} m · descente {loss} m +Length {length} · gain {gain} · loss {loss} Longueur {length} · montée {gain} · descente {loss} Let the assistant hand tasks to agents on this computer Laisser l'assistant confier des tâches aux agents de cet ordinateur Let your Agents Control RustGIS Laissez vos agents piloter RustGIS Letter spacing Espacement des lettres @@ -1439,6 +1558,7 @@ List by drawing order Lister par ordre d'affichage List by selection Lister par sélection List Columns in File Lister les colonnes du fichier List Commands Lister les commandes +List Connections Lister les connexions List Data Sources Lister les sources de données List Database Columns Lister les colonnes de la base de données List Database Tables Lister les tables de la base de données @@ -1475,6 +1595,11 @@ Locate… Localiser… Location Localisation Locator Localisateur Lock Verrouiller +Locked Verrouillé +Locked: unlock it in the Format pane to move or change it Verrouillé : déverrouillez-le dans le volet Format pour le déplacer ou le modifier +Log In to Connection Se connecter à la connexion +Log In to {name}… Se connecter à {name}… +Log In… Se connecter… Long Entier long Longitude Longitude Longitude (X) Longitude (X) @@ -1493,6 +1618,7 @@ Manage Gérer Manage Bookmarks Gérer les signets Manage Edits Gérer les modifications Manage Templates Gérer les modèles +Mann–Kendall Mann–Kendall Map Carte Map\nFrame Bloc\nde carte Map (the current view) Carte (la vue actuelle) @@ -1504,6 +1630,7 @@ Map File Fichier de carte Map Frame Bloc de carte Map Frames Blocs de carte Map frames per second (live while the map moves) Images de la carte par seconde (en direct pendant que la carte bouge) +Map Labeling Étiquetage de la carte Map Link Lien de carte Map name Nom de la carte Map Page Page de carte @@ -1515,6 +1642,7 @@ Map style (JSON) Style de carte (JSON) Map Surrounds Habillage de la carte Map Thumbnail Miniature de la carte Map tiles of the whole world Tuiles de carte du monde entier +Map Tool: {tool} Outil cartographique : {tool} Map View Vue cartographique map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Carte : {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Marqueur Mask Values Masquer des valeurs Masking Masquage +Match Option Option de correspondance Matching places Lieux correspondants Max Max Max % Max % +Max Neighbors Voisins max. Maximum zoom Zoom maximal MCP Relay Relais MCP MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} Le relais MCP écoute déjà sur 127.0.0.1:{port} ; arrêtez-le d'abord pour utiliser le port {asked} @@ -1542,6 +1672,8 @@ Measure Distance Mesurer une distance Measure geodesic distances and areas on the map. Mesurer des distances et surfaces géodésiques sur la carte. Measure on this body, with one of its basemaps. Mesurer sur ce corps, avec l'un de ses fonds de carte. Measure with an elevation raster Mesurer avec un raster d'altitude +Measurement units Unités de mesure +Measurement units must be metric or imperial Les unités de mesure doivent être métriques ou impériales Measurements so far. Mesures effectuées jusqu'ici. Mensuration Mensuration Mensuration Tools Outils de mensuration @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Fusionne le Messages appear here. Les messages apparaissent ici. Meters Mètres Method Méthode +Metric Métrique Midnight Minuit Miles Miles Miller Cylindrical Cylindrique de Miller @@ -1560,21 +1693,43 @@ Min must be less than Max Min doit être inférieur à Max Min must not be more than Max Min ne doit pas dépasser Max Minimum Bounding Geometry Géométrie d'emprise minimale Minimum Maximum Minimum Maximum +Minus Minus Minutes Minutes minutes ago il y a quelques minutes Miter Onglet Mobile Map Carte mobile Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Modèle Model\nBuilder Model\nBuilder model id identifiant du modèle Model output Sortie du modèle ModelBuilder ModelBuilder Models Modèles +Modified Mann–Kendall (Hamed & Rao) Mann–Kendall modifié (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Mann–Kendall modifié (Yue & Wang) Modify Modifier Modify Selected Features Modifier les entités sélectionnées Mollweide (equal-area world) Mollweide (monde équivalent) Month Mois +Months Mois +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Les mois sont des nombres de 1 à 12 (12, 1, 2 pour décembre à février) More Plus More basemaps: Plus de fonds de carte : Mosaic images into an orthomosaic Mosaïquer des images en une orthomosaïque @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Mosaïquer les rasters visibles Most cloud cover (%) Couverture nuageuse maximale (%) Mouse wheel Molette Move Déplacer -Move (m) Déplacer (m) Move Bookmark Déplacer le signet Move Down Descendre Move down Descendre Move Group Déplacer le groupe +Move Label Class Déplacer la classe d'étiquettes Move Task Step Déplacer l'étape de tâche +Move To Bottom Déplacer tout en bas Move To Group Déplacer vers un groupe +Move To Top Déplacer tout en haut Move To {group} Déplacer vers {group} Move Up Monter Move up Monter @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Mes compartiments My OGC Layer Ma couche OGC +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Nom name the plugin whose panel to open: {"id": plugin id} indiquez l'extension dont ouvrir le panneau : {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Nouveau\nbloc-notes New\nPresentation Nouvelle\nprésentation New\nReport Nouveau\nrapport New\nWorkspace Nouvel espace\nde travail -New / Open / Save project Nouveau / Ouvrir / Enregistrer le projet New Bookmark Nouveau signet New Bookmark… Nouveau signet… +New Connection Nouvelle connexion +New Connection ▾ Nouvelle connexion ▾ New conversation Nouvelle conversation New Custom Template Nouveau modèle personnalisé New Custom Template… Nouveau modèle personnalisé… New features use this template's values Les nouvelles entités utilisent les valeurs de ce modèle +New field Nouveau champ New Group Nouveau groupe New job name Nom du nouveau travail New Layout Nouvelle mise en page @@ -1649,6 +1809,7 @@ New Notebook Nouveau bloc-notes New Presentation Nouvelle présentation New Project Nouveau projet New project Nouveau projet +New Publication Layout Nouvelle mise en page de publication New Relationship… Nouvelle relation… New Report Nouveau rapport New Selection Nouvelle sélection @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Aucun signet p No catalogs below this one: Find Items lists its items Aucun catalogue sous celui-ci : Rechercher des éléments liste ses éléments No COMID was returned for this point. Aucun COMID n'a été renvoyé pour ce point. no command aucune commande -No connections this session Aucune connexion dans cette session +No data at the points picked. Aucune donnée aux points choisis. No datasets match. Aucun jeu de données ne correspond. No datasets with data RustGIS can add Aucun jeu de données avec des données que RustGIS peut ajouter +no date in the name pas de date dans le nom No edits since the project was saved Aucune modification depuis l'enregistrement du projet No field of this layer holds dates. Aucun champ de cette couche ne contient de dates. +No Fill Sans remplissage +No geometry to calculate Aucune géométrie à calculer No granules in this view and date range. Aucun granule dans cette vue et cette plage de dates. no home folder aucun dossier personnel no layer aucune couche @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) Aucune relation (Conception des No relationships yet Aucune relation pour l'instant No reports yet. Publish › New Report lists the selected layer's records. Aucun rapport pour l'instant. Publication › Nouveau rapport liste les enregistrements de la couche sélectionnée. no row aucune ligne +No serif font is installed, so this text is drawn in the sans-serif Aucune police avec empattement n'est installée, ce texte est donc dessiné dans la police sans empattement no service or file links aucun lien vers un service ou un fichier No services match your search. Aucun service ne correspond à votre recherche. No standalone tables Aucune table autonome @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Aucun None (public buckets) Aucune (compartiments publics) none, a .duckdb file, or host=… dbname=… user=… aucune, un fichier .duckdb, ou host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD aucun, ou depuis PGPASSWORD ou MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD aucun, ou depuis PGPASSWORD, MYSQL_PWD ou SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. Aucune. Ajoutez-en une par adresse ou depuis une extension de fonds de carte. North Nord North\nArrow Flèche\ndu nord North Polar Stereographic Stéréographique polaire nord +NOT NOT not a picture pas une image not a RustGIS layout pas une mise en page RustGIS not a RustGIS project pas un projet RustGIS +Not applied yet. Pas encore appliqué. Not for texts: a text is edited in the Format pane. Pas pour les textes : un texte se modifie dans le volet Format. not needed non requise Not sandboxed Hors bac à sable @@ -1739,9 +1906,12 @@ Nothing found where the map is Rien trouvé là où se trouve la carte Nothing here Rien ici Nothing named {name} to rename Rien ne s'appelle {name} à renommer Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Rien de partagé pour l'instant. Publication › Partager en tant que crée des cartes web, des couches, des styles, des scènes et plus. +Nothing to add here Rien à ajouter ici +Nothing to test Rien à tester Nothing was found at this address. Rien n'a été trouvé à cette adresse. Nothing was recorded, so no task was made. Rien n'a été enregistré, aucune tâche n'a donc été créée. Now Maintenant +Nugget Pépite NULL NULL Nulls Valeurs nulles Observations (observer, date, species, count) Observations (observateur, date, espèce, nombre) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. Sur le bureau, la clé peut a On-screen compass, zoom slider and pan pad. Boussole à l'écran, curseur de zoom et pavé de déplacement. One command per line: id {json}. Lines starting with # are comments. Une commande par ligne : id {json}. Les lignes commençant par # sont des commentaires. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Une seule requête SELECT DuckDB : les fonctions ST_ de DuckDB Spatial, des fichiers et adresses web (read_parquet('…')) et les tables de la base de données ci-dessous. Ctrl+Enter l'exécute. +One layer: a Zarr variable or a raster's bands Une couche : une variable Zarr ou les bandes d'un raster One page per feature of an index layer. Une page par entité d'une couche d'index. One per line: code=name Une par ligne : code=nom One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Un trait, issu du symbole. Ajoutez des traits pour des enveloppes, des lignes parallèles et des décalages. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Ouvrir le dossier Open In Geoprocessing… Ouvrir dans Géotraitement… Open in ModelBuilder Ouvrir dans ModelBuilder +Open in Tool Ouvrir dans l'outil Open Layout Ouvrir une mise en page Open ModelBuilder Ouvrir ModelBuilder Open Notebook Ouvrir un bloc-notes @@ -1797,6 +1969,7 @@ Open Presentation Ouvrir une présentation Open Project Ouvrir un projet Open project Ouvrir un projet Open Project (from text) Ouvrir un projet (depuis du texte) +Open Project… Ouvrir un projet… Open Report Ouvrir un rapport Open the attribute table of the selected layer. Ouvrir la table attributaire de la couche sélectionnée. Open the Basemaps tab Ouvrir l'onglet Fonds de carte @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Ou exécutez dans un shell PO Or run in PowerShell, for Claude Code or Codex: Ou exécutez dans PowerShell, pour Claude Code ou Codex : Or run, for Claude Code or Codex: Ou exécutez, pour Claude Code ou Codex : ORDER BY ORDER BY +Ordinary Kriging Krigeage ordinaire Orientation Orientation Ortho Mapping Ortho-cartographie Other Autre Other Data Autres données +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Autres classes d'étiquettes de cette couche (créées par des commandes) : {n}. Chacune étiquette les entités que sa propre clause retient, une entité peut donc recevoir une étiquette de plusieurs classes. Out Beyond Masquer en deçà de Outline Contour Outline another frame's extent in this map frame. Dessiner le contour de l'emprise d'un autre bloc dans ce bloc de carte. @@ -1851,6 +2026,7 @@ Output Sortie Output Name Nom en sortie Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Plus de 1 000 outils raster, vecteur, LiDAR, hydrologie et terrain qui s'exécutent dans le navigateur, après un téléchargement unique d'environ 25 Mo. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Plus de 1 000 outils raster, vecteur, LiDAR, hydrologie et terrain, en téléchargement facultatif d'environ 20 Mo. +Overlapping Chevauchantes Overlay Superposition overlay superposition Overlay (a transparent layer kept on top, such as labels or roads) Superposition (une couche transparente gardée au-dessus, comme des étiquettes ou des routes) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture n'a pas de données {ty} là Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Les données ouvertes d'Overture Maps : bâtiments, lieux, transports, divisions administratives, couches de base et adresses. Là où se trouve la carte, les entités sont lues sur place depuis les fichiers GeoParquet d'Overture (zoomez sur une ville ou moins) ; le monde entier est fourni en tuiles de carte. Overture Maps… Overture Maps… +Overwrite existing values Écraser les valeurs existantes Package Paquetage Package a trained deep learning model. Empaqueter un modèle d'apprentissage profond entraîné. Package Map Empaqueter la carte @@ -1907,8 +2084,12 @@ Performance Performances Phase Phase Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Choisissez une valeur dynamique, comme l'échelle ou la date, puis cliquez sur la carte pour la placer. +Pick a Point Choisir un point +Pick a point on the map to see the variable's values through time there. Choisissez un point sur la carte pour y voir les valeurs de la variable au fil du temps. Pick a route in the list, or click a grey route on the map. Choisissez un itinéraire dans la liste, ou cliquez sur un itinéraire gris sur la carte. +Pick a single layer. Choisissez une seule couche. Pick it on the map Le choisir sur la carte +Pick one… Choisissez… Picture Image Pictures Images Pictures, attachments and chart Images, pièces jointes et diagramme @@ -1966,8 +2147,11 @@ Portal Portail Portolan Portolan Portolan… Portolan… Portrait Portrait +Position Position +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Post-traitement power 2 puissance 2 +Precision 6 (OSRM, Valhalla) Précision 6 (OSRM, Valhalla) Prepare Préparer Prepare every layer's geometry for this projection. Préparer la géométrie de chaque couche pour cette projection. Prepare Plugin Environment Préparer l'environnement de l'extension @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Modèles de projet : déma project's path is required le chemin du projet est requis Projection Projection Projections Projections +Properties Propriétés +Property Propriété Proportional Symbols Symboles proportionnels Provider Fournisseur +Provinces: Provinces : Proximity Proximité Public GIS portals of US counties and cities, by state. Portails SIG publics des comtés et villes des États-Unis, par État. Public GIS portals of US federal agencies, by department. Portails SIG publics des agences fédérales des États-Unis, par département. Public GIS portals of US states and the District of Columbia. Portails SIG publics des États des États-Unis et du District of Columbia. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. Couches publiques sur ArcGIS Online ou un autre portail ArcGIS : services d'entités, de cartes, d'images et de tuiles vectorielles, cartes web et fichiers GeoJSON. +Publication Layout Mise en page de publication Publish Publication Publish › Output Publication › Sortie Put the layer back as it was then Restaurer la couche telle qu'elle était alors Python environment failed: {error} Échec de l'environnement Python : {error} Python environment not prepared Environnement Python non préparé Python environment ready Environnement Python prêt +Python script Script Python Qualitative Qualitative Quantile Quantile Query files, web addresses and databases with DuckDB Spatial; add results to the map. Interroger des fichiers, adresses web et bases de données avec DuckDB Spatial ; ajouter les résultats à la carte. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Élever les polygones d'une hauteur ou se Raise Priority Augmenter la priorité Random points in polygons Points aléatoires dans des polygones Range Plage +Range (Degrees) Portée (degrés) +range: 0, 0.9 plage : 0, 0.9 Raster Raster Raster\nAnalysis Analyse\nraster Raster\nInformation Informations\nraster @@ -2075,6 +2266,7 @@ Redo {action} Rétablir {action} Red–Blue Rouge–Bleu Refine the search to see {n} more Affinez la recherche pour en voir {n} de plus Refresh Actualiser +Refresh every connection's lists Actualiser les listes de toutes les connexions Refresh Map Series Actualiser la série de cartes Refresh the map series from its index layer. Actualiser la série de cartes à partir de sa couche d'index. Region Région @@ -2091,11 +2283,10 @@ Relationships Relations Release Version Reload Recharger Reload Web Layers Recharger les couches web -Remember (without the secret) Mémoriser (sans la clé secrète) Remember the key on this computer Mémoriser la clé sur cet ordinateur -Remembered: Mémorisées : Remove Supprimer Remove All Tout supprimer +Remove All Label Classes Supprimer toutes les classes d'étiquettes Remove an offline area. Supprimer une zone hors connexion. Remove Bookmark Supprimer le signet Remove Connection Supprimer la connexion @@ -2108,11 +2299,14 @@ Remove from Favourites Retirer des favoris Remove From Group Retirer du groupe Remove Group Supprimer le groupe Remove Keyframe Supprimer l'image clé +Remove Label Class Supprimer la classe d'étiquettes Remove Section Supprimer la section Remove Table Supprimer la table +Remove the clause. Supprimer la clause. Remove the last keyframe. Supprimer la dernière image clé. Remove the layer from the map. Supprimer la couche de la carte. Remove the strokes and draw the symbol's line alone Supprimer les traits et dessiner uniquement la ligne du symbole +Remove this class's own strokes and draw it with the layer's Supprimer les traits propres à cette classe et la dessiner avec ceux de la couche Remove this stop Retirer cet arrêt Remove Tile Layer Supprimer la couche de tuiles Remove token Supprimer le jeton @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Supprim Rename Renommer Rename Bookmark Renommer le signet Rename Group Renommer le groupe +Rename it to save it: a connection has this name Renommez-la pour l'enregistrer : une connexion porte déjà ce nom +Rename Label Class Renommer la classe d'étiquettes Rename Layer Renommer la couche Rename Layout Renommer la mise en page Rename Map Renommer la carte @@ -2149,6 +2345,7 @@ Reset Default Rétablir la valeur par défaut Reset Effects Réinitialiser les effets Reset North Réorienter au nord Reset north Réorienter au nord +Resolution (0–30) Résolution (0–30) Response Réponse Restore Restaurer Restore From Archive Restaurer depuis l'archive @@ -2162,6 +2359,7 @@ RGB RVB Ribbon Ruban Right Droite Right-click a source to add it here Clic droit sur une source pour l'ajouter ici +Right-click for more Clic droit pour plus d'options Robinson Robinson Rotate Faire pivoter Rotate (°) Rotation (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Données d'exemple (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Données d'exemple © Natural Earth (domaine public). RustGIS est sous licence Apache-2.0. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Données d'exemple : Natural Earth (domaine public). Sous licence Apache-2.0. +Sans-serif Sans empattement Satellite Embedding Datasets Jeux de données de plongements satellitaires Satellite Embeddings Plongements satellitaires Satellite Embeddings… Plongements satellitaires… Saturation Saturation Save Enregistrer Save As Enregistrer sous +Save as Connection Enregistrer comme connexion Save as Default Enregistrer par défaut Save as Default Raster Style Enregistrer comme style raster par défaut Save as Layer Enregistrer comme couche Save As Presentation File… Enregistrer comme fichier de présentation… +Save as Python Script… Enregistrer comme script Python… Save As Report File… Enregistrer comme fichier de rapport… Save As Report Template… Enregistrer comme modèle de rapport… Save as {name} Enregistrer sous {name} Save As… Enregistrer sous… Save Color Ramps Enregistrer les dégradés de couleurs Save Data Source Enregistrer la source de données +Save History As Python Script Enregistrer l'historique comme script Python Save Layer File Enregistrer le fichier de couche Save Layout File Enregistrer le fichier de mise en page Save Map File Enregistrer le fichier de carte @@ -2254,6 +2456,7 @@ save or load a pane set by name enregistrer ou charger un jeu de volets par son Save Project Enregistrer le projet Save project Enregistrer le projet Save Project As Enregistrer le projet sous +Save Project As… Enregistrer le projet sous… Save Project Template Enregistrer le modèle de projet Save Style Items Enregistrer les éléments de style Save Task Item Enregistrer l'élément de tâche @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Enregistrer le modèle dans un fichier ( Save the model in the project Enregistrer le modèle dans le projet Save the open layout as a layout file. Enregistrer la mise en page ouverte comme fichier de mise en page. Save the panes you have open, and switch back to them. Enregistrer les volets ouverts, et y revenir. +Save the project too: the script opens its saved file, without changes made since. Enregistrez aussi le projet : le script ouvre son fichier enregistré, sans les modifications faites depuis. Save the project under a new name. Enregistrer le projet sous un nouveau nom. Save the project with your edits. Enregistrer le projet avec vos modifications. Save the project's custom color ramps to a color ramp file. Enregistrer les dégradés de couleurs personnalisés du projet dans un fichier de dégradés de couleurs. @@ -2275,13 +2479,16 @@ Save Web Link Chart Enregistrer le diagramme de liens web Save Web Map Enregistrer la carte web Save Web Scene Enregistrer la scène web Saved Enregistré +Saved as the connection {name} Enregistré comme connexion {name} Saved as the default style for new single-band rasters. Enregistré comme style par défaut des nouveaux rasters monobandes. +Saved connection Connexion enregistrée Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Les clés enregistrées sont conservées non chiffrées dans les préférences de l'application (dans le stockage du navigateur sur le web). Saved models Modèles enregistrés Saved symbology: apply it to the selected layer, or save the layer's. Symbologie enregistrée : l'appliquer à la couche sélectionnée, ou enregistrer celle de la couche. Saved View Vue enregistrée Saved {name} {name} enregistré Saved {name}. {name} enregistré. +Saved {path} {path} enregistré saving failed unexpectedly l'enregistrement a échoué de manière inattendue Scale Échelle Scale\nBar Barre\nd'échelle @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Rechercher (ce portail exige des mots)… Search Catalogue Rechercher dans le catalogue Search commands — e.g. buffer, basemap, labels Rechercher des commandes — p. ex. tampon, fond de carte, étiquettes Search datasets, e.g. GEDI biomass Rechercher des jeux de données, p. ex. GEDI biomass -Search Distance (km) Distance de recherche (km) +Search Distance Distance de recherche Search every command and its parameters Rechercher toutes les commandes et leurs paramètres Search layers (e.g. temperature) Rechercher des couches (p. ex. température) Search layers, places, lat/lon… Rechercher couches, lieux, lat/lon… @@ -2329,6 +2536,8 @@ Searching the folders… Recherche dans les dossiers… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. La recherche fonctionne ici, mais les serveurs de données de la NASA ne permettent pas aux pages web de lire leurs fichiers (pas de CORS) : les GeoTIFF et les téléchargements de données protégées nécessitent donc l'application de bureau. Télécharger ouvre un fichier dans un nouvel onglet, où Earthdata Login vous demande de vous connecter. Searching… Recherche… Searching… try again in a moment. Recherche en cours… réessayez dans un instant. +Seasonal Mann–Kendall Mann–Kendall saisonnier +Seasons a year Saisons par an Second standard parallel Second parallèle standard Seconds Secondes Secret access key Clé d'accès secrète @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Séparateur Sepia Sépia Sequential Séquentielle +Serif Avec empattement Server Serveur Service Group Groupe de service Service group data exceeds 64 MB. Les données du groupe de service dépassent 64 Mo. Service Import Requests Requêtes d’importation du service Service Layers Couches de service -Services Services +Session token Jeton de session Set Définir Set Cell Size Définir la taille de cellule Set Domain Définir le domaine @@ -2411,6 +2621,7 @@ Shaded Ombré Shaded Relief Relief ombré Shape Forme Shapefile Shapefile +Shapes Formes Share Partager Share As Partager en tant que Share every layer and table as OGC API Features collections. Partager chaque couche et table comme collections OGC API Features. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Afficher des détails au s Show different features at different scales. Afficher différentes entités à différentes échelles. Show grid Afficher la grille Show Group Afficher le groupe +Show labels Afficher les étiquettes Show latitude/longitude lines on the basemap. Afficher les lignes de latitude/longitude sur le fond de carte. +Show Layers Afficher les couches Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Afficher les couches dans le temps : seules les entités dont la date tombe dans la fenêtre du curseur, et l'imagerie datée pour sa date. Show Map Afficher la carte Show Me Montrer @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Afficher ou masquer le volet Outils. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Afficher les règles. Faites glisser depuis une règle pour ajouter un repère ; ramenez un repère sur la règle pour le retirer. Show the bands picked, read in place as the map needs them Afficher les bandes choisies, lues sur place selon les besoins de la carte Show the description Afficher la description +Show the Labeling pane for the selected layer. Afficher le volet Étiquetage pour la couche sélectionnée. Show the labels that don't fit, in red. Afficher en rouge les étiquettes qui ne tiennent pas. Show the layer only inside another layer's polygons. Afficher la couche uniquement à l'intérieur des polygones d'une autre couche. Show the layer only left of a divider, to compare it with what's beneath. Afficher la couche uniquement à gauche d'un séparateur, pour la comparer avec ce qui est en dessous. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Connexion annulée. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. Le délai de connexion est dépassé. Recommencez et autorisez le navigateur à accéder à l’adresse de bouclage de cet ordinateur. signed in connecté Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Déconnecté sur cet ordinateur, mais OpenAI ne l'a pas confirmé. Vous pouvez déconnecter RustGIS dans les paramètres de ChatGPT. +Significance level Seuil de signification Signing in with ChatGPT works in the desktop app. La connexion avec ChatGPT fonctionne dans l'application de bureau. Signing the asset (Planetary Computer)… Signature de l'asset (Planetary Computer)… +Sill Palier Simplify Simplifier Simulate the drive, or save the route as a layer. Simuler le trajet, ou enregistrer l'itinéraire comme couche. Simulation Simulation @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} Compétences pour l'Assistant IA : {names} Slideshow, PDF or PNG Diaporama, PDF ou PNG slight left tournez légèrement à gauche slight right tournez légèrement à droite +Slope per Pente par Slope, aspect, hillshade, NDVI, band math, thresholds. Pente, exposition, ombrage, NDVI, calcul de bandes, seuils. Slope, hillshade, NDVI and more. Pente, ombrage, NDVI et plus. Small scales Petites échelles @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Sud South Polar Stereographic Stéréographique polaire sud +Spatial Join Jointure spatiale Speaker notes Notes de l'orateur Spectral Analysis Analyse spectrale Spectral indices Indices spectraux @@ -2530,6 +2748,7 @@ Split (click the line) Scinder (cliquer sur la ligne) SQL SQL SQL Pane Volet SQL SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. Les requêtes SQL passent par DuckDB, un programme appelé par l'application de bureau : ouvrez ce projet dans l'application de bureau pour l'interroger. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL qui doit être vrai, p. ex. pop > 0 Square Carré Squares Carrés @@ -2588,6 +2807,7 @@ Start over at the end while playing. Recommencer à la fin pendant la lecture. Start With Démarrer avec Start-up Démarrage starting with a project file needs the desktop app démarrer avec un fichier de projet nécessite l'application de bureau +Statistic Statistique Statistics Statistiques Statistics Field Champ statistique Status État @@ -2613,8 +2833,10 @@ Stretch\nType Type\nd'étirement Stretch it over the map view, or give control points (one per line: column row lon lat). L'étirer sur la vue cartographique, ou fournir des points de contrôle (un par ligne : colonne ligne lon lat). Stretch Type Type d'étirement Stretch type Type d'étirement +Stroke Levels Niveaux de traits Stroke {n} Trait {n} Strokes Traits +Strokes for Traits pour Style Style Style Items Éléments de style Style › Drawing Style › Dessin @@ -2624,7 +2846,10 @@ Subreport: {name} Sous-rapport : {name} Subtypes Sous-types Subtypes, one per line: code=name Sous-types, un par ligne : code=nom Suitability Modeler Modélisateur d'aptitude +Summarise Synthétiser +Summarise over time Synthétiser dans le temps Summarise the conversation so far, so a long chat stays within the model's context Résumer la conversation jusqu'ici, pour qu'une longue discussion tienne dans le contexte du modèle +Summarise Zarr Over Time Synthétiser un Zarr dans le temps Summarising the earlier conversation to keep it within the model's context… Résumé de la conversation précédente pour qu'elle tienne dans le contexte du modèle… Summarize Synthétiser Summarize\nWithin Synthétiser\nà l'intérieur @@ -2658,6 +2883,8 @@ Table or layer Table ou couche Tables Tables Tags Tags Take the data in view offline, to edit and sync back later. Mettre hors connexion les données visibles, pour les modifier et les synchroniser plus tard. +Target Features Entités cibles +Target Records Enregistrements cibles Task Tâche Task Item Élément de tâche Task name Nom de la tâche @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Données SIG publiques du Tennessee (Tennessee GeoData, geodata.tn.gov) : limites de propriétés, transports, santé, hydrologie et plus. Terrain Terrain Test Test +Test Connection Tester la connexion +Testing… Test en cours… Text Texte Text Symbol Symbole de texte That connection would create a loop. Cette connexion créerait une boucle. The address fields are empty in every row. Les champs d'adresse sont vides dans chaque ligne. +The answer isn't UTF-8 text La réponse n'est pas du texte UTF-8 the answer isn't UTF-8 text la réponse n'est pas du texte UTF-8 The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. L'assistant suit les instructions d'une compétence lorsqu'il l'utilise. Quoi qu'elles disent, ses commandes qui demandent votre accord le demandent toujours. The assistant is busy; stop it or wait for it to finish. L'assistant est occupé ; arrêtez-le ou attendez qu'il ait terminé. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. L'onglet de fonds de carte publics the capture failed unexpectedly la capture a échoué de manière inattendue The connection failed; trying again in {seconds} s. La connexion a échoué ; nouvel essai dans {seconds} s. The country list couldn't be read ({e}): type a country's code La liste des pays n'a pas pu être lue ({e}) : saisissez le code d'un pays +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. L'altitude actuelle reste en vigueur pendant que vous modifiez les valeurs. Cliquez sur Appliquer l'altitude pour appliquer la valeur et les unités. The dataset has nothing RustGIS can add Le jeu de données ne contient rien que RustGIS puisse ajouter The environment (AWS_ACCESS_KEY_ID…) L'environnement (AWS_ACCESS_KEY_ID…) The features where the map is Les entités là où se trouve la carte @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. Le t The installed GeoLibre runner lists no tools. L'exécuteur GeoLibre installé ne liste aucun outil. The job covers the map view Le travail couvre la vue cartographique The key is kept for this session only. La clé n'est conservée que pour cette session. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. La couche affiche le pas où se trouve le curseur temporel, et active le curseur sur les dates de la variable. The layer's file couldn't be read: {error} Le fichier de la couche n'a pas pu être lu : {error} +The layer's strokes Les traits de la couche the layout export failed unexpectedly l'export de la mise en page a échoué de manière inattendue The link doesn't say its STAC API: Find Items searches {api} Le lien n'indique pas son API STAC : Rechercher des éléments interroge {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. La liste des extensions à parcourir. Laissez vide pour utiliser le registre des extensions RustGIS. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. La miniature du projet, à partir de The projection the map opens in. La projection dans laquelle la carte s'ouvre. The provider's content filter stopped the reply. Le filtre de contenu du fournisseur a arrêté la réponse. The public server is free and shared by many: it answers light use, and may ask you to wait. Le serveur public est gratuit et partagé par beaucoup : il répond à un usage modéré et peut vous demander de patienter. +The range is two numbers, low and high (0, 0.9) La plage est composée de deux nombres, bas et haut (0, 0.9) The raster's data couldn't be read. Impossible de lire les données du raster. The raster's data couldn't be read: {path} Impossible de lire les données du raster : {path} the release isn't known la version est inconnue @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Les fichiers du Shapefile sont télé The share of each cell covered by fields, for the zoomed-out view La part de chaque cellule couverte par des parcelles, pour la vue dézoomée The sign-in expired or was revoked. Sign in again. La connexion a expiré ou a été révoquée. Reconnectez-vous. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. La connexion est conservée dans le dossier de paramètres de l'application, pour votre compte utilisateur seulement, et jamais dans un fichier de projet. +The significance level is a number, such as 0.05 Le seuil de signification est un nombre, par exemple 0.05 The start-up project {path} didn't open: {error} Le projet de démarrage {path} ne s'est pas ouvert : {error} The start-up project {path} isn't a RustGIS project file. Le projet de démarrage {path} n'est pas un fichier de projet RustGIS. The step's layer {name} isn't in the map. La couche {name} de l'étape n'est pas dans la carte. The store has no variables with two or more dimensions. Le magasin n'a aucune variable à deux dimensions ou plus. the store is gone le magasin a disparu +The summary couldn't start: {error} La synthèse n'a pas pu démarrer : {error} +The summary failed unexpectedly La synthèse a échoué de manière inattendue +The summary finished after another project was opened, so it wasn't added La synthèse s'est terminée après l'ouverture d'un autre projet, elle n'a donc pas été ajoutée The task's steps changed, so the step being edited was closed: edit it again. Les étapes de la tâche ont changé, l'étape en cours de modification a donc été fermée : modifiez-la à nouveau. the time slider is off le curseur temporel est désactivé The token expires on {date}. Le jeton expire le {date}. +The trend couldn't start: {error} L'analyse de tendance n'a pas pu démarrer : {error} +The trend failed unexpectedly L'analyse de tendance a échoué de manière inattendue +The trend finished after another project was opened, so it wasn't added L'analyse de tendance s'est terminée après l'ouverture d'un autre projet, elle n'a donc pas été ajoutée +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. Les valeurs affichées du noir au blanc, identiques pour chaque date et chaque tuile (dans les unités propres à la bande). Vide : l'étirement propre à chaque fichier. the view has no extent la vue n'a pas d'étendue The view is larger than one read: its centre was read (zoom in for the rest) La vue dépasse une seule lecture : son centre a été lu (zoomez pour le reste) The web app always runs the latest release: reload the page to get it. L'application web exécute toujours la dernière version : rechargez la page pour l'obtenir. the window's screenshot didn't come back in time, so the map was drawn offscreen la capture d'écran de la fenêtre n'est pas revenue à temps, la carte a donc été dessinée hors écran the window's screenshot didn't hold the map, so it was drawn offscreen la capture d'écran de la fenêtre ne contenait pas la carte, elle a donc été dessinée hors écran Theme Thème +There is no expression defined. Aucune expression n'est définie. Thermal Thermique +Thesis figure, full page (160 × 225 mm) Figure de thèse, pleine page (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Figure de thèse, demi-page (160 × 110 mm) Thinking… Réflexion… +Third-party licences Licences tierces +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins cette version ne peut pas télécharger d'extensions this build can't reach AI providers cette version ne peut pas joindre les fournisseurs d'IA this build saves projects with Save As cette version enregistre les projets avec Enregistrer sous This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Ce compte ChatGPT était déjà enregistré. La prochaine fois, choisissez-le dans la liste au lieu de l'ajouter à nouveau. +This class is drawn with the layer's strokes. Changing them here gives it its own. Cette classe est dessinée avec les traits de la couche. Les modifier ici lui donne ses propres traits. +This class is hidden, so it labels nothing. Cette classe est masquée, elle n'étiquette donc rien. +This clause no longer reads, so no feature is labelled: {error} Cette clause n'est plus lisible, aucune entité n'est donc étiquetée : {error} This map has no layers yet. Cette carte n'a pas encore de couche. This map's thumbnail, from an image. La miniature de cette carte, à partir d'une image. This map's thumbnail, from the view. La miniature de cette carte, à partir de la vue. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Cette demande n'a pas d'option d'autorisation ponctuelle. This request uses an unsupported capability. Check the parameter and change the request. Cette requête utilise une fonction non prise en charge. Vérifiez le paramètre et modifiez la requête. This result is kept Ce résultat est conservé +This run has no tool form to open Cette exécution n'a pas de formulaire d'outil à ouvrir +This session only (optional) Cette session uniquement (facultatif) This step Cette étape This step names no tool to show. Cette étape n'indique aucun outil à montrer. Thumbnail Miniature @@ -2789,15 +3041,21 @@ Tilt Map Incliner la carte Tilt-shift Tilt-shift Tilt-shift blur and film grain. Flou tilt-shift et grain de film. Time Temps +Time\nSeries Série\ntemporelle Time\nSlider Curseur\ntemporel Time Fields Champs temporels +Time Series Série temporelle +Time series Série temporelle +Time Series at a Point Série temporelle en un point Time Slider Curseur temporel Time Slider Settings Paramètres du curseur temporel Tint by elevation Teinter selon l'altitude Tissot's Indicatrix Indicatrice de Tissot Title Titre +To Au Toggle Dark Mode Basculer le mode sombre Toggle GPU Rendering Activer/désactiver le rendu GPU +Toggle Imperial Measurements Basculer les mesures impériales Tolerance (km) Tolérance (km) Tolls Péages Tool Gallery Galerie d'outils @@ -2828,7 +3086,10 @@ Train Deep Learning Model Entraîner un modèle d'apprentissage profond Trained models Modèles entraînés Transparency Transparence Traverse Cheminement +Trend Analysis Analyse de tendance +Trend over a series of rasters Tendance sur une série de rasters Triangle Triangle +TRUE TRUE Try again in a moment. Réessayez dans un instant. Try again later. Réessayez plus tard. Turn editing on or off for the map. Activer ou désactiver l'édition de la carte. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map saisissez une longitude et une l Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Saisissez une échelle, comme 1:12,345, 12345 ou 25k, et appuyez sur Entrée Type an address or coordinates and press Enter, or click the map. Saisissez une adresse ou des coordonnées et appuyez sur Entrée, ou cliquez sur la carte. Type numbers separated by commas Saisissez des nombres séparés par des virgules +Type the keys to log in for this session, then Save. Saisissez les clés pour vous connecter pendant cette session, puis Enregistrer. +Type the password or keys to log in for this session, then Save. Saisissez le mot de passe ou les clés pour vous connecter pendant cette session, puis Enregistrer. Typed below Saisies ci-dessous UM UM Unavailable: {reason} Indisponible : {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Délier du curseur temporel Unbind from Time Slider Délier du curseur temporel Undo Annuler Undo (Ctrl+Z) Annuler (Ctrl+Z) -Undo / Redo Annuler / Rétablir Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Annuler ne retire pas l'étape enregistrée : supprimez-la dans Modifier les étapes une fois l'enregistrement arrêté. Undo the last change. Annuler la dernière modification. Undo {action} Annuler {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools Désinstaller les outils GeoLibre Uninstall Plugin Désinstaller une extension Uninstalled {id}. {id} désinstallée. Unique Values Valeurs uniques +Unit Unité United States États-Unis Units Unités Units per step Unités par pas @@ -2877,6 +3140,7 @@ Up a level Remonter d'un niveau Update Mettre à jour Update Bookmark Mettre à jour le signet Update Job Mettre à jour le travail +Update Label Class Mettre à jour la classe d'étiquettes Update to {version} Mettre à jour vers {version} Upload File Téléverser un fichier Upload File… Téléverser un fichier… @@ -2895,6 +3159,7 @@ Use another account Utiliser un autre compte Use Current View Utiliser la vue actuelle Use each feature's line color Utiliser la couleur de ligne de chaque entité Use each feature's line width Utiliser l'épaisseur de ligne de chaque entité +Use the Layer's Strokes Utiliser les traits de la couche USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan Forfait ChatGPT utilisé Utilities Réseaux publics Valid combinations, one per line: a, b Combinaisons valides, une par ligne : a, b Valid expression · {n} matching rows Expression valide · {n} lignes correspondantes +Valid: {n} of {of} features match Valide : {n} entités sur {of} correspondent Validate Valider Validate Model Valider le modèle value (empty: no-data) valeur (vide : no-data) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. L'imagerie ouverte de catastrophes de Vantor : choisissez un événement et une phase (avant ou après), puis Rechercher des éléments. Vantor… Vantor… Variable Variable +Variogram Model Modèle de variogramme Vector and raster data Données vecteur et raster Vector Layer Couche vecteur +vector tiles tuiles vectorielles Vector tiles (MVT) Tuiles vectorielles (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Vecteurs qu'un modèle de fondation géospatial a produits pour chaque pixel ou imagette, trouvés là où se trouve la carte et affichés en couleurs. Version {version} Version {version} @@ -2933,6 +3201,9 @@ Video file or URL Fichier vidéo ou URL Video Page Page vidéo Videos Vidéos Video… Vidéo… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Système de coordonnées national du Vietnam (VN-2000) en Mercator transverse à fuseaux de 3 degrés, avec une transformation de Helmert à 7 paramètres vers WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Système de coordonnées national du Vietnam (VN-2000) UTM zone 48N (102°E–108°E, méridien central 105°E). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Système de coordonnées national du Vietnam (VN-2000) UTM zone 49N (108°E–114°E, méridien central 111°E). View Affichage View Clipping Découpage de la vue View Effects Effets de vue @@ -2952,6 +3223,9 @@ Visibility Range Plage de visibilité Visible Visible Visible Layers Couches visibles Visual Effect Effet visuel +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vietnam) +VN-2000 / UTM zone 48N VN-2000 / UTM zone 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zone 49N Volume Volume Waiting for the browser… En attente du navigateur… Waiting for your answer En attente de votre réponse @@ -2970,6 +3244,7 @@ Web Link Chart Diagramme de liens web Web maps Cartes web Web Mercator Web Mercator Web Scene Scène web +Web service (detected) Service web (détecté) Web Style Style web Web Tile Layers Couches de tuiles web Web Tool Outil web @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Position des étiquettes par rappor Where new features get their elevation (the z field). D'où les nouvelles entités tirent leur altitude (le champ z). Where the map opens: the full extent or a view you saved. Où la carte s'ouvre : l'étendue complète ou une vue enregistrée. Where tools work Où les outils travaillent +Whether the Select tool picks these layers' features on the map Indique si l'outil Sélectionner choisit les entités de ces couches sur la carte Which 5 countries have the largest GDP? Quels sont les 5 pays ayant le PIB le plus élevé ? Whitebox and GeoLibre tools Outils Whitebox et GeoLibre Whole map Carte entière @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Monde, équivalente : vraies s World, equal-area: true sizes, shapes bend towards the edges. Monde, équivalente : vraies surfaces, les formes se courbent vers les bords. Worldview Frontières Write a plugin Écrire une extension +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Écrit la pente de Sen, le Z et la valeur p du test, et la pente là où la tendance est significative. X (mm) X (mm) X column Colonne X X Field Champ X @@ -3050,7 +3327,6 @@ Yours Les vôtres YYYY-MM-DD AAAA-MM-JJ YYYY-MM-DD, for the layers that change with time AAAA-MM-JJ, pour les couches qui changent avec le temps YYYY-MM-DD, for time layers AAAA-MM-JJ, pour les couches temporelles -z (m) z (m) Z factor Facteur Z Zarr Zarr Zarr Variables Variables Zarr @@ -3061,7 +3337,6 @@ Zoom Box Zoom rectangle Zoom Frame Bloc de zoom Zoom In Zoom avant Zoom in Zoom avant -Zoom in / out Zoom avant / arrière Zoom In Here Zoom avant ici Zoom in on the centre of the map. Zoomer en avant sur le centre de la carte. Zoom in · finish a sketch Zoom avant · terminer un croquis @@ -3073,6 +3348,7 @@ Zoom To\nLayer Zoomer sur\nla couche Zoom to a box (Explore) Zoomer sur un rectangle (Explorer) Zoom To Extent Zoomer sur l'étendue Zoom To Layer Zoomer sur la couche +Zoom To Layers Zoomer sur les couches Zoom To Map View Zoomer sur la vue cartographique Zoom To Selection Zoomer sur la sélection Zoom to selection Zoomer sur la sélection @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Zoomer sur l'emprise de la couche. Zoom to the selected features. Zoomer sur les entités sélectionnées. Zoom to the whole world. Zoomer sur le monde entier. Zoom to this bookmark Zoomer sur ce signet +Zoom To Tile Layer Zoomer sur la couche de tuiles Zoom to Tokyo Zoomer sur Tokyo Zoom To World Zoomer sur le monde +Zoom {z} · Bearing {b}° · Pitch {p}° Zoom {z} · Orientation {b}° · Inclinaison {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Zoom : le niveau de zoom de la carte, tel que le comptent les cartes web. Orientation : la direction vers laquelle pointe le haut de la carte, dans le sens horaire depuis le nord. Inclinaison : l'écart par rapport à une vue verticale. Cliquez pour réinitialiser l'orientation et l'inclinaison. {agent} was stopped by you. Vous avez arrêté {agent}. {id} changed since it was installed; install it again from the Plugin Manager. {id} a été modifiée depuis son installation ; réinstallez-la depuis le Gestionnaire d'extensions. {names}: needs Node.js (npx) for its ACP adapter. {names} : nécessite Node.js (npx) pour son adaptateur ACP. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9ee2607fc..0ea0cea5e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6,6 +6,7 @@ (filtered) (disaring) + Add Stop + Tambah Persinggahan + Input + Masukan ++ New expression + Ekspresi baru + Output + Keluaran /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh Mesh 3D A 3D globe seen from space. Globe 3D yang terlihat dari luar angkasa. A blank map with a basemap Peta kosong dengan peta dasar +A call for the rustgis Python package Panggilan untuk paket Python rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. Alamat katalog (CSW 2.0.2): cari rekamannya berdasarkan kata dan, jika mau, area peta, lalu tambahkan layanan dan file yang ditautkannya. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. Batas penggunaan paket ChatGPT atau aplikasi telah tercapai. Tinjau penggunaan mingguan sebelum mencoba lagi. A ChatGPT sign-in can only be used with the OpenAI API. Masuk dengan ChatGPT hanya dapat digunakan dengan API OpenAI. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). Dataset A globe scene. Scene globe. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Lintang dan bujur (40.7, -74.0 atau 40°42'N 74°0'W), layer, penanda, atau nilai di tabel layer. Enter memilih baris yang disorot; Cari tempat mencari teks sebagai tempat A layer the model reads Layer yang dibaca model +A line for the Script pane Satu baris untuk panel Skrip A magnifying lens that follows the pointer. Lensa pembesar yang mengikuti penunjuk. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). Alamat layanan peta, fitur, atau coverage: WMS, WMTS, WFS, WCS, OGC API Features atau Tiles, atau ArcGIS FeatureServer, MapServer, ImageServer, atau VectorTileServer (atau direktori layanan, untuk dijelajahi). a name, or owner/name such as giswqs/geospatial nama, atau pemilik/nama seperti giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Endpoint bernama yang dapat Anda gunakan kembali. Kata sandi dan kunci disimpan hingga RustGIS ditutup, tidak pernah disimpan permanen. A node cannot connect to itself. Node tidak dapat terhubung ke dirinya sendiri. A notebook of text and command cells. Buku catatan berisi sel teks dan sel perintah. +A password or token in the address was left out: connections never keep secrets. Kata sandi atau token di alamat tidak disertakan: koneksi tidak pernah menyimpan rahasia. A place or an address Tempat atau alamat A project keeps at least one map Proyek selalu menyimpan setidaknya satu peta A projected coordinate system. Sistem koordinat terproyeksi. A report listing the selected layer's records, page after page. Laporan yang mencantumkan rekaman layer terpilih, halaman demi halaman. A result that only feeds other tools is removed once the model has run; add an output to keep it. Hasil yang hanya menjadi masukan alat lain dihapus setelah model dijalankan; tambahkan keluaran untuk menyimpannya. A result the model keeps, under a name Hasil yang disimpan model, dengan sebuah nama +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) Perintah rustgis-cli, untuk shell POSIX (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Impor atau penyegaran layanan sedang berjalan. +A service's layers stay in their service group. Layer suatu layanan tetap berada di grup layanannya. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Satu tugas memuat paling banyak 200 langkah: hentikan perekaman untuk membuatnya, lalu rekam sisanya sebagai tugas lain. A template with the first selected feature's values Templat dengan nilai fitur terpilih pertama +A tile layer per grid tile, each following the time slider through its dates. Satu layer ubin per ubin grid, masing-masing mengikuti penggeser waktu melalui tanggal-tanggalnya. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. Alamat layanan ubin: ubin raster dengan {z}/{x}/{y} (atau {-y}, {q}, {bbox-epsg-3857} untuk WMS), ubin vektor (.pbf) atau TileJSON, gaya MapLibre, arsip PMTiles (alamat web, atau berkas di komputer ini), atau berkas MBTiles di komputer ini. A token is set. Token sudah diatur. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Sebuah nilai, kolom, atau dua di antaranya yang digabung dengan + − × ÷. Berlaku untuk baris terpilih, atau semua baris jika tidak ada yang dipilih. a vector a pixel satu vektor per piksel a vector an image patch satu vektor per patch citra A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Penyimpanan Zarr (format 2 atau 3) melalui alamat webnya, dengan metadata terkonsolidasi, atau folder di komputer ini. Peta variabel dibaca untuk langkah yang dipilih dari dimensi lainnya, seperti waktu. +A Zarr variable's values through time at points clicked on the map. Nilai variabel Zarr sepanjang waktu di titik-titik yang diklik pada peta. +A2 landscape sheet Lembar A2 lanskap +A2 portrait sheet Lembar A2 potret +A3 landscape sheet Lembar A3 lanskap +A3 portrait sheet Lembar A3 potret +A4 landscape sheet Lembar A4 lanskap +A4 portrait sheet Lembar A4 potret +A5 Grid Grid A5 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. Sel pentagonal luas sama A5 pada suatu resolusi, di atas cakupan peta, sebuah layer, atau seluruh dunia, sebagai poligon beserta id-nya. About Tentang About plugins Tentang plugin About RustGIS Tentang RustGIS @@ -65,6 +83,7 @@ Add Tambah Add\nData Tambah\nData Add ({n}) Tambah ({n}) Add a bar chart of a layer's numeric field. Tambahkan diagram batang dari kolom numerik suatu layer. +Add a circle. Tambahkan lingkaran. Add a colorbar of a raster layer's colour scale in the map frame. Tambahkan bilah warna dari skala warna layer raster di bingkai peta. Add a description. Tambahkan deskripsi. Add a field. Tambahkan kolom. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Tambahkan tata letak halam Add a page to the presentation. Tambahkan halaman ke presentasi. Add a PNG or JPEG picture to the page. Tambahkan gambar PNG atau JPEG ke halaman. Add a presentation file. Tambahkan file presentasi. +Add a rectangle, to frame or highlight part of the page. Tambahkan persegi panjang, untuk membingkai atau menyorot bagian halaman. Add a report file, or a report template for the selected layer. Tambahkan file laporan, atau templat laporan untuk layer terpilih. Add a scale bar for the map frame. Tambahkan bilah skala untuk bingkai peta. add a second map first (Data › New Map) tambahkan peta kedua terlebih dahulu (Data › Peta Baru) Add a second map frame first Tambahkan bingkai peta kedua terlebih dahulu +Add a straight line. Tambahkan garis lurus. Add a text element. Tambahkan elemen teks. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Tambahkan peta dasar web di atas yang lain alih-alih menggantinya, untuk memadukannya dengan yang lain. Klik peta dasar yang tampil untuk melepasnya. +Add a Zarr variable first (Add Data › Zarr…). Tambahkan variabel Zarr terlebih dahulu (Tambah Data › Zarr…). Add Administrative Boundaries Tambah Batas Administratif +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Tambahkan panah. Putar dengan pegangan bulat di atasnya, atau atur sudutnya di propertinya. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. Tambahkan token Earthdata Login (Earthdata Login, di atas) untuk menampilkan GeoTIFF NASA di peta. Add an elevation raster first. Tambahkan raster elevasi terlebih dahulu. Add an elevation raster to the map first Tambahkan raster elevasi ke peta terlebih dahulu +Add an ellipse. Tambahkan elips. Add an empty layer with a ready-made schema, to draw into. Tambahkan layer kosong dengan skema siap pakai, untuk digambari. Add an input for each layer the model reads. Tambahkan masukan untuk setiap layer yang dibaca model. Add an output to keep a result under a name. Tambahkan keluaran untuk menyimpan hasil dengan sebuah nama. Add and Open Tambah dan Buka Add and Open Notebook Tambah dan Buka Notebook Add Archive Tambah Arsip +Add as Time Series Tambah sebagai Deret Waktu add at least two keyframes tambahkan setidaknya dua keyframe Add Cell Tambah Sel Add Color Ramp File… Tambah File Ramp Warna… @@ -132,6 +157,7 @@ Add Graphics Layer Tambah Layer Grafik Add Input Tambah Masukan Add Job Tambah Pekerjaan Add Keyframe Tambah Keyframe +Add Label Class Tambah Kelas Label Add Layer File Tambah File Layer Add Open Data Tambah Data Terbuka Add Output Tambah Keluaran @@ -147,9 +173,11 @@ Add Rule Tambah Aturan Add sample countries Tambah contoh negara Add Sample Data Tambah Data Contoh Add Satellite Embeddings Tambah Embedding Satelit +Add Selected ({n}) Tambah Terpilih ({n}) Add Service Layer Tambah Layer Layanan Add skill… Tambah skill… Add STAC Asset Tambah Aset STAC +Add STAC Data Cube Tambah Kubus Data STAC Add STAC Footprints Tambah Jejak STAC Add Step Tambah Langkah Add Stroke Tambah Goresan @@ -171,6 +199,7 @@ Add to Favourites Tambah ke Favorit Add to Map Tambah ke Peta Add vector data. Tambahkan data vektor. Add view Tambah tampilan +Add With Options… Tambah dengan Opsi… Add Zarr Variable Tambah Variabel Zarr Add Zarr Variable… Tambah Variabel Zarr… Add ▾ Tambah ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Agen memiliki akses penuh: mereka dapat menjalankan skrip, tugas, model, dan plugin. Klik untuk menghentikan relay MCP Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Agen di komputer ini yang terhubung (Claude Code, Codex, Cursor, dan lainnya) bekerja pada peta yang terbuka di sini dengan alat RustGIS: mereka dapat membuka dan menyimpan file serta menggunakan web. Skrip dan pengelolaan plugin tetap nonaktif. Agents then run commands, change files and use the web on this computer without asking you. Agen akan menjalankan perintah, mengubah file, dan menggunakan web di komputer ini tanpa bertanya kepada Anda. +Aggregate Points to A5 Agregasi Titik ke A5 Aggregation Agregasi Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Batas lahan pertanian yang ditemukan model Fields of the World pada citra Sentinel-2, di seluruh dunia, per tahun (Edisi ke-2, CC BY 4.0). Ini adalah prediksi model, bukan persil hasil survei. AI AI @@ -221,15 +251,18 @@ All steps done. Semua langkah selesai. All tables with geometry Semua tabel dengan geometri Allow Izinkan Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Izinkan penggunaan paket ChatGPT saat masuk. Jika workspace Anda tidak mengizinkannya, tanyakan kepada admin-nya atau pilih penyedia dengan kunci API. +Allow Feature Selection Izinkan Seleksi Fitur Allow only certain combinations of values across fields. Izinkan hanya kombinasi nilai tertentu di antara kolom. Allow such actions for this conversation Izinkan tindakan seperti ini untuk percakapan ini Allow this action? Izinkan tindakan ini? Allow this agent's RustGIS action? Izinkan tindakan RustGIS dari agen ini? Along Selected Line Sepanjang Garis Terpilih +Alt+ Alt+ Alternating bar Bilah berselang-seling Altitude Ketinggian Always allow Selalu izinkan -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3, atau layanan yang kompatibel dengan S3 (MinIO, Cloudflare R2, Wasabi…) melalui endpoint-nya. Kunci disimpan sampai RustGIS ditutup; koneksi yang diingat menyimpan nama, endpoint, dan wilayahnya, tidak pernah rahasianya. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3, atau layanan yang kompatibel dengan S3 (MinIO, Cloudflare R2, Wasabi…) melalui endpoint-nya. Kunci disimpan hingga RustGIS ditutup; koneksi tersimpan menyimpan nama, endpoint, dan wilayahnya, tidak pernah rahasianya. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Agen di komputer ini meminta untuk: An AWS profile Profil AWS @@ -248,6 +281,7 @@ API key Kunci API Apply Terapkan Apply a function to a raster Terapkan fungsi pada raster Apply Control Points Terapkan Titik Kontrol +Apply elevation Terapkan elevasi Apply Style Terapkan Gaya Apply to layers Terapkan ke layer Apply to the selected layer Terapkan pada layer terpilih @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Area penggunaan: dalam {r}° dari Area {area} · Perimeter {perimeter} Luas {area} · Keliling {perimeter} Areas (name, land use, area) Area (nama, penggunaan lahan, luas) Arrange Atur +Arrow Panah +Arrowheads Kepala panah Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Ajukan pertanyaan tentang data Anda, atau jelaskan apa yang harus dilakukan pada peta. Asisten menjalankan perintah RustGIS untuk Anda; setiap perubahan dapat diurungkan. Ask about the map, or tell it what to do… Tanyakan tentang peta, atau beri tahu apa yang harus dilakukan… Ask about your data, or tell the assistant what to do with the map. Tanyakan tentang data Anda, atau beri tahu asisten apa yang harus dilakukan pada peta. @@ -275,12 +311,13 @@ Asking NASA's CMR… Menanyakan ke CMR NASA… Asking the catalog… Menanyakan ke katalog… Asking the NLDI… Menanyakan ke NLDI… Assistant settings Pengaturan asisten +At the end Di akhir +At the start Di awal Atlas Atlas Attachments Lampiran Attribute\nTable Tabel\nAtribut Attribute Rules Aturan Atribut Attribute Table Tabel Atribut -Attribute table Tabel atribut Attributes Atribut Attribution Atribusi Aurora Aurora @@ -295,11 +332,9 @@ Automatic (system language) Otomatis (bahasa sistem) Automatic (system time zone and region) Otomatis (zona waktu dan wilayah sistem) Automatic ({kind}) Otomatis ({kind}) Avoid Hindari -AWS AWS AWS, anonymous AWS, anonim Azimuth Azimut Back Kembali -Back to Explore; cancel a sketch Kembali ke Jelajah; batalkan sketsa Back to the Map Kembali ke Peta Back to the map Kembali ke peta Back to tools Kembali ke alat @@ -314,8 +349,6 @@ Basemap Peta dasar Basemaps Peta dasar Basin from hydrolocation DAS dari hidrolokasi Bearing Arah -Bearing {b}° · Pitch {p}° Arah {b}° · Kemiringan {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Arah: arah yang dihadap bagian atas peta, searah jarum jam dari utara. Kemiringan: tingkat miring dari pandangan tegak lurus ke bawah. Klik untuk mengatur ulang keduanya. Before the event Sebelum kejadian Below Bawah Below 1:5,000,000 show everything; beyond it only what matches: Di bawah 1:5.000.000 tampilkan semuanya; di atasnya hanya yang cocok: @@ -328,10 +361,12 @@ Bilinear Bilinear Bind To Time Slider Ikat ke Penggeser Waktu Bind to Time Slider Ikat ke penggeser waktu Blank Kosong +Blank for Amazon S3 Kosongkan untuk Amazon S3 Blank Letter page Halaman Letter kosong Blank Map Peta kosong Blank Page Halaman Kosong Blend: {mode} Campuran: {mode} +Block Feature Selection Blokir Seleksi Fitur Blue Biru Bold Tebal Bold label text. Teks label tebal. @@ -339,6 +374,7 @@ Bookmark Description Deskripsi Penanda Bookmarks Penanda Bookmarks Pane Panel Penanda Boolean Boolean +Both ends Kedua ujung Bottom Bawah Boundaries… Batas… Bounding\nGeometry Geometri\nBatas @@ -346,6 +382,7 @@ Brightness Kecerahan Brightness, contrast, saturation and sepia for the whole map. Kecerahan, kontras, saturasi, dan sepia untuk seluruh peta. Bring this bookmark's map into view to update it Tampilkan peta penanda ini untuk memperbaruinya Browse Jelajahi +Browse Connection Jelajahi Koneksi Browse it in the Browser Jelajahi di Peramban Browse Plugins Jelajahi Plugin Browse the geoprocessing toolbox. Jelajahi kotak alat geoprosesing. @@ -357,6 +394,7 @@ Browser Peramban Browser\nPane Panel\nPeramban Browser View Tampilan Peramban Bucket Bucket +Buckets Bucket Buffer Buffer Buffer A Point Buffer Sebuah Titik Buffer the cities by 200 km Buat buffer 200 km di sekitar kota-kota @@ -381,7 +419,9 @@ Calculate Field Hitung Kolom Calculate field Hitung kolom Calculate field values. Hitung nilai kolom. Calculate fields and enforce conditions on every edit. Hitung kolom dan terapkan ketentuan pada setiap suntingan. +Calculate Geometry Hitung Geometri Calculate Geometry Attributes Hitung Atribut Geometri +Calculated {rows} features {rows} fitur dihitung Calculation Perhitungan Camera Kamera Camera Effect Efek Kamera @@ -396,12 +436,14 @@ Case Field Kolom Kasus Cast Shadow Bayangan Jatuh Catalog Katalog catalog is a static STAC catalog's catalog.json or collection.json catalog adalah catalog.json atau collection.json dari katalog STAC statis +Catalogue (CSW) Katalog (CSW) Catalogue search (CSW) Pencarian katalog (CSW) Celestial Bodies Benda Langit Celestial Body Benda Langit Celestial body: {body} Benda langit: {body} Cell size Ukuran sel cell size ° ukuran sel ° +Cells Sel Center Tengah Center Map Here Pusatkan Peta di Sini Centered Tengah @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. Penggunaan paket ChatGPT sign-in failed. Check the connection and local settings, then try again. Gagal masuk ke ChatGPT. Periksa koneksi dan pengaturan lokal, lalu coba lagi. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. Penggunaan ChatGPT untuk sementara tidak tersedia. Pertahankan sesi masuk Anda dan coba lagi nanti. CHE CHE +Check Periksa Check Again Periksa Lagi Check for\nUpdates Periksa\nPembaruan Check for Updates Periksa Pembaruan @@ -428,6 +471,7 @@ Check for Updates… Periksa Pembaruan… Check the address and the service type. Periksa alamat dan jenis layanan. Check the address and your connection. Periksa alamat dan koneksi Anda. Check the address. Periksa alamat. +Check the clause, and count the features it takes. Periksa klausa, dan hitung fitur yang dicakupnya. Check the integration's client, permissions, workspace policy and route configuration. Periksa klien, izin, kebijakan ruang kerja, dan konfigurasi rute integrasi. Checking for updates… Memeriksa pembaruan… China Tiongkok @@ -455,6 +499,7 @@ Choose which layers a click identifies. Pilih layer mana yang diidentifikasi ole Choose which tabs the ribbon shows. Pilih tab yang ditampilkan ribbon. Circle Lingkaran Circle Radius (km) Radius Lingkaran (km) +Class Kelas Class color Warna kelas Class Style Gaya Kelas Classes Kelas @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Klik untuk menambah ver Click to edit the description Klik untuk mengedit deskripsi Click to place a point graphic. Klik untuk menempatkan grafik titik. Click to select the node Klik untuk memilih node +Click to select, double-click to add, right-click for more Klik untuk memilih, klik ganda untuk menambahkan, klik kanan untuk opsi lain Click to switch between decimal degrees and DMS Klik untuk beralih antara derajat desimal dan DMS Clip Potong (Clip) Clip a raster to the view, or resample it. Potong raster ke tampilan, atau lakukan resampling. @@ -532,7 +578,7 @@ Close thread Tutup utas Close {name} Tutup {name} Closest Terdekat Coded values, one per line: code=description Nilai berkode, satu per baris: kode=deskripsi -Collapse or expand the ribbon Ciutkan atau perluas ribbon +Collapse or Expand the Ribbon Ciutkan atau Perluas Ribbon Collapse the ribbon (Ctrl+F1) Ciutkan ribbon (Ctrl+F1) Collection Koleksi Collection {id}: choose dates and Find Items Koleksi {id}: pilih tanggal lalu Cari Item @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Kombinasi dari kotak di atas, satu per baris COMID {comid} COMID {comid} Comma Koma +Comma-separated (optional) Dipisahkan koma (opsional) Command\nSearch Cari\nPerintah command is required command wajib diisi Command Search Cari Perintah @@ -572,10 +619,13 @@ Connect Hubungkan Connect a folder to the Browser. Hubungkan folder ke Peramban. Connect to S3 Hubungkan ke S3 Connect, or pick a layer Hubungkan, atau pilih layer +Connected to {kind} {title} Terhubung ke {kind} {title} Connecting to {url}… Menghubungkan ke {url}… Connection Koneksi +connection.new returned no id connection.new tidak mengembalikan id Connections Koneksi -Connect… Hubungkan… +Connections you add are listed here Koneksi yang Anda tambahkan tercantum di sini +Connections… Koneksi… Constant Konstanta Constant height Tinggi konstan Constant… Konstanta… @@ -583,11 +633,14 @@ Contingent Values Nilai Kontingen Continue Lanjutkan Continue with ChatGPT Lanjutkan dengan ChatGPT Contrast Kontras +Control Point Fit Kecocokan Titik Kontrol Conversion Konversi Convert Konversi Convert a layer to another format Konversi layer ke format lain Convert the selected layer's labels into an annotation layer. Konversi label layer terpilih menjadi layer anotasi. +Convert this layer's labels into an annotation layer. Konversi label layer ini menjadi layer anotasi. Convert To Annotation Konversi ke Anotasi +Convert to Annotation Konversi ke Anotasi Converted data Data hasil konversi Convert… Konversi… Coordinate Koordinat @@ -599,11 +652,15 @@ Copied the map to the clipboard Peta disalin ke papan klip Copied {path} {path} disalin Copies the (selected) features of a layer to a new layer. Menyalin fitur (terpilih) dari suatu layer ke layer baru. Copy Salin +Copy / Cut / Paste features Salin / Potong / Tempel fitur Copy a model.run command that runs this model, for the Script pane or Python Salin perintah model.run yang menjalankan model ini, untuk panel Skrip atau Python Copy Address Salin Alamat Copy address Salin alamat Copy an image of the map to the clipboard. Salin gambar peta ke papan klip. +Copy as CLI Command Salin sebagai Perintah CLI +Copy as Command Salin sebagai Perintah Copy As GeoJSON Salin sebagai GeoJSON +Copy as Python Salin sebagai Python Copy Command Salin Perintah Copy coordinates to the clipboard Salin koordinat ke papan klip Copy details Salin detail @@ -611,11 +668,13 @@ Copy Group Salin Grup Copy Layer Salin Layer Copy Layers Salin Layer Copy Path Salin Path +Copy Run As Code Salin Jalankan sebagai Kode Copy sign-in link Salin tautan masuk Copy the selected features (also as GeoJSON text). Salin fitur terpilih (juga sebagai teks GeoJSON). Copy the selected features and delete them. Salin fitur terpilih lalu hapus. Copy the selected layer's data source path. Salin path sumber data layer terpilih. Copy this element (Ctrl+D, ⌘D on a Mac) Salin elemen ini (Ctrl+D, ⌘D di Mac) +Copy this run as code, or open it in its tool Salin jalannya ini sebagai kode, atau buka di alatnya Corrections Koreksi Cost Biaya could not draw the map tidak dapat menggambar peta @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Tidak dapat memuat registri plugin: { Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Jumlah, total, rata-rata, min, maks, dan simpangan baku suatu kolom, opsional per kolom kasus. Countries, cities, rivers and lakes Negara, kota, sungai, dan danau Country Negara +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Menghitung titik suatu layer menurut sel A5 yang memuatnya (sel berluas sama, sehingga jumlahnya adalah kepadatan), beserta total dan rata-rata suatu kolom. Counts the points that fall inside each polygon. Menghitung titik yang berada di dalam setiap poligon. Counts, totals and top categories of what's in view. Jumlah, total, dan kategori teratas dari yang terlihat. CPU CPU @@ -647,12 +707,8 @@ Cross Section Penampang Melintang Cross Section (2 clicks) Penampang Melintang (2 klik) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Kumulatif Current date Tanggal saat ini Current Project Proyek Saat Ini @@ -680,13 +736,16 @@ Data type Tipe data Data you add is listed here Data yang Anda tambahkan tercantum di sini data.gov and CKAN catalogues Katalog data.gov dan CKAN Database Basis Data -Databases Basis Data +Database connections are used by the desktop app Koneksi basis data digunakan oleh aplikasi desktop +databases are read through DuckDB, a program the browser can't run basis data dibaca melalui DuckDB, program yang tidak dapat dijalankan peramban Database… Basis Data… Dataset Dataset Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Dataset di Hugging Face berdasarkan nama atau pemilik/nama; jelajahi file dataset dan tambahkan GeoTIFF, GeoParquet, PMTiles, LiDAR, dan file lainnya. Data… Data… Date Tanggal Dates Tanggal +Dates a cell needs Tanggal yang dibutuhkan sel +Dates a cell needs is a whole number, or empty for half Tanggal yang dibutuhkan sel berupa bilangan bulat, atau kosong untuk separuh Day Hari Day and night shading for a time. Bayangan siang dan malam untuk suatu waktu. Decimal Desimal @@ -715,7 +774,6 @@ Delete Notebook Hapus Notebook Delete Page Hapus Halaman Delete Presentation Hapus Presentasi Delete Report Hapus Laporan -Delete selected features Hapus fitur terpilih Delete selected rows Hapus baris terpilih Delete Style Hapus Gaya Delete Task Hapus Tugas @@ -736,11 +794,15 @@ Desktop and command line only Hanya aplikasi desktop dan baris perintah Details Detail Details of the selected layer. Detail layer terpilih. Deviations Simpangan +DGGS DGGS Diagnostic\nMonitor Monitor\nDiagnostik Diagnostic Monitor Monitor Diagnostik Diagnostics Diagnostik +Diagonal ↗ Diagonal ↗ +Diagonal ↘ Diagonal ↘ Diamond Belah ketupat Difference, ratio or percent change between two rasters. Selisih, rasio, atau persentase perubahan antara dua raster. +Digit Digit Directions Petunjuk Arah Directions From Here Petunjuk Arah dari Sini Directions To Here Petunjuk Arah ke Sini @@ -758,6 +820,7 @@ Dissolve Field Kolom Dissolve Distance Jarak Distance (km) Jarak (km) Distance Coefficient P Koefisien Jarak P +Distance units Satuan jarak Distances along the surface, heights, areas and volumes. Jarak di sepanjang permukaan, ketinggian, luas, dan volume. DISTINCT DISTINCT Distinct Unik @@ -804,18 +867,23 @@ Drag to draw Seret untuk menggambar Drag to draw a freehand line. Seret untuk menggambar garis bebas. Drag to draw a freehand polygon. Seret untuk menggambar poligon bebas. Drag to draw a rectangle. Seret untuk menggambar persegi panjang. +Drag to resize the message box Seret untuk mengubah ukuran kotak pesan Drag tools from the palette onto the canvas. Seret alat dari palet ke kanvas. Drag vertices on the map; click an edge to add one Seret verteks di peta; klik tepi untuk menambah verteks Draw a halo around label text. Gambar halo di sekitar teks label. Draw a labelled latitude/longitude grid over the map frame. Gambar grid lintang/bujur berlabel di atas bingkai peta. Draw a model: chain tools from input layers to the results to keep Gambar model: rangkai alat dari layer masukan ke hasil yang akan disimpan Draw a model: chain tools from input layers to the results to keep. Gambar model: rangkai alat dari layer masukan ke hasil yang akan disimpan. +Draw at its place in the stack, the bottom stroke first Gambar di posisinya dalam tumpukan, goresan terbawah lebih dulu +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Gambar goresan satu level dari setiap fitur sebelum level berikutnya, agar casing menyatu di bawah inti saat garis bertemu Draw labels at every scale and distance. Gambar label di setiap skala dan jarak. Draw new features in the selected layer. Double-click to finish lines and polygons. Gambar fitur baru di layer terpilih. Klik ganda untuk menyelesaikan garis dan poligon. Draw new single-band rasters with the inverted grey stretch again. Gambar raster band tunggal baru kembali dengan peregangan abu-abu terbalik. +Draw only polygon outlines, keeping their colors and widths. Gambar garis tepi poligon saja, dengan mempertahankan warna dan lebarnya. Draw points as bins coloured by how many fall in each. Gambar titik sebagai bin yang diwarnai menurut jumlah titik di setiap bin. Draw polygons as outlines only. Gambar poligon hanya sebagai garis tepi. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Gambar raster band tunggal yang ditambahkan mulai sekarang dengan skema warna, peregangan, dan resampling ini. +Draw strokes in levels Gambar goresan per level Draw the layer at every scale. Gambar layer pada semua skala. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Gambar peta di GPU (beta; globe, efek tampilan, dan simulasi penglihatan warna tetap di perender CPU). Draw the selected layer above others. Gambar layer terpilih di atas layer lain. @@ -828,6 +896,7 @@ drawn by the server digambar oleh server Drive Mobil Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. Citra drone dan udara yang dibagikan di OpenAerialMap, terbaru lebih dulu, ditambahkan sebagai GeoTIFF yang dioptimalkan untuk cloud. Duplicate Duplikat +Duplicate Connection Duplikat Koneksi Duplicate Element Duplikat Elemen Duplicate Layout Duplikat Tata Letak Duplicate Map Duplikat Peta @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name mis. pop_est / 1000000 at e.g. 0, 255, -9999 mis. 0, 255, -9999 e.g. continent = 'Europe' mis. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' mis. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 mis. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Setiap proses geoprosesing menjadi satu langkah, dirangkai melalui keluarannya Earth Index has no points in the view Earth Index tidak memiliki titik di tampilan Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Edit\nVerteks Edit and run the notebook. Edit dan jalankan notebook. Edit Attribute Edit Atribut Edit Cell Edit Sel +Edit Connection Edit Koneksi Edit Description Edit Deskripsi Edit Graphic Edit Grafik Edit Status Status Edit @@ -882,6 +953,7 @@ Editor\nTools Alat\nEditor Editor Tools Alat Editor Edits since the project was saved. Suntingan sejak proyek disimpan. Edits the project Mengedit proyek +Edit… Edit… Effects Efek Element Elemen Element Properties Properti Elemen @@ -898,21 +970,25 @@ Enable Lighting Aktifkan Pencahayaan Enable Plugin Aktifkan Plugin Enabled Aktif Encloses each feature, or all features, in its convex hull. Melingkupi setiap fitur, atau semua fitur, dengan convex hull-nya. +Encoded polyline Polyline terenkode End Akhir End date Tanggal akhir Endpoint Endpoint Enhancement Peningkatan Enter Enter Enter an API key. Masukkan kunci API. +Enter an EPSG code as a number, such as 32650 Masukkan kode EPSG sebagai angka, misalnya 32650 Enter the provider's address. Masukkan alamat penyedia. Enter to send, Shift+Enter for a new line Enter untuk mengirim, Shift+Enter untuk baris baru EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Lingkungan EPSG EPSG +EPSG code Kode EPSG EPSG or ESRI code Kode EPSG atau ESRI EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Interval Sama Equirectangular (Plate Carrée) Ekuirektangular (Plate Carrée) @@ -922,14 +998,18 @@ Error Galat Error Inspector Pemeriksa Galat Error Inspector (validate) Pemeriksa Galat (validasi) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Eropa Luas Sama (ETRS89 LAEA) Every band's value at a pixel. Nilai setiap band pada suatu piksel. Every editing tool. Semua alat penyuntingan. +Every label setting of this layer, in the Labeling pane. Semua pengaturan label layer ini, di panel Pelabelan. Every layer, as FEMA draws them Setiap layer, seperti yang digambar FEMA +Every run, in order, as a script that opens the project Setiap jalannya, secara berurutan, sebagai skrip yang membuka proyek every Socrata site, or one such as data.cityofnewyork.us semua situs Socrata, atau satu situs seperti data.cityofnewyork.us Everywhere Di mana saja +Existing field Kolom yang ada Existing Template Templat yang Ada exit rotary keluar dari bundaran exit roundabout keluar dari bundaran @@ -948,6 +1028,7 @@ Export\nRaster Ekspor\nRaster Export Animation Ekspor Animasi Export Bookmarks Ekspor Penanda Export Bookmarks… Ekspor Penanda… +Export CSV… Ekspor CSV… Export Features Ekspor Fitur Export Features (tool) Ekspor Fitur (alat) Export Features needs a vector layer (Data › Export Raster writes a raster) Ekspor Fitur memerlukan layer vektor (Data › Ekspor Raster menulis raster) @@ -996,6 +1077,7 @@ Face Culling Face Culling Faces Muka Factor Faktor Failed Gagal +FALSE FALSE Farthest Terjauh Faster drawing: no antialiasing. Penggambaran lebih cepat: tanpa antialiasing. Favourite Data Source Sumber Data Favorit @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Batas lahan Field density Kepadatan lahan Field name Nama kolom +Field Prefix Awalan Kolom +Field to Sum and Average Kolom untuk Total dan Rata-rata Fields Kolom Fields are drawn from zoom 9: zoom in to see them. Lahan digambar mulai zoom 9: perbesar untuk melihatnya. Fields colored by score, drawn from zoom 9 Lahan diwarnai menurut skor, digambar mulai zoom 9 @@ -1079,6 +1163,8 @@ Folder Connections Koneksi Folder folder connections need the desktop app koneksi folder memerlukan aplikasi desktop Folders Folder Folder… Folder… +Follow the time slider Ikuti penggeser waktu +Font Fon for a store that doesn't say untuk penyimpanan yang tidak menyebutkannya Forest Hutan Forget Earthdata Login Token Lupakan Token Earthdata Login @@ -1090,9 +1176,11 @@ France Prancis Free the prepared geometry. Bebaskan geometri yang telah disiapkan. Freehand Bebas FROM FROM +From Dari From Selection Dari Seleksi From Service… Dari Layanan… From symbol Dari simbol +From the address Dari alamat from the address dari alamat from the service dari layanan From URL… Dari URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Sembunyikan fitur yang jauh dari pusat Hide Group Sembunyikan Grup Hide labels when the viewer is closer than this. Sembunyikan label saat penampil lebih dekat dari nilai ini. Hide labels when the viewer is farther than this. Sembunyikan label saat penampil lebih jauh dari nilai ini. +Hide Layers Sembunyikan Layer Hide other values Sembunyikan nilai lain Hide Steps Sembunyikan Langkah Hide the description Sembunyikan deskripsi @@ -1213,6 +1302,8 @@ Hold Tahan HOME HOME Home Beranda Home › Add Beranda › Tambah +Home › Add Data Beranda › Tambah Data +Home › Inspect › Measure Beranda › Periksa › Ukur Home › Navigate Beranda › Navigasi Home › Navigate › Bookmarks Beranda › Navigasi › Penanda Home › Web Data Beranda › Data Web @@ -1220,7 +1311,7 @@ Home › Web Data › United States Beranda › Data Web › Amerika Serikat Homepage Beranda Horizontal Horizontal host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=…, atau file .duckdb +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Citra Terhosting Hour Jam How the layer's colours combine with the tile layers and basemap beneath. Cara warna layer berpadu dengan layer ubin dan peta dasar di bawahnya. @@ -1248,6 +1339,7 @@ Imagery Sharing Berbagi Citra Imagery Tools Alat Citra ImageServer ImageServer Image… Gambar… +Imperial Imperial Import Impor Import\nProject Impor\nProyek Import All Impor semua @@ -1267,6 +1359,7 @@ Import Task Impor Tugas Import Task… Impor Tugas… Import Thumbnail Impor Thumbnail Import… Impor… +IN IN In Beyond Perbesar Melewati In memory Di memori In Progress Sedang Berjalan @@ -1281,6 +1374,8 @@ Information Informasi INPUT MASUKAN Input Masukan Input Features Fitur Masukan +Input Layer Layer Masukan +Input Points Titik Masukan Input Polygons Poligon Masukan Input Rows Baris Masukan Input Summary Features Fitur Ringkasan Masukan @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Plugin yang terpasang disimpan di {folder Installed with Homebrew, winget, the AUR or COPR? Update it there. Dipasang dengan Homebrew, winget, AUR, atau COPR? Perbarui di sana. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} telah dipasang. Temukan di tab Plugin; alat-alatnya juga ada di panel Alat. Installing it downloads Python {version} and the packages it needs from the internet. Memasangnya akan mengunduh Python {version} dan paket yang diperlukan dari internet. +Integer field: values are rounded. Kolom bilangan bulat: nilai dibulatkan. Interface font: {font} Fon antarmuka: {font} Interpolate a surface from points (IDW) Interpolasi permukaan dari titik (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Menginterpolasi permukaan raster 2 band (Prediksi dan Varians) dari fitur titik menggunakan Ordinary Kriging. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. Menginterpolasi permukaan raster dari nilai fitur dengan pembobotan jarak terbalik (IDW), seperti di QGIS. Interpolation Interpolasi Interpolation Attribute Atribut Interpolasi @@ -1316,10 +1413,13 @@ Invalid: {error} Tidak valid: {error} Invert Balik Invert color scheme Balik skema warna Invert the selection. Balik seleksi. +IS IS IS NULL IS NULL it connects to an address on the internet terhubung ke alamat di internet It has no code: nothing in it runs. Tidak berisi kode: tidak ada yang dijalankan. +It is locked, or one of its surrounds is: unlock it first Elemen ini atau salah satu elemen pelengkapnya terkunci: buka kuncinya terlebih dahulu it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) hanya boleh dilakukan oleh Anda (plugin, skrip, tugas, model, dan runner GeoLibre) +it needs input: open it from its pane or button, or run it from a script or the assistant perlu masukan: buka dari panel atau tombolnya, atau jalankan dari skrip atau asisten it reads or writes files on this computer membaca atau menulis file di komputer ini it replaces the open project mengganti proyek yang terbuka it runs a plugin's command menjalankan perintah plugin @@ -1332,20 +1432,30 @@ Italic Miring Item Pop-ups Pop-up Item Item {id} Item {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. Firewall-nya ({firewall}) memblokir RustGIS. Layanan ini mungkin hanya mengizinkan beberapa aplikasi: hubungi administratornya dan kirimkan detail di bawah. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Alatnya tidak tersedia sekarang (milik plugin yang tidak dimuat, atau milik suite yang tidak dipasang) Jobs Pekerjaan JOIN JOIN Join Sambungan +Join Features Fitur Gabungan +Join Operation Operasi Gabungan +Joins attributes from a join layer to a target layer based on their spatial relationship. Menggabungkan atribut dari layer gabungan ke layer target berdasarkan hubungan spasialnya. +Journal figure, 1 column (90 mm) Gambar jurnal, 1 kolom (90 mm) +Journal figure, 1.5 columns (140 mm) Gambar jurnal, 1,5 kolom (140 mm) +Journal figure, 2 columns (190 mm) Gambar jurnal, 2 kolom (190 mm) Keep a history of every edit to the layer, to look back or restore. Simpan riwayat setiap suntingan pada layer, untuk ditinjau atau dipulihkan. Keep an archive of edits Simpan arsip suntingan keep current biarkan saat ini +Keep it from being moved, resized, deleted or edited by mistake Cegah agar tidak dipindahkan, diubah ukurannya, dihapus, atau diedit secara tidak sengaja Keep one value of each point's vector as its value field Simpan satu nilai dari vektor setiap titik sebagai kolom nilainya Keep Result Simpan Hasil Keep the labelled features fixed as the map moves. Pertahankan fitur berlabel tetap saat peta bergerak. Keep the last drawing tool active after each graphic. Biarkan alat gambar terakhir tetap aktif setelah setiap grafik. Keep the last tool active Biarkan alat terakhir tetap aktif +Keep this endpoint under Data Sources › Connections, without its password or keys Simpan endpoint ini di Sumber Data › Koneksi, tanpa kata sandi atau kuncinya Keep this result Simpan hasil ini Keep Tool Pertahankan Alat Keep value Simpan nilai +Key Key Keyboard Shortcuts Pintasan Keyboard Keyboard shortcuts. Pintasan keyboard. Keys from Kunci dari @@ -1356,13 +1466,20 @@ KMZ KMZ Label Label Label Class Kelas Label Label Distance Range Rentang Jarak Label +Label features in this layer Beri label fitur di layer ini Label Field Kolom Label +Label only the features that match. Beri label hanya pada fitur yang cocok. Label Placement Penempatan Label Label Range Rentang Label Label the cities with their names Beri label nama pada kota-kota Label Visibility Range Rentang Visibilitas Label Labeling Pelabelan +Labeling Pane Panel Pelabelan +Labeling Properties… Properti Pelabelan… Labels Label +labels are drawn for feature layers, not rasters label digambar untuk layer fitur, bukan raster +Labels are drawn for feature layers, not rasters. Label digambar untuk layer fitur, bukan raster. +Labels are off for this layer: the clause applies once they are on. Label untuk layer ini nonaktif: klausa berlaku setelah label diaktifkan. Lagoon Laguna Lambert Conformal Conic Kerucut Konformal Lambert Lambert Cylindrical Equal Area Silinder Luas Sama Lambert @@ -1383,12 +1500,14 @@ Layer Blend Campuran Layer Layer Extrusion Ekstrusi Layer Layer File File Layer Layer From\nSelection Layer dari\nSeleksi +Layer not found Layer tidak ditemukan Layer Template Templat Layer Layer Templates Templat Layer layer {id} layer {id} Layers Layer Layers ({n}) Layer ({n}) Layers and Table Views Layer dan Tampilan Tabel +Layers, a date each Layer, masing-masing satu tanggal Layout Tata Letak Layout\nFile File\nTata Letak Layout after: {name} Tata letak sesudah: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Sumber\nBelajar Left Kiri Legend Legenda Legend label. Clear it to show the value again. Label legenda. Kosongkan untuk menampilkan nilainya lagi. -Length {length} · gain {gain} m · loss {loss} m Panjang {length} · naik {gain} m · turun {loss} m +Length {length} · gain {gain} · loss {loss} Panjang {length} · naik {gain} · turun {loss} Let the assistant hand tasks to agents on this computer Izinkan asisten menyerahkan tugas ke agen di komputer ini Let your Agents Control RustGIS Biarkan agen Anda mengendalikan RustGIS Letter spacing Spasi huruf @@ -1439,6 +1558,7 @@ List by drawing order Daftar menurut urutan gambar List by selection Daftar menurut seleksi List Columns in File Daftar Kolom dalam Berkas List Commands Daftar Perintah +List Connections Daftar Koneksi List Data Sources Daftar Sumber Data List Database Columns Daftar Kolom Basis Data List Database Tables Daftar Tabel Basis Data @@ -1475,6 +1595,11 @@ Locate… Temukan… Location Lokasi Locator Lokator Lock Kunci +Locked Terkunci +Locked: unlock it in the Format pane to move or change it Terkunci: buka kuncinya di panel Format untuk memindahkan atau mengubahnya +Log In to Connection Masuk ke Koneksi +Log In to {name}… Masuk ke {name}… +Log In… Masuk… Long Long Longitude Bujur Longitude (X) Bujur (X) @@ -1493,6 +1618,7 @@ Manage Kelola Manage Bookmarks Kelola Penanda Manage Edits Kelola Edit Manage Templates Kelola Templat +Mann–Kendall Mann–Kendall Map Peta Map\nFrame Bingkai\nPeta Map (the current view) Peta (tampilan saat ini) @@ -1504,6 +1630,7 @@ Map File File Peta Map Frame Bingkai Peta Map Frames Bingkai Peta Map frames per second (live while the map moves) Frame peta per detik (langsung saat peta bergerak) +Map Labeling Pelabelan Peta Map Link Tautan Peta Map name Nama peta Map Page Halaman Peta @@ -1515,6 +1642,7 @@ Map style (JSON) Gaya peta (JSON) Map Surrounds Pelengkap Peta Map Thumbnail Thumbnail Peta Map tiles of the whole world Ubin peta seluruh dunia +Map Tool: {tool} Alat Peta: {tool} Map View Tampilan Peta map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Peta: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Marker Mask Values Mask Nilai Masking Masking +Match Option Opsi Pencocokan Matching places Tempat yang cocok Max Maks Max % Maks % +Max Neighbors Tetangga Maksimum Maximum zoom Zoom maksimum MCP Relay Relay MCP MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} Relay MCP sudah mendengarkan di 127.0.0.1:{port}; hentikan dulu untuk memakai port {asked} @@ -1542,6 +1672,8 @@ Measure Distance Ukur Jarak Measure geodesic distances and areas on the map. Ukur jarak dan luas geodesik di peta. Measure on this body, with one of its basemaps. Ukur pada benda langit ini, dengan salah satu peta dasarnya. Measure with an elevation raster Ukur dengan raster elevasi +Measurement units Satuan pengukuran +Measurement units must be metric or imperial Satuan pengukuran harus metrik atau imperial Measurements so far. Pengukuran sejauh ini. Mensuration Mensurasi Mensuration Tools Alat Mensurasi @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Menggabungk Messages appear here. Pesan muncul di sini. Meters Meter Method Metode +Metric Metrik Midnight Tengah Malam Miles Mil Miller Cylindrical Silinder Miller @@ -1560,21 +1693,43 @@ Min must be less than Max Min harus kurang dari Maks Min must not be more than Max Min tidak boleh lebih dari Maks Minimum Bounding Geometry Geometri Batas Minimum Minimum Maximum Minimum Maksimum +Minus Minus Minutes Menit minutes ago menit lalu Miter Runcing Mobile Map Peta Seluler Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Model Model\nBuilder Model\nBuilder model id id model Model output Keluaran model ModelBuilder ModelBuilder Models Model +Modified Mann–Kendall (Hamed & Rao) Mann–Kendall Termodifikasi (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Mann–Kendall Termodifikasi (Yue & Wang) Modify Ubah Modify Selected Features Ubah Fitur Terpilih Mollweide (equal-area world) Mollweide (dunia luas sama) Month Bulan +Months Bulan +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Bulan berupa angka 1 sampai 12 (12, 1, 2 untuk Desember hingga Februari) More Lainnya More basemaps: Peta dasar lainnya: Mosaic images into an orthomosaic Mosaikkan citra menjadi ortomosaik @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Mosaikkan raster yang terlihat m Most cloud cover (%) Tutupan awan maksimum (%) Mouse wheel Roda tetikus Move Pindahkan -Move (m) Pindahkan (m) Move Bookmark Pindahkan Penanda Move Down Turunkan Move down Turunkan Move Group Pindahkan Grup +Move Label Class Pindahkan Kelas Label Move Task Step Pindahkan Langkah Tugas +Move To Bottom Pindahkan ke Paling Bawah Move To Group Pindahkan ke Grup +Move To Top Pindahkan ke Paling Atas Move To {group} Pindahkan ke {group} Move Up Naikkan Move up Naikkan @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Bucket Saya My OGC Layer Layer OGC Saya +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Nama name the plugin whose panel to open: {"id": plugin id} sebutkan plugin yang panelnya akan dibuka: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Notebook\nBaru New\nPresentation Presentasi\nBaru New\nReport Laporan\nBaru New\nWorkspace Ruang Kerja\nBaru -New / Open / Save project Proyek Baru / Buka / Simpan New Bookmark Penanda Baru New Bookmark… Penanda Baru… +New Connection Koneksi Baru +New Connection ▾ Koneksi Baru ▾ New conversation Percakapan baru New Custom Template Templat Kustom Baru New Custom Template… Templat Kustom Baru… New features use this template's values Fitur baru menggunakan nilai templat ini +New field Kolom baru New Group Grup Baru New job name Nama pekerjaan baru New Layout Tata Letak Baru @@ -1649,6 +1809,7 @@ New Notebook Notebook Baru New Presentation Presentasi Baru New Project Proyek Baru New project Proyek baru +New Publication Layout Tata Letak Publikasi Baru New Relationship… Relasi Baru… New Report Laporan Baru New Selection Seleksi Baru @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Belum ada pena No catalogs below this one: Find Items lists its items Tidak ada katalog di bawah ini: Cari Item menampilkan item-nya No COMID was returned for this point. Tidak ada COMID yang dikembalikan untuk titik ini. no command tidak ada perintah -No connections this session Tidak ada koneksi di sesi ini +No data at the points picked. Tidak ada data di titik yang dipilih. No datasets match. Tidak ada dataset yang cocok. No datasets with data RustGIS can add Tidak ada dataset dengan data yang dapat ditambahkan RustGIS +no date in the name tidak ada tanggal di nama No edits since the project was saved Tidak ada suntingan sejak proyek disimpan No field of this layer holds dates. Tidak ada kolom layer ini yang berisi tanggal. +No Fill Tanpa Isian +No geometry to calculate Tidak ada geometri untuk dihitung No granules in this view and date range. Tidak ada granule di tampilan dan rentang tanggal ini. no home folder tidak ada folder home no layer tidak ada layer @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) Tidak ada relasi (Desain Data No relationships yet Belum ada relasi No reports yet. Publish › New Report lists the selected layer's records. Belum ada laporan. Publikasi › Laporan Baru mencantumkan rekaman layer terpilih. no row tidak ada baris +No serif font is installed, so this text is drawn in the sans-serif Tidak ada fon serif yang terpasang, jadi teks ini digambar dengan sans-serif no service or file links tidak ada tautan layanan atau file No services match your search. Tidak ada layanan yang cocok dengan pencarian Anda. No standalone tables Tidak ada tabel mandiri @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Tidak ada None (public buckets) Tidak ada (bucket publik) none, a .duckdb file, or host=… dbname=… user=… tidak ada, file .duckdb, atau host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD tidak ada, atau dari PGPASSWORD atau MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD tidak ada, atau dari PGPASSWORD, MYSQL_PWD, atau SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. Tidak ada. Tambahkan melalui alamat, atau dari plugin peta dasar. North Utara North\nArrow Panah\nUtara North Polar Stereographic Stereografis Kutub Utara +NOT NOT not a picture bukan gambar not a RustGIS layout bukan tata letak RustGIS not a RustGIS project bukan proyek RustGIS +Not applied yet. Belum diterapkan. Not for texts: a text is edited in the Format pane. Bukan untuk teks: teks diedit di panel Format. not needed tidak diperlukan Not sandboxed Tanpa sandbox @@ -1739,9 +1906,12 @@ Nothing found where the map is Tidak ada yang ditemukan di lokasi peta Nothing here Tidak ada apa pun di sini Nothing named {name} to rename Tidak ada yang bernama {name} untuk diganti namanya Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Belum ada yang dibagikan. Publikasi › Bagikan Sebagai membuat peta web, layer, gaya, scene, dan lainnya. +Nothing to add here Tidak ada yang bisa ditambahkan di sini +Nothing to test Tidak ada yang bisa diuji Nothing was found at this address. Tidak ada yang ditemukan di alamat ini. Nothing was recorded, so no task was made. Tidak ada yang direkam, jadi tidak ada tugas yang dibuat. Now Sekarang +Nugget Nugget NULL NULL Nulls Null Observations (observer, date, species, count) Observasi (pengamat, tanggal, spesies, jumlah) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. Di desktop, kunci juga dapat On-screen compass, zoom slider and pan pad. Kompas di layar, penggeser zoom, dan pad geser. One command per line: id {json}. Lines starting with # are comments. Satu perintah per baris: id {json}. Baris yang diawali # adalah komentar. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Satu SELECT DuckDB: fungsi ST_ dari DuckDB Spatial, file dan alamat web (read_parquet('…')), serta tabel dari basis data di bawah. Ctrl+Enter menjalankannya. +One layer: a Zarr variable or a raster's bands Satu layer: variabel Zarr atau band suatu raster One page per feature of an index layer. Satu halaman per fitur dari layer indeks. One per line: code=name Satu per baris: kode=nama One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Satu goresan, dari simbol. Tambahkan goresan untuk casing, garis paralel, dan offset. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Buka folder Open In Geoprocessing… Buka di Geoprosesing… Open in ModelBuilder Buka di ModelBuilder +Open in Tool Buka di Alat Open Layout Buka Tata Letak Open ModelBuilder Buka ModelBuilder Open Notebook Buka Notebook @@ -1797,6 +1969,7 @@ Open Presentation Buka Presentasi Open Project Buka Proyek Open project Buka proyek Open Project (from text) Buka Proyek (dari teks) +Open Project… Buka Proyek… Open Report Buka Laporan Open the attribute table of the selected layer. Buka tabel atribut layer terpilih. Open the Basemaps tab Buka tab Peta dasar @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Atau jalankan di shell POSIX, Or run in PowerShell, for Claude Code or Codex: Atau jalankan di PowerShell, untuk Claude Code atau Codex: Or run, for Claude Code or Codex: Atau jalankan, untuk Claude Code atau Codex: ORDER BY ORDER BY +Ordinary Kriging Ordinary Kriging Orientation Orientasi Ortho Mapping Pemetaan Orto Other Lainnya Other Data Data Lainnya +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Kelas label lain di layer ini (dibuat dengan perintah): {n}. Masing-masing memberi label pada fitur yang cocok dengan klausanya sendiri, sehingga satu fitur bisa mendapat label dari lebih dari satu kelas. Out Beyond Perkecil Melewati Outline Garis tepi Outline another frame's extent in this map frame. Gambar garis cakupan bingkai lain di bingkai peta ini. @@ -1851,6 +2026,7 @@ Output Keluaran Output Name Nama Keluaran Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Lebih dari 1.000 alat raster, vektor, LiDAR, hidrologi, dan medan yang berjalan di peramban, dengan unduhan sekali sekitar 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Lebih dari 1.000 alat raster, vektor, LiDAR, hidrologi, dan medan, dari unduhan opsional sekitar 20 MB. +Overlapping Tumpang tindih Overlay Overlay overlay overlay Overlay (a transparent layer kept on top, such as labels or roads) Overlay (layer transparan yang tetap di atas, seperti label atau jalan) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture tidak memiliki data {ty} di Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Data terbuka Overture Maps: bangunan, tempat, transportasi, pembagian administratif, layer dasar, dan alamat. Di area peta, fiturnya dibaca di tempat dari file GeoParquet Overture (perbesar ke kota atau lebih kecil); seluruh dunia tersedia sebagai ubin peta. Overture Maps… Overture Maps… +Overwrite existing values Timpa nilai yang ada Package Paket Package a trained deep learning model. Kemas model deep learning yang telah dilatih. Package Map Kemas Peta @@ -1907,8 +2084,12 @@ Performance Performa Phase Fase Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Pilih nilai langsung, seperti skala atau tanggal, lalu klik peta untuk menempatkannya. +Pick a Point Pilih Titik +Pick a point on the map to see the variable's values through time there. Pilih titik di peta untuk melihat nilai variabel sepanjang waktu di sana. Pick a route in the list, or click a grey route on the map. Pilih rute di daftar, atau klik rute abu-abu di peta. +Pick a single layer. Pilih satu layer. Pick it on the map Pilih di peta +Pick one… Pilih salah satu… Picture Gambar Pictures Gambar Pictures, attachments and chart Gambar, lampiran, dan diagram @@ -1966,8 +2147,11 @@ Portal Portal Portolan Portolan Portolan… Portolan… Portrait Potret +Position Posisi +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Pascaproses power 2 pangkat 2 +Precision 6 (OSRM, Valhalla) Presisi 6 (OSRM, Valhalla) Prepare Siapkan Prepare every layer's geometry for this projection. Siapkan geometri setiap layer untuk proyeksi ini. Prepare Plugin Environment Siapkan Lingkungan Plugin @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Templat proyek: mulai proy project's path is required path project wajib diisi Projection Proyeksi Projections Proyeksi +Properties Properti +Property Properti Proportional Symbols Simbol Proporsional Provider Penyedia +Provinces: Provinsi: Proximity Kedekatan Public GIS portals of US counties and cities, by state. Portal SIG publik county dan kota di AS, menurut negara bagian. Public GIS portals of US federal agencies, by department. Portal SIG publik lembaga federal AS, menurut departemen. Public GIS portals of US states and the District of Columbia. Portal SIG publik negara bagian AS dan District of Columbia. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. Layer publik di ArcGIS Online atau portal ArcGIS lainnya: layanan fitur, peta, citra, dan ubin vektor, peta web, dan file GeoJSON. +Publication Layout Tata Letak Publikasi Publish Publikasi Publish › Output Publikasi › Keluaran Put the layer back as it was then Kembalikan layer seperti pada saat itu Python environment failed: {error} Lingkungan Python gagal: {error} Python environment not prepared Lingkungan Python belum disiapkan Python environment ready Lingkungan Python siap +Python script Skrip Python Qualitative Kualitatif Quantile Kuantil Query files, web addresses and databases with DuckDB Spatial; add results to the map. Kueri file, alamat web, dan basis data dengan DuckDB Spatial; tambahkan hasilnya ke peta. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Tinggikan poligon dengan nilai tinggi ata Raise Priority Naikkan Prioritas Random points in polygons Titik acak di dalam poligon Range Rentang +Range (Degrees) Jangkauan (Derajat) +range: 0, 0.9 rentang: 0, 0.9 Raster Raster Raster\nAnalysis Analisis\nRaster Raster\nInformation Informasi\nRaster @@ -2075,6 +2266,7 @@ Redo {action} Ulangi {action} Red–Blue Merah–Biru Refine the search to see {n} more Persempit pencarian untuk melihat {n} lagi Refresh Segarkan +Refresh every connection's lists Segarkan daftar setiap koneksi Refresh Map Series Segarkan Seri Peta Refresh the map series from its index layer. Segarkan seri peta dari layer indeksnya. Region Wilayah @@ -2091,11 +2283,10 @@ Relationships Relasi Release Rilis Reload Muat Ulang Reload Web Layers Muat Ulang Layer Web -Remember (without the secret) Ingat (tanpa rahasia) Remember the key on this computer Ingat kunci di komputer ini -Remembered: Diingat: Remove Hapus Remove All Hapus Semua +Remove All Label Classes Hapus Semua Kelas Label Remove an offline area. Hapus area offline. Remove Bookmark Hapus Penanda Remove Connection Hapus Koneksi @@ -2108,11 +2299,14 @@ Remove from Favourites Hapus dari Favorit Remove From Group Hapus dari Grup Remove Group Hapus Grup Remove Keyframe Hapus Keyframe +Remove Label Class Hapus Kelas Label Remove Section Hapus Bagian Remove Table Hapus Tabel +Remove the clause. Hapus klausa. Remove the last keyframe. Hapus keyframe terakhir. Remove the layer from the map. Hapus layer dari peta. Remove the strokes and draw the symbol's line alone Hapus goresan dan gambar garis simbol saja +Remove this class's own strokes and draw it with the layer's Hapus goresan milik kelas ini dan gambar dengan goresan layer Remove this stop Hapus persinggahan ini Remove Tile Layer Hapus Layer Ubin Remove token Hapus token @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Menghap Rename Ganti Nama Rename Bookmark Ganti Nama Penanda Rename Group Ganti Nama Grup +Rename it to save it: a connection has this name Ganti namanya untuk menyimpan: sudah ada koneksi dengan nama ini +Rename Label Class Ganti Nama Kelas Label Rename Layer Ganti Nama Layer Rename Layout Ganti Nama Tata Letak Rename Map Ganti Nama Peta @@ -2149,6 +2345,7 @@ Reset Default Atur Ulang Bawaan Reset Effects Atur Ulang Efek Reset North Atur Ulang Utara Reset north Atur ulang utara +Resolution (0–30) Resolusi (0–30) Response Respons Restore Pulihkan Restore From Archive Pulihkan dari Arsip @@ -2162,6 +2359,7 @@ RGB RGB Ribbon Ribbon Right Kanan Right-click a source to add it here Klik kanan sumber untuk menambahkannya di sini +Right-click for more Klik kanan untuk opsi lain Robinson Robinson Rotate Putar Rotate (°) Putar (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Data contoh (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Data contoh © Natural Earth (domain publik). RustGIS dilisensikan di bawah Apache-2.0. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Data contoh: Natural Earth (domain publik). Dilisensikan di bawah Apache-2.0. +Sans-serif Sans-serif Satellite Embedding Datasets Dataset Embedding Satelit Satellite Embeddings Embedding Satelit Satellite Embeddings… Embedding Satelit… Saturation Saturasi Save Simpan Save As Simpan Sebagai +Save as Connection Simpan sebagai Koneksi Save as Default Simpan sebagai Bawaan Save as Default Raster Style Simpan sebagai Gaya Raster Bawaan Save as Layer Simpan sebagai Layer Save As Presentation File… Simpan Sebagai File Presentasi… +Save as Python Script… Simpan sebagai Skrip Python… Save As Report File… Simpan Sebagai File Laporan… Save As Report Template… Simpan Sebagai Templat Laporan… Save as {name} Simpan sebagai {name} Save As… Simpan Sebagai… Save Color Ramps Simpan Ramp Warna Save Data Source Simpan Sumber Data +Save History As Python Script Simpan Riwayat sebagai Skrip Python Save Layer File Simpan File Layer Save Layout File Simpan File Tata Letak Save Map File Simpan File Peta @@ -2254,6 +2456,7 @@ save or load a pane set by name simpan atau muat set panel berdasarkan nama Save Project Simpan Proyek Save project Simpan proyek Save Project As Simpan Proyek Sebagai +Save Project As… Simpan Proyek Sebagai… Save Project Template Simpan Templat Proyek Save Style Items Simpan Item Gaya Save Task Item Simpan Item Tugas @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Simpan model sebagai file (*.model.json) Save the model in the project Simpan model di proyek Save the open layout as a layout file. Simpan tata letak yang terbuka sebagai file tata letak. Save the panes you have open, and switch back to them. Simpan panel yang sedang terbuka, dan kembali ke panel tersebut. +Save the project too: the script opens its saved file, without changes made since. Simpan juga proyeknya: skrip membuka file tersimpannya, tanpa perubahan yang dibuat setelahnya. Save the project under a new name. Simpan proyek dengan nama baru. Save the project with your edits. Simpan proyek beserta suntingan Anda. Save the project's custom color ramps to a color ramp file. Simpan ramp warna kustom proyek ke file ramp warna. @@ -2275,13 +2479,16 @@ Save Web Link Chart Simpan Diagram Tautan Web Save Web Map Simpan Peta Web Save Web Scene Simpan Scene Web Saved Tersimpan +Saved as the connection {name} Disimpan sebagai koneksi {name} Saved as the default style for new single-band rasters. Disimpan sebagai gaya bawaan untuk raster band tunggal baru. +Saved connection Koneksi tersimpan Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Kunci yang disimpan disimpan tanpa enkripsi di preferensi aplikasi (di penyimpanan browser pada versi web). Saved models Model tersimpan Saved symbology: apply it to the selected layer, or save the layer's. Simbologi tersimpan: terapkan pada layer terpilih, atau simpan simbologi layer. Saved View Tampilan tersimpan Saved {name} {name} disimpan Saved {name}. {name} disimpan. +Saved {path} {path} disimpan saving failed unexpectedly penyimpanan gagal secara tak terduga Scale Skala Scale\nBar Bilah\nSkala @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Cari (portal ini memerlukan kata kunci)… Search Catalogue Cari Katalog Search commands — e.g. buffer, basemap, labels Cari perintah — mis. buffer, peta dasar, label Search datasets, e.g. GEDI biomass Cari dataset, mis. GEDI biomass -Search Distance (km) Jarak Pencarian (km) +Search Distance Jarak Pencarian Search every command and its parameters Cari setiap perintah dan parameternya Search layers (e.g. temperature) Cari layer (mis. temperature) Search layers, places, lat/lon… Cari layer, tempat, lintang/bujur… @@ -2329,6 +2536,8 @@ Searching the folders… Mencari di folder… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. Pencarian berfungsi di sini, tetapi server data NASA tidak mengizinkan halaman web membaca file-nya (tanpa CORS), sehingga GeoTIFF dan unduhan data terlindungi memerlukan aplikasi desktop. Unduh membuka file di tab baru, tempat Earthdata Login meminta Anda masuk. Searching… Mencari… Searching… try again in a moment. Mencari… coba lagi sebentar lagi. +Seasonal Mann–Kendall Mann–Kendall Musiman +Seasons a year Musim per tahun Second standard parallel Paralel standar kedua Seconds Detik Secret access key Kunci akses rahasia @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Pemisah Sepia Sepia Sequential Sekuensial +Serif Serif Server Server Service Group Grup layanan Service group data exceeds 64 MB. Data grup layanan melebihi 64 MB. Service Import Requests Permintaan impor layanan Service Layers Layer Layanan -Services Layanan +Session token Token sesi Set Atur Set Cell Size Atur Ukuran Sel Set Domain Atur Domain @@ -2411,6 +2621,7 @@ Shaded Gelap Shaded Relief Relief Bayangan Shape Bentuk Shapefile Shapefile +Shapes Bentuk Share Bagikan Share As Bagikan Sebagai Share every layer and table as OGC API Features collections. Bagikan setiap layer dan tabel sebagai koleksi OGC API Features. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Tampilkan detail saat meng Show different features at different scales. Tampilkan fitur yang berbeda pada skala yang berbeda. Show grid Tampilkan grid Show Group Tampilkan Grup +Show labels Tampilkan label Show latitude/longitude lines on the basemap. Tampilkan garis lintang/bujur pada peta dasar. +Show Layers Tampilkan Layer Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Tampilkan layer menurut waktu: hanya fitur yang tanggalnya berada dalam jendela penggeser, dan citra bertanggal untuk tanggalnya. Show Map Tampilkan Peta Show Me Tunjukkan @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Tampilkan atau sembunyikan panel Alat. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Tampilkan penggaris. Seret dari penggaris untuk menambah garis bantu; seret garis bantu kembali ke penggaris untuk menghapusnya. Show the bands picked, read in place as the map needs them Tampilkan band yang dipilih, dibaca di tempat sesuai kebutuhan peta Show the description Tampilkan deskripsi +Show the Labeling pane for the selected layer. Tampilkan panel Pelabelan untuk layer terpilih. Show the labels that don't fit, in red. Tampilkan label yang tidak muat dengan warna merah. Show the layer only inside another layer's polygons. Tampilkan layer hanya di dalam poligon layer lain. Show the layer only left of a divider, to compare it with what's beneath. Tampilkan layer hanya di sebelah kiri pembatas, untuk membandingkannya dengan yang di bawahnya. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Proses masuk dibatalkan. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. Waktu masuk habis. Mulai lagi dan izinkan browser mengakses alamat loopback komputer ini. signed in sudah masuk Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Keluar di komputer ini, tetapi OpenAI tidak mengonfirmasinya. Anda dapat memutus RustGIS di pengaturan ChatGPT. +Significance level Tingkat signifikansi Signing in with ChatGPT works in the desktop app. Masuk dengan ChatGPT berfungsi di aplikasi desktop. Signing the asset (Planetary Computer)… Menandatangani aset (Planetary Computer)… +Sill Sill Simplify Sederhanakan Simulate the drive, or save the route as a layer. Simulasikan perjalanan, atau simpan rute sebagai layer. Simulation Simulasi @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} Skill untuk Asisten AI: {names} Slideshow, PDF or PNG Tayangan slide, PDF, atau PNG slight left sedikit ke kiri slight right sedikit ke kanan +Slope per Kemiringan per Slope, aspect, hillshade, NDVI, band math, thresholds. Kemiringan, aspek, hillshade, NDVI, aritmetika band, ambang batas. Slope, hillshade, NDVI and more. Kemiringan, hillshade, NDVI, dan lainnya. Small scales Skala kecil @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Selatan South Polar Stereographic Stereografis Kutub Selatan +Spatial Join Gabungan Spasial Speaker notes Catatan pembicara Spectral Analysis Analisis Spektral Spectral indices Indeks spektral @@ -2530,6 +2748,7 @@ Split (click the line) Belah (klik garis) SQL SQL SQL Pane Panel SQL SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. Kueri SQL berjalan melalui DuckDB, program yang dipanggil aplikasi desktop: buka proyek ini di aplikasi desktop untuk mengkuerinya. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL yang harus bernilai benar, mis. pop > 0 Square Persegi Squares Persegi @@ -2588,6 +2807,7 @@ Start over at the end while playing. Mulai lagi di akhir saat diputar. Start With Mulai dengan Start-up Memulai starting with a project file needs the desktop app memulai dengan file proyek memerlukan aplikasi desktop +Statistic Statistik Statistics Statistik Statistics Field Kolom Statistik Status Status @@ -2613,8 +2833,10 @@ Stretch\nType Tipe\nPeregangan Stretch it over the map view, or give control points (one per line: column row lon lat). Regangkan di atas tampilan peta, atau berikan titik kontrol (satu per baris: kolom baris bujur lintang). Stretch Type Tipe Peregangan Stretch type Tipe peregangan +Stroke Levels Level Goresan Stroke {n} Goresan {n} Strokes Goresan +Strokes for Goresan untuk Style Gaya Style Items Item Gaya Style › Drawing Gaya › Penggambaran @@ -2624,7 +2846,10 @@ Subreport: {name} Sublaporan: {name} Subtypes Subtipe Subtypes, one per line: code=name Subtipe, satu per baris: kode=nama Suitability Modeler Pemodel Kesesuaian +Summarise Ringkas +Summarise over time Ringkas sepanjang waktu Summarise the conversation so far, so a long chat stays within the model's context Ringkas percakapan sejauh ini agar obrolan panjang tetap muat dalam konteks model +Summarise Zarr Over Time Ringkas Zarr Sepanjang Waktu Summarising the earlier conversation to keep it within the model's context… Meringkas percakapan sebelumnya agar tetap muat dalam konteks model… Summarize Ringkas Summarize\nWithin Ringkas\ndi Dalam @@ -2658,6 +2883,8 @@ Table or layer Tabel atau layer Tables Tabel Tags Tag Take the data in view offline, to edit and sync back later. Bawa data yang terlihat ke mode offline, untuk disunting dan disinkronkan kembali nanti. +Target Features Fitur Target +Target Records Rekaman Target Task Tugas Task Item Item Tugas Task name Nama tugas @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Data SIG publik Tennessee (Tennessee GeoData, geodata.tn.gov): batas properti, transportasi, kesehatan, hidrologi, dan lainnya. Terrain Medan Test Test +Test Connection Uji Koneksi +Testing… Menguji… Text Teks Text Symbol Simbol Teks That connection would create a loop. Koneksi itu akan membuat perulangan. The address fields are empty in every row. Kolom alamat kosong di setiap baris. +The answer isn't UTF-8 text Jawabannya bukan teks UTF-8 the answer isn't UTF-8 text jawaban bukan teks UTF-8 The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. Asisten mengikuti instruksi skill saat menggunakannya. Apa pun isinya, perintah yang memerlukan persetujuan Anda tetap meminta izin. The assistant is busy; stop it or wait for it to finish. Asisten sedang sibuk; hentikan atau tunggu hingga selesai. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. Tab peta dasar publik dari plugin the capture failed unexpectedly penangkapan gagal secara tak terduga The connection failed; trying again in {seconds} s. Koneksi gagal; mencoba lagi dalam {seconds} dtk. The country list couldn't be read ({e}): type a country's code Daftar negara tidak dapat dibaca ({e}): ketik kode negara +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. Elevasi saat ini tetap berlaku selama Anda mengedit masukan. Klik Terapkan elevasi untuk menerapkan nilai dan satuannya. The dataset has nothing RustGIS can add Dataset ini tidak berisi apa pun yang dapat ditambahkan RustGIS The environment (AWS_ACCESS_KEY_ID…) Lingkungan (AWS_ACCESS_KEY_ID…) The features where the map is Fitur di area peta @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. Flow The installed GeoLibre runner lists no tools. Runner GeoLibre yang terpasang tidak mencantumkan alat apa pun. The job covers the map view Pekerjaan mencakup tampilan peta The key is kept for this session only. Kunci hanya disimpan untuk sesi ini. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. Layer menampilkan langkah tempat penggeser waktu berada, dan mengaktifkan penggeser di rentang tanggal variabel. The layer's file couldn't be read: {error} Berkas layer tidak dapat dibaca: {error} +The layer's strokes Goresan layer the layout export failed unexpectedly ekspor tata letak gagal secara tak terduga The link doesn't say its STAC API: Find Items searches {api} Tautan tidak menyebutkan STAC API-nya: Cari Item mencari di {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. Daftar plugin untuk dijelajahi. Biarkan kosong untuk registri plugin RustGIS. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. Thumbnail proyek, dari tampilan peta The projection the map opens in. Proyeksi saat peta dibuka. The provider's content filter stopped the reply. Filter konten penyedia menghentikan balasan. The public server is free and shared by many: it answers light use, and may ask you to wait. Server publik ini gratis dan dipakai bersama banyak orang: server melayani penggunaan ringan, dan mungkin meminta Anda menunggu. +The range is two numbers, low and high (0, 0.9) Rentang berupa dua angka, rendah dan tinggi (0, 0.9) The raster's data couldn't be read. Data raster tidak dapat dibaca. The raster's data couldn't be read: {path} Data raster tidak dapat dibaca: {path} the release isn't known rilis tidak dikenal @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Berkas-berkas Shapefile diunduh sebag The share of each cell covered by fields, for the zoomed-out view Proporsi setiap sel yang tertutup lahan, untuk tampilan yang diperkecil The sign-in expired or was revoked. Sign in again. Sesi masuk kedaluwarsa atau dicabut. Masuk lagi. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. Info masuk disimpan di folder pengaturan aplikasi, hanya untuk akun pengguna Anda, dan tidak pernah di berkas proyek. +The significance level is a number, such as 0.05 Tingkat signifikansi berupa angka, misalnya 0.05 The start-up project {path} didn't open: {error} Proyek awal {path} tidak dapat dibuka: {error} The start-up project {path} isn't a RustGIS project file. Proyek awal {path} bukan file proyek RustGIS. The step's layer {name} isn't in the map. Layer langkah ini, {name}, tidak ada di peta. The store has no variables with two or more dimensions. Penyimpanan tidak memiliki variabel dengan dua dimensi atau lebih. the store is gone penyimpanan sudah tidak ada +The summary couldn't start: {error} Ringkasan tidak dapat dimulai: {error} +The summary failed unexpectedly Ringkasan gagal secara tak terduga +The summary finished after another project was opened, so it wasn't added Ringkasan selesai setelah proyek lain dibuka, sehingga tidak ditambahkan The task's steps changed, so the step being edited was closed: edit it again. Langkah tugas berubah, jadi langkah yang sedang diedit ditutup: edit lagi. the time slider is off penggeser waktu nonaktif The token expires on {date}. Token kedaluwarsa pada {date}. +The trend couldn't start: {error} Tren tidak dapat dimulai: {error} +The trend failed unexpectedly Tren gagal secara tak terduga +The trend finished after another project was opened, so it wasn't added Tren selesai setelah proyek lain dibuka, sehingga tidak ditambahkan +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. Nilai yang ditampilkan dari hitam ke putih, sama untuk setiap tanggal dan ubin (dalam satuan band itu sendiri). Kosong: peregangan masing-masing file. the view has no extent tampilan tidak memiliki cakupan The view is larger than one read: its centre was read (zoom in for the rest) Tampilan lebih besar dari satu kali baca: bagian tengahnya dibaca (perbesar untuk sisanya) The web app always runs the latest release: reload the page to get it. Aplikasi web selalu menjalankan rilis terbaru: muat ulang halaman untuk mendapatkannya. the window's screenshot didn't come back in time, so the map was drawn offscreen tangkapan layar jendela tidak kembali tepat waktu, sehingga peta digambar di luar layar the window's screenshot didn't hold the map, so it was drawn offscreen tangkapan layar jendela tidak memuat peta, sehingga peta digambar di luar layar Theme Tema +There is no expression defined. Tidak ada ekspresi yang didefinisikan. Thermal Termal +Thesis figure, full page (160 × 225 mm) Gambar tesis, satu halaman penuh (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Gambar tesis, setengah halaman (160 × 110 mm) Thinking… Berpikir… +Third-party licences Lisensi pihak ketiga +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins build ini tidak dapat mengunduh plugin this build can't reach AI providers build ini tidak dapat menjangkau penyedia AI this build saves projects with Save As build ini menyimpan proyek dengan Simpan Sebagai This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Akun ChatGPT ini sudah tersimpan. Lain kali, pilih dari daftar alih-alih menambahkannya lagi. +This class is drawn with the layer's strokes. Changing them here gives it its own. Kelas ini digambar dengan goresan layer. Mengubahnya di sini memberinya goresan sendiri. +This class is hidden, so it labels nothing. Kelas ini disembunyikan, sehingga tidak memberi label apa pun. +This clause no longer reads, so no feature is labelled: {error} Klausa ini tidak lagi terbaca, sehingga tidak ada fitur yang diberi label: {error} This map has no layers yet. Peta ini belum memiliki layer. This map's thumbnail, from an image. Thumbnail peta ini, dari sebuah gambar. This map's thumbnail, from the view. Thumbnail peta ini, dari tampilan. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Permintaan ini tidak memiliki opsi izinkan sekali. This request uses an unsupported capability. Check the parameter and change the request. Permintaan ini menggunakan kemampuan yang tidak didukung. Periksa parameter dan ubah permintaan. This result is kept Hasil ini disimpan +This run has no tool form to open Jalannya ini tidak memiliki formulir alat untuk dibuka +This session only (optional) Hanya sesi ini (opsional) This step Langkah ini This step names no tool to show. Langkah ini tidak menyebut alat untuk ditunjukkan. Thumbnail Thumbnail @@ -2789,15 +3041,21 @@ Tilt Map Miringkan Peta Tilt-shift Tilt-shift Tilt-shift blur and film grain. Blur tilt-shift dan butiran film. Time Waktu +Time\nSeries Deret\nWaktu Time\nSlider Penggeser\nWaktu Time Fields Kolom Waktu +Time Series Deret Waktu +Time series Deret waktu +Time Series at a Point Deret Waktu di Suatu Titik Time Slider Penggeser Waktu Time Slider Settings Pengaturan Penggeser Waktu Tint by elevation Warnai menurut elevasi Tissot's Indicatrix Indikatriks Tissot Title Judul +To Sampai Toggle Dark Mode Alihkan Mode Gelap Toggle GPU Rendering Alihkan Rendering GPU +Toggle Imperial Measurements Alihkan Pengukuran Imperial Tolerance (km) Toleransi (km) Tolls Jalan berbayar Tool Gallery Galeri Alat @@ -2828,7 +3086,10 @@ Train Deep Learning Model Latih Model Deep Learning Trained models Model terlatih Transparency Transparansi Traverse Poligon Traverse +Trend Analysis Analisis Tren +Trend over a series of rasters Tren pada serangkaian raster Triangle Segitiga +TRUE TRUE Try again in a moment. Coba lagi sebentar lagi. Try again later. Coba lagi nanti. Turn editing on or off for the map. Aktifkan atau nonaktifkan penyuntingan untuk peta. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map ketik bujur dan lintang, atau kl Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Ketik skala, seperti 1:12,345, 12345, atau 25k, lalu tekan Enter Type an address or coordinates and press Enter, or click the map. Ketik alamat atau koordinat lalu tekan Enter, atau klik peta. Type numbers separated by commas Ketik angka yang dipisahkan koma +Type the keys to log in for this session, then Save. Ketik kunci untuk masuk pada sesi ini, lalu Simpan. +Type the password or keys to log in for this session, then Save. Ketik kata sandi atau kunci untuk masuk pada sesi ini, lalu Simpan. Typed below Diketik di bawah UM UM Unavailable: {reason} Tidak tersedia: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Lepas dari Penggeser Waktu Unbind from Time Slider Lepas dari penggeser waktu Undo Urungkan Undo (Ctrl+Z) Urungkan (Ctrl+Z) -Undo / Redo Urungkan / Ulangi Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Urungkan tidak menghapus langkah yang direkam: hapus di Edit Langkah setelah Anda menghentikan perekaman. Undo the last change. Urungkan perubahan terakhir. Undo {action} Urungkan {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools Copot alat GeoLibre Uninstall Plugin Copot Plugin Uninstalled {id}. {id} telah dicopot. Unique Values Nilai Unik +Unit Satuan United States Amerika Serikat Units Satuan Units per step Unit per langkah @@ -2877,6 +3140,7 @@ Up a level Naik satu tingkat Update Perbarui Update Bookmark Perbarui Penanda Update Job Perbarui Pekerjaan +Update Label Class Perbarui Kelas Label Update to {version} Perbarui ke {version} Upload File Unggah File Upload File… Unggah File… @@ -2895,6 +3159,7 @@ Use another account Gunakan akun lain Use Current View Gunakan tampilan saat ini Use each feature's line color Gunakan warna garis setiap fitur Use each feature's line width Gunakan lebar garis setiap fitur +Use the Layer's Strokes Gunakan Goresan Layer USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan Menggunakan paket ChatGPT Utilities Utilitas Valid combinations, one per line: a, b Kombinasi valid, satu per baris: a, b Valid expression · {n} matching rows Ekspresi valid · {n} baris cocok +Valid: {n} of {of} features match Valid: {n} dari {of} fitur cocok Validate Validasi Validate Model Validasi Model value (empty: no-data) nilai (kosong: tanpa data) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Citra bencana terbuka dari Vantor: pilih kejadian dan fase (sebelum atau sesudahnya), lalu Cari Item. Vantor… Vantor… Variable Variabel +Variogram Model Model Variogram Vector and raster data Data vektor dan raster Vector Layer Layer Vektor +vector tiles ubin vektor Vector tiles (MVT) Ubin vektor (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Vektor yang dibuat model fondasi geospasial untuk setiap piksel atau patch citra, ditemukan di lokasi peta dan ditampilkan sebagai warna. Version {version} Versi {version} @@ -2933,6 +3201,9 @@ Video file or URL File video atau URL Video Page Halaman Video Videos Video Video… Video… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Sistem koordinat nasional Vietnam (VN-2000) menggunakan Transverse Mercator 3 derajat dengan pergeseran Helmert 7 parameter ke WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Sistem koordinat nasional Vietnam (VN-2000) UTM zona 48N (102°BT–108°BT, meridian tengah 105°BT). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Sistem koordinat nasional Vietnam (VN-2000) UTM zona 49N (108°BT–114°BT, meridian tengah 111°BT). View Tampilan View Clipping Pemotongan Tampilan View Effects Efek Tampilan @@ -2952,6 +3223,9 @@ Visibility Range Rentang Visibilitas Visible Terlihat Visible Layers Layer Terlihat Visual Effect Efek Visual +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vietnam) +VN-2000 / UTM zone 48N VN-2000 / UTM zone 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zone 49N Volume Volume Waiting for the browser… Menunggu peramban… Waiting for your answer Menunggu jawaban Anda @@ -2970,6 +3244,7 @@ Web Link Chart Diagram Tautan Web Web maps Peta web Web Mercator Web Mercator Web Scene Scene Web +Web service (detected) Layanan web (terdeteksi) Web Style Gaya Web Web Tile Layers Layer Ubin Web Web Tool Alat Web @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Posisi label relatif terhadap fitur Where new features get their elevation (the z field). Asal elevasi fitur baru (kolom z). Where the map opens: the full extent or a view you saved. Tempat peta dibuka: cakupan penuh atau tampilan yang Anda simpan. Where tools work Area kerja alat +Whether the Select tool picks these layers' features on the map Apakah alat Pilih memilih fitur layer ini di peta Which 5 countries have the largest GDP? 5 negara mana yang memiliki PDB terbesar? Whitebox and GeoLibre tools Alat Whitebox dan GeoLibre Whole map Seluruh peta @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Dunia, luas sama: ukuran seben World, equal-area: true sizes, shapes bend towards the edges. Dunia, luas sama: ukuran sebenarnya, bentuk melengkung ke arah tepi. Worldview Batas Negara Write a plugin Tulis plugin +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Menulis kemiringan Sen, nilai Z dan p-value uji, serta kemiringan di tempat tren signifikan. X (mm) X (mm) X column Kolom X X Field Kolom X @@ -3050,7 +3327,6 @@ Yours Milik Anda YYYY-MM-DD YYYY-MM-DD YYYY-MM-DD, for the layers that change with time YYYY-MM-DD, untuk layer yang berubah seiring waktu YYYY-MM-DD, for time layers YYYY-MM-DD, untuk layer waktu -z (m) z (m) Z factor Faktor Z Zarr Zarr Zarr Variables Variabel Zarr @@ -3061,7 +3337,6 @@ Zoom Box Zoom Kotak Zoom Frame Bingkai Zoom Zoom In Perbesar Zoom in Perbesar -Zoom in / out Perbesar / perkecil Zoom In Here Perbesar di Sini Zoom in on the centre of the map. Perbesar di tengah peta. Zoom in · finish a sketch Perbesar · selesaikan sketsa @@ -3073,6 +3348,7 @@ Zoom To\nLayer Zoom ke\nLayer Zoom to a box (Explore) Zoom ke kotak (Jelajah) Zoom To Extent Zoom ke Cakupan Zoom To Layer Zoom ke Layer +Zoom To Layers Zoom ke Layer Zoom To Map View Zoom ke Tampilan Peta Zoom To Selection Zoom ke Seleksi Zoom to selection Zoom ke seleksi @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Zoom ke cakupan layer. Zoom to the selected features. Zoom ke fitur terpilih. Zoom to the whole world. Zoom ke seluruh dunia. Zoom to this bookmark Zoom ke penanda ini +Zoom To Tile Layer Zoom ke Layer Ubin Zoom to Tokyo Zoom ke Tokyo Zoom To World Zoom ke Dunia +Zoom {z} · Bearing {b}° · Pitch {p}° Zoom {z} · Arah {b}° · Kemiringan {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Zoom: tingkat zoom peta, sebagaimana peta web menghitungnya. Arah: arah yang dihadap bagian atas peta, searah jarum jam dari utara. Kemiringan: kemiringan dari pandangan lurus ke bawah. Klik untuk mengatur ulang arah dan kemiringan. {agent} was stopped by you. {agent} dihentikan oleh Anda. {id} changed since it was installed; install it again from the Plugin Manager. {id} telah berubah sejak dipasang; pasang ulang dari Pengelola Plugin. {names}: needs Node.js (npx) for its ACP adapter. {names}: memerlukan Node.js (npx) untuk adaptor ACP-nya. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9ce5176b3..e46f809b6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6,6 +6,7 @@ (filtered) (filtrato) + Add Stop + Aggiungi tappa + Input + Input ++ New expression + Nuova espressione + Output + Output /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh Mesh 3D A 3D globe seen from space. Un globo 3D visto dallo spazio. A blank map with a basemap Una mappa vuota con una mappa di base +A call for the rustgis Python package Una chiamata per il pacchetto Python rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. L'indirizzo di un catalogo (CSW 2.0.2): cerca nei suoi record per parole e, se vuoi, per l'area della mappa, poi aggiungi i servizi e i file a cui rimandano. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. È stato raggiunto un limite di utilizzo del piano ChatGPT o dell’app. Controlla l’utilizzo settimanale prima di riprovare. A ChatGPT sign-in can only be used with the OpenAI API. Un accesso ChatGPT può essere usato solo con l'API di OpenAI. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). I datas A globe scene. Una scena a globo. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Una latitudine e longitudine (40.7, -74.0 o 40°42'N 74°0'W), un layer, un segnalibro o un valore nella tabella di un layer. Enter sceglie la riga evidenziata; Cerca luoghi cerca il testo come luogo A layer the model reads Un layer letto dal modello +A line for the Script pane Una riga per il pannello Script A magnifying lens that follows the pointer. Una lente d'ingrandimento che segue il puntatore. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). L'indirizzo di un servizio di mappe, elementi o coperture: WMS, WMTS, WFS, WCS, OGC API Features o Tiles, oppure un ArcGIS FeatureServer, MapServer, ImageServer o VectorTileServer (o una directory di servizi, da sfogliare). a name, or owner/name such as giswqs/geospatial un nome, o proprietario/nome come giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Un endpoint con nome che puoi riutilizzare. Password e chiavi vengono conservate fino alla chiusura di RustGIS, mai salvate. A node cannot connect to itself. Un nodo non può collegarsi a se stesso. A notebook of text and command cells. Un notebook di celle di testo e di comando. +A password or token in the address was left out: connections never keep secrets. Una password o un token nell'indirizzo è stato omesso: le connessioni non conservano mai segreti. A place or an address Un luogo o un indirizzo A project keeps at least one map Un progetto conserva almeno una mappa A projected coordinate system. Un sistema di coordinate proiettate. A report listing the selected layer's records, page after page. Un report che elenca i record del layer selezionato, pagina dopo pagina. A result that only feeds other tools is removed once the model has run; add an output to keep it. Un risultato che alimenta solo altri strumenti viene rimosso dopo l'esecuzione del modello; aggiungi un output per conservarlo. A result the model keeps, under a name Un risultato che il modello conserva, con un nome +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) Un comando rustgis-cli, per una shell POSIX (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. È già in corso una importazione o un aggiornamento del servizio. +A service's layers stay in their service group. I layer di un servizio restano nel gruppo del loro servizio. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Un'attività contiene al massimo 200 passaggi: interrompi la registrazione per crearla, poi registra il resto come un'altra attività. A template with the first selected feature's values Un modello con i valori del primo elemento selezionato +A tile layer per grid tile, each following the time slider through its dates. Un layer di tile per ogni tile della griglia, ciascuno che segue il cursore temporale attraverso le sue date. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. L'indirizzo di un servizio di tile: tile raster con {z}/{x}/{y} (o {-y}, {q}, {bbox-epsg-3857} per WMS), tile vettoriali (.pbf) o TileJSON, uno stile MapLibre, un archivio PMTiles (un indirizzo web o un file su questo computer) o un file MBTiles su questo computer. A token is set. È impostato un token. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Un valore, un campo o due di essi uniti da + − × ÷. Si applica alle righe selezionate, o a tutte le righe se nessuna è selezionata. a vector a pixel un vettore per pixel a vector an image patch un vettore per porzione di immagine A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Un archivio Zarr (formato 2 o 3) tramite il suo indirizzo web, con metadati consolidati, o una cartella su questo computer. La mappa di una variabile viene letta per i passi scelti delle sue altre dimensioni, come il tempo. +A Zarr variable's values through time at points clicked on the map. I valori di una variabile Zarr nel tempo nei punti cliccati sulla mappa. +A2 landscape sheet Foglio A2 orizzontale +A2 portrait sheet Foglio A2 verticale +A3 landscape sheet Foglio A3 orizzontale +A3 portrait sheet Foglio A3 verticale +A4 landscape sheet Foglio A4 orizzontale +A4 portrait sheet Foglio A4 verticale +A5 Grid Griglia A5 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. Le celle pentagonali equivalenti di A5 a una data risoluzione, sull'estensione della mappa, su un layer o sul mondo, come poligoni con i loro id. About Informazioni About plugins Informazioni sui plugin About RustGIS Informazioni su RustGIS @@ -65,6 +83,7 @@ Add Aggiungi Add\nData Aggiungi\ndati Add ({n}) Aggiungi ({n}) Add a bar chart of a layer's numeric field. Aggiungi un grafico a barre di un campo numerico di un layer. +Add a circle. Aggiungi un cerchio. Add a colorbar of a raster layer's colour scale in the map frame. Aggiungi nella cornice della mappa una barra dei colori della scala di colori di un layer raster. Add a description. Aggiungi una descrizione. Add a field. Aggiungi un campo. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Aggiungi un layout di pagi Add a page to the presentation. Aggiungi una pagina alla presentazione. Add a PNG or JPEG picture to the page. Aggiungi un'immagine PNG o JPEG alla pagina. Add a presentation file. Aggiungi un file di presentazione. +Add a rectangle, to frame or highlight part of the page. Aggiungi un rettangolo, per incorniciare o evidenziare una parte della pagina. Add a report file, or a report template for the selected layer. Aggiungi un file di report, o un modello di report per il layer selezionato. Add a scale bar for the map frame. Aggiungi una barra di scala per la cornice della mappa. add a second map first (Data › New Map) aggiungi prima una seconda mappa (Dati › Nuova mappa) Add a second map frame first Aggiungi prima una seconda cornice della mappa +Add a straight line. Aggiungi una linea retta. Add a text element. Aggiungi un elemento di testo. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Aggiungi una mappa di base web sopra le altre invece di sostituirle, per fonderla con esse. Fai clic su una visibile per toglierla. +Add a Zarr variable first (Add Data › Zarr…). Aggiungi prima una variabile Zarr (Aggiungi dati › Zarr…). Add Administrative Boundaries Aggiungi confini amministrativi +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Aggiungi una freccia. Ruotala con la maniglia rotonda sopra di essa, o imposta il suo angolo nelle sue proprietà. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. Aggiungi un token Earthdata Login (Earthdata Login, sopra) per mettere sulla mappa i GeoTIFF della NASA. Add an elevation raster first. Aggiungi prima un raster di quota. Add an elevation raster to the map first Aggiungi prima un raster di quota alla mappa +Add an ellipse. Aggiungi un'ellisse. Add an empty layer with a ready-made schema, to draw into. Aggiungi un layer vuoto con uno schema pronto, su cui disegnare. Add an input for each layer the model reads. Aggiungi un input per ogni layer letto dal modello. Add an output to keep a result under a name. Aggiungi un output per conservare un risultato con un nome. Add and Open Aggiungi e apri Add and Open Notebook Aggiungi e apri notebook Add Archive Aggiungi archivio +Add as Time Series Aggiungi come serie temporale add at least two keyframes aggiungi almeno due fotogrammi chiave Add Cell Aggiungi cella Add Color Ramp File… Aggiungi file di scala di colori… @@ -132,6 +157,7 @@ Add Graphics Layer Aggiungi layer grafico Add Input Aggiungi input Add Job Aggiungi lavoro Add Keyframe Aggiungi fotogramma chiave +Add Label Class Aggiungi classe di etichette Add Layer File Aggiungi file di layer Add Open Data Aggiungi dati aperti Add Output Aggiungi output @@ -147,9 +173,11 @@ Add Rule Aggiungi regola Add sample countries Aggiungi paesi di esempio Add Sample Data Aggiungi dati di esempio Add Satellite Embeddings Aggiungi embedding satellitari +Add Selected ({n}) Aggiungi selezionati ({n}) Add Service Layer Aggiungi layer di servizio Add skill… Aggiungi skill… Add STAC Asset Aggiungi asset STAC +Add STAC Data Cube Aggiungi cubo di dati STAC Add STAC Footprints Aggiungi impronte STAC Add Step Aggiungi passaggio Add Stroke Aggiungi tratto @@ -171,6 +199,7 @@ Add to Favourites Aggiungi ai preferiti Add to Map Aggiungi alla mappa Add vector data. Aggiungi dati vettoriali. Add view Aggiungi vista +Add With Options… Aggiungi con opzioni… Add Zarr Variable Aggiungi variabile Zarr Add Zarr Variable… Aggiungi variabile Zarr… Add ▾ Aggiungi ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Gli agenti hanno accesso completo: possono eseguire script, attività, modelli e plugin. Fai clic per fermare il relay MCP Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Gli agenti di questo computer che si collegano (Claude Code, Codex, Cursor e altri) lavorano sulla mappa aperta qui con gli strumenti di RustGIS: possono aprire e salvare file e usare il web. Gli script e la gestione dei plugin restano disattivati. Agents then run commands, change files and use the web on this computer without asking you. Gli agenti eseguiranno comandi, modificheranno file e useranno il web su questo computer senza chiederti nulla. +Aggregate Points to A5 Aggrega punti in A5 Aggregation Aggregazione Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Confini dei campi agricoli trovati dal modello Fields of the World nelle immagini Sentinel-2, in tutto il mondo, un anno alla volta (2ª edizione, CC BY 4.0). Sono previsioni del modello, non particelle rilevate. AI IA @@ -221,15 +251,18 @@ All steps done. Tutti i passaggi completati. All tables with geometry Tutte le tabelle con geometria Allow Consenti Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Consenti l'uso del piano ChatGPT durante l'accesso. Se il tuo workspace non lo permette, chiedi al suo amministratore o scegli un provider con chiave API. +Allow Feature Selection Consenti selezione elementi Allow only certain combinations of values across fields. Consenti solo determinate combinazioni di valori tra i campi. Allow such actions for this conversation Consenti azioni di questo tipo per questa conversazione Allow this action? Consentire questa azione? Allow this agent's RustGIS action? Consentire l'azione RustGIS di questo agente? Along Selected Line Lungo la linea selezionata +Alt+ Alt+ Alternating bar Barra alternata Altitude Altitudine Always allow Consenti sempre -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3, o un servizio compatibile con S3 (MinIO, Cloudflare R2, Wasabi…) tramite il suo endpoint. Le chiavi vengono conservate fino alla chiusura di RustGIS; una connessione ricordata conserva nome, endpoint e regione, mai il segreto. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3, o un servizio compatibile con S3 (MinIO, Cloudflare R2, Wasabi…) tramite il suo endpoint. Le chiavi vengono conservate fino alla chiusura di RustGIS; una connessione salvata conserva nome, endpoint e regione, mai il segreto. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Un agente su questo computer chiede di: An AWS profile Un profilo AWS @@ -248,6 +281,7 @@ API key Chiave API Apply Applica Apply a function to a raster Applica una funzione a un raster Apply Control Points Applica punti di controllo +Apply elevation Applica quota Apply Style Applica stile Apply to layers Applica ai layer Apply to the selected layer Applica al layer selezionato @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Area di utilizzo: entro {r}° da { Area {area} · Perimeter {perimeter} Area {area} · Perimetro {perimeter} Areas (name, land use, area) Aree (nome, uso del suolo, area) Arrange Disponi +Arrow Freccia +Arrowheads Punte di freccia Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Fai una domanda sui tuoi dati, o descrivi cosa fare con la mappa. L'assistente esegue i comandi di RustGIS per te; ogni modifica può essere annullata. Ask about the map, or tell it what to do… Chiedi qualcosa sulla mappa, o digli cosa fare… Ask about your data, or tell the assistant what to do with the map. Chiedi qualcosa sui tuoi dati, o di' all'assistente cosa fare con la mappa. @@ -275,12 +311,13 @@ Asking NASA's CMR… Interrogazione del CMR della NASA… Asking the catalog… Interrogazione del catalogo… Asking the NLDI… Interrogazione dell'NLDI… Assistant settings Impostazioni dell'assistente +At the end Alla fine +At the start All'inizio Atlas Atlante Attachments Allegati Attribute\nTable Tabella\nattributi Attribute Rules Regole degli attributi Attribute Table Tabella degli attributi -Attribute table Tabella degli attributi Attributes Attributi Attribution Attribuzione Aurora Aurora @@ -295,11 +332,9 @@ Automatic (system language) Automatica (lingua di sistema) Automatic (system time zone and region) Automatico (fuso orario e regione di sistema) Automatic ({kind}) Automatico ({kind}) Avoid Evita -AWS AWS AWS, anonymous AWS, anonimo Azimuth Azimut Back Indietro -Back to Explore; cancel a sketch Torna a Esplora; annulla uno schizzo Back to the Map Torna alla mappa Back to the map Torna alla mappa Back to tools Torna agli strumenti @@ -314,8 +349,6 @@ Basemap Mappa di base Basemaps Mappe di base Basin from hydrolocation Bacino da idrolocalizzazione Bearing Orientamento -Bearing {b}° · Pitch {p}° Direzione {b}° · Inclinazione {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Direzione: verso cui è rivolta la parte superiore della mappa, in senso orario dal nord. Inclinazione: lo scostamento dalla vista dall'alto in verticale. Fai clic per reimpostarle entrambe. Before the event Prima dell'evento Below Sotto Below 1:5,000,000 show everything; beyond it only what matches: Sotto 1:5.000.000 mostra tutto; oltre mostra solo ciò che corrisponde: @@ -328,10 +361,12 @@ Bilinear Bilineare Bind To Time Slider Collega al cursore temporale Bind to Time Slider Collega al cursore temporale Blank Vuota +Blank for Amazon S3 Vuoto per Amazon S3 Blank Letter page Pagina Letter vuota Blank Map Mappa vuota Blank Page Pagina vuota Blend: {mode} Fusione: {mode} +Block Feature Selection Blocca selezione elementi Blue Blu Bold Grassetto Bold label text. Testo dell'etichetta in grassetto. @@ -339,6 +374,7 @@ Bookmark Description Descrizione del segnalibro Bookmarks Segnalibri Bookmarks Pane Pannello Segnalibri Boolean Booleano +Both ends Entrambe le estremità Bottom Basso Boundaries… Confini… Bounding\nGeometry Geometria di\ndelimitazione @@ -346,6 +382,7 @@ Brightness Luminosità Brightness, contrast, saturation and sepia for the whole map. Luminosità, contrasto, saturazione e seppia per l'intera mappa. Bring this bookmark's map into view to update it Porta in vista la mappa di questo segnalibro per aggiornarlo Browse Sfoglia +Browse Connection Sfoglia connessione Browse it in the Browser Sfoglialo nel Browser Browse Plugins Sfoglia plugin Browse the geoprocessing toolbox. Sfoglia gli strumenti di geoprocessing. @@ -357,6 +394,7 @@ Browser Browser Browser\nPane Pannello\nBrowser Browser View Vista Browser Bucket Bucket +Buckets Bucket Buffer Buffer Buffer A Point Buffer di un punto Buffer the cities by 200 km Crea un buffer di 200 km attorno alle città @@ -381,7 +419,9 @@ Calculate Field Calcola campo Calculate field Calcola campo Calculate field values. Calcola i valori del campo. Calculate fields and enforce conditions on every edit. Calcola campi e applica condizioni a ogni modifica. +Calculate Geometry Calcola geometria Calculate Geometry Attributes Calcola attributi geometrici +Calculated {rows} features {rows} elementi calcolati Calculation Calcolo Camera Fotocamera Camera Effect Effetto fotocamera @@ -396,12 +436,14 @@ Case Field Campo di raggruppamento Cast Shadow Proietta ombra Catalog Catalogo catalog is a static STAC catalog's catalog.json or collection.json catalog è il catalog.json o collection.json di un catalogo STAC statico +Catalogue (CSW) Catalogo (CSW) Catalogue search (CSW) Ricerca nel catalogo (CSW) Celestial Bodies Corpi celesti Celestial Body Corpo celeste Celestial body: {body} Corpo celeste: {body} Cell size Dimensione cella cell size ° dimensione cella ° +Cells Celle Center Centro Center Map Here Centra la mappa qui Centered Centrato @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. L’uso del piano ChatGPT sign-in failed. Check the connection and local settings, then try again. Accesso a ChatGPT non riuscito. Controlla la connessione e le impostazioni locali, quindi riprova. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. L’utilizzo di ChatGPT non è temporaneamente disponibile. Mantieni l’accesso e riprova più tardi. CHE CHE +Check Verifica Check Again Controlla di nuovo Check for\nUpdates Cerca\naggiornamenti Check for Updates Cerca aggiornamenti @@ -428,6 +471,7 @@ Check for Updates… Cerca aggiornamenti… Check the address and the service type. Controlla l'indirizzo e il tipo di servizio. Check the address and your connection. Controlla l'indirizzo e la connessione. Check the address. Controlla l'indirizzo. +Check the clause, and count the features it takes. Verifica la clausola e conta gli elementi che include. Check the integration's client, permissions, workspace policy and route configuration. Controlla il client, i permessi, i criteri dell’area di lavoro e la configurazione delle rotte dell’integrazione. Checking for updates… Ricerca aggiornamenti… China Cina @@ -455,6 +499,7 @@ Choose which layers a click identifies. Scegli quali layer identifica un clic. Choose which tabs the ribbon shows. Scegli quali schede mostra la barra multifunzione. Circle Cerchio Circle Radius (km) Raggio del cerchio (km) +Class Classe Class color Colore della classe Class Style Stile per classe Classes Classi @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Clic per aggiungere ver Click to edit the description Fai clic per modificare la descrizione Click to place a point graphic. Fai clic per posizionare una grafica puntuale. Click to select the node Fai clic per selezionare il nodo +Click to select, double-click to add, right-click for more Fai clic per selezionare, doppio clic per aggiungere, clic destro per altre opzioni Click to switch between decimal degrees and DMS Fai clic per passare da gradi decimali a GMS Clip Ritaglia Clip a raster to the view, or resample it. Ritaglia un raster sulla vista o ricampionalo. @@ -532,7 +578,7 @@ Close thread Chiudi thread Close {name} Chiudi {name} Closest Più vicina Coded values, one per line: code=description Valori codificati, uno per riga: codice=descrizione -Collapse or expand the ribbon Comprimi o espandi la barra multifunzione +Collapse or Expand the Ribbon Comprimi o espandi la barra multifunzione Collapse the ribbon (Ctrl+F1) Comprimi la barra multifunzione (Ctrl+F1) Collection Collezione Collection {id}: choose dates and Find Items Collezione {id}: scegli le date e Trova item @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Combinazioni dal riquadro sopra, una per riga COMID {comid} COMID {comid} Comma Virgola +Comma-separated (optional) Separati da virgole (facoltativo) Command\nSearch Cerca\ncomandi command is required command è obbligatorio Command Search Cerca comandi @@ -572,10 +619,13 @@ Connect Connetti Connect a folder to the Browser. Collega una cartella al Browser. Connect to S3 Connetti a S3 Connect, or pick a layer Collega o scegli un layer +Connected to {kind} {title} Connesso a {kind} {title} Connecting to {url}… Connessione a {url}… Connection Connessione +connection.new returned no id connection.new non ha restituito alcun id Connections Connessioni -Connect… Connetti… +Connections you add are listed here Le connessioni che aggiungi sono elencate qui +Connections… Connessioni… Constant Costante Constant height Altezza costante Constant… Costante… @@ -583,11 +633,14 @@ Contingent Values Valori condizionati Continue Continua Continue with ChatGPT Continua con ChatGPT Contrast Contrasto +Control Point Fit Adattamento dei punti di controllo Conversion Conversione Convert Converti Convert a layer to another format Converti un layer in un altro formato Convert the selected layer's labels into an annotation layer. Converti le etichette del layer selezionato in un layer di annotazioni. +Convert this layer's labels into an annotation layer. Converti le etichette di questo layer in un layer di annotazioni. Convert To Annotation Converti in annotazioni +Convert to Annotation Converti in annotazioni Converted data Dati convertiti Convert… Converti… Coordinate Coordinata @@ -599,11 +652,15 @@ Copied the map to the clipboard Mappa copiata negli appunti Copied {path} Copiato {path} Copies the (selected) features of a layer to a new layer. Copia gli elementi (selezionati) di un layer in un nuovo layer. Copy Copia +Copy / Cut / Paste features Copia / Taglia / Incolla elementi Copy a model.run command that runs this model, for the Script pane or Python Copia un comando model.run che esegue questo modello, per il pannello Script o Python Copy Address Copia indirizzo Copy address Copia indirizzo Copy an image of the map to the clipboard. Copia un'immagine della mappa negli appunti. +Copy as CLI Command Copia come comando CLI +Copy as Command Copia come comando Copy As GeoJSON Copia come GeoJSON +Copy as Python Copia come Python Copy Command Copia comando Copy coordinates to the clipboard Copia le coordinate negli appunti Copy details Copia dettagli @@ -611,11 +668,13 @@ Copy Group Copia gruppo Copy Layer Copia layer Copy Layers Copia layer Copy Path Copia percorso +Copy Run As Code Copia esecuzione come codice Copy sign-in link Copia il link di accesso Copy the selected features (also as GeoJSON text). Copia gli elementi selezionati (anche come testo GeoJSON). Copy the selected features and delete them. Copia gli elementi selezionati ed eliminali. Copy the selected layer's data source path. Copia il percorso della sorgente dati del layer selezionato. Copy this element (Ctrl+D, ⌘D on a Mac) Copia questo elemento (Ctrl+D, ⌘D su Mac) +Copy this run as code, or open it in its tool Copia questa esecuzione come codice, o aprila nel suo strumento Corrections Correzioni Cost Costo could not draw the map impossibile disegnare la mappa @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Impossibile caricare il registro dei Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Conteggio, somma, media, min, max e deviazione standard di un campo, facoltativamente per campo di raggruppamento. Countries, cities, rivers and lakes Paesi, città, fiumi e laghi Country Paese +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Conta i punti di un layer per la cella A5 che li contiene (celle di area uguale, quindi il conteggio è una densità), con la somma e la media di un campo. Counts the points that fall inside each polygon. Conta i punti che ricadono in ciascun poligono. Counts, totals and top categories of what's in view. Conteggi, totali e categorie principali di ciò che è in vista. CPU CPU @@ -647,12 +707,8 @@ Cross Section Sezione trasversale Cross Section (2 clicks) Sezione trasversale (2 clic) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Cumulativo Current date Data corrente Current Project Progetto corrente @@ -680,13 +736,16 @@ Data type Tipo di dati Data you add is listed here I dati che aggiungi sono elencati qui data.gov and CKAN catalogues Cataloghi data.gov e CKAN Database Database -Databases Database +Database connections are used by the desktop app Le connessioni ai database sono usate dall'applicazione desktop +databases are read through DuckDB, a program the browser can't run i database vengono letti tramite DuckDB, un programma che il browser non può eseguire Database… Database… Dataset Dataset Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Dataset su Hugging Face per nome o proprietario/nome; sfoglia i file di un dataset e aggiungi GeoTIFF, GeoParquet, PMTiles, LiDAR e altri file. Data… Dati… Date Data Dates Date +Dates a cell needs Date necessarie per cella +Dates a cell needs is a whole number, or empty for half Le date necessarie per cella sono un numero intero, o vuoto per la metà Day Giorno Day and night shading for a time. Ombreggiatura giorno e notte per un dato orario. Decimal Decimale @@ -715,7 +774,6 @@ Delete Notebook Elimina notebook Delete Page Elimina pagina Delete Presentation Elimina presentazione Delete Report Elimina report -Delete selected features Elimina elementi selezionati Delete selected rows Elimina righe selezionate Delete Style Elimina stile Delete Task Elimina attività @@ -736,11 +794,15 @@ Desktop and command line only Solo app desktop e riga di comando Details Dettagli Details of the selected layer. Dettagli del layer selezionato. Deviations Deviazioni +DGGS DGGS Diagnostic\nMonitor Monitor\ndiagnostico Diagnostic Monitor Monitor diagnostico Diagnostics Diagnostica +Diagonal ↗ Diagonale ↗ +Diagonal ↘ Diagonale ↘ Diamond Rombo Difference, ratio or percent change between two rasters. Differenza, rapporto o variazione percentuale tra due raster. +Digit Digit Directions Indicazioni stradali Directions From Here Indicazioni da qui Directions To Here Indicazioni fino a qui @@ -758,6 +820,7 @@ Dissolve Field Campo di dissoluzione Distance Distanza Distance (km) Distanza (km) Distance Coefficient P Coefficiente di distanza P +Distance units Unità di distanza Distances along the surface, heights, areas and volumes. Distanze lungo la superficie, altezze, aree e volumi. DISTINCT DISTINCT Distinct Distinti @@ -804,18 +867,23 @@ Drag to draw Trascina per disegnare Drag to draw a freehand line. Trascina per disegnare una linea a mano libera. Drag to draw a freehand polygon. Trascina per disegnare un poligono a mano libera. Drag to draw a rectangle. Trascina per disegnare un rettangolo. +Drag to resize the message box Trascina per ridimensionare la casella del messaggio Drag tools from the palette onto the canvas. Trascina gli strumenti dalla tavolozza sull'area di disegno. Drag vertices on the map; click an edge to add one Trascina i vertici sulla mappa; fai clic su un bordo per aggiungerne uno Draw a halo around label text. Disegna un alone attorno al testo dell'etichetta. Draw a labelled latitude/longitude grid over the map frame. Disegna un reticolo di latitudine/longitudine con etichette sulla cornice della mappa. Draw a model: chain tools from input layers to the results to keep Disegna un modello: concatena strumenti dai layer di input ai risultati da conservare Draw a model: chain tools from input layers to the results to keep. Disegna un modello: concatena strumenti dai layer di input ai risultati da conservare. +Draw at its place in the stack, the bottom stroke first Disegna nella sua posizione nella pila, prima il tratto in basso +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Disegna i tratti di un livello per tutti gli elementi prima del successivo, così le bordature si uniscono sotto i tratti centrali dove le linee si incontrano Draw labels at every scale and distance. Disegna le etichette a ogni scala e distanza. Draw new features in the selected layer. Double-click to finish lines and polygons. Disegna nuovi elementi nel layer selezionato. Doppio clic per terminare linee e poligoni. Draw new single-band rasters with the inverted grey stretch again. Disegna di nuovo i nuovi raster a banda singola con lo stretch grigio invertito. +Draw only polygon outlines, keeping their colors and widths. Disegna solo i contorni dei poligoni, mantenendone colori e spessori. Draw points as bins coloured by how many fall in each. Disegna i punti come celle colorate in base a quanti ne cadono in ciascuna. Draw polygons as outlines only. Disegna i poligoni solo come contorni. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Disegna i raster a banda singola aggiunti d'ora in poi con questo schema di colori, stretch e ricampionamento. +Draw strokes in levels Disegna i tratti per livelli Draw the layer at every scale. Disegna il layer a ogni scala. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Disegna la mappa sulla GPU (beta; il globo, gli effetti di vista e la simulazione della visione dei colori restano sul renderer CPU). Draw the selected layer above others. Disegna il layer selezionato sopra gli altri. @@ -828,6 +896,7 @@ drawn by the server disegnato dal server Drive In auto Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. Immagini da drone e aeree condivise su OpenAerialMap, le più recenti per prime, aggiunte come GeoTIFF ottimizzati per il cloud. Duplicate Duplica +Duplicate Connection Duplica connessione Duplicate Element Duplica elemento Duplicate Layout Duplica layout Duplicate Map Duplica mappa @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name Ad es. pop_est / 1000000 e.g. 0, 255, -9999 es. 0, 255, -9999 e.g. continent = 'Europe' ad es. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' ad es. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 ad es. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Ogni esecuzione di geoprocessing diventa un passaggio, concatenato tramite i suoi output Earth Index has no points in the view Earth Index non ha punti nella vista Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Modifica\nvertici Edit and run the notebook. Modifica ed esegui il notebook. Edit Attribute Modifica attributo Edit Cell Modifica cella +Edit Connection Modifica connessione Edit Description Modifica descrizione Edit Graphic Modifica grafica Edit Status Stato delle modifiche @@ -882,6 +953,7 @@ Editor\nTools Strumenti di\nmodifica Editor Tools Strumenti di modifica Edits since the project was saved. Modifiche dal salvataggio del progetto. Edits the project Modifica il progetto +Edit… Modifica… Effects Effetti Element Elemento Element Properties Proprietà elemento @@ -898,21 +970,25 @@ Enable Lighting Attiva illuminazione Enable Plugin Attiva plugin Enabled Attivato Encloses each feature, or all features, in its convex hull. Racchiude ogni elemento, o tutti gli elementi, nel suo inviluppo convesso. +Encoded polyline Polilinea codificata End Fine End date Data di fine Endpoint Endpoint Enhancement Miglioramento Enter Enter Enter an API key. Inserisci una chiave API. +Enter an EPSG code as a number, such as 32650 Inserisci un codice EPSG come numero, ad esempio 32650 Enter the provider's address. Inserisci l'indirizzo del provider. Enter to send, Shift+Enter for a new line Invio per inviare, Maiusc+Invio per andare a capo EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Ambienti EPSG EPSG +EPSG code Codice EPSG EPSG or ESRI code Codice EPSG o ESRI EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Intervallo uguale Equirectangular (Plate Carrée) Equirettangolare (Plate Carrée) @@ -922,14 +998,18 @@ Error Errore Error Inspector Ispettore errori Error Inspector (validate) Ispettore errori (convalida) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Europa equivalente (ETRS89 LAEA) Every band's value at a pixel. Il valore di ogni banda in un pixel. Every editing tool. Ogni strumento di modifica. +Every label setting of this layer, in the Labeling pane. Tutte le impostazioni delle etichette di questo layer, nel pannello Etichettatura. Every layer, as FEMA draws them Ogni layer, come li disegna FEMA +Every run, in order, as a script that opens the project Ogni esecuzione, in ordine, come script che apre il progetto every Socrata site, or one such as data.cityofnewyork.us tutti i siti Socrata, o uno come data.cityofnewyork.us Everywhere Ovunque +Existing field Campo esistente Existing Template Modello esistente exit rotary esci dalla rotatoria exit roundabout esci dalla rotonda @@ -948,6 +1028,7 @@ Export\nRaster Esporta\nraster Export Animation Esporta animazione Export Bookmarks Esporta segnalibri Export Bookmarks… Esporta segnalibri… +Export CSV… Esporta CSV… Export Features Esporta elementi Export Features (tool) Esporta elementi (strumento) Export Features needs a vector layer (Data › Export Raster writes a raster) Esporta elementi richiede un layer vettoriale (Dati › Esporta raster scrive un raster) @@ -996,6 +1077,7 @@ Face Culling Culling delle facce Faces Facce Factor Fattore Failed Non riuscito +FALSE FALSE Farthest Più lontana Faster drawing: no antialiasing. Disegno più veloce: nessun antialiasing. Favourite Data Source Origine dati preferita @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Confini dei campi agricoli Field density Densità dei campi agricoli Field name Nome campo +Field Prefix Prefisso campo +Field to Sum and Average Campo da sommare e mediare Fields Campi Fields are drawn from zoom 9: zoom in to see them. I campi agricoli sono disegnati dallo zoom 9: ingrandisci per vederli. Fields colored by score, drawn from zoom 9 Campi agricoli colorati per punteggio, disegnati dallo zoom 9 @@ -1079,6 +1163,8 @@ Folder Connections Connessioni a cartelle folder connections need the desktop app le connessioni a cartelle richiedono l'app desktop Folders Cartelle Folder… Cartella… +Follow the time slider Segui il cursore temporale +Font Carattere for a store that doesn't say per un archivio che non lo indica Forest Foresta Forget Earthdata Login Token Dimentica token Earthdata Login @@ -1090,9 +1176,11 @@ France Francia Free the prepared geometry. Libera la geometria preparata. Freehand Mano libera FROM FROM +From Da From Selection Dalla selezione From Service… Da servizio… From symbol Dal simbolo +From the address Dall'indirizzo from the address dall'indirizzo from the service dal servizio From URL… Da URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Nascondi gli elementi lontani dal cent Hide Group Nascondi gruppo Hide labels when the viewer is closer than this. Nascondi le etichette quando l'osservatore è più vicino di questo valore. Hide labels when the viewer is farther than this. Nascondi le etichette quando l'osservatore è più lontano di questo valore. +Hide Layers Nascondi layer Hide other values Nascondi altri valori Hide Steps Nascondi passaggi Hide the description Nascondi la descrizione @@ -1213,6 +1302,8 @@ Hold Sospendi HOME HOME Home Home Home › Add Home › Aggiungi +Home › Add Data Home › Aggiungi dati +Home › Inspect › Measure Home › Ispeziona › Misura Home › Navigate Home › Naviga Home › Navigate › Bookmarks Home › Naviga › Segnalibri Home › Web Data Home › Dati web @@ -1220,7 +1311,7 @@ Home › Web Data › United States Home › Dati web › Stati Uniti Homepage Sito web Horizontal Orizzontale host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=…, o un file .duckdb +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Immagini ospitate Hour Ora How the layer's colours combine with the tile layers and basemap beneath. Come i colori del layer si combinano con i layer di tile e la mappa di base sottostanti. @@ -1248,6 +1339,7 @@ Imagery Sharing Condivisione immagini Imagery Tools Strumenti immagini ImageServer ImageServer Image… Immagine… +Imperial Imperiale Import Importa Import\nProject Importa\nprogetto Import All Importa tutto @@ -1267,6 +1359,7 @@ Import Task Importa attività Import Task… Importa attività… Import Thumbnail Importa miniatura Import… Importa… +IN IN In Beyond Oltre (ingrandimento) In memory In memoria In Progress In corso @@ -1281,6 +1374,8 @@ Information Informazioni INPUT INPUT Input Input Input Features Elementi di input +Input Layer Layer di input +Input Points Punti di input Input Polygons Poligoni di input Input Rows Righe di input Input Summary Features Elementi di riepilogo di input @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} I plugin installati sono conservati in {f Installed with Homebrew, winget, the AUR or COPR? Update it there. Installato con Homebrew, winget, AUR o COPR? Aggiornalo da lì. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} installato. Lo trovi nella scheda Plugin; i suoi strumenti sono anche nel pannello Strumenti. Installing it downloads Python {version} and the packages it needs from the internet. L'installazione scarica da Internet Python {version} e i pacchetti di cui ha bisogno. +Integer field: values are rounded. Campo intero: i valori vengono arrotondati. Interface font: {font} Font dell'interfaccia: {font} Interpolate a surface from points (IDW) Interpola una superficie da punti (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Interpola una superficie raster a 2 bande (Previsione e Varianza) da elementi puntuali con il kriging ordinario. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. Interpola una superficie raster dai valori degli elementi con la ponderazione inversa della distanza, come fa QGIS. Interpolation Interpolazione Interpolation Attribute Attributo di interpolazione @@ -1316,10 +1413,13 @@ Invalid: {error} Non valida: {error} Invert Inverti Invert color scheme Inverti schema di colori Invert the selection. Inverti la selezione. +IS IS IS NULL IS NULL it connects to an address on the internet si connette a un indirizzo su Internet It has no code: nothing in it runs. Non contiene codice: nulla al suo interno viene eseguito. +It is locked, or one of its surrounds is: unlock it first È bloccato, o lo è uno dei suoi elementi di contorno: sbloccalo prima it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) è riservato a te (plugin, script, attività, modelli e l'esecutore GeoLibre) +it needs input: open it from its pane or button, or run it from a script or the assistant richiede dei dati in ingresso: aprilo dal suo pannello o pulsante, o eseguilo da uno script o dall'assistente it reads or writes files on this computer legge o scrive file su questo computer it replaces the open project sostituisce il progetto aperto it runs a plugin's command esegue un comando di un plugin @@ -1332,20 +1432,30 @@ Italic Corsivo Item Pop-ups Pop-up degli elementi Item {id} Item {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. Il suo firewall ({firewall}) ha bloccato RustGIS. Il servizio potrebbe consentire solo alcune applicazioni: contatta il suo amministratore e inviagli i dettagli qui sotto. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Il suo strumento non è disponibile ora (quello di un plugin non caricato, o di una suite non installata) Jobs Lavori JOIN JOIN Join Unione +Join Features Elementi da unire +Join Operation Operazione di unione +Joins attributes from a join layer to a target layer based on their spatial relationship. Unisce gli attributi di un layer di unione a un layer di destinazione in base alla loro relazione spaziale. +Journal figure, 1 column (90 mm) Figura per rivista, 1 colonna (90 mm) +Journal figure, 1.5 columns (140 mm) Figura per rivista, 1,5 colonne (140 mm) +Journal figure, 2 columns (190 mm) Figura per rivista, 2 colonne (190 mm) Keep a history of every edit to the layer, to look back or restore. Conserva una cronologia di ogni modifica al layer, per consultarla o ripristinarla. Keep an archive of edits Conserva un archivio delle modifiche keep current mantieni attuale +Keep it from being moved, resized, deleted or edited by mistake Evita che venga spostato, ridimensionato, eliminato o modificato per errore Keep one value of each point's vector as its value field Conserva un valore del vettore di ogni punto come suo campo valore Keep Result Conserva risultato Keep the labelled features fixed as the map moves. Mantieni fissi gli elementi etichettati quando la mappa si sposta. Keep the last drawing tool active after each graphic. Mantieni attivo l'ultimo strumento di disegno dopo ogni grafica. Keep the last tool active Mantieni attivo l'ultimo strumento +Keep this endpoint under Data Sources › Connections, without its password or keys Conserva questo endpoint in Origini dati › Connessioni, senza la sua password o le sue chiavi Keep this result Conserva questo risultato Keep Tool Mantieni strumento Keep value Conserva valore +Key Key Keyboard Shortcuts Scorciatoie da tastiera Keyboard shortcuts. Scorciatoie da tastiera. Keys from Chiavi da @@ -1356,13 +1466,20 @@ KMZ KMZ Label Etichetta Label Class Classe di etichette Label Distance Range Intervallo di distanza etichette +Label features in this layer Etichetta gli elementi di questo layer Label Field Campo etichetta +Label only the features that match. Etichetta solo gli elementi corrispondenti. Label Placement Posizionamento etichette Label Range Intervallo etichette Label the cities with their names Etichetta le città con i loro nomi Label Visibility Range Intervallo di visibilità etichette Labeling Etichettatura +Labeling Pane Pannello Etichettatura +Labeling Properties… Proprietà di etichettatura… Labels Etichette +labels are drawn for feature layers, not rasters le etichette sono disegnate per i layer di elementi, non per i raster +Labels are drawn for feature layers, not rasters. Le etichette sono disegnate per i layer di elementi, non per i raster. +Labels are off for this layer: the clause applies once they are on. Le etichette sono disattivate per questo layer: la clausola si applica una volta attivate. Lagoon Laguna Lambert Conformal Conic Conica conforme di Lambert Lambert Cylindrical Equal Area Cilindrica equivalente di Lambert @@ -1383,12 +1500,14 @@ Layer Blend Fusione layer Layer Extrusion Estrusione del layer Layer File File di layer Layer From\nSelection Layer da\nselezione +Layer not found Layer non trovato Layer Template Modello di layer Layer Templates Modelli di layer layer {id} layer {id} Layers Layer Layers ({n}) Layer ({n}) Layers and Table Views Layer e viste tabella +Layers, a date each Layer, una data ciascuno Layout Layout Layout\nFile File di\nlayout Layout after: {name} Layout dopo: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Risorse di\napprendimento Left Sinistra Legend Legenda Legend label. Clear it to show the value again. Etichetta della legenda. Cancellala per mostrare di nuovo il valore. -Length {length} · gain {gain} m · loss {loss} m Lunghezza {length} · salita {gain} m · discesa {loss} m +Length {length} · gain {gain} · loss {loss} Lunghezza {length} · salita {gain} · discesa {loss} Let the assistant hand tasks to agents on this computer Consenti all'assistente di affidare attività agli agenti su questo computer Let your Agents Control RustGIS Lascia che i tuoi agenti controllino RustGIS Letter spacing Spaziatura lettere @@ -1439,6 +1558,7 @@ List by drawing order Elenca per ordine di disegno List by selection Elenca per selezione List Columns in File Elenca colonne nel file List Commands Elenca comandi +List Connections Elenca connessioni List Data Sources Elenca origini dati List Database Columns Elenca colonne del database List Database Tables Elenca tabelle del database @@ -1475,6 +1595,11 @@ Locate… Individua… Location Posizione Locator Localizzatore Lock Blocca +Locked Bloccato +Locked: unlock it in the Format pane to move or change it Bloccato: sbloccalo nel pannello Formato per spostarlo o modificarlo +Log In to Connection Accedi alla connessione +Log In to {name}… Accedi a {name}… +Log In… Accedi… Long Intero lungo Longitude Longitudine Longitude (X) Longitudine (X) @@ -1493,6 +1618,7 @@ Manage Gestisci Manage Bookmarks Gestisci segnalibri Manage Edits Gestisci modifiche Manage Templates Gestisci modelli +Mann–Kendall Mann–Kendall Map Mappa Map\nFrame Cornice\nmappa Map (the current view) Mappa (la vista corrente) @@ -1504,6 +1630,7 @@ Map File File di mappa Map Frame Cornice della mappa Map Frames Cornici della mappa Map frames per second (live while the map moves) Fotogrammi al secondo della mappa (in tempo reale mentre la mappa si muove) +Map Labeling Etichettatura della mappa Map Link Link della mappa Map name Nome della mappa Map Page Pagina mappa @@ -1515,6 +1642,7 @@ Map style (JSON) Stile di mappa (JSON) Map Surrounds Elementi di contorno della mappa Map Thumbnail Miniatura della mappa Map tiles of the whole world Tile di mappa del mondo intero +Map Tool: {tool} Strumento mappa: {tool} Map View Vista della mappa map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Mappa: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Marcatore Mask Values Valori da mascherare Masking Mascheratura +Match Option Opzione di corrispondenza Matching places Luoghi corrispondenti Max Max Max % Max % +Max Neighbors Vicini massimi Maximum zoom Zoom massimo MCP Relay Relay MCP MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} Il relay MCP sta già ascoltando su 127.0.0.1:{port}; fermalo prima per usare la porta {asked} @@ -1542,6 +1672,8 @@ Measure Distance Misura distanza Measure geodesic distances and areas on the map. Misura distanze e aree geodetiche sulla mappa. Measure on this body, with one of its basemaps. Misura su questo corpo, con una delle sue mappe di base. Measure with an elevation raster Misura con un raster di quota +Measurement units Unità di misura +Measurement units must be metric or imperial Le unità di misura devono essere metriche o imperiali Measurements so far. Misure finora. Mensuration Mensurazione Mensuration Tools Strumenti di mensurazione @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Unisce i po Messages appear here. I messaggi appaiono qui. Meters Metri Method Metodo +Metric Metrico Midnight Mezzanotte Miles Miglia Miller Cylindrical Cilindrica di Miller @@ -1560,21 +1693,43 @@ Min must be less than Max Min deve essere minore di Max Min must not be more than Max Min non deve essere maggiore di Max Minimum Bounding Geometry Geometria di delimitazione minima Minimum Maximum Minimo Massimo +Minus Minus Minutes Minuti minutes ago minuti fa Miter Spigolo vivo Mobile Map Mappa mobile Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Modello Model\nBuilder Model\nBuilder model id id del modello Model output Output del modello ModelBuilder ModelBuilder Models Modelli +Modified Mann–Kendall (Hamed & Rao) Mann–Kendall modificato (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Mann–Kendall modificato (Yue & Wang) Modify Modifica Modify Selected Features Modifica elementi selezionati Mollweide (equal-area world) Mollweide (mondo equivalente) Month Mese +Months Mesi +Months are numbers from 1 to 12 (12, 1, 2 for December to February) I mesi sono numeri da 1 a 12 (12, 1, 2 per dicembre-febbraio) More Altro More basemaps: Altre mappe di base: Mosaic images into an orthomosaic Unisci immagini in un ortomosaico @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Unisci i raster visibili in un o Most cloud cover (%) Copertura nuvolosa massima (%) Mouse wheel Rotellina del mouse Move Sposta -Move (m) Sposta (m) Move Bookmark Sposta segnalibro Move Down Sposta giù Move down Sposta giù Move Group Sposta gruppo +Move Label Class Sposta classe di etichette Move Task Step Sposta passaggio dell'attività +Move To Bottom Sposta in fondo Move To Group Sposta nel gruppo +Move To Top Sposta in cima Move To {group} Sposta in {group} Move Up Sposta su Move up Sposta su @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets I miei bucket My OGC Layer Il mio layer OGC +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Nome name the plugin whose panel to open: {"id": plugin id} indica il plugin di cui aprire il pannello: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Nuovo\nnotebook New\nPresentation Nuova\npresentazione New\nReport Nuovo\nreport New\nWorkspace Nuovo spazio\ndi lavoro -New / Open / Save project Nuovo / Apri / Salva progetto New Bookmark Nuovo segnalibro New Bookmark… Nuovo segnalibro… +New Connection Nuova connessione +New Connection ▾ Nuova connessione ▾ New conversation Nuova conversazione New Custom Template Nuovo modello personalizzato New Custom Template… Nuovo modello personalizzato… New features use this template's values I nuovi elementi usano i valori di questo modello +New field Nuovo campo New Group Nuovo gruppo New job name Nome del nuovo lavoro New Layout Nuovo layout @@ -1649,6 +1809,7 @@ New Notebook Nuovo notebook New Presentation Nuova presentazione New Project Nuovo progetto New project Nuovo progetto +New Publication Layout Nuovo layout di pubblicazione New Relationship… Nuova relazione… New Report Nuovo report New Selection Nuova selezione @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Ancora nessun No catalogs below this one: Find Items lists its items Nessun catalogo sotto questo: Trova item ne elenca gli item No COMID was returned for this point. Nessun COMID restituito per questo punto. no command nessun comando -No connections this session Nessuna connessione in questa sessione +No data at the points picked. Nessun dato nei punti scelti. No datasets match. Nessun dataset corrisponde. No datasets with data RustGIS can add Nessun dataset con dati che RustGIS possa aggiungere +no date in the name nessuna data nel nome No edits since the project was saved Nessuna modifica dal salvataggio del progetto No field of this layer holds dates. Nessun campo di questo layer contiene date. +No Fill Nessun riempimento +No geometry to calculate Nessuna geometria da calcolare No granules in this view and date range. Nessun granulo in questa vista e intervallo di date. no home folder nessuna cartella home no layer nessun layer @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) Nessuna relazione (Progettazion No relationships yet Ancora nessuna relazione No reports yet. Publish › New Report lists the selected layer's records. Ancora nessun report. Pubblica › Nuovo report elenca i record del layer selezionato. no row nessuna riga +No serif font is installed, so this text is drawn in the sans-serif Nessun carattere con grazie è installato, quindi questo testo è disegnato con quello senza grazie no service or file links nessun collegamento a servizi o file No services match your search. Nessun servizio corrisponde alla ricerca. No standalone tables Nessuna tabella autonoma @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Nessuno None (public buckets) Nessuna (bucket pubblici) none, a .duckdb file, or host=… dbname=… user=… nessuno, un file .duckdb o host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD nessuna, o da PGPASSWORD o MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD nessuna, o da PGPASSWORD, MYSQL_PWD o SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. Nessuno. Aggiungine uno tramite indirizzo o da un plugin di mappe di base. North Nord North\nArrow Freccia\ndel nord North Polar Stereographic Stereografica polare nord +NOT NOT not a picture non è un'immagine not a RustGIS layout non è un layout RustGIS not a RustGIS project non è un progetto RustGIS +Not applied yet. Non ancora applicato. Not for texts: a text is edited in the Format pane. Non per i testi: un testo si modifica nel pannello Formato. not needed non necessaria Not sandboxed Fuori dalla sandbox @@ -1739,9 +1906,12 @@ Nothing found where the map is Nulla trovato dove si trova la mappa Nothing here Qui non c'è nulla Nothing named {name} to rename Niente di nome {name} da rinominare Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Ancora nulla di condiviso. Pubblica › Condividi come crea mappe web, layer, stili, scene e altro. +Nothing to add here Niente da aggiungere qui +Nothing to test Niente da verificare Nothing was found at this address. Nulla trovato a questo indirizzo. Nothing was recorded, so no task was made. Non è stato registrato nulla, quindi non è stata creata alcuna attività. Now Adesso +Nugget Effetto pepita NULL NULL Nulls Valori nulli Observations (observer, date, species, count) Osservazioni (osservatore, data, specie, conteggio) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. Sul desktop, la chiave può p On-screen compass, zoom slider and pan pad. Bussola a schermo, cursore di zoom e controllo di spostamento. One command per line: id {json}. Lines starting with # are comments. Un comando per riga: id {json}. Le righe che iniziano con # sono commenti. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Una sola SELECT di DuckDB: le funzioni ST_ di DuckDB Spatial, file e indirizzi web (read_parquet('…')) e le tabelle del database qui sotto. Ctrl+Enter la esegue. +One layer: a Zarr variable or a raster's bands Un layer: una variabile Zarr o le bande di un raster One page per feature of an index layer. Una pagina per ogni elemento di un layer indice. One per line: code=name Uno per riga: codice=nome One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Un tratto, dal simbolo. Aggiungi tratti per bordature, linee parallele e scostamenti. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Apri cartella Open In Geoprocessing… Apri in Geoprocessing… Open in ModelBuilder Apri in ModelBuilder +Open in Tool Apri nello strumento Open Layout Apri layout Open ModelBuilder Apri ModelBuilder Open Notebook Apri notebook @@ -1797,6 +1969,7 @@ Open Presentation Apri presentazione Open Project Apri progetto Open project Apri progetto Open Project (from text) Apri progetto (da testo) +Open Project… Apri progetto… Open Report Apri report Open the attribute table of the selected layer. Apri la tabella degli attributi del layer selezionato. Open the Basemaps tab Apri la scheda Mappe di base @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Oppure esegui in una shell PO Or run in PowerShell, for Claude Code or Codex: Oppure esegui in PowerShell, per Claude Code o Codex: Or run, for Claude Code or Codex: Oppure esegui, per Claude Code o Codex: ORDER BY ORDER BY +Ordinary Kriging Kriging ordinario Orientation Orientamento Ortho Mapping Ortomappatura Other Altro Other Data Altri dati +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Altre classi di etichette su questo layer (create con comandi): {n}. Ognuna etichetta gli elementi che corrispondono alla propria clausola, quindi un elemento può ricevere un'etichetta da più di una. Out Beyond Oltre (riduzione) Outline Contorno Outline another frame's extent in this map frame. Delinea l'estensione di un'altra cornice in questa cornice della mappa. @@ -1851,6 +2026,7 @@ Output Output Output Name Nome output Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Oltre 1.000 strumenti raster, vettoriali, LiDAR, idrologici e del terreno che funzionano nel browser, con un unico download di circa 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Oltre 1.000 strumenti raster, vettoriali, LiDAR, idrologici e del terreno, in un download facoltativo di circa 20 MB. +Overlapping Sovrapposte Overlay Sovrapposizione overlay sovrapposizione Overlay (a transparent layer kept on top, such as labels or roads) Sovrapposizione (un layer trasparente mantenuto in cima, come etichette o strade) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture non ha dati {ty} dove si tro Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. I dati aperti di Overture Maps: edifici, luoghi, trasporti, divisioni amministrative, layer di base e indirizzi. Dove si trova la mappa, i suoi elementi vengono letti sul posto dai file GeoParquet di Overture (ingrandisci fino a una città o meno); il mondo intero arriva come tile di mappa. Overture Maps… Overture Maps… +Overwrite existing values Sovrascrivi i valori esistenti Package Pacchetto Package a trained deep learning model. Crea un pacchetto di un modello di deep learning addestrato. Package Map Crea pacchetto di mappa @@ -1907,8 +2084,12 @@ Performance Prestazioni Phase Fase Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Scegli un valore dinamico, come la scala o la data, poi fai clic sulla mappa per posizionarlo. +Pick a Point Scegli un punto +Pick a point on the map to see the variable's values through time there. Scegli un punto sulla mappa per vedere lì i valori della variabile nel tempo. Pick a route in the list, or click a grey route on the map. Scegli un percorso nell'elenco, o fai clic su un percorso grigio sulla mappa. +Pick a single layer. Scegli un solo layer. Pick it on the map Sceglilo sulla mappa +Pick one… Scegli… Picture Immagine Pictures Immagini Pictures, attachments and chart Immagini, allegati e grafico @@ -1966,8 +2147,11 @@ Portal Portale Portolan Portolan Portolan… Portolan… Portrait Verticale +Position Posizione +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Post-elaborazione power 2 potenza 2 +Precision 6 (OSRM, Valhalla) Precisione 6 (OSRM, Valhalla) Prepare Prepara Prepare every layer's geometry for this projection. Prepara la geometria di ogni layer per questa proiezione. Prepare Plugin Environment Prepara l'ambiente dei plugin @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Modelli di progetto: avvia project's path is required il percorso di project è obbligatorio Projection Proiezione Projections Proiezioni +Properties Proprietà +Property Proprietà Proportional Symbols Simboli proporzionali Provider Provider +Provinces: Province: Proximity Prossimità Public GIS portals of US counties and cities, by state. Portali GIS pubblici di contee e città degli USA, per stato. Public GIS portals of US federal agencies, by department. Portali GIS pubblici delle agenzie federali degli USA, per dipartimento. Public GIS portals of US states and the District of Columbia. Portali GIS pubblici degli stati USA e del Distretto di Columbia. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. Layer pubblici su ArcGIS Online o un altro portale ArcGIS: servizi di elementi, mappe, immagini e tile vettoriali, mappe web e file GeoJSON. +Publication Layout Layout di pubblicazione Publish Pubblica Publish › Output Pubblica › Output Put the layer back as it was then Riporta il layer com'era allora Python environment failed: {error} Ambiente Python non riuscito: {error} Python environment not prepared Ambiente Python non preparato Python environment ready Ambiente Python pronto +Python script Script Python Qualitative Qualitativa Quantile Quantile Query files, web addresses and databases with DuckDB Spatial; add results to the map. Interroga file, indirizzi web e database con DuckDB Spatial; aggiungi i risultati alla mappa. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Solleva i poligoni di un'altezza o di un Raise Priority Alza priorità Random points in polygons Punti casuali nei poligoni Range Intervallo +Range (Degrees) Portata (gradi) +range: 0, 0.9 intervallo: 0, 0.9 Raster Raster Raster\nAnalysis Analisi\nraster Raster\nInformation Informazioni\nraster @@ -2075,6 +2266,7 @@ Redo {action} Ripeti {action} Red–Blue Rosso–Blu Refine the search to see {n} more Affina la ricerca per vederne altri {n} Refresh Aggiorna +Refresh every connection's lists Aggiorna gli elenchi di tutte le connessioni Refresh Map Series Aggiorna serie di mappe Refresh the map series from its index layer. Aggiorna la serie di mappe dal suo layer indice. Region Regione @@ -2091,11 +2283,10 @@ Relationships Relazioni Release Release Reload Ricarica Reload Web Layers Ricarica layer web -Remember (without the secret) Ricorda (senza il segreto) Remember the key on this computer Ricorda la chiave su questo computer -Remembered: Ricordate: Remove Rimuovi Remove All Rimuovi tutti +Remove All Label Classes Rimuovi tutte le classi di etichette Remove an offline area. Rimuovi un'area offline. Remove Bookmark Rimuovi segnalibro Remove Connection Rimuovi collegamento @@ -2108,11 +2299,14 @@ Remove from Favourites Rimuovi dai preferiti Remove From Group Rimuovi dal gruppo Remove Group Rimuovi gruppo Remove Keyframe Rimuovi fotogramma chiave +Remove Label Class Rimuovi classe di etichette Remove Section Rimuovi sezione Remove Table Rimuovi tabella +Remove the clause. Rimuovi la clausola. Remove the last keyframe. Rimuovi l'ultimo fotogramma chiave. Remove the layer from the map. Rimuovi il layer dalla mappa. Remove the strokes and draw the symbol's line alone Rimuovi i tratti e disegna solo la linea del simbolo +Remove this class's own strokes and draw it with the layer's Rimuovi i tratti propri di questa classe e disegnala con quelli del layer Remove this stop Rimuovi questa tappa Remove Tile Layer Rimuovi layer di tile Remove token Rimuovi token @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Rimuove Rename Rinomina Rename Bookmark Rinomina segnalibro Rename Group Rinomina gruppo +Rename it to save it: a connection has this name Rinominala per salvarla: una connessione ha già questo nome +Rename Label Class Rinomina classe di etichette Rename Layer Rinomina layer Rename Layout Rinomina layout Rename Map Rinomina mappa @@ -2149,6 +2345,7 @@ Reset Default Reimposta predefinito Reset Effects Reimposta effetti Reset North Reimposta nord Reset north Reimposta nord +Resolution (0–30) Risoluzione (0–30) Response Risposta Restore Ripristina Restore From Archive Ripristina dall'archivio @@ -2162,6 +2359,7 @@ RGB RGB Ribbon Barra multifunzione Right Destra Right-click a source to add it here Fai clic destro su un'origine per aggiungerla qui +Right-click for more Clic destro per altre opzioni Robinson Robinson Rotate Ruota Rotate (°) Ruota (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Dati di esempio (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Dati di esempio © Natural Earth (pubblico dominio). RustGIS è distribuito con licenza Apache-2.0. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Dati di esempio: Natural Earth (pubblico dominio). Licenza Apache-2.0. +Sans-serif Senza grazie Satellite Embedding Datasets Dataset di embedding satellitari Satellite Embeddings Embedding satellitari Satellite Embeddings… Embedding satellitari… Saturation Saturazione Save Salva Save As Salva con nome +Save as Connection Salva come connessione Save as Default Salva come predefinito Save as Default Raster Style Salva come stile raster predefinito Save as Layer Salva come layer Save As Presentation File… Salva come file di presentazione… +Save as Python Script… Salva come script Python… Save As Report File… Salva come file di report… Save As Report Template… Salva come modello di report… Save as {name} Salva come {name} Save As… Salva con nome… Save Color Ramps Salva scale di colori Save Data Source Salva origine dati +Save History As Python Script Salva cronologia come script Python Save Layer File Salva file di layer Save Layout File Salva file di layout Save Map File Salva file di mappa @@ -2254,6 +2456,7 @@ save or load a pane set by name salva o carica un set di pannelli per nome Save Project Salva progetto Save project Salva progetto Save Project As Salva progetto con nome +Save Project As… Salva progetto con nome… Save Project Template Salva modello di progetto Save Style Items Salva elementi di stile Save Task Item Salva elemento attività @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Salva il modello come file (*.model.json Save the model in the project Salva il modello nel progetto Save the open layout as a layout file. Salva il layout aperto come file di layout. Save the panes you have open, and switch back to them. Salva i pannelli aperti e torna a essi in seguito. +Save the project too: the script opens its saved file, without changes made since. Salva anche il progetto: lo script apre il suo file salvato, senza le modifiche fatte da allora. Save the project under a new name. Salva il progetto con un nuovo nome. Save the project with your edits. Salva il progetto con le tue modifiche. Save the project's custom color ramps to a color ramp file. Salva le scale di colori personalizzate del progetto in un file di scale di colori. @@ -2275,13 +2479,16 @@ Save Web Link Chart Salva diagramma di collegamenti web Save Web Map Salva mappa web Save Web Scene Salva scena web Saved Salvato +Saved as the connection {name} Salvato come connessione {name} Saved as the default style for new single-band rasters. Salvato come stile predefinito per i nuovi raster a banda singola. +Saved connection Connessione salvata Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Le chiavi salvate sono conservate non cifrate nelle preferenze dell'app (nella memoria del browser sul web). Saved models Modelli salvati Saved symbology: apply it to the selected layer, or save the layer's. Simbologia salvata: applicala al layer selezionato o salva quella del layer. Saved View Vista salvata Saved {name} {name} salvato Saved {name}. Salvato {name}. +Saved {path} {path} salvato saving failed unexpectedly il salvataggio non è riuscito in modo imprevisto Scale Scala Scale\nBar Barra\ndi scala @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Cerca (questo portale richiede parole)… Search Catalogue Cerca nel catalogo Search commands — e.g. buffer, basemap, labels Cerca comandi — ad es. buffer, mappa di base, etichette Search datasets, e.g. GEDI biomass Cerca dataset, ad es. GEDI biomass -Search Distance (km) Distanza di ricerca (km) +Search Distance Distanza di ricerca Search every command and its parameters Cerca ogni comando e i suoi parametri Search layers (e.g. temperature) Cerca layer (ad es. temperature) Search layers, places, lat/lon… Cerca layer, luoghi, lat/lon… @@ -2329,6 +2536,8 @@ Searching the folders… Ricerca nelle cartelle… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. La ricerca funziona qui, ma i server dati della NASA non consentono alle pagine web di leggere i loro file (nessun CORS), quindi i GeoTIFF e i download di dati protetti richiedono l'app desktop. Scarica apre un file in una nuova scheda, dove Earthdata Login ti chiede di accedere. Searching… Ricerca in corso… Searching… try again in a moment. Ricerca in corso… riprova tra un momento. +Seasonal Mann–Kendall Mann–Kendall stagionale +Seasons a year Stagioni all'anno Second standard parallel Secondo parallelo standard Seconds Secondi Secret access key Chiave di accesso segreta @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Separatore Sepia Seppia Sequential Sequenziale +Serif Con grazie Server Server Service Group Gruppo di servizio Service group data exceeds 64 MB. I dati del gruppo di servizio superano 64 MB. Service Import Requests Richieste di importazione del servizio Service Layers Layer di servizio -Services Servizi +Session token Token di sessione Set Imposta Set Cell Size Imposta dimensione cella Set Domain Imposta dominio @@ -2411,6 +2621,7 @@ Shaded Ombreggiato Shaded Relief Rilievo ombreggiato Shape Forma Shapefile Shapefile +Shapes Forme Share Condividi Share As Condividi come Share every layer and table as OGC API Features collections. Condividi ogni layer e tabella come raccolte OGC API Features. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Mostra i dettagli al passa Show different features at different scales. Mostra elementi diversi a scale diverse. Show grid Mostra griglia Show Group Mostra gruppo +Show labels Mostra etichette Show latitude/longitude lines on the basemap. Mostra le linee di latitudine/longitudine sulla mappa di base. +Show Layers Mostra layer Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Mostra i layer nel tempo: solo gli elementi la cui data cade nella finestra del cursore, e le immagini datate per la sua data. Show Map Mostra mappa Show Me Mostrami @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Mostra o nascondi il pannello Strumenti. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Mostra i righelli. Trascina da un righello per aggiungere una guida; trascina una guida di nuovo sul righello per rimuoverla. Show the bands picked, read in place as the map needs them Mostra le bande scelte, lette sul posto quando la mappa ne ha bisogno Show the description Mostra la descrizione +Show the Labeling pane for the selected layer. Mostra il pannello Etichettatura per il layer selezionato. Show the labels that don't fit, in red. Mostra in rosso le etichette che non trovano posto. Show the layer only inside another layer's polygons. Mostra il layer solo all'interno dei poligoni di un altro layer. Show the layer only left of a divider, to compare it with what's beneath. Mostra il layer solo a sinistra di un divisore, per confrontarlo con ciò che sta sotto. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Accesso annullato. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. Tempo di accesso scaduto. Ricomincia e consenti al browser di raggiungere l’indirizzo di loopback di questo computer. signed in connesso Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Disconnesso su questo computer, ma OpenAI non l'ha confermato. Puoi scollegare RustGIS nelle impostazioni di ChatGPT. +Significance level Livello di significatività Signing in with ChatGPT works in the desktop app. L'accesso con ChatGPT funziona nell'app desktop. Signing the asset (Planetary Computer)… Firma dell'asset (Planetary Computer)… +Sill Soglia Simplify Semplifica Simulate the drive, or save the route as a layer. Simula il viaggio, o salva il percorso come layer. Simulation Simulazione @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} Skill per l'Assistente IA: {names} Slideshow, PDF or PNG Slideshow, PDF o PNG slight left svolta leggermente a sinistra slight right svolta leggermente a destra +Slope per Pendenza per Slope, aspect, hillshade, NDVI, band math, thresholds. Pendenza, esposizione, ombreggiatura, NDVI, calcolo tra bande, soglie. Slope, hillshade, NDVI and more. Pendenza, ombreggiatura, NDVI e altro. Small scales Piccole scale @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Sud South Polar Stereographic Stereografica polare sud +Spatial Join Unione spaziale Speaker notes Note del relatore Spectral Analysis Analisi spettrale Spectral indices Indici spettrali @@ -2530,6 +2748,7 @@ Split (click the line) Dividi (clic sulla linea) SQL SQL SQL Pane Pannello SQL SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. Le query SQL vengono eseguite tramite DuckDB, un programma richiamato dall'app desktop: apri questo progetto nell'app desktop per interrogarlo. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL che deve essere vero, ad es. pop > 0 Square Quadrato Squares Quadrati @@ -2588,6 +2807,7 @@ Start over at the end while playing. Ricomincia alla fine durante la riproduzion Start With Inizia con Start-up Avvio starting with a project file needs the desktop app l'avvio con un file di progetto richiede l'app desktop +Statistic Statistica Statistics Statistiche Statistics Field Campo statistico Status Stato @@ -2613,8 +2833,10 @@ Stretch\nType Tipo di\nstretch Stretch it over the map view, or give control points (one per line: column row lon lat). Estendilo sulla vista della mappa, oppure fornisci punti di controllo (uno per riga: colonna riga lon lat). Stretch Type Tipo di stretch Stretch type Tipo di stretch +Stroke Levels Livelli dei tratti Stroke {n} Tratto {n} Strokes Tratti +Strokes for Tratti per Style Stile Style Items Elementi di stile Style › Drawing Stile › Disegno @@ -2624,7 +2846,10 @@ Subreport: {name} Sottoreport: {name} Subtypes Sottotipi Subtypes, one per line: code=name Sottotipi, uno per riga: codice=nome Suitability Modeler Modellatore di idoneità +Summarise Riepiloga +Summarise over time Riepiloga nel tempo Summarise the conversation so far, so a long chat stays within the model's context Riassumi la conversazione fin qui, così una chat lunga resta nel contesto del modello +Summarise Zarr Over Time Riepiloga Zarr nel tempo Summarising the earlier conversation to keep it within the model's context… Riassunto della conversazione precedente per restare nel contesto del modello… Summarize Riepiloga Summarize\nWithin Riepiloga\nall'interno @@ -2658,6 +2883,8 @@ Table or layer Tabella o layer Tables Tabelle Tags Tag Take the data in view offline, to edit and sync back later. Porta offline i dati in vista, per modificarli e sincronizzarli in seguito. +Target Features Elementi di destinazione +Target Records Record di destinazione Task Attività Task Item Elemento attività Task name Nome dell'attività @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Dati GIS pubblici del Tennessee (Tennessee GeoData, geodata.tn.gov): confini delle proprietà, trasporti, sanità, idrologia e altro. Terrain Terreno Test Test +Test Connection Verifica connessione +Testing… Verifica in corso… Text Testo Text Symbol Simbolo di testo That connection would create a loop. Quel collegamento creerebbe un ciclo. The address fields are empty in every row. I campi dell'indirizzo sono vuoti in tutte le righe. +The answer isn't UTF-8 text La risposta non è testo UTF-8 the answer isn't UTF-8 text la risposta non è testo UTF-8 The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. L'assistente segue le istruzioni di una skill quando la usa. Qualunque cosa dicano, i suoi comandi che richiedono la tua approvazione continuano a chiederla. The assistant is busy; stop it or wait for it to finish. L'assistente è occupato; fermalo o attendi che finisca. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. La scheda di mappe di base pubblic the capture failed unexpectedly la cattura non è riuscita in modo imprevisto The connection failed; trying again in {seconds} s. La connessione non è riuscita; nuovo tentativo tra {seconds} s. The country list couldn't be read ({e}): type a country's code Impossibile leggere l'elenco dei paesi ({e}): digita il codice di un paese +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. La quota attuale resta in vigore mentre modifichi i valori. Fai clic su Applica quota per applicare il valore e le unità. The dataset has nothing RustGIS can add Il dataset non contiene nulla che RustGIS possa aggiungere The environment (AWS_ACCESS_KEY_ID…) L'ambiente (AWS_ACCESS_KEY_ID…) The features where the map is Gli elementi dove si trova la mappa @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. Il f The installed GeoLibre runner lists no tools. L'esecutore GeoLibre installato non elenca alcuno strumento. The job covers the map view Il lavoro copre la vista della mappa The key is kept for this session only. La chiave viene conservata solo per questa sessione. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. Il layer mostra il passo in cui si trova il cursore temporale, e attiva il cursore sulle date della variabile. The layer's file couldn't be read: {error} Impossibile leggere il file del layer: {error} +The layer's strokes I tratti del layer the layout export failed unexpectedly l'esportazione del layout non è riuscita in modo imprevisto The link doesn't say its STAC API: Find Items searches {api} Il collegamento non indica la sua STAC API: Trova item cerca in {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. L'elenco dei plugin da sfogliare. Lascialo vuoto per il registro dei plugin di RustGIS. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. La miniatura del progetto, dalla vis The projection the map opens in. La proiezione in cui si apre la mappa. The provider's content filter stopped the reply. Il filtro dei contenuti del provider ha interrotto la risposta. The public server is free and shared by many: it answers light use, and may ask you to wait. Il server pubblico è gratuito e condiviso da molti: risponde a un uso leggero e può chiederti di attendere. +The range is two numbers, low and high (0, 0.9) L'intervallo è composto da due numeri, minimo e massimo (0, 0.9) The raster's data couldn't be read. Impossibile leggere i dati del raster. The raster's data couldn't be read: {path} Impossibile leggere i dati del raster: {path} the release isn't known la release non è nota @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. I file dello Shapefile vengono scaric The share of each cell covered by fields, for the zoomed-out view La quota di ogni cella coperta da campi agricoli, per la vista rimpicciolita The sign-in expired or was revoked. Sign in again. L’accesso è scaduto o è stato revocato. Accedi di nuovo. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. L'accesso è conservato nella cartella delle impostazioni dell'app, solo per il tuo account utente, e mai in un file di progetto. +The significance level is a number, such as 0.05 Il livello di significatività è un numero, ad esempio 0.05 The start-up project {path} didn't open: {error} Il progetto di avvio {path} non si è aperto: {error} The start-up project {path} isn't a RustGIS project file. Il progetto di avvio {path} non è un file di progetto RustGIS. The step's layer {name} isn't in the map. Il layer {name} del passaggio non è nella mappa. The store has no variables with two or more dimensions. L'archivio non ha variabili con due o più dimensioni. the store is gone l'archivio non esiste più +The summary couldn't start: {error} Il riepilogo non è potuto partire: {error} +The summary failed unexpectedly Il riepilogo non è riuscito inaspettatamente +The summary finished after another project was opened, so it wasn't added Il riepilogo è terminato dopo l'apertura di un altro progetto, quindi non è stato aggiunto The task's steps changed, so the step being edited was closed: edit it again. I passaggi dell'attività sono cambiati, quindi il passaggio in modifica è stato chiuso: modificalo di nuovo. the time slider is off il cursore temporale è disattivato The token expires on {date}. Il token scade il {date}. +The trend couldn't start: {error} L'analisi di tendenza non è potuta partire: {error} +The trend failed unexpectedly L'analisi di tendenza non è riuscita inaspettatamente +The trend finished after another project was opened, so it wasn't added L'analisi di tendenza è terminata dopo l'apertura di un altro progetto, quindi non è stata aggiunta +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. I valori mostrati dal nero al bianco, uguali per ogni data e tile (nelle unità proprie della banda). Vuoto: lo stretch proprio di ogni file. the view has no extent la vista non ha un'estensione The view is larger than one read: its centre was read (zoom in for the rest) La vista è più grande di una lettura: è stato letto il suo centro (ingrandisci per il resto) The web app always runs the latest release: reload the page to get it. L'app web esegue sempre l'ultima release: ricarica la pagina per ottenerla. the window's screenshot didn't come back in time, so the map was drawn offscreen lo screenshot della finestra non è arrivato in tempo, quindi la mappa è stata disegnata fuori schermo the window's screenshot didn't hold the map, so it was drawn offscreen lo screenshot della finestra non conteneva la mappa, quindi è stata disegnata fuori schermo Theme Tema +There is no expression defined. Nessuna espressione definita. Thermal Termico +Thesis figure, full page (160 × 225 mm) Figura per tesi, pagina intera (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Figura per tesi, mezza pagina (160 × 110 mm) Thinking… Sto pensando… +Third-party licences Licenze di terze parti +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins questa build non può scaricare plugin this build can't reach AI providers questa build non può raggiungere i provider di IA this build saves projects with Save As questa build salva i progetti con Salva con nome This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Questo account ChatGPT era già salvato. La prossima volta sceglilo dall'elenco invece di aggiungerlo di nuovo. +This class is drawn with the layer's strokes. Changing them here gives it its own. Questa classe è disegnata con i tratti del layer. Modificarli qui le dà i propri. +This class is hidden, so it labels nothing. Questa classe è nascosta, quindi non etichetta nulla. +This clause no longer reads, so no feature is labelled: {error} Questa clausola non è più leggibile, quindi nessun elemento è etichettato: {error} This map has no layers yet. Questa mappa non ha ancora layer. This map's thumbnail, from an image. La miniatura di questa mappa, da un'immagine. This map's thumbnail, from the view. La miniatura di questa mappa, dalla vista. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Questa richiesta non prevede un'opzione per consentire una sola volta. This request uses an unsupported capability. Check the parameter and change the request. Questa richiesta utilizza una funzionalità non supportata. Controlla il parametro e modifica la richiesta. This result is kept Questo risultato viene conservato +This run has no tool form to open Questa esecuzione non ha un modulo dello strumento da aprire +This session only (optional) Solo per questa sessione (facoltativo) This step Questo passo This step names no tool to show. Questo passaggio non indica alcuno strumento da mostrare. Thumbnail Miniatura @@ -2789,15 +3041,21 @@ Tilt Map Inclina mappa Tilt-shift Tilt-shift Tilt-shift blur and film grain. Sfocatura tilt-shift e grana della pellicola. Time Tempo +Time\nSeries Serie\ntemporale Time\nSlider Cursore\ntemporale Time Fields Campi temporali +Time Series Serie temporale +Time series Serie temporale +Time Series at a Point Serie temporale in un punto Time Slider Cursore temporale Time Slider Settings Impostazioni del cursore temporale Tint by elevation Colora in base alla quota Tissot's Indicatrix Indicatrice di Tissot Title Titolo +To A Toggle Dark Mode Attiva/disattiva modalità scura Toggle GPU Rendering Attiva/disattiva rendering GPU +Toggle Imperial Measurements Attiva/disattiva misure imperiali Tolerance (km) Tolleranza (km) Tolls Pedaggi Tool Gallery Galleria strumenti @@ -2828,7 +3086,10 @@ Train Deep Learning Model Addestra modello di deep learning Trained models Modelli addestrati Transparency Trasparenza Traverse Poligonale +Trend Analysis Analisi di tendenza +Trend over a series of rasters Tendenza su una serie di raster Triangle Triangolo +TRUE TRUE Try again in a moment. Riprova tra un momento. Try again later. Riprova più tardi. Turn editing on or off for the map. Attiva o disattiva la modifica per la mappa. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map digita una longitudine e una lat Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Digita una scala, come 1:12,345, 12345 o 25k, e premi Enter Type an address or coordinates and press Enter, or click the map. Digita un indirizzo o delle coordinate e premi Invio, oppure fai clic sulla mappa. Type numbers separated by commas Digita numeri separati da virgole +Type the keys to log in for this session, then Save. Digita le chiavi per accedere in questa sessione, poi Salva. +Type the password or keys to log in for this session, then Save. Digita la password o le chiavi per accedere in questa sessione, poi Salva. Typed below Digitate qui sotto UM UM Unavailable: {reason} Non disponibile: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Scollega dal cursore temporale Unbind from Time Slider Scollega dal cursore temporale Undo Annulla Undo (Ctrl+Z) Annulla (Ctrl+Z) -Undo / Redo Annulla / Ripeti Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Annulla non rimuove il passaggio registrato: eliminalo in Modifica passaggi dopo aver interrotto la registrazione. Undo the last change. Annulla l'ultima modifica. Undo {action} Annulla {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools Disinstalla gli strumenti GeoLibre Uninstall Plugin Disinstalla plugin Uninstalled {id}. {id} disinstallato. Unique Values Valori univoci +Unit Unità United States Stati Uniti Units Unità Units per step Unità per passo @@ -2877,6 +3140,7 @@ Up a level Su di un livello Update Aggiorna Update Bookmark Aggiorna segnalibro Update Job Aggiorna lavoro +Update Label Class Aggiorna classe di etichette Update to {version} Aggiorna a {version} Upload File Carica file Upload File… Carica file… @@ -2895,6 +3159,7 @@ Use another account Usa un altro account Use Current View Usa la vista corrente Use each feature's line color Usa il colore della linea di ogni elemento Use each feature's line width Usa lo spessore della linea di ogni elemento +Use the Layer's Strokes Usa i tratti del layer USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan Piano ChatGPT in uso Utilities Utilità Valid combinations, one per line: a, b Combinazioni valide, una per riga: a, b Valid expression · {n} matching rows Espressione valida · {n} righe corrispondenti +Valid: {n} of {of} features match Valida: {n} elementi su {of} corrispondono Validate Convalida Validate Model Convalida modello value (empty: no-data) valore (vuoto: no-data) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Immagini aperte di disastri di Vantor: scegli un evento e una fase (prima o dopo), poi Trova item. Vantor… Vantor… Variable Variabile +Variogram Model Modello di variogramma Vector and raster data Dati vettoriali e raster Vector Layer Layer vettoriale +vector tiles tile vettoriali Vector tiles (MVT) Tile vettoriali (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Vettori che un modello di fondazione geospaziale ha prodotto per ogni pixel o porzione di immagine, trovati dove si trova la mappa e mostrati come colori. Version {version} Versione {version} @@ -2933,6 +3201,9 @@ Video file or URL File video o URL Video Page Pagina video Videos Video Video… Video… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Sistema di coordinate nazionale del Vietnam (VN-2000) in Mercatore Trasverso a fusi di 3 gradi, con una trasformazione di Helmert a 7 parametri verso WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Sistema di coordinate nazionale del Vietnam (VN-2000) UTM zona 48N (102°E–108°E, meridiano centrale 105°E). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Sistema di coordinate nazionale del Vietnam (VN-2000) UTM zona 49N (108°E–114°E, meridiano centrale 111°E). View Visualizza View Clipping Ritaglio della vista View Effects Effetti della vista @@ -2952,6 +3223,9 @@ Visibility Range Intervallo di visibilità Visible Visibile Visible Layers Layer visibili Visual Effect Effetto visivo +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vietnam) +VN-2000 / UTM zone 48N VN-2000 / UTM zone 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zone 49N Volume Volume Waiting for the browser… In attesa del browser… Waiting for your answer In attesa della tua risposta @@ -2970,6 +3244,7 @@ Web Link Chart Diagramma di collegamenti web Web maps Mappe web Web Mercator Web Mercator Web Scene Scena web +Web service (detected) Servizio web (rilevato) Web Style Stile web Web Tile Layers Layer di tile web Web Tool Strumento web @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Dove si trovano le etichette rispet Where new features get their elevation (the z field). Da dove i nuovi elementi ricevono la quota (il campo z). Where the map opens: the full extent or a view you saved. Dove si apre la mappa: l'estensione totale o una vista salvata. Where tools work Dove operano gli strumenti +Whether the Select tool picks these layers' features on the map Se lo strumento Seleziona sceglie gli elementi di questi layer sulla mappa Which 5 countries have the largest GDP? Quali sono i 5 paesi con il PIL più alto? Whitebox and GeoLibre tools Strumenti Whitebox e GeoLibre Whole map Intera mappa @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Mondo, equivalente: dimensioni World, equal-area: true sizes, shapes bend towards the edges. Mondo, equivalente: dimensioni reali, le forme si piegano verso i bordi. Worldview Confini Write a plugin Scrivi un plugin +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Scrive la pendenza di Sen, lo Z e il p-value del test, e la pendenza dove la tendenza è significativa. X (mm) X (mm) X column Colonna X X Field Campo X @@ -3050,7 +3327,6 @@ Yours Tue YYYY-MM-DD AAAA-MM-GG YYYY-MM-DD, for the layers that change with time AAAA-MM-GG, per i layer che cambiano nel tempo YYYY-MM-DD, for time layers AAAA-MM-GG, per i layer temporali -z (m) z (m) Z factor Fattore Z Zarr Zarr Zarr Variables Variabili Zarr @@ -3061,7 +3337,6 @@ Zoom Box Zoom riquadro Zoom Frame Zoom sulla cornice Zoom In Ingrandisci Zoom in Ingrandisci -Zoom in / out Ingrandisci / riduci Zoom In Here Ingrandisci qui Zoom in on the centre of the map. Ingrandisci al centro della mappa. Zoom in · finish a sketch Ingrandisci · termina uno schizzo @@ -3073,6 +3348,7 @@ Zoom To\nLayer Zoom al\nlayer Zoom to a box (Explore) Zoom a un riquadro (Esplora) Zoom To Extent Zoom all'estensione Zoom To Layer Zoom al layer +Zoom To Layers Zoom ai layer Zoom To Map View Zoom alla vista della mappa Zoom To Selection Zoom alla selezione Zoom to selection Zoom alla selezione @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Zoom all'estensione del layer. Zoom to the selected features. Zoom agli elementi selezionati. Zoom to the whole world. Zoom sul mondo intero. Zoom to this bookmark Zoom a questo segnalibro +Zoom To Tile Layer Zoom al layer di tile Zoom to Tokyo Zoom su Tokyo Zoom To World Zoom al mondo +Zoom {z} · Bearing {b}° · Pitch {p}° Zoom {z} · Direzione {b}° · Inclinazione {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Zoom: il livello di zoom della mappa, come lo contano le mappe web. Direzione: verso cui è rivolta la parte superiore della mappa, in senso orario dal nord. Inclinazione: lo scostamento dalla vista dall'alto in verticale. Fai clic per reimpostare direzione e inclinazione. {agent} was stopped by you. Hai fermato {agent}. {id} changed since it was installed; install it again from the Plugin Manager. {id} è cambiato dopo l'installazione; installalo di nuovo dal Gestore plugin. {names}: needs Node.js (npx) for its ACP adapter. {names}: richiede Node.js (npx) per il suo adattatore ACP. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 82c7c51d3..d491c7276 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6,6 +6,7 @@ (filtered) (フィルター適用) + Add Stop + 経由地を追加 + Input + 入力 ++ New expression + 新しい式 + Output + 出力 /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh 3D メッシュ <なし> A 3D globe seen from space. 宇宙から見た 3D 地球儀。 A blank map with a basemap ベースマップ付きの空白マップ +A call for the rustgis Python package rustgis Python パッケージ用の呼び出し A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. カタログ (CSW 2.0.2) のアドレス: 単語と、必要に応じてマップの範囲でレコードを検索し、リンク先のサービスやファイルを追加します。 A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. ChatGPT プランまたはアプリの使用上限に達しました。週間使用量を確認してから再試行してください。 A ChatGPT sign-in can only be used with the OpenAI API. ChatGPT のサインインは OpenAI API でのみ使用できます。 @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). GeoLens A globe scene. 地球儀シーン。 A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place 緯度経度 (40.7, -74.0 または 40°42'N 74°0'W)、レイヤー、ブックマーク、またはレイヤーのテーブル内の値。Enter で強調表示された行を選択し、[場所を検索] でテキストを場所として検索します A layer the model reads モデルが読み込むレイヤー +A line for the Script pane スクリプト ウィンドウ用の 1 行 A magnifying lens that follows the pointer. ポインターに追従する拡大鏡。 A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). マップ、フィーチャ、またはカバレッジ サービスのアドレス: WMS、WMTS、WFS、WCS、OGC API Features または Tiles、あるいは ArcGIS FeatureServer、MapServer、ImageServer または VectorTileServer (参照する場合はサービス ディレクトリ)。 a name, or owner/name such as giswqs/geospatial 名前、または giswqs/geospatial のような所有者/名前 +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. 再利用できる名前付きのエンドポイントです。パスワードとキーは RustGIS を閉じるまで保持され、保存されることはありません。 A node cannot connect to itself. ノードを自分自身に接続することはできません。 A notebook of text and command cells. テキスト セルとコマンド セルからなるノートブック。 +A password or token in the address was left out: connections never keep secrets. アドレス内のパスワードまたはトークンは除外されました: 接続にシークレットが保持されることはありません。 A place or an address 場所または住所 A project keeps at least one map プロジェクトには少なくとも 1 つのマップが必要です A projected coordinate system. 投影座標系。 A report listing the selected layer's records, page after page. 選択したレイヤーのレコードをページごとに一覧表示するレポート。 A result that only feeds other tools is removed once the model has run; add an output to keep it. 他のツールに渡すだけの結果は、モデルの実行後に削除されます。残すには出力を追加してください。 A result the model keeps, under a name モデルが名前を付けて残す結果 +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) rustgis-cli コマンド (POSIX シェル用: macOS、Linux、WSL、Git Bash) A service import or refresh is already running. サービスのインポートまたは更新が既に進行中です。 +A service's layers stay in their service group. サービスのレイヤーはそのサービス グループ内に留まります。 A task holds at most 200 steps: stop recording to make it, then record the rest as another task. 1 つのタスクに含められるステップは最大 200 個です。記録を停止してタスクを作成し、残りは別のタスクとして記録してください。 A template with the first selected feature's values 最初に選択したフィーチャの値を持つテンプレート +A tile layer per grid tile, each following the time slider through its dates. グリッド タイルごとに 1 つのタイル レイヤーを作成し、それぞれが日付に沿ってタイム スライダーに追従します。 A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. タイル サービスのアドレス: {z}/{x}/{y} を含むラスター タイル (WMS の場合は {-y}, {q}, {bbox-epsg-3857})、ベクター タイル (.pbf) または TileJSON、MapLibre スタイル、PMTiles アーカイブ(Web アドレスまたはこのコンピューター上のファイル)、またはこのコンピューター上の MBTiles ファイル。 A token is set. トークンが設定されています。 A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. 値、フィールド、またはそれらを + − × ÷ で結合したもの。選択した行に適用され、何も選択されていない場合はすべての行に適用されます。 a vector a pixel ピクセルごとに 1 つのベクトル a vector an image patch 画像パッチごとに 1 つのベクトル A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. 統合メタデータを持つ Zarr ストア (フォーマット 2 または 3) の Web アドレス、またはこのコンピューター上のフォルダー。変数のマップは、時間など他の次元で選んだステップについて読み込まれます。 +A Zarr variable's values through time at points clicked on the map. マップ上でクリックしたポイントにおける Zarr 変数の値の時間変化。 +A2 landscape sheet A2 横向き用紙 +A2 portrait sheet A2 縦向き用紙 +A3 landscape sheet A3 横向き用紙 +A3 portrait sheet A3 縦向き用紙 +A4 landscape sheet A4 横向き用紙 +A4 portrait sheet A4 縦向き用紙 +A5 Grid A5 グリッド +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. 指定した解像度の A5 の等面積五角形セルを、マップの範囲、レイヤー、または全世界にわたって ID 付きのポリゴンとして作成します。 About バージョン情報 About plugins プラグインについて About RustGIS RustGIS について @@ -65,6 +83,7 @@ Add 追加 Add\nData データの\n追加 Add ({n}) 追加 ({n}) Add a bar chart of a layer's numeric field. レイヤーの数値フィールドの棒グラフを追加します。 +Add a circle. 円を追加します。 Add a colorbar of a raster layer's colour scale in the map frame. マップ フレーム内のラスター レイヤーのカラー スケールを示すカラーバーを追加します。 Add a description. 説明を追加します。 Add a field. フィールドを追加します。 @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. マップの印刷とエ Add a page to the presentation. プレゼンテーションにページを追加します。 Add a PNG or JPEG picture to the page. ページに PNG または JPEG の画像を追加します。 Add a presentation file. プレゼンテーション ファイルを追加します。 +Add a rectangle, to frame or highlight part of the page. ページの一部を囲んだり強調したりする四角形を追加します。 Add a report file, or a report template for the selected layer. レポート ファイル、または選択したレイヤーのレポート テンプレートを追加します。 Add a scale bar for the map frame. マップ フレームの縮尺記号を追加します。 add a second map first (Data › New Map) 先に 2 つ目のマップを追加してください ([データ] › [新しいマップ]) Add a second map frame first 先に 2 つ目のマップ フレームを追加してください +Add a straight line. 直線を追加します。 Add a text element. テキスト エレメントを追加します。 Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Web ベースマップを置き換えずに他のベースマップの上に重ねて、ブレンドします。表示中のものをクリックすると外せます。 +Add a Zarr variable first (Add Data › Zarr…). 先に Zarr 変数を追加してください (データの追加 › Zarr…)。 Add Administrative Boundaries 行政界の追加 +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. 矢印を追加します。上にある丸いハンドルで回転させるか、プロパティで角度を設定します。 Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. NASA の GeoTIFF をマップに追加するには、Earthdata Login トークン (上の Earthdata Login) を追加してください。 Add an elevation raster first. 先に標高ラスターを追加してください。 Add an elevation raster to the map first 先にマップに標高ラスターを追加してください +Add an ellipse. 楕円を追加します。 Add an empty layer with a ready-made schema, to draw into. 作成済みのスキーマを持つ空のレイヤーを追加し、そこに描画します。 Add an input for each layer the model reads. モデルが読み込むレイヤーごとに入力を追加します。 Add an output to keep a result under a name. 結果を名前付きで残すには出力を追加します。 Add and Open 追加して開く Add and Open Notebook ノートブックを追加して開く Add Archive アーカイブの追加 +Add as Time Series 時系列として追加 add at least two keyframes キーフレームを 2 つ以上追加してください Add Cell セルの追加 Add Color Ramp File… カラー ランプ ファイルの追加… @@ -132,6 +157,7 @@ Add Graphics Layer グラフィックス レイヤーの追加 Add Input 入力の追加 Add Job ジョブの追加 Add Keyframe キーフレームの追加 +Add Label Class ラベル クラスの追加 Add Layer File レイヤー ファイルの追加 Add Open Data オープン データの追加 Add Output 出力の追加 @@ -147,9 +173,11 @@ Add Rule ルールの追加 Add sample countries サンプルの国データを追加 Add Sample Data サンプル データの追加 Add Satellite Embeddings 衛星埋め込みの追加 +Add Selected ({n}) 選択項目を追加 ({n}) Add Service Layer サービス レイヤーの追加 Add skill… スキルを追加… Add STAC Asset STAC アセットの追加 +Add STAC Data Cube STAC データ キューブの追加 Add STAC Footprints STAC フットプリントの追加 Add Step ステップの追加 Add Stroke ストロークの追加 @@ -171,6 +199,7 @@ Add to Favourites お気に入りに追加 Add to Map マップに追加 Add vector data. ベクター データを追加します。 Add view ビューの追加 +Add With Options… オプションを指定して追加… Add Zarr Variable Zarr 変数の追加 Add Zarr Variable… Zarr 変数の追加… Add ▾ 追加 ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay エージェントはフル アクセス権を持ち、スクリプト、タスク、モデル、プラグインを実行できます。クリックすると MCP リレーを停止します Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. 接続したこのコンピューターのエージェント (Claude Code、Codex、Cursor など) は、RustGIS のツールでここで開いている地図を操作し、ファイルを開いて保存したり Web を使ったりできます。スクリプトとプラグイン管理はオフのままです。 Agents then run commands, change files and use the web on this computer without asking you. エージェントは確認なしで、このコンピューター上でコマンドを実行し、ファイルを変更し、Web を使用します。 +Aggregate Points to A5 ポイントを A5 に集約 Aggregation 集約 Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Fields of the World モデルが Sentinel-2 画像から検出した世界の農地の圃場境界 (年単位、第 2 版、CC BY 4.0)。これらはモデルの予測であり、測量された区画ではありません。 AI AI @@ -221,15 +251,18 @@ All steps done. すべてのステップが完了しました。 All tables with geometry ジオメトリを持つすべてのテーブル Allow 許可 Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. サインイン時に ChatGPT プランの使用を許可してください。ワークスペースで許可されていない場合は、管理者に確認するか、API キーを使うプロバイダーを選んでください。 +Allow Feature Selection フィーチャの選択を許可 Allow only certain combinations of values across fields. フィールド間で特定の値の組み合わせのみを許可します。 Allow such actions for this conversation この会話ではこの種の操作を許可する Allow this action? この操作を許可しますか? Allow this agent's RustGIS action? このエージェントの RustGIS 操作を許可しますか? Along Selected Line 選択したラインに沿って +Alt+ Alt+ Alternating bar 交互バー Altitude 高度 Always allow 常に許可 -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3、またはエンドポイントで指定する S3 互換サービス (MinIO、Cloudflare R2、Wasabi…)。キーは RustGIS を閉じるまで保持されます。記憶した接続には名前、エンドポイント、リージョンが保存され、シークレットは保存されません。 +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3、またはエンドポイントで指定する S3 互換サービス (MinIO、Cloudflare R2、Wasabi…)。キーは RustGIS を閉じるまで保持されます。保存した接続には名前、エンドポイント、リージョンが保持され、シークレットは保持されません。 amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: このコンピューターのエージェントが次の操作を求めています: An AWS profile AWS プロファイル @@ -248,6 +281,7 @@ API key API キー Apply 適用 Apply a function to a raster ラスターに関数を適用 Apply Control Points コントロール ポイントの適用 +Apply elevation 標高を適用 Apply Style スタイルの適用 Apply to layers レイヤーに適用 Apply to the selected layer 選択したレイヤーに適用 @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° 使用範囲: {lon}°, {lat}° か Area {area} · Perimeter {perimeter} 面積 {area} · 周長 {perimeter} Areas (name, land use, area) エリア (名前、土地利用、面積) Arrange 配置 +Arrow 矢印 +Arrowheads 矢じり Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. データについて質問するか、マップで行いたいことを説明してください。アシスタントが RustGIS のコマンドを代わりに実行します。変更はすべて元に戻せます。 Ask about the map, or tell it what to do… マップについて質問するか、操作を指示してください… Ask about your data, or tell the assistant what to do with the map. データについて質問するか、マップで行うことをアシスタントに指示してください。 @@ -275,12 +311,13 @@ Asking NASA's CMR… NASA の CMR に問い合わせています… Asking the catalog… カタログに問い合わせています… Asking the NLDI… NLDI に問い合わせています… Assistant settings アシスタントの設定 +At the end 終点 +At the start 始点 Atlas アトラス Attachments 添付ファイル Attribute\nTable 属性\nテーブル Attribute Rules 属性ルール Attribute Table 属性テーブル -Attribute table 属性テーブル Attributes 属性 Attribution 帰属 Aurora オーロラ @@ -295,11 +332,9 @@ Automatic (system language) 自動(システムの言語) Automatic (system time zone and region) 自動 (システムのタイム ゾーンと地域) Automatic ({kind}) 自動 ({kind}) Avoid 回避 -AWS AWS AWS, anonymous AWS (匿名) Azimuth 方位角 Back 戻る -Back to Explore; cancel a sketch マップ操作に戻る;スケッチをキャンセル Back to the Map マップに戻る Back to the map マップに戻る Back to tools ツールに戻る @@ -314,8 +349,6 @@ Basemap ベースマップ Basemaps ベースマップ Basin from hydrolocation 水文地点からの流域 Bearing 方位 -Bearing {b}° · Pitch {p}° 方位 {b}° · 傾き {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. 方位: マップの上端が向く方向で、北から時計回りに測ります。傾き: 真上から見下ろした状態からの傾斜です。クリックすると両方をリセットします。 Before the event イベント前 Below 下 Below 1:5,000,000 show everything; beyond it only what matches: 1:5,000,000 より大縮尺ではすべてを表示し、それより小縮尺では一致するものだけを表示: @@ -328,10 +361,12 @@ Bilinear 共一次内挿 Bind To Time Slider タイム スライダーにバインド Bind to Time Slider タイム スライダーにバインド Blank 空白 +Blank for Amazon S3 Amazon S3 の場合は空欄 Blank Letter page 空白の Letter ページ Blank Map 空白のマップ Blank Page 空白ページ Blend: {mode} ブレンド: {mode} +Block Feature Selection フィーチャの選択をブロック Blue 青 Bold 太字 Bold label text. ラベル テキストを太字にします。 @@ -339,6 +374,7 @@ Bookmark Description ブックマークの説明 Bookmarks ブックマーク Bookmarks Pane ブックマーク ウィンドウ Boolean ブール型 +Both ends 両端 Bottom 下 Boundaries… 行政界… Bounding\nGeometry 境界\nジオメトリ @@ -346,6 +382,7 @@ Brightness 明るさ Brightness, contrast, saturation and sepia for the whole map. マップ全体の明るさ、コントラスト、彩度、セピア。 Bring this bookmark's map into view to update it 更新するには、このブックマークのマップを表示してください Browse 参照 +Browse Connection 接続の参照 Browse it in the Browser ブラウザーで参照 Browse Plugins プラグインの参照 Browse the geoprocessing toolbox. ジオプロセシング ツールボックスを参照します。 @@ -357,6 +394,7 @@ Browser ブラウザー Browser\nPane ブラウザー\nウィンドウ Browser View ブラウザー ビュー Bucket バケット +Buckets バケット Buffer バッファー Buffer A Point ポイントのバッファー Buffer the cities by 200 km 都市に 200 km のバッファーを作成 @@ -381,7 +419,9 @@ Calculate Field フィールド演算 Calculate field フィールド演算 Calculate field values. フィールド値を計算します。 Calculate fields and enforce conditions on every edit. フィールドを計算し、編集のたびに条件を適用します。 +Calculate Geometry ジオメトリ演算 Calculate Geometry Attributes ジオメトリ属性の計算 +Calculated {rows} features {rows} 件のフィーチャを計算しました Calculation 計算 Camera カメラ Camera Effect カメラ効果 @@ -396,12 +436,14 @@ Case Field ケース フィールド Cast Shadow 影を落とす Catalog カタログ catalog is a static STAC catalog's catalog.json or collection.json catalog は静的 STAC カタログの catalog.json または collection.json です +Catalogue (CSW) カタログ (CSW) Catalogue search (CSW) カタログ検索 (CSW) Celestial Bodies 天体 Celestial Body 天体 Celestial body: {body} 天体: {body} Cell size セル サイズ cell size ° セル サイズ ° +Cells セル Center 中心 Center Map Here ここをマップの中心にする Centered 中央 @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. このアカウ ChatGPT sign-in failed. Check the connection and local settings, then try again. ChatGPT へのサインインに失敗しました。接続とローカル設定を確認してから再試行してください。 ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. ChatGPT の使用状況を一時的に確認できません。サインイン状態を維持して後で再試行してください。 CHE CHE +Check 確認 Check Again 再確認 Check for\nUpdates 更新の\n確認 Check for Updates 更新の確認 @@ -428,6 +471,7 @@ Check for Updates… 更新の確認… Check the address and the service type. アドレスとサービスの種類を確認してください。 Check the address and your connection. アドレスと接続を確認してください。 Check the address. アドレスを確認してください。 +Check the clause, and count the features it takes. 句を確認し、該当するフィーチャの数を数えます。 Check the integration's client, permissions, workspace policy and route configuration. 連携のクライアント、権限、ワークスペースのポリシー、ルート設定を確認してください。 Checking for updates… 更新を確認しています… China 中国 @@ -455,6 +499,7 @@ Choose which layers a click identifies. クリックで個別属性を表示す Choose which tabs the ribbon shows. リボンに表示するタブを選択します。 Circle 円 Circle Radius (km) 円の半径 (km) +Class クラス Class color クラスの色 Class Style クラス スタイル Classes クラス @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line クリックで頂点 Click to edit the description クリックして説明を編集 Click to place a point graphic. クリックしてポイント グラフィックを配置します。 Click to select the node クリックしてノードを選択 +Click to select, double-click to add, right-click for more クリックで選択、ダブルクリックで追加、右クリックでその他の操作 Click to switch between decimal degrees and DMS クリックで十進経緯度と度分秒を切り替え Clip クリップ Clip a raster to the view, or resample it. ラスターをビューでクリップするか、リサンプリングします。 @@ -532,7 +578,7 @@ Close thread スレッドを閉じる Close {name} {name} を閉じる Closest 最近距離 Coded values, one per line: code=description コード値 (1 行に 1 つ): コード=説明 -Collapse or expand the ribbon リボンの折りたたみ/展開 +Collapse or Expand the Ribbon リボンの折りたたみ/展開 Collapse the ribbon (Ctrl+F1) リボンを折りたたむ (Ctrl+F1) Collection コレクション Collection {id}: choose dates and Find Items コレクション {id}: 日付を選んで [アイテムを検索] @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line 上のボックスの値の組み合わせ (1 行に 1 つ) COMID {comid} COMID {comid} Comma カンマ +Comma-separated (optional) カンマ区切り (省略可) Command\nSearch コマンド\n検索 command is required command は必須です Command Search コマンド検索 @@ -572,10 +619,13 @@ Connect 接続 Connect a folder to the Browser. フォルダーをブラウザーに接続します。 Connect to S3 S3 に接続 Connect, or pick a layer 接続するか、レイヤーを選択 +Connected to {kind} {title} {kind} {title} に接続しました Connecting to {url}… {url} に接続しています… Connection 接続 +connection.new returned no id connection.new が ID を返しませんでした Connections 接続 -Connect… 接続… +Connections you add are listed here 追加した接続がここに一覧表示されます +Connections… 接続… Constant 定数 Constant height 一定の高さ Constant… 定数… @@ -583,11 +633,14 @@ Contingent Values 条件付き値 Continue 続行 Continue with ChatGPT ChatGPT で続ける Contrast コントラスト +Control Point Fit コントロール ポイントでフィット Conversion 変換 Convert 変換 Convert a layer to another format レイヤーを別の形式に変換 Convert the selected layer's labels into an annotation layer. 選択したレイヤーのラベルをアノテーション レイヤーに変換します。 +Convert this layer's labels into an annotation layer. このレイヤーのラベルをアノテーション レイヤーに変換します。 Convert To Annotation アノテーションに変換 +Convert to Annotation アノテーションに変換 Converted data 変換したデータ Convert… 変換… Coordinate 座標 @@ -599,11 +652,15 @@ Copied the map to the clipboard マップをクリップボードにコピーし Copied {path} {path} をコピーしました Copies the (selected) features of a layer to a new layer. レイヤーの(選択された)フィーチャを新しいレイヤーにコピーします。 Copy コピー +Copy / Cut / Paste features フィーチャのコピー/切り取り/貼り付け Copy a model.run command that runs this model, for the Script pane or Python このモデルを実行する model.run コマンドをコピーします (スクリプト ウィンドウまたは Python 用) Copy Address アドレスのコピー Copy address アドレスをコピー Copy an image of the map to the clipboard. マップの画像をクリップボードにコピーします。 +Copy as CLI Command CLI コマンドとしてコピー +Copy as Command コマンドとしてコピー Copy As GeoJSON GeoJSON としてコピー +Copy as Python Python としてコピー Copy Command コマンドのコピー Copy coordinates to the clipboard 座標をクリップボードにコピー Copy details 詳細をコピー @@ -611,11 +668,13 @@ Copy Group グループのコピー Copy Layer レイヤーのコピー Copy Layers レイヤーのコピー Copy Path パスのコピー +Copy Run As Code 実行をコードとしてコピー Copy sign-in link サインインリンクをコピー Copy the selected features (also as GeoJSON text). 選択したフィーチャをコピーします(GeoJSON テキストとしても)。 Copy the selected features and delete them. 選択したフィーチャをコピーして削除します。 Copy the selected layer's data source path. 選択したレイヤーのデータ ソース パスをコピーします。 Copy this element (Ctrl+D, ⌘D on a Mac) このエレメントを複製します (Ctrl+D、Mac では ⌘D) +Copy this run as code, or open it in its tool この実行をコードとしてコピーするか、そのツールで開きます Corrections 補正 Cost コスト could not draw the map マップを描画できませんでした @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} プラグイン レジストリを読 Count, sum, mean, min, max and standard deviation of a field, optionally per case field. フィールドの個数、合計、平均、最小、最大、標準偏差。必要に応じてケース フィールドごとに集計します。 Countries, cities, rivers and lakes 国、都市、河川、湖沼 Country 国 +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. レイヤーのポイントを、それを含む A5 セルごとに数え (セルは等面積なので、件数は密度になります)、フィールドの合計と平均も求めます。 Counts the points that fall inside each polygon. 各ポリゴン内に含まれるポイントの数をカウントします。 Counts, totals and top categories of what's in view. 表示中のものの個数、合計、上位カテゴリ。 CPU CPU @@ -647,12 +707,8 @@ Cross Section 断面 Cross Section (2 clicks) 断面 (2 クリック) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative 累積 Current date 現在の日付 Current Project 現在のプロジェクト @@ -680,13 +736,16 @@ Data type データ型 Data you add is listed here 追加したデータがここに表示されます data.gov and CKAN catalogues data.gov と CKAN のカタログ Database データベース -Databases データベース +Database connections are used by the desktop app データベース接続はデスクトップ アプリで使用されます +databases are read through DuckDB, a program the browser can't run データベースは DuckDB を通じて読み込みますが、ブラウザーでは DuckDB を実行できません Database… データベース… Dataset データセット Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Hugging Face のデータセットを名前または所有者/名前で検索します。データセットのファイルを参照し、GeoTIFF、GeoParquet、PMTiles、LiDAR などのファイルを追加します。 Data… データ… Date 日付 Dates 日付 +Dates a cell needs セルに必要な日付数 +Dates a cell needs is a whole number, or empty for half セルに必要な日付数は整数で指定するか、半数にする場合は空欄にします Day 日 Day and night shading for a time. 指定時刻の昼夜の陰影。 Decimal 10 進 @@ -715,7 +774,6 @@ Delete Notebook ノートブックの削除 Delete Page ページの削除 Delete Presentation プレゼンテーションの削除 Delete Report レポートの削除 -Delete selected features 選択したフィーチャを削除 Delete selected rows 選択した行を削除 Delete Style スタイルの削除 Delete Task タスクの削除 @@ -736,11 +794,15 @@ Desktop and command line only デスクトップ アプリとコマンド ライ Details 詳細 Details of the selected layer. 選択したレイヤーの詳細。 Deviations 標準偏差数 +DGGS DGGS Diagnostic\nMonitor 診断\nモニター Diagnostic Monitor 診断モニター Diagnostics 診断情報 +Diagonal ↗ 斜め ↗ +Diagonal ↘ 斜め ↘ Diamond ひし形 Difference, ratio or percent change between two rasters. 2 つのラスター間の差分、比率、変化率。 +Digit Digit Directions ルート Directions From Here ここからのルート Directions To Here ここへのルート @@ -758,6 +820,7 @@ Dissolve Field ディゾルブ フィールド Distance 距離 Distance (km) 距離 (km) Distance Coefficient P 距離係数 P +Distance units 距離の単位 Distances along the surface, heights, areas and volumes. 地表面に沿った距離、高さ、面積、体積。 DISTINCT DISTINCT Distinct 重複なし @@ -804,18 +867,23 @@ Drag to draw ドラッグして描画 Drag to draw a freehand line. ドラッグしてフリーハンドのラインを描画します。 Drag to draw a freehand polygon. ドラッグしてフリーハンドのポリゴンを描画します。 Drag to draw a rectangle. ドラッグして四角形を描画します。 +Drag to resize the message box ドラッグしてメッセージ ボックスのサイズを変更 Drag tools from the palette onto the canvas. パレットからキャンバスにツールをドラッグします。 Drag vertices on the map; click an edge to add one マップ上で頂点をドラッグ。エッジをクリックで頂点を追加 Draw a halo around label text. ラベル テキストの周囲にハローを描画します。 Draw a labelled latitude/longitude grid over the map frame. マップ フレーム上にラベル付きの経緯線グリッドを描画します。 Draw a model: chain tools from input layers to the results to keep モデルを作成: 入力レイヤーから残す結果までツールをつなげます Draw a model: chain tools from input layers to the results to keep. モデルを作成: 入力レイヤーから残す結果までツールをつなげます。 +Draw at its place in the stack, the bottom stroke first 重なり順の位置で、下のストロークから描画します +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet すべてのフィーチャのストロークをレベルごとに描画し、ラインが交わる箇所でケーシングが中心線の下でつながるようにします Draw labels at every scale and distance. すべての縮尺と距離でラベルを描画します。 Draw new features in the selected layer. Double-click to finish lines and polygons. 選択したレイヤーに新しいフィーチャを描画します。ラインとポリゴンはダブルクリックで終了します。 Draw new single-band rasters with the inverted grey stretch again. 新しい単バンド ラスターを再び反転グレー ストレッチで描画します。 +Draw only polygon outlines, keeping their colors and widths. 色と幅を保ったまま、ポリゴンのアウトラインのみを描画します。 Draw points as bins coloured by how many fall in each. ポイントを、各ビンに含まれる数で色分けしたビンとして描画します。 Draw polygons as outlines only. ポリゴンをアウトラインのみで描画します。 Draw single-band rasters added from now on with this color scheme, stretch and resampling. 今後追加する単バンド ラスターを、この配色、ストレッチ、リサンプリングで描画します。 +Draw strokes in levels ストロークをレベル順に描画 Draw the layer at every scale. すべての縮尺でレイヤーを描画します。 Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). マップを GPU で描画します (ベータ版。地球儀、表示エフェクト、色覚シミュレーションは CPU レンダラーのままです)。 Draw the selected layer above others. 選択したレイヤーを他のレイヤーの上に描画します。 @@ -828,6 +896,7 @@ drawn by the server サーバーで描画 Drive 車 Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. OpenAerialMap で共有されたドローン画像と航空写真 (新しい順)。クラウド最適化 GeoTIFF として追加されます。 Duplicate 複製 +Duplicate Connection 接続の複製 Duplicate Element エレメントの複製 Duplicate Layout レイアウトの複製 Duplicate Map マップの複製 @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name 例: pop_est / 1000000 e.g. 0, 255, -9999 例: 0, 255, -9999 e.g. continent = 'Europe' 例: continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' 例: pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 例: pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs 各ジオプロセシングの実行がステップになり、出力によって連結されます Earth Index has no points in the view Earth Index のポイントがビュー内にありません Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices 頂点の\n編集 Edit and run the notebook. ノートブックを編集して実行します。 Edit Attribute 属性の編集 Edit Cell セルの編集 +Edit Connection 接続の編集 Edit Description 説明の編集 Edit Graphic グラフィックの編集 Edit Status 編集状態 @@ -882,6 +953,7 @@ Editor\nTools エディター\nツール Editor Tools エディター ツール Edits since the project was saved. プロジェクトの保存後の編集。 Edits the project プロジェクトの編集 +Edit… 編集… Effects エフェクト Element エレメント Element Properties エレメントのプロパティ @@ -898,21 +970,25 @@ Enable Lighting ライティングを有効化 Enable Plugin プラグインの有効化 Enabled 有効 Encloses each feature, or all features, in its convex hull. 各フィーチャ、またはすべてのフィーチャを凸包で囲みます。 +Encoded polyline エンコード済みポリライン End 終了 End date 終了日 Endpoint エンドポイント Enhancement 強調 Enter Enter Enter an API key. API キーを入力してください。 +Enter an EPSG code as a number, such as 32650 EPSG コードを数値で入力してください (例: 32650) Enter the provider's address. プロバイダーのアドレスを入力してください。 Enter to send, Shift+Enter for a new line Enter で送信、Shift+Enter で改行 EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments 環境 EPSG EPSG +EPSG code EPSG コード EPSG or ESRI code EPSG または ESRI コード EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth イコールアース図法 Equal Interval 等間隔 Equirectangular (Plate Carrée) 正距円筒図法 (Plate Carrée) @@ -922,14 +998,18 @@ Error エラー Error Inspector エラー インスペクター Error Inspector (validate) エラー インスペクター (検証) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) ヨーロッパ正積 (ETRS89 LAEA) Every band's value at a pixel. ピクセルにおける全バンドの値。 Every editing tool. すべての編集ツール。 +Every label setting of this layer, in the Labeling pane. このレイヤーのすべてのラベル設定 (ラベリング ウィンドウ内)。 Every layer, as FEMA draws them すべてのレイヤー (FEMA の描画どおり) +Every run, in order, as a script that opens the project すべての実行を順番に、プロジェクトを開くスクリプトとして出力 every Socrata site, or one such as data.cityofnewyork.us すべての Socrata サイト、または data.cityofnewyork.us などの特定のサイト Everywhere すべての場所 +Existing field 既存のフィールド Existing Template 既存のテンプレート exit rotary ロータリーを出る exit roundabout 環状交差点を出る @@ -948,6 +1028,7 @@ Export\nRaster ラスターの\nエクスポート Export Animation アニメーションのエクスポート Export Bookmarks ブックマークのエクスポート Export Bookmarks… ブックマークのエクスポート… +Export CSV… CSV のエクスポート… Export Features フィーチャのエクスポート Export Features (tool) フィーチャのエクスポート(ツール) Export Features needs a vector layer (Data › Export Raster writes a raster) フィーチャのエクスポートにはベクター レイヤーが必要です ([データ] › [ラスターのエクスポート] でラスターを書き出します) @@ -996,6 +1077,7 @@ Face Culling 面のカリング Faces 面 Factor 係数 Failed 失敗 +FALSE FALSE Farthest 最遠距離 Faster drawing: no antialiasing. 高速描画: アンチエイリアスなし。 Favourite Data Source お気に入りのデータ ソース @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries 圃場境界 Field density 圃場密度 Field name フィールド名 +Field Prefix フィールド接頭辞 +Field to Sum and Average 合計と平均を求めるフィールド Fields フィールド Fields are drawn from zoom 9: zoom in to see them. 圃場はズーム 9 から描画されます: 拡大して表示してください。 Fields colored by score, drawn from zoom 9 スコアで色分けされた圃場 (ズーム 9 から描画) @@ -1079,6 +1163,8 @@ Folder Connections フォルダー接続 folder connections need the desktop app フォルダー接続にはデスクトップ アプリが必要です Folders フォルダー Folder… フォルダー… +Follow the time slider タイム スライダーに追従 +Font フォント for a store that doesn't say 指定のないストアの場合 Forest 森林 Forget Earthdata Login Token Earthdata Login トークンを削除 @@ -1090,9 +1176,11 @@ France フランス Free the prepared geometry. 準備したジオメトリを解放します。 Freehand フリーハンド FROM FROM +From 開始 From Selection 選択から From Service… サービスから… From symbol シンボルから +From the address アドレスから from the address アドレスから from the service サービスから From URL… URL から… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. ビューの中心から遠いフィ Hide Group グループを非表示 Hide labels when the viewer is closer than this. 視点がこれより近いときにラベルを非表示にします。 Hide labels when the viewer is farther than this. 視点がこれより遠いときにラベルを非表示にします。 +Hide Layers レイヤーを非表示 Hide other values その他の値を隠す Hide Steps ステップを非表示 Hide the description 説明を非表示 @@ -1213,6 +1302,8 @@ Hold 保留 HOME HOME Home ホーム Home › Add ホーム › 追加 +Home › Add Data ホーム › データの追加 +Home › Inspect › Measure ホーム › 調査 › 計測 Home › Navigate ホーム › ナビゲート Home › Navigate › Bookmarks ホーム › ナビゲート › ブックマーク Home › Web Data ホーム › Web データ @@ -1220,7 +1311,7 @@ Home › Web Data › United States ホーム › Web データ › アメリカ Homepage ホームページ Horizontal 横 host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=…、または .duckdb ファイル +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery ホスト イメージ Hour 時 How the layer's colours combine with the tile layers and basemap beneath. レイヤーの色と、下にあるタイル レイヤーやベースマップとの合成方法。 @@ -1248,6 +1339,7 @@ Imagery Sharing 画像の共有 Imagery Tools 画像ツール ImageServer ImageServer Image… 画像… +Imperial ヤード・ポンド法 Import インポート Import\nProject プロジェクトの\nインポート Import All すべてインポート @@ -1267,6 +1359,7 @@ Import Task タスクのインポート Import Task… タスクのインポート… Import Thumbnail サムネイルのインポート Import… インポート… +IN IN In Beyond 拡大の限界 In memory メモリ内 In Progress 進行中 @@ -1281,6 +1374,8 @@ Information 情報 INPUT 入力 Input 入力 Input Features 入力フィーチャ +Input Layer 入力レイヤー +Input Points 入力ポイント Input Polygons 入力ポリゴン Input Rows 入力行 Input Summary Features 入力集計フィーチャ @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} インストールしたプラグイン Installed with Homebrew, winget, the AUR or COPR? Update it there. Homebrew、winget、AUR、COPR でインストールした場合は、そちらで更新してください。 Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} をインストールしました。[プラグイン] タブにあります。ツールはツール ウィンドウにもあります。 Installing it downloads Python {version} and the packages it needs from the internet. インストールすると、Python {version} と必要なパッケージがインターネットからダウンロードされます。 +Integer field: values are rounded. 整数フィールド: 値は丸められます。 Interface font: {font} インターフェイスのフォント:{font} Interpolate a surface from points (IDW) ポイントから表面を内挿 (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. 普通クリギングを使用して、ポイント フィーチャから 2 バンドのラスター表面 (予測値と分散) を内挿します。 Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. QGIS と同様に、逆距離加重法でフィーチャの値からラスター表面を内挿します。 Interpolation 内挿 Interpolation Attribute 内挿属性 @@ -1316,10 +1413,13 @@ Invalid: {error} 無効:{error} Invert 反転 Invert color scheme 配色を反転 Invert the selection. 選択を反転します。 +IS IS IS NULL IS NULL it connects to an address on the internet インターネット上のアドレスに接続する It has no code: nothing in it runs. コードは含まれていません。実行されるものはありません。 +It is locked, or one of its surrounds is: unlock it first このエレメントまたはその整飾のいずれかがロックされています: 先にロックを解除してください it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) ユーザー本人専用の操作(プラグイン、スクリプト、タスク、モデル、GeoLibre ランナー)である +it needs input: open it from its pane or button, or run it from a script or the assistant 入力が必要です: ウィンドウまたはボタンから開くか、スクリプトまたはアシスタントから実行してください it reads or writes files on this computer このコンピューター上のファイルを読み書きする it replaces the open project 開いているプロジェクトを置き換える it runs a plugin's command プラグインのコマンドを実行する @@ -1332,20 +1432,30 @@ Italic 斜体 Item Pop-ups アイテムのポップアップ Item {id} アイテム {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. ファイアウォール ({firewall}) が RustGIS をブロックしました。このサービスは一部のアプリケーションのみを許可している可能性があります: 管理者に連絡し、以下の詳細を送ってください。 +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) そのツールは現在使用できません (プラグインが読み込まれていないか、ツール スイートがインストールされていません) Jobs ジョブ JOIN JOIN Join 結合 +Join Features 結合フィーチャ +Join Operation 結合操作 +Joins attributes from a join layer to a target layer based on their spatial relationship. 空間的な関係に基づいて、結合レイヤーの属性をターゲット レイヤーに結合します。 +Journal figure, 1 column (90 mm) 学術誌の図、1 段 (90 mm) +Journal figure, 1.5 columns (140 mm) 学術誌の図、1.5 段 (140 mm) +Journal figure, 2 columns (190 mm) 学術誌の図、2 段 (190 mm) Keep a history of every edit to the layer, to look back or restore. レイヤーへのすべての編集の履歴を保持し、振り返りや復元に使用します。 Keep an archive of edits 編集のアーカイブを保持 keep current 現在のまま +Keep it from being moved, resized, deleted or edited by mistake 誤って移動、サイズ変更、削除、編集されないようにします Keep one value of each point's vector as its value field 各ポイントのベクトルの値を 1 つ、値フィールドとして残す Keep Result 結果を残す Keep the labelled features fixed as the map moves. マップの移動中もラベル付きフィーチャを固定します。 Keep the last drawing tool active after each graphic. 各グラフィックの後も最後の描画ツールをアクティブのままにします。 Keep the last tool active 最後のツールをアクティブのままにする +Keep this endpoint under Data Sources › Connections, without its password or keys このエンドポイントを、パスワードやキーを除いて [データ ソース] › [接続] に保持します Keep this result この結果を残す Keep Tool ツールを保持 Keep value 値を残す +Key Key Keyboard Shortcuts キーボード ショートカット Keyboard shortcuts. キーボード ショートカット。 Keys from キーの取得元 @@ -1356,13 +1466,20 @@ KMZ KMZ Label ラベル Label Class ラベル クラス Label Distance Range ラベル距離範囲 +Label features in this layer このレイヤーのフィーチャにラベルを付ける Label Field ラベル フィールド +Label only the features that match. 一致するフィーチャにのみラベルを付けます。 Label Placement ラベルの配置 Label Range ラベル範囲 Label the cities with their names 都市に名前のラベルを付ける Label Visibility Range ラベル表示範囲 Labeling ラベリング +Labeling Pane ラベリング ウィンドウ +Labeling Properties… ラベリングのプロパティ… Labels ラベル +labels are drawn for feature layers, not rasters ラベルはラスターではなくフィーチャ レイヤーに対して描画されます +Labels are drawn for feature layers, not rasters. ラベルはラスターではなくフィーチャ レイヤーに対して描画されます。 +Labels are off for this layer: the clause applies once they are on. このレイヤーのラベルはオフです: ラベルをオンにすると句が適用されます。 Lagoon ラグーン Lambert Conformal Conic ランベルト正角円錐図法 Lambert Cylindrical Equal Area ランベルト正積円筒図法 @@ -1383,12 +1500,14 @@ Layer Blend レイヤーのブレンド Layer Extrusion レイヤーの押し出し Layer File レイヤー ファイル Layer From\nSelection 選択から\nレイヤー作成 +Layer not found レイヤーが見つかりません Layer Template レイヤー テンプレート Layer Templates レイヤー テンプレート layer {id} レイヤー {id} Layers レイヤー Layers ({n}) レイヤー ({n}) Layers and Table Views レイヤーとテーブル ビュー +Layers, a date each レイヤー (それぞれ 1 つの日付) Layout レイアウト Layout\nFile レイアウト\nファイル Layout after: {name} 後のレイアウト: {name} @@ -1402,7 +1521,7 @@ Learning\nResources 学習\nリソース Left 左 Legend 凡例 Legend label. Clear it to show the value again. 凡例ラベル。クリアすると値が再び表示されます。 -Length {length} · gain {gain} m · loss {loss} m 長さ {length} · 上昇 {gain} m · 下降 {loss} m +Length {length} · gain {gain} · loss {loss} 長さ {length} · 上昇 {gain} · 下降 {loss} Let the assistant hand tasks to agents on this computer このコンピューターのエージェントにアシスタントがタスクを任せられるようにする Let your Agents Control RustGIS エージェントに RustGIS を操作させる Letter spacing 文字間隔 @@ -1439,6 +1558,7 @@ List by drawing order 描画順序別に表示 List by selection 選択別に表示 List Columns in File ファイル内の列を一覧表示 List Commands コマンド一覧 +List Connections 接続の一覧表示 List Data Sources データ ソースを一覧表示 List Database Columns データベースの列を一覧表示 List Database Tables データベースのテーブルを一覧表示 @@ -1475,6 +1595,11 @@ Locate… 場所を指定… Location 場所 Locator ロケーター Lock ロック +Locked ロック済み +Locked: unlock it in the Format pane to move or change it ロック済み: 移動または変更するには、書式ウィンドウでロックを解除してください +Log In to Connection 接続にログイン +Log In to {name}… {name} にログイン… +Log In… ログイン… Long 長整数 Longitude 経度 Longitude (X) 経度(X) @@ -1493,6 +1618,7 @@ Manage 管理 Manage Bookmarks ブックマークの管理 Manage Edits 編集の管理 Manage Templates テンプレートの管理 +Mann–Kendall Mann–Kendall Map マップ Map\nFrame マップ\nフレーム Map (the current view) マップ (現在のビュー) @@ -1504,6 +1630,7 @@ Map File マップ ファイル Map Frame マップ フレーム Map Frames マップ フレーム Map frames per second (live while the map moves) マップの 1 秒あたりのフレーム数 (マップの移動中にリアルタイム表示) +Map Labeling マップのラベリング Map Link マップ リンク Map name マップ名 Map Page マップ ページ @@ -1515,6 +1642,7 @@ Map style (JSON) マップ スタイル (JSON) Map Surrounds マップ整飾 Map Thumbnail マップのサムネイル Map tiles of the whole world 全世界のマップ タイル +Map Tool: {tool} マップ ツール: {tool} Map View マップ ビュー map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} マップ: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker マーカー Mask Values マスク値 Masking マスキング +Match Option 一致オプション Matching places 一致する場所 Max 最大 Max % 最大 % +Max Neighbors 最大近傍数 Maximum zoom 最大ズーム MCP Relay MCPリレー MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCPリレーは既に127.0.0.1:{port}で待機中です。ポート{asked}を使うには先に停止してください @@ -1542,6 +1672,8 @@ Measure Distance 距離の計測 Measure geodesic distances and areas on the map. マップ上で測地線距離と面積を計測します。 Measure on this body, with one of its basemaps. この天体上で、そのベースマップの 1 つを使って計測します。 Measure with an elevation raster 標高ラスターで計測 +Measurement units 計測単位 +Measurement units must be metric or imperial 計測単位は metric または imperial である必要があります Measurements so far. これまでの計測値。 Mensuration 計測 Mensuration Tools 計測ツール @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. フィー Messages appear here. ここにメッセージが表示されます。 Meters メートル Method 手法 +Metric メートル法 Midnight 真夜中 Miles マイル Miller Cylindrical ミラー図法 @@ -1560,21 +1693,43 @@ Min must be less than Max 最小は最大より小さくする必要がありま Min must not be more than Max 最小は最大以下にする必要があります Minimum Bounding Geometry 最小境界ジオメトリ Minimum Maximum 最小-最大 +Minus Minus Minutes 分 minutes ago 分前 Miter マイター Mobile Map モバイル マップ Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model モデル Model\nBuilder モデル\nビルダー model id モデル ID Model output モデルの出力 ModelBuilder ModelBuilder Models モデル +Modified Mann–Kendall (Hamed & Rao) 修正 Mann–Kendall (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) 修正 Mann–Kendall (Yue & Wang) Modify 変更 Modify Selected Features 選択したフィーチャの変更 Mollweide (equal-area world) モルワイデ図法 (正積世界) Month 月 +Months 月 +Months are numbers from 1 to 12 (12, 1, 2 for December to February) 月は 1 から 12 の数値で指定します (12 月から 2 月は 12, 1, 2) More その他 More basemaps: その他のベースマップ: Mosaic images into an orthomosaic 画像をモザイクしてオルソモザイクを作成 @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. 表示中のラスターをモ Most cloud cover (%) 最大雲量 (%) Mouse wheel マウス ホイール Move 移動 -Move (m) 移動 (m) Move Bookmark ブックマークの移動 Move Down 下へ移動 Move down 下へ移動 Move Group グループの移動 +Move Label Class ラベル クラスの移動 Move Task Step タスク ステップの移動 +Move To Bottom 最背面へ移動 Move To Group グループへ移動 +Move To Top 最前面へ移動 Move To {group} {group} へ移動 Move Up 上へ移動 Move up 上へ移動 @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets マイ バケット My OGC Layer マイ OGC レイヤー +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name 名前 name the plugin whose panel to open: {"id": plugin id} パネルを開くプラグインを指定してください: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook 新しい\nノートブック New\nPresentation 新しい\nプレゼン New\nReport 新しい\nレポート New\nWorkspace 新しい\nワークスペース -New / Open / Save project プロジェクトの新規作成 / 開く / 保存 New Bookmark 新しいブックマーク New Bookmark… 新しいブックマーク… +New Connection 新しい接続 +New Connection ▾ 新しい接続 ▾ New conversation 新しい会話 New Custom Template 新しいカスタム テンプレート New Custom Template… 新しいカスタム テンプレート… New features use this template's values 新しいフィーチャはこのテンプレートの値を使用します +New field 新しいフィールド New Group 新しいグループ New job name 新しいジョブ名 New Layout 新しいレイアウト @@ -1649,6 +1809,7 @@ New Notebook 新しいノートブック New Presentation 新しいプレゼンテーション New Project 新しいプロジェクト New project 新しいプロジェクト +New Publication Layout 新しい出版用レイアウト New Relationship… 新しいリレーションシップ… New Report 新しいレポート New Selection 新規選択 @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. ブックマ No catalogs below this one: Find Items lists its items この下にカタログはありません: アイテムを検索でそのアイテムを一覧表示します No COMID was returned for this point. このポイントの COMID が返されませんでした。 no command コマンドがありません -No connections this session このセッションの接続はありません +No data at the points picked. 選択したポイントにデータがありません。 No datasets match. 一致するデータセットがありません。 No datasets with data RustGIS can add RustGIS が追加できるデータを含むデータセットはありません +no date in the name 名前に日付なし No edits since the project was saved プロジェクトの保存後の編集はありません No field of this layer holds dates. このレイヤーには日付を持つフィールドがありません。 +No Fill 塗りつぶしなし +No geometry to calculate 計算するジオメトリがありません No granules in this view and date range. このビューと期間にグラニュールがありません。 no home folder ホーム フォルダーがありません no layer レイヤーなし @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) リレーションシップは No relationships yet リレーションシップはまだありません No reports yet. Publish › New Report lists the selected layer's records. レポートはまだありません。[公開] › [新しいレポート] で選択したレイヤーのレコードを一覧表示します。 no row 行なし +No serif font is installed, so this text is drawn in the sans-serif セリフ フォントがインストールされていないため、このテキストはサンセリフで描画されます no service or file links サービスやファイルへのリンクがありません No services match your search. 検索に一致するサービスがありません。 No standalone tables スタンドアロン テーブルはありません @@ -1718,14 +1883,16 @@ Nominatim Nominatim None なし None (public buckets) なし (公開バケット) none, a .duckdb file, or host=… dbname=… user=… なし、.duckdb ファイル、または host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD なし、または PGPASSWORD か MYSQL_PWD から取得 +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD なし、または PGPASSWORD、MYSQL_PWD、SQLCMDPASSWORD から取得 None. Add one by address, or from a basemap plugin. なし。アドレスで、またはベースマップ プラグインから追加します。 North 北 North\nArrow 方位\n記号 North Polar Stereographic 北極ステレオ図法 +NOT NOT not a picture 画像ではありません not a RustGIS layout RustGIS レイアウトではありません not a RustGIS project RustGIS プロジェクトではありません +Not applied yet. まだ適用されていません。 Not for texts: a text is edited in the Format pane. テキストには使用できません: テキストは書式ウィンドウで編集します。 not needed 不要 Not sandboxed サンドボックス外 @@ -1739,9 +1906,12 @@ Nothing found where the map is マップの位置には何も見つかりませ Nothing here 何もありません Nothing named {name} to rename 名前を変更する {name} という項目はありません Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. まだ何も共有していません。[公開] › [共有形式] で Web マップ、レイヤー、スタイル、シーンなどを作成します。 +Nothing to add here ここに追加できるものはありません +Nothing to test テストするものがありません Nothing was found at this address. このアドレスには何も見つかりませんでした。 Nothing was recorded, so no task was made. 何も記録されなかったため、タスクは作成されませんでした。 Now 現在 +Nugget ナゲット NULL NULL Nulls Null Observations (observer, date, species, count) 観測 (観測者、日付、種、個体数) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. デスクトップ版では On-screen compass, zoom slider and pan pad. 画面上のコンパス、ズーム スライダー、パン パッド。 One command per line: id {json}. Lines starting with # are comments. 1 行に 1 コマンド: id {json}。# で始まる行はコメントです。 One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. DuckDB の SELECT 文 1 つ: DuckDB Spatial の ST_ 関数、ファイルと Web アドレス (read_parquet('…'))、下のデータベースのテーブルを使用できます。Ctrl+Enter で実行します。 +One layer: a Zarr variable or a raster's bands 1 つのレイヤー: Zarr 変数またはラスターのバンド One page per feature of an index layer. インデックス レイヤーのフィーチャごとに 1 ページ。 One per line: code=name 1 行に 1 つ: コード=名前 One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. シンボルからの 1 本のストローク。ケーシング、平行線、オフセットにはストロークを追加してください。 @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder フォルダーを開く Open In Geoprocessing… ジオプロセシングで開く… Open in ModelBuilder ModelBuilder で開く +Open in Tool ツールで開く Open Layout レイアウトを開く Open ModelBuilder ModelBuilder を開く Open Notebook ノートブックを開く @@ -1797,6 +1969,7 @@ Open Presentation プレゼンテーションを開く Open Project プロジェクトを開く Open project プロジェクトを開く Open Project (from text) プロジェクトを開く(テキストから) +Open Project… プロジェクトを開く… Open Report レポートを開く Open the attribute table of the selected layer. 選択したレイヤーの属性テーブルを開きます。 Open the Basemaps tab [ベースマップ] タブを開く @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: または POSIX シェルで Or run in PowerShell, for Claude Code or Codex: または PowerShell で実行 (Claude Code または Codex の場合): Or run, for Claude Code or Codex: または、Claude Code か Codex では次を実行します: ORDER BY ORDER BY +Ordinary Kriging 普通クリギング Orientation 向き Ortho Mapping オルソ マッピング Other その他 Other Data その他のデータ +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. このレイヤー上のその他のラベル クラス (コマンドで作成): {n}。各クラスは自身の句に一致するフィーチャにラベルを付けるため、1 つのフィーチャに複数のクラスからラベルが付くことがあります。 Out Beyond 縮小の限界 Outline アウトライン Outline another frame's extent in this map frame. このマップ フレームに別のフレームの範囲の輪郭を表示します。 @@ -1851,6 +2026,7 @@ Output 出力 Output Name 出力名 Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. ブラウザーで動く、ラスター、ベクター、LiDAR、水文、地形の 1,000 以上のツール (初回のみ約 25 MB をダウンロード)。 Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. ラスター、ベクター、LiDAR、水文、地形の 1,000 以上のツール (約 20 MB のオプションのダウンロード)。 +Overlapping 重なり Overlay オーバーレイ overlay オーバーレイ Overlay (a transparent layer kept on top, such as labels or roads) オーバーレイ (ラベルや道路など、最前面に保持される透明なレイヤー) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is マップの表示範囲に Overture Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Overture Maps のオープン データ: 建物、場所、交通、行政区画、ベース レイヤー、住所。マップの表示範囲のフィーチャは Overture の GeoParquet ファイルからその場で読み込まれ (都市以下の範囲にズーム)、全世界はマップ タイルとして表示されます。 Overture Maps… Overture Maps… +Overwrite existing values 既存の値を上書き Package パッケージ Package a trained deep learning model. 学習済みのディープ ラーニング モデルをパッケージ化します。 Package Map マップのパッケージ化 @@ -1907,8 +2084,12 @@ Performance パフォーマンス Phase フェーズ Photon Photon Pick a live value, such as the scale or date, then click the map to place it. 縮尺や日付などの動的な値を選び、マップをクリックして配置します。 +Pick a Point ポイントを選択 +Pick a point on the map to see the variable's values through time there. マップ上でポイントを選択すると、その地点における変数の値の時間変化を表示します。 Pick a route in the list, or click a grey route on the map. 一覧からルートを選ぶか、マップ上の灰色のルートをクリックしてください。 +Pick a single layer. レイヤーを 1 つ選択してください。 Pick it on the map マップ上で選択 +Pick one… 1 つ選択… Picture 画像 Pictures 画像 Pictures, attachments and chart 画像、添付ファイル、グラフ @@ -1966,8 +2147,11 @@ Portal ポータル Portolan Portolan Portolan… Portolan… Portrait 縦向き +Position 位置 +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing 後処理 power 2 べき乗 2 +Precision 6 (OSRM, Valhalla) 精度 6 (OSRM、Valhalla) Prepare 準備 Prepare every layer's geometry for this projection. この投影法用にすべてのレイヤーのジオメトリを準備します。 Prepare Plugin Environment プラグイン環境の準備 @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. プロジェクト テン project's path is required プロジェクトのパスが必要です Projection 投影法 Projections 投影法 +Properties プロパティ +Property プロパティ Proportional Symbols 比例シンボル Provider プロバイダー +Provinces: 省: Proximity 近接 Public GIS portals of US counties and cities, by state. 米国の郡と市の公開 GIS ポータル (州別)。 Public GIS portals of US federal agencies, by department. 米国連邦機関の公開 GIS ポータル (省庁別)。 Public GIS portals of US states and the District of Columbia. 米国の州とコロンビア特別区の公開 GIS ポータル。 Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. ArcGIS Online または他の ArcGIS ポータル上の公開レイヤー: フィーチャ、マップ、イメージ、ベクター タイル サービス、Web マップ、GeoJSON ファイル。 +Publication Layout 出版用レイアウト Publish 公開 Publish › Output 公開 › 出力 Put the layer back as it was then レイヤーをその時点の状態に戻す Python environment failed: {error} Python 環境の準備に失敗しました: {error} Python environment not prepared Python 環境が準備されていません Python environment ready Python 環境の準備完了 +Python script Python スクリプト Qualitative 定性 Quantile 等量 Query files, web addresses and databases with DuckDB Spatial; add results to the map. DuckDB Spatial でファイル、Web アドレス、データベースをクエリし、結果をマップに追加します。 @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. 高さまたはフィールドでポリ Raise Priority 優先度を上げる Random points in polygons ポリゴン内のランダム ポイント Range 範囲 +Range (Degrees) レンジ (度) +range: 0, 0.9 範囲: 0, 0.9 Raster ラスター Raster\nAnalysis ラスター\n解析 Raster\nInformation ラスター\n情報 @@ -2075,6 +2266,7 @@ Redo {action} {action} をやり直す Red–Blue 赤–青 Refine the search to see {n} more 検索を絞り込むと、さらに {n} 件表示されます Refresh 更新 +Refresh every connection's lists すべての接続の一覧を更新 Refresh Map Series マップ シリーズの更新 Refresh the map series from its index layer. インデックス レイヤーからマップ シリーズを更新します。 Region リージョン @@ -2091,11 +2283,10 @@ Relationships リレーションシップ Release リリース Reload 再読み込み Reload Web Layers Web レイヤーの再読み込み -Remember (without the secret) 記憶する (シークレットを除く) Remember the key on this computer このコンピューターにキーを保存する -Remembered: 記憶済み: Remove 削除 Remove All すべて削除 +Remove All Label Classes すべてのラベル クラスを削除 Remove an offline area. オフライン エリアを削除します。 Remove Bookmark ブックマークの削除 Remove Connection 接続の削除 @@ -2108,11 +2299,14 @@ Remove from Favourites お気に入りから削除 Remove From Group グループから削除 Remove Group グループの削除 Remove Keyframe キーフレームの削除 +Remove Label Class ラベル クラスの削除 Remove Section セクションの削除 Remove Table テーブルの削除 +Remove the clause. 句を削除します。 Remove the last keyframe. 最後のキーフレームを削除します。 Remove the layer from the map. マップからレイヤーを削除します。 Remove the strokes and draw the symbol's line alone ストロークを削除し、シンボルのラインのみを描画します +Remove this class's own strokes and draw it with the layer's このクラス独自のストロークを削除し、レイヤーのストロークで描画します Remove this stop この経由地を削除 Remove Tile Layer タイル レイヤーの削除 Remove token トークンを削除 @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). 許容 Rename 名前の変更 Rename Bookmark ブックマーク名の変更 Rename Group グループ名の変更 +Rename it to save it: a connection has this name 保存するには名前を変更してください: この名前の接続がすでにあります +Rename Label Class ラベル クラス名の変更 Rename Layer レイヤー名の変更 Rename Layout レイアウト名の変更 Rename Map マップ名の変更 @@ -2149,6 +2345,7 @@ Reset Default 既定値にリセット Reset Effects 効果のリセット Reset North 北を上にリセット Reset north 北を上にリセット +Resolution (0–30) 解像度 (0–30) Response レスポンス Restore 復元 Restore From Archive アーカイブから復元 @@ -2162,6 +2359,7 @@ RGB RGB Ribbon リボン Right 右 Right-click a source to add it here ソースを右クリックしてここに追加 +Right-click for more 右クリックでその他の操作 Robinson ロビンソン図法 Rotate 回転 Rotate (°) 回転 (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) サンプル データ(Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. サンプル データ © Natural Earth(パブリック ドメイン)。RustGIS は Apache-2.0 ライセンスで提供されています。 Sample data: Natural Earth (public domain). Licensed under Apache-2.0. サンプル データ:Natural Earth(パブリック ドメイン)。Apache-2.0 ライセンス。 +Sans-serif サンセリフ Satellite Embedding Datasets 衛星埋め込みデータセット Satellite Embeddings 衛星埋め込み Satellite Embeddings… 衛星埋め込み… Saturation 彩度 Save 保存 Save As 名前を付けて保存 +Save as Connection 接続として保存 Save as Default 既定値として保存 Save as Default Raster Style 既定のラスター スタイルとして保存 Save as Layer レイヤーとして保存 Save As Presentation File… プレゼンテーション ファイルとして保存… +Save as Python Script… Python スクリプトとして保存… Save As Report File… レポート ファイルとして保存… Save As Report Template… レポート テンプレートとして保存… Save as {name} {name} として保存 Save As… 名前を付けて保存… Save Color Ramps カラー ランプの保存 Save Data Source データ ソースの保存 +Save History As Python Script 履歴を Python スクリプトとして保存 Save Layer File レイヤー ファイルの保存 Save Layout File レイアウト ファイルの保存 Save Map File マップ ファイルの保存 @@ -2254,6 +2456,7 @@ save or load a pane set by name 名前を指定してウィンドウ セット Save Project プロジェクトの保存 Save project プロジェクトの保存 Save Project As 名前を付けてプロジェクトを保存 +Save Project As… 名前を付けてプロジェクトを保存… Save Project Template プロジェクト テンプレートの保存 Save Style Items スタイル アイテムの保存 Save Task Item タスク アイテムの保存 @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) モデルをファイル (*.model.json) Save the model in the project モデルをプロジェクトに保存 Save the open layout as a layout file. 開いているレイアウトをレイアウト ファイルとして保存します。 Save the panes you have open, and switch back to them. 開いているウィンドウを保存し、後でその状態に戻ります。 +Save the project too: the script opens its saved file, without changes made since. プロジェクトも保存してください: スクリプトは保存済みのファイルを開くため、保存後の変更は含まれません。 Save the project under a new name. プロジェクトを新しい名前で保存します。 Save the project with your edits. 編集内容を含めてプロジェクトを保存します。 Save the project's custom color ramps to a color ramp file. プロジェクトのカスタム カラー ランプをカラー ランプ ファイルに保存します。 @@ -2275,13 +2479,16 @@ Save Web Link Chart Web リンク チャートの保存 Save Web Map Web マップの保存 Save Web Scene Web シーンの保存 Saved 保存済み +Saved as the connection {name} 接続 {name} として保存しました Saved as the default style for new single-band rasters. 新しい単バンド ラスターの既定のスタイルとして保存しました。 +Saved connection 保存済みの接続 Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). 保存したキーは暗号化されずにアプリの設定に保存されます(Web 版ではブラウザーのストレージ)。 Saved models 保存したモデル Saved symbology: apply it to the selected layer, or save the layer's. 保存したシンボル: 選択したレイヤーに適用するか、レイヤーのシンボルを保存します。 Saved View 保存したビュー Saved {name} {name} を保存しました Saved {name}. {name} を保存しました。 +Saved {path} {path} を保存しました saving failed unexpectedly 保存が予期せず失敗しました Scale 縮尺 Scale\nBar 縮尺\n記号 @@ -2304,7 +2511,7 @@ Search (this portal needs words)… 検索 (このポータルには単語が必 Search Catalogue カタログの検索 Search commands — e.g. buffer, basemap, labels コマンドを検索(例: バッファー、ベースマップ、ラベル) Search datasets, e.g. GEDI biomass データセットを検索 (例: GEDI biomass) -Search Distance (km) 検索距離(km) +Search Distance 検索距離 Search every command and its parameters すべてのコマンドとそのパラメーターを検索 Search layers (e.g. temperature) レイヤーを検索 (例: temperature) Search layers, places, lat/lon… レイヤー、場所、緯度/経度を検索… @@ -2329,6 +2536,8 @@ Searching the folders… フォルダーを検索しています… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. ここでも検索はできますが、NASA のデータ サーバーは Web ページからのファイル読み込みを許可していない (CORS なし) ため、GeoTIFF や保護されたデータのダウンロードにはデスクトップ アプリが必要です。ダウンロードすると新しいタブでファイルが開き、Earthdata Login がサインインを求めます。 Searching… 検索しています… Searching… try again in a moment. 検索しています… しばらくしてからもう一度お試しください。 +Seasonal Mann–Kendall 季節 Mann–Kendall +Seasons a year 1 年あたりの季節数 Second standard parallel 第 2 標準緯線 Seconds 秒 Secret access key シークレット アクセス キー @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator 区切り文字 Sepia セピア Sequential 連続 +Serif セリフ Server サーバー Service Group サービスグループ Service group data exceeds 64 MB. サービスグループのデータが 64 MB を超えています。 Service Import Requests サービスのインポート要求 Service Layers サービス レイヤー -Services サービス +Session token セッション トークン Set 設定 Set Cell Size セル サイズの設定 Set Domain ドメインの設定 @@ -2411,6 +2621,7 @@ Shaded 陰影 Shaded Relief 陰影起伏 Shape 形状 Shapefile Shapefile +Shapes 図形 Share 共有 Share As 共有形式 Share every layer and table as OGC API Features collections. すべてのレイヤーとテーブルを OGC API Features コレクションとして共有します。 @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. ブラウザー ビュー Show different features at different scales. 縮尺ごとに異なるフィーチャを表示します。 Show grid グリッドを表示 Show Group グループを表示 +Show labels ラベルを表示 Show latitude/longitude lines on the basemap. ベースマップに経緯線を表示します。 +Show Layers レイヤーを表示 Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. レイヤーを時系列で表示します: 日付がスライダーの期間内にあるフィーチャと、その日付の日付付き画像のみを表示します。 Show Map マップの表示 Show Me 場所を表示 @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. ツール ウィンドウの表示/非表示を切 Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. ルーラーを表示します。ルーラーからドラッグしてガイドを追加し、ガイドをルーラーへドラッグして戻すと削除します。 Show the bands picked, read in place as the map needs them 選択したバンドを表示し、マップに必要な分だけその場で読み込みます Show the description 説明を表示 +Show the Labeling pane for the selected layer. 選択したレイヤーのラベリング ウィンドウを表示します。 Show the labels that don't fit, in red. 配置できないラベルを赤で表示します。 Show the layer only inside another layer's polygons. 別のレイヤーのポリゴン内にのみレイヤーを表示します。 Show the layer only left of a divider, to compare it with what's beneath. 区切り線の左側にのみレイヤーを表示し、下にあるものと比較します。 @@ -2491,8 +2705,10 @@ Sign-in cancelled. サインインをキャンセルしました。 Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. サインインがタイムアウトしました。最初からやり直し、ブラウザーがこのコンピューターのループバックアドレスに接続できるようにしてください。 signed in サインイン済み Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. このコンピューターではサインアウトしましたが、OpenAI による確認は取れませんでした。ChatGPT の設定で RustGIS の接続を解除できます。 +Significance level 有意水準 Signing in with ChatGPT works in the desktop app. ChatGPT でのサインインはデスクトップアプリで使えます。 Signing the asset (Planetary Computer)… アセットに署名しています (Planetary Computer)… +Sill シル Simplify 単純化 Simulate the drive, or save the route as a layer. 走行をシミュレートするか、ルートをレイヤーとして保存します。 Simulation シミュレーション @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} AI アシスタント用スキル: {names} Slideshow, PDF or PNG スライドショー、PDF、PNG slight left 斜め左方向 slight right 斜め右方向 +Slope per 傾きの単位 Slope, aspect, hillshade, NDVI, band math, thresholds. 傾斜角、傾斜方向、陰影起伏、NDVI、バンド演算、しきい値。 Slope, hillshade, NDVI and more. 傾斜角、陰影起伏、NDVI など。 Small scales 小縮尺 @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South 南 South Polar Stereographic 南極ステレオ図法 +Spatial Join 空間結合 Speaker notes スピーカー ノート Spectral Analysis スペクトル解析 Spectral indices スペクトル指数 @@ -2530,6 +2748,7 @@ Split (click the line) 分割 (ラインをクリック) SQL SQL SQL Pane SQL ウィンドウ SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL クエリはデスクトップ アプリが呼び出すプログラム DuckDB で実行されます: クエリを実行するには、このプロジェクトをデスクトップ アプリで開いてください。 +SQL Server SQL Server SQL that must be true, e.g. pop > 0 真でなければならない SQL (例: pop > 0) Square 正方形 Squares 正方形 @@ -2588,6 +2807,7 @@ Start over at the end while playing. 再生中、最後に達したら最初か Start With 起動時 Start-up 起動 starting with a project file needs the desktop app プロジェクト ファイルで起動するにはデスクトップ アプリが必要です +Statistic 統計量 Statistics 統計 Statistics Field 統計フィールド Status ステータス @@ -2613,8 +2833,10 @@ Stretch\nType ストレッチ\nタイプ Stretch it over the map view, or give control points (one per line: column row lon lat). マップ ビューに合わせて引き伸ばすか、コントロール ポイントを指定します (1 行に 1 つ: 列 行 経度 緯度)。 Stretch Type ストレッチ タイプ Stretch type ストレッチ タイプ +Stroke Levels ストローク レベル Stroke {n} ストローク {n} Strokes ストローク +Strokes for ストロークの対象 Style スタイル Style Items スタイル アイテム Style › Drawing スタイル › 描画 @@ -2624,7 +2846,10 @@ Subreport: {name} サブレポート: {name} Subtypes サブタイプ Subtypes, one per line: code=name サブタイプ (1 行に 1 つ): コード=名前 Suitability Modeler 適性モデラー +Summarise 集計 +Summarise over time 時間方向に集計 Summarise the conversation so far, so a long chat stays within the model's context これまでの会話を要約し、長いチャットがモデルのコンテキストに収まるようにします +Summarise Zarr Over Time Zarr を時間方向に集計 Summarising the earlier conversation to keep it within the model's context… モデルのコンテキストに収まるよう、これまでの会話を要約しています… Summarize 集計 Summarize\nWithin 範囲内で\n集計 @@ -2658,6 +2883,8 @@ Table or layer テーブルまたはレイヤー Tables テーブル Tags タグ Take the data in view offline, to edit and sync back later. 表示中のデータをオフラインにして編集し、後で同期します。 +Target Features ターゲット フィーチャ +Target Records ターゲット レコード Task タスク Task Item タスク アイテム Task name タスク名 @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. テネシー州の公開 GIS データ (Tennessee GeoData、geodata.tn.gov): 土地境界、交通、保健、水文など。 Terrain 地形 Test Test +Test Connection 接続のテスト +Testing… テストしています… Text テキスト Text Symbol テキスト シンボル That connection would create a loop. その接続はループを作ります。 The address fields are empty in every row. すべての行で住所フィールドが空です。 +The answer isn't UTF-8 text 応答が UTF-8 テキストではありません the answer isn't UTF-8 text 応答が UTF-8 テキストではありません The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. アシスタントはスキルを使うとき、その指示に従います。指示の内容にかかわらず、承認が必要なコマンドは引き続き確認を求めます。 The assistant is busy; stop it or wait for it to finish. アシスタントは処理中です。停止するか、完了するまでお待ちください。 @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. ベースマップ プラグイン the capture failed unexpectedly キャプチャが予期せず失敗しました The connection failed; trying again in {seconds} s. 接続に失敗しました。{seconds} 秒後に再試行します。 The country list couldn't be read ({e}): type a country's code 国のリストを読み込めませんでした ({e}): 国コードを入力してください +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. 入力を編集している間は現在の標高が有効なままです。値と単位を適用するには [標高を適用] をクリックしてください。 The dataset has nothing RustGIS can add このデータセットには RustGIS が追加できるものがありません The environment (AWS_ACCESS_KEY_ID…) 環境変数 (AWS_ACCESS_KEY_ID…) The features where the map is マップの表示範囲のフィーチャ @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. フ The installed GeoLibre runner lists no tools. インストール済みの GeoLibre ランナーにツールがありません。 The job covers the map view ジョブはマップ ビューを対象とします The key is kept for this session only. キーはこのセッションの間だけ保持されます。 +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. レイヤーにはタイム スライダーの現在のステップが表示され、変数の日付範囲でスライダーがオンになります。 The layer's file couldn't be read: {error} レイヤーのファイルを読み込めませんでした: {error} +The layer's strokes レイヤーのストローク the layout export failed unexpectedly レイアウトのエクスポートが予期せず失敗しました The link doesn't say its STAC API: Find Items searches {api} リンクに STAC API が示されていません: [アイテムを検索] は {api} を検索します The list of plugins to browse. Leave it empty for the RustGIS plugin registry. 参照するプラグインの一覧です。空欄にすると RustGIS プラグイン レジストリを使用します。 @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. マップ ビューから作成す The projection the map opens in. マップを開くときの投影法。 The provider's content filter stopped the reply. プロバイダーのコンテンツフィルターが回答を停止しました。 The public server is free and shared by many: it answers light use, and may ask you to wait. 公開サーバーは無料で多くの人が共有しています。軽い利用には応答しますが、待つよう求められることがあります。 +The range is two numbers, low and high (0, 0.9) 範囲は下限と上限の 2 つの数値です (0, 0.9) The raster's data couldn't be read. ラスターのデータを読み込めませんでした。 The raster's data couldn't be read: {path} ラスターのデータを読み込めませんでした: {path} the release isn't known リリースが不明です @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Shapefile のファイルは 1 つの The share of each cell covered by fields, for the zoomed-out view 縮小表示用の、各セルのうち圃場が占める割合 The sign-in expired or was revoked. Sign in again. サインインの有効期限が切れたか、取り消されました。再度サインインしてください。 The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. サインイン情報はアプリの設定フォルダーに、あなたのユーザーアカウント専用で保存され、プロジェクトファイルには保存されません。 +The significance level is a number, such as 0.05 有意水準は数値で指定します (例: 0.05) The start-up project {path} didn't open: {error} 起動時のプロジェクト {path} を開けませんでした: {error} The start-up project {path} isn't a RustGIS project file. 起動時のプロジェクト {path} は RustGIS のプロジェクト ファイルではありません。 The step's layer {name} isn't in the map. ステップのレイヤー {name} がマップにありません。 The store has no variables with two or more dimensions. ストアに 2 次元以上の変数がありません。 the store is gone ストアがなくなりました +The summary couldn't start: {error} 集計を開始できませんでした: {error} +The summary failed unexpectedly 集計が予期せず失敗しました +The summary finished after another project was opened, so it wasn't added 別のプロジェクトを開いた後に集計が完了したため、追加されませんでした The task's steps changed, so the step being edited was closed: edit it again. タスクのステップが変更されたため、編集中のステップを閉じました。もう一度編集してください。 the time slider is off タイム スライダーはオフです The token expires on {date}. トークンの有効期限は {date} です。 +The trend couldn't start: {error} トレンド分析を開始できませんでした: {error} +The trend failed unexpectedly トレンド分析が予期せず失敗しました +The trend finished after another project was opened, so it wasn't added 別のプロジェクトを開いた後にトレンド分析が完了したため、追加されませんでした +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. 黒から白で表示する値の範囲で、すべての日付とタイルで共通です (バンド自体の単位)。空欄: 各ファイル独自のストレッチ。 the view has no extent ビューに範囲がありません The view is larger than one read: its centre was read (zoom in for the rest) ビューが 1 回の読み込みより大きいため、中心部を読み込みました (残りは拡大してください) The web app always runs the latest release: reload the page to get it. Web アプリは常に最新リリースで動作します。ページを再読み込みすると取得できます。 the window's screenshot didn't come back in time, so the map was drawn offscreen ウィンドウのスクリーンショットが時間内に返されなかったため、オフスクリーンでマップを描画しました the window's screenshot didn't hold the map, so it was drawn offscreen ウィンドウのスクリーンショットにマップが含まれていなかったため、オフスクリーンで描画しました Theme テーマ +There is no expression defined. 式が定義されていません。 Thermal サーマル +Thesis figure, full page (160 × 225 mm) 学位論文の図、1 ページ (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) 学位論文の図、半ページ (160 × 110 mm) Thinking… 考え中… +Third-party licences サードパーティのライセンス +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins このビルドではプラグインをダウンロードできません this build can't reach AI providers このビルドでは AI プロバイダーに接続できません this build saves projects with Save As このビルドでは [名前を付けて保存] でプロジェクトを保存します This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. この ChatGPT アカウントはすでに保存されています。次回は再度追加せず、一覧から選んでください。 +This class is drawn with the layer's strokes. Changing them here gives it its own. このクラスはレイヤーのストロークで描画されます。ここで変更すると、このクラス独自のストロークになります。 +This class is hidden, so it labels nothing. このクラスは非表示のため、何もラベル付けされません。 +This clause no longer reads, so no feature is labelled: {error} この句を解釈できなくなったため、どのフィーチャにもラベルが付きません: {error} This map has no layers yet. このマップにはまだレイヤーがありません。 This map's thumbnail, from an image. 画像から作成するこのマップのサムネイル。 This map's thumbnail, from the view. ビューから作成するこのマップのサムネイル。 @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. この要求には「今回のみ許可」のオプションがありません。 This request uses an unsupported capability. Check the parameter and change the request. このリクエストは未対応の機能を使用しています。パラメーターを確認してリクエストを変更してください。 This result is kept この結果は残されます +This run has no tool form to open この実行には開くツール フォームがありません +This session only (optional) このセッションのみ (省略可) This step このステップ This step names no tool to show. このステップには表示するツールが指定されていません。 Thumbnail サムネイル @@ -2789,15 +3041,21 @@ Tilt Map マップの傾斜 Tilt-shift ティルトシフト Tilt-shift blur and film grain. ティルトシフト ぼかしとフィルム グレイン。 Time 時間 +Time\nSeries 時系列\nグラフ Time\nSlider タイム\nスライダー Time Fields 時間フィールド +Time Series 時系列 +Time series 時系列 +Time Series at a Point ポイントの時系列 Time Slider タイム スライダー Time Slider Settings タイム スライダーの設定 Tint by elevation 標高で色付け Tissot's Indicatrix ティソーの指示楕円 Title タイトル +To 終了 Toggle Dark Mode ダーク モードの切り替え Toggle GPU Rendering GPU レンダリングの切り替え +Toggle Imperial Measurements ヤード・ポンド法の計測を切り替え Tolerance (km) 許容値(km) Tolls 有料道路 Tool Gallery ツール ギャラリー @@ -2828,7 +3086,10 @@ Train Deep Learning Model ディープ ラーニング モデルのトレーニ Trained models 学習済みモデル Transparency 透過表示 Traverse トラバース +Trend Analysis トレンド分析 +Trend over a series of rasters 一連のラスターにわたるトレンド Triangle 三角形 +TRUE TRUE Try again in a moment. しばらくしてからもう一度お試しください。 Try again later. 後でもう一度お試しください。 Turn editing on or off for the map. マップの編集のオン/オフを切り替えます。 @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map 経度と緯度を入力する Type a scale, such as 1:12,345, 12345 or 25k, and press Enter 1:12,345、12345、25k のように縮尺を入力し、Enter を押します Type an address or coordinates and press Enter, or click the map. 住所または座標を入力して Enter を押すか、マップをクリックしてください。 Type numbers separated by commas カンマ区切りで数値を入力 +Type the keys to log in for this session, then Save. このセッションでログインするためのキーを入力し、[保存] をクリックしてください。 +Type the password or keys to log in for this session, then Save. このセッションでログインするためのパスワードまたはキーを入力し、[保存] をクリックしてください。 Typed below 下に入力 UM UM Unavailable: {reason} 利用不可:{reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider タイム スライダーからバインド解除 Unbind from Time Slider タイム スライダーからバインド解除 Undo 元に戻す Undo (Ctrl+Z) 元に戻す (Ctrl+Z) -Undo / Redo 元に戻す / やり直し Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. 元に戻しても記録したステップは削除されません。記録を停止した後、[ステップの編集] で削除してください。 Undo the last change. 最後の変更を元に戻します。 Undo {action} {action} を元に戻す @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools GeoLibre ツールのアンインストール Uninstall Plugin プラグインのアンインストール Uninstalled {id}. {id} をアンインストールしました。 Unique Values 個別値 +Unit 単位 United States アメリカ合衆国 Units 単位 Units per step ステップあたりの単位数 @@ -2877,6 +3140,7 @@ Up a level 1 つ上の階層へ Update 更新 Update Bookmark ブックマークの更新 Update Job ジョブの更新 +Update Label Class ラベル クラスの更新 Update to {version} {version} に更新 Upload File ファイルのアップロード Upload File… ファイルのアップロード… @@ -2895,6 +3159,7 @@ Use another account 別のアカウントを使用 Use Current View 現在のビューを使用 Use each feature's line color 各フィーチャのラインの色を使用 Use each feature's line width 各フィーチャのラインの幅を使用 +Use the Layer's Strokes レイヤーのストロークを使用 USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan ChatGPT プランを使用中 Utilities ユーティリティ Valid combinations, one per line: a, b 有効な組み合わせ (1 行に 1 つ): a, b Valid expression · {n} matching rows 有効な式 · 一致する行 {n} 件 +Valid: {n} of {of} features match 有効: {of} 件中 {n} 件のフィーチャが一致 Validate 検証 Validate Model モデルの検証 value (empty: no-data) 値 (空: NoData) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Vantor による災害のオープン画像: イベントとフェーズ (前または後) を選び、アイテムを検索します。 Vantor… Vantor… Variable 変数 +Variogram Model バリオグラム モデル Vector and raster data ベクター データとラスター データ Vector Layer ベクター レイヤー +vector tiles ベクター タイル Vector tiles (MVT) ベクター タイル (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. 地理空間基盤モデルがピクセルまたは画像パッチごとに作成したベクトル。マップの位置で検索し、色として表示します。 Version {version} バージョン {version} @@ -2933,6 +3201,9 @@ Video file or URL 動画ファイルまたは URL Video Page 動画ページ Videos 動画 Video… 動画… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. ベトナム国家座標系 (VN-2000)。3 度帯の横メルカトル図法を使用し、WGS 84 への 7 パラメータ Helmert 変換を適用します。 +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). ベトナム国家座標系 (VN-2000) UTM ゾーン 48N (102°E–108°E、中央子午線 105°E)。 +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). ベトナム国家座標系 (VN-2000) UTM ゾーン 49N (108°E–114°E、中央子午線 111°E)。 View 表示 View Clipping ビューのクリッピング View Effects ビュー効果 @@ -2952,6 +3223,9 @@ Visibility Range 表示縮尺範囲 Visible 表示 Visible Layers 表示レイヤー Visual Effect 視覚効果 +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (ベトナム) +VN-2000 / UTM zone 48N VN-2000 / UTM ゾーン 48N +VN-2000 / UTM zone 49N VN-2000 / UTM ゾーン 49N Volume 体積 Waiting for the browser… ブラウザーを待っています… Waiting for your answer あなたの返答を待っています @@ -2970,6 +3244,7 @@ Web Link Chart Web リンク チャート Web maps Web マップ Web Mercator Web メルカトル Web Scene Web シーン +Web service (detected) Web サービス (検出済み) Web Style Web スタイル Web Tile Layers Web タイル レイヤー Web Tool Web ツール @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. フィーチャに対するラベ Where new features get their elevation (the z field). 新しいフィーチャの標高の取得元 (z フィールド)。 Where the map opens: the full extent or a view you saved. マップを開く位置: 全範囲または保存したビュー。 Where tools work ツールを実行する範囲 +Whether the Select tool picks these layers' features on the map 選択ツールでマップ上のこれらのレイヤーのフィーチャを選択できるかどうか Which 5 countries have the largest GDP? GDP が最も大きい 5 か国はどこですか? Whitebox and GeoLibre tools Whitebox と GeoLibre のツール Whole map マップ全体 @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. 世界、正積: 面積が正 World, equal-area: true sizes, shapes bend towards the edges. 世界、正積: 面積が正しく、縁に向かって形が曲がります。 Worldview 国境 Write a plugin プラグインを作成 +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Sen の傾き、検定の Z 値と p 値、およびトレンドが有意な箇所の傾きを出力します。 X (mm) X (mm) X column X 列 X Field X フィールド @@ -3050,7 +3327,6 @@ Yours ユーザー YYYY-MM-DD YYYY-MM-DD YYYY-MM-DD, for the layers that change with time YYYY-MM-DD (時間とともに変化するレイヤー用) YYYY-MM-DD, for time layers YYYY-MM-DD (時系列レイヤー用) -z (m) z (m) Z factor Z 係数 Zarr Zarr Zarr Variables Zarr 変数 @@ -3061,7 +3337,6 @@ Zoom Box ボックス ズーム Zoom Frame フレームをズーム Zoom In ズームイン Zoom in ズームイン -Zoom in / out ズームイン / ズームアウト Zoom In Here ここでズームイン Zoom in on the centre of the map. マップの中心でズームインします。 Zoom in · finish a sketch ズームイン · スケッチを終了 @@ -3073,6 +3348,7 @@ Zoom To\nLayer レイヤーに\nズーム Zoom to a box (Explore) ボックスにズーム(マップ操作) Zoom To Extent 範囲にズーム Zoom To Layer レイヤーにズーム +Zoom To Layers レイヤーにズーム Zoom To Map View マップ ビューにズーム Zoom To Selection 選択範囲にズーム Zoom to selection 選択範囲にズーム @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. レイヤーの範囲にズームします。 Zoom to the selected features. 選択したフィーチャにズームします。 Zoom to the whole world. 世界全体にズームします。 Zoom to this bookmark このブックマークにズーム +Zoom To Tile Layer タイル レイヤーにズーム Zoom to Tokyo 東京にズーム Zoom To World 世界にズーム +Zoom {z} · Bearing {b}° · Pitch {p}° ズーム {z} · 方位 {b}° · 傾き {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. ズーム: Web マップでの数え方によるマップのズーム レベル。方位: マップの上端が向く方向で、北から時計回りに測ります。傾き: 真上から見下ろした状態からの傾斜です。クリックすると方位と傾きをリセットします。 {agent} was stopped by you. {agent} を停止しました。 {id} changed since it was installed; install it again from the Plugin Manager. {id} はインストール後に変更されています。プラグイン マネージャーから再インストールしてください。 {names}: needs Node.js (npx) for its ACP adapter. {names}: ACP アダプターに Node.js (npx) が必要です。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 9313cda2c..a96cf40ea 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6,6 +6,7 @@ (filtered) (გაფილტრული) + Add Stop + გაჩერების დამატება + Input + შესავალი ++ New expression + ახალი გამოსახულება + Output + გამოსავალი /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh 3D მესი <არაფერი> A 3D globe seen from space. 3D გლობუსი კოსმოსიდან დანახული. A blank map with a basemap ცარიელი რუკა საბაზისო რუკით +A call for the rustgis Python package გამოძახება rustgis Python პაკეტისთვის A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. კატალოგის (CSW 2.0.2) მისამართი: მოძებნეთ მისი ჩანაწერები სიტყვებით და, სურვილისამებრ, რუკის არეალით, შემდეგ დაამატეთ სერვისები და ფაილები, რომლებზეც ისინი მიუთითებს. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. მიღწეულია ChatGPT-ის გეგმის ან აპის გამოყენების ლიმიტი. ხელახლა ცდამდე გადახედეთ კვირის გამოყენებას. A ChatGPT sign-in can only be used with the OpenAI API. ChatGPT-ით შესვლის გამოყენება მხოლოდ OpenAI API-სთან შეიძლება. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). GeoLens A globe scene. გლობუსის სცენა. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place განედი და გრძედი (40.7, -74.0 ან 40°42'N 74°0'W), ფენა, სანიშნე ან მნიშვნელობა ფენის ცხრილში. Enter ირჩევს მონიშნულ სტრიქონს; „ადგილების ძიება“ ეძებს ტექსტს როგორც ადგილს A layer the model reads ფენა, რომელსაც მოდელი კითხულობს +A line for the Script pane სტრიქონი სკრიპტის პანელისთვის A magnifying lens that follows the pointer. გამადიდებელი შუშა, რომელიც კურსორს მიჰყვება. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). რუკის, ობიექტების ან დაფარვის სერვისის მისამართი: WMS, WMTS, WFS, WCS, OGC API Features ან Tiles, ან ArcGIS FeatureServer, MapServer, ImageServer ან VectorTileServer (ან სერვისების კატალოგი დასათვალიერებლად). a name, or owner/name such as giswqs/geospatial სახელი ან მფლობელი/სახელი, მაგალითად giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. სახელდებული ბოლო წერტილი განმეორებითი გამოყენებისთვის. პაროლები და გასაღებები ინახება RustGIS-ის დახურვამდე და არასდროს ინახება მუდმივად. A node cannot connect to itself. კვანძი საკუთარ თავს ვერ დაუკავშირდება. A notebook of text and command cells. ტექსტისა და ბრძანების უჯრედების რვეული. +A password or token in the address was left out: connections never keep secrets. მისამართში არსებული პაროლი ან ტოკენი გამოტოვებულია: კავშირები არასდროს ინახავს საიდუმლოებებს. A place or an address ადგილი ან მისამართი A project keeps at least one map პროექტში ყოველთვის რჩება მინიმუმ ერთი რუკა A projected coordinate system. დაპროექტებული კოორდინატთა სისტემა. A report listing the selected layer's records, page after page. ანგარიში, რომელიც არჩეული ფენის ჩანაწერებს გვერდ-გვერდ ჩამოთვლის. A result that only feeds other tools is removed once the model has run; add an output to keep it. შედეგი, რომელიც მხოლოდ სხვა ხელსაწყოებს გადაეცემა, მოდელის გაშვების შემდეგ იშლება; მის შესანარჩუნებლად დაამატეთ გამოსავალი. A result the model keeps, under a name შედეგი, რომელსაც მოდელი სახელით ინახავს +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) rustgis-cli ბრძანება POSIX გარსისთვის (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. სერვისის იმპორტი ან განახლება უკვე მიმდინარეობს. +A service's layers stay in their service group. სერვისის ფენები რჩება თავიანთი სერვისის ჯგუფში. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. ამოცანა მაქსიმუმ 200 საფეხურს იტევს: შესაქმნელად შეაჩერეთ ჩაწერა, დანარჩენი კი სხვა ამოცანად ჩაწერეთ. A template with the first selected feature's values შაბლონი პირველი მონიშნული ობიექტის მნიშვნელობებით +A tile layer per grid tile, each following the time slider through its dates. ფილების ფენა ბადის თითო ფილაზე; თითოეული მიჰყვება დროის სლაიდერს თავისი თარიღების გავლით. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. ფილების სერვისის მისამართი: რასტრული ფილები {z}/{x}/{y}-ით (WMS-ისთვის {-y}, {q}, {bbox-epsg-3857}), ვექტორული ფილები (.pbf) ან TileJSON, MapLibre სტილი, PMTiles არქივი (ვებ მისამართი ან ფაილი ამ კომპიუტერში) ან MBTiles ფაილი ამ კომპიუტერში. A token is set. ტოკენი დაყენებულია. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. მნიშვნელობა, ველი ან ორი მათგანი, შეერთებული + − × ÷ ნიშნებით. ვრცელდება მონიშნულ სტრიქონებზე, მონიშვნის გარეშე კი ყველა სტრიქონზე. a vector a pixel თითო ვექტორი პიქსელზე a vector an image patch თითო ვექტორი გამოსახულების ფრაგმენტზე A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Zarr საცავი (ფორმატი 2 ან 3) ვებ მისამართით, კონსოლიდირებული მეტამონაცემებით, ან საქაღალდე ამ კომპიუტერში. ცვლადის რუკა იკითხება მისი სხვა განზომილებების არჩეული ნაბიჯებისთვის, მაგ. დროისთვის. +A Zarr variable's values through time at points clicked on the map. Zarr ცვლადის მნიშვნელობები დროში რუკაზე დაწკაპუნებულ წერტილებში. +A2 landscape sheet A2 ფურცელი, ჰორიზონტალური +A2 portrait sheet A2 ფურცელი, ვერტიკალური +A3 landscape sheet A3 ფურცელი, ჰორიზონტალური +A3 portrait sheet A3 ფურცელი, ვერტიკალური +A4 landscape sheet A4 ფურცელი, ჰორიზონტალური +A4 portrait sheet A4 ფურცელი, ვერტიკალური +A5 Grid A5 ბადე +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. A5-ის ტოლფართობიანი ხუთკუთხა უჯრედები მოცემული გარჩევადობით, რუკის ფარგლებში, ფენაზე ან მთელ მსოფლიოზე, პოლიგონების სახით თავიანთი იდენტიფიკატორებით. About შესახებ About plugins დანამატების შესახებ About RustGIS RustGIS-ის შესახებ @@ -65,6 +83,7 @@ Add დამატება Add\nData მონაცემების\nდამატება Add ({n}) დამატება ({n}) Add a bar chart of a layer's numeric field. ფენის რიცხვითი ველის სვეტოვანი დიაგრამის დამატება. +Add a circle. წრის დამატება. Add a colorbar of a raster layer's colour scale in the map frame. რასტრული ფენის ფერთა შკალის ფერთა ზოლის დამატება რუკის ჩარჩოში. Add a description. დაამატეთ აღწერა. Add a field. ველის დამატება. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. გვერდის გ Add a page to the presentation. გვერდის დამატება პრეზენტაციაში. Add a PNG or JPEG picture to the page. PNG ან JPEG სურათის დამატება გვერდზე. Add a presentation file. პრეზენტაციის ფაილის დამატება. +Add a rectangle, to frame or highlight part of the page. მართკუთხედის დამატება გვერდის ნაწილის ჩასაჩარჩოებლად ან გამოსაყოფად. Add a report file, or a report template for the selected layer. ანგარიშის ფაილის ან არჩეული ფენის ანგარიშის შაბლონის დამატება. Add a scale bar for the map frame. რუკის ჩარჩოსთვის მასშტაბის ხაზის დამატება. add a second map first (Data › New Map) ჯერ დაამატეთ მეორე რუკა (მონაცემები › ახალი რუკა) Add a second map frame first ჯერ დაამატეთ მეორე რუკის ჩარჩო +Add a straight line. სწორი ხაზის დამატება. Add a text element. ტექსტური ელემენტის დამატება. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. ვებ საბაზისო რუკის დამატება სხვების ზემოდან მათი ჩანაცვლების ნაცვლად, რათა მათ შეერწყას. მოსახსნელად დააწკაპუნეთ ნაჩვენებ რუკაზე. +Add a Zarr variable first (Add Data › Zarr…). ჯერ დაამატეთ Zarr ცვლადი (მონაცემების დამატება › Zarr…). Add Administrative Boundaries ადმინისტრაციული საზღვრების დამატება +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. ისრის დამატება. შეატრიალეთ ის მის ზემოთ მრგვალი სახელურით ან მიუთითეთ კუთხე მის თვისებებში. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. NASA-ს GeoTIFF-ების რუკაზე დასადებად დაამატეთ Earthdata Login ტოკენი (ზემოთ, Earthdata Login). Add an elevation raster first. ჯერ დაამატეთ სიმაღლის რასტრი. Add an elevation raster to the map first ჯერ რუკაზე დაამატეთ სიმაღლის რასტრი +Add an ellipse. ელიფსის დამატება. Add an empty layer with a ready-made schema, to draw into. ცარიელი ფენის დამატება მზა სქემით, რომ მასში დახატოთ. Add an input for each layer the model reads. დაამატეთ შესავალი თითოეული ფენისთვის, რომელსაც მოდელი კითხულობს. Add an output to keep a result under a name. შედეგის სახელით შესანარჩუნებლად დაამატეთ გამოსავალი. Add and Open დამატება და გახსნა Add and Open Notebook რვეულის დამატება და გახსნა Add Archive არქივის დამატება +Add as Time Series დამატება დროით მწკრივად add at least two keyframes დაამატეთ მინიმუმ ორი საკვანძო კადრი Add Cell უჯრედის დამატება Add Color Ramp File… ფერთა შკალის ფაილის დამატება… @@ -132,6 +157,7 @@ Add Graphics Layer გრაფიკული ფენის დამატ Add Input შესავლის დამატება Add Job დავალების დამატება Add Keyframe საკვანძო კადრის დამატება +Add Label Class წარწერის კლასის დამატება Add Layer File ფენის ფაილის დამატება Add Open Data ღია მონაცემების დამატება Add Output გამოსავლის დამატება @@ -147,9 +173,11 @@ Add Rule წესის დამატება Add sample countries სანიმუშო ქვეყნების დამატება Add Sample Data სანიმუშო მონაცემების დამატება Add Satellite Embeddings სატელიტური ემბედინგების დამატება +Add Selected ({n}) მონიშნულის დამატება ({n}) Add Service Layer სერვისის ფენის დამატება Add skill… უნარის დამატება… Add STAC Asset STAC რესურსის დამატება +Add STAC Data Cube STAC მონაცემთა კუბის დამატება Add STAC Footprints STAC კონტურების დამატება Add Step საფეხურის დამატება Add Stroke შტრიხის დამატება @@ -171,6 +199,7 @@ Add to Favourites რჩეულებში დამატება Add to Map რუკაზე დამატება Add vector data. ვექტორული მონაცემების დამატება. Add view ხედის დამატება +Add With Options… დამატება პარამეტრებით… Add Zarr Variable Zarr ცვლადის დამატება Add Zarr Variable… Zarr ცვლადის დამატება… Add ▾ დამატება ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay აგენტებს სრული წვდომა აქვთ: მათ შეუძლიათ სკრიპტების, დავალებების, მოდელებისა და დანამატების გაშვება. დააწკაპუნეთ MCP რელეს გასაჩერებლად Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. ამ კომპიუტერის დაკავშირებული აგენტები (Claude Code, Codex, Cursor და სხვები) აქ გახსნილ რუკაზე RustGIS-ის ხელსაწყოებით მუშაობენ: შეუძლიათ ფაილების გახსნა და შენახვა და ვების გამოყენება. სკრიპტები და მოდულების მართვა გამორთული რჩება. Agents then run commands, change files and use the web on this computer without asking you. აგენტები ამ კომპიუტერზე შეკითხვის გარეშე გაუშვებენ ბრძანებებს, შეცვლიან ფაილებს და გამოიყენებენ ვებს. +Aggregate Points to A5 წერტილების აგრეგაცია A5-ში Aggregation აგრეგაცია Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. სასოფლო-სამეურნეო მინდვრების საზღვრები, რომლებიც Fields of the World მოდელმა Sentinel-2-ის გამოსახულებებში იპოვა მთელ მსოფლიოში, თითო წლით (2nd Edition, CC BY 4.0). ეს მოდელის პროგნოზებია და არა აზომილი ნაკვეთები. AI AI @@ -221,15 +251,18 @@ All steps done. ყველა საფეხური შესრულდ All tables with geometry ყველა ცხრილი გეომეტრიით Allow დაშვება Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. შესვლისას დაუშვით ChatGPT გეგმის გამოყენება. თუ თქვენი სამუშაო სივრცე ამას არ უშვებს, მიმართეთ მის ადმინისტრატორს ან აირჩიეთ API გასაღებიანი პროვაიდერი. +Allow Feature Selection ობიექტების მონიშვნის დაშვება Allow only certain combinations of values across fields. მხოლოდ ველებს შორის მნიშვნელობების გარკვეული კომბინაციების დაშვება. Allow such actions for this conversation ასეთი მოქმედებების დაშვება ამ საუბარში Allow this action? დაგეშვათ ეს მოქმედება? Allow this agent's RustGIS action? დავუშვათ ამ აგენტის RustGIS-ის მოქმედება? Along Selected Line მონიშნული ხაზის გასწვრივ +Alt+ Alt+ Alternating bar მონაცვლე ზოლი Altitude სიმაღლე Always allow ყოველთვის დაშვება -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3 ან S3-თან თავსებადი სერვისი (MinIO, Cloudflare R2, Wasabi…) მისი ბოლო წერტილით. გასაღებები ინახება RustGIS-ის დახურვამდე; დამახსოვრებული კავშირი ინახავს სახელს, ბოლო წერტილსა და რეგიონს, მაგრამ არასდროს საიდუმლო გასაღებს. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3 ან S3-თან თავსებადი სერვისი (MinIO, Cloudflare R2, Wasabi…) მისი ბოლო წერტილით. გასაღებები ინახება RustGIS-ის დახურვამდე; შენახული კავშირი ინახავს სახელს, ბოლო წერტილსა და რეგიონს, მაგრამ არასდროს საიდუმლო გასაღებს. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: ამ კომპიუტერის აგენტი ითხოვს: An AWS profile AWS პროფილი @@ -248,6 +281,7 @@ API key API გასაღები Apply გამოყენება Apply a function to a raster ფუნქციის გამოყენება რასტრზე Apply Control Points საკონტროლო წერტილების გამოყენება +Apply elevation სიმაღლის გამოყენება Apply Style სტილის გამოყენება Apply to layers ფენებზე გამოყენება Apply to the selected layer არჩეულ ფენაზე გამოყენება @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° გამოყენების Area {area} · Perimeter {perimeter} ფართობი {area} · პერიმეტრი {perimeter} Areas (name, land use, area) არეები (სახელი, მიწის გამოყენება, ფართობი) Arrange დალაგება +Arrow ისარი +Arrowheads ისრის წვერები Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. დასვით შეკითხვა თქვენი მონაცემების შესახებ ან აღწერეთ, რა უნდა გაკეთდეს რუკაზე. ასისტენტი RustGIS-ის ბრძანებებს თავად გაუშვებს; ყველა ცვლილების გაუქმება შესაძლებელია. Ask about the map, or tell it what to do… დასვით შეკითხვა რუკის შესახებ ან მიუთითეთ, რა გააკეთოს… Ask about your data, or tell the assistant what to do with the map. დასვით შეკითხვა თქვენი მონაცემების შესახებ ან უთხარით ასისტენტს, რა გააკეთოს რუკაზე. @@ -275,12 +311,13 @@ Asking NASA's CMR… NASA-ს CMR-თან მიმართვა… Asking the catalog… კატალოგთან მიმართვა… Asking the NLDI… NLDI-სთან მიმართვა… Assistant settings ასისტენტის პარამეტრები +At the end ბოლოში +At the start დასაწყისში Atlas ატლასი Attachments დანართები Attribute\nTable ატრიბუტების\nცხრილი Attribute Rules ატრიბუტის წესები Attribute Table ატრიბუტების ცხრილი -Attribute table ატრიბუტების ცხრილი Attributes ატრიბუტები Attribution ატრიბუცია Aurora პოლარული ციალი @@ -295,11 +332,9 @@ Automatic (system language) ავტომატური (სისტემ Automatic (system time zone and region) ავტომატური (სისტემის დროის სარტყელი და რეგიონი) Automatic ({kind}) ავტომატური ({kind}) Avoid თავის არიდება -AWS AWS AWS, anonymous AWS, ანონიმურად Azimuth აზიმუტი Back უკან -Back to Explore; cancel a sketch შესწავლაზე დაბრუნება; ესკიზის გაუქმება Back to the Map უკან რუკაზე Back to the map უკან რუკაზე Back to tools უკან ხელსაწყოებზე @@ -314,8 +349,6 @@ Basemap საბაზისო რუკა Basemaps საბაზისო რუკები Basin from hydrolocation აუზი ჰიდროლოკაციიდან Bearing აზიმუტი -Bearing {b}° · Pitch {p}° აზიმუტი {b}° · დახრა {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. აზიმუტი: მიმართულება, საითაც რუკის ზედა მხარეა მიქცეული, ჩრდილოეთიდან საათის ისრის მიმართულებით. დახრა: გადახრა პირდაპირ ქვემოთ ყურებიდან. დააწკაპუნეთ ორივეს საწყისზე დასაბრუნებლად. Before the event მოვლენამდე Below ქვემოთ Below 1:5,000,000 show everything; beyond it only what matches: 1:5 000 000-ზე უფრო წვრილ მასშტაბში ყველაფერი ჩანს; მასზე უფრო მსხვილში მხოლოდ ის, რაც შეესაბამება: @@ -328,10 +361,12 @@ Bilinear ბილინეარული Bind To Time Slider დროის სლაიდერზე მიბმა Bind to Time Slider დროის სლაიდერზე მიბმა Blank ცარიელი +Blank for Amazon S3 ცარიელი Amazon S3-ისთვის Blank Letter page ცარიელი Letter გვერდი Blank Map ცარიელი რუკა Blank Page ცარიელი გვერდი Blend: {mode} შერევა: {mode} +Block Feature Selection ობიექტების მონიშვნის აკრძალვა Blue ლურჯი Bold მუქი Bold label text. წარწერის ტექსტის მუქი შრიფტი. @@ -339,6 +374,7 @@ Bookmark Description სანიშნის აღწერა Bookmarks სანიშნეები Bookmarks Pane სანიშნეების პანელი Boolean ლოგიკური +Both ends ორივე ბოლო Bottom ქვედა Boundaries… საზღვრები… Bounding\nGeometry შემომსაზღვრელი\nგეომეტრია @@ -346,6 +382,7 @@ Brightness სიკაშკაშე Brightness, contrast, saturation and sepia for the whole map. სიკაშკაშე, კონტრასტი, ნაჯერობა და სეპია მთელი რუკისთვის. Bring this bookmark's map into view to update it განსაახლებლად გამოაჩინეთ ამ სანიშნის რუკა Browse დათვალიერება +Browse Connection კავშირის დათვალიერება Browse it in the Browser ბრაუზერში დათვალიერება Browse Plugins დანამატების დათვალიერება Browse the geoprocessing toolbox. გეოდამუშავების ხელსაწყოების დათვალიერება. @@ -357,6 +394,7 @@ Browser ბრაუზერი Browser\nPane ბრაუზერის\nპანელი Browser View ბრაუზერის ხედი Bucket ბაკეტი +Buckets ბაკეტები Buffer ბუფერი Buffer A Point წერტილის ბუფერი Buffer the cities by 200 km ქალაქების 200 კმ-იანი ბუფერი @@ -381,7 +419,9 @@ Calculate Field ველის გამოთვლა Calculate field ველის გამოთვლა Calculate field values. ველის მნიშვნელობების გამოთვლა. Calculate fields and enforce conditions on every edit. ველების გამოთვლა და პირობების დაცვა ყოველი რედაქტირებისას. +Calculate Geometry გეომეტრიის გამოთვლა Calculate Geometry Attributes გეომეტრიის ატრიბუტების გამოთვლა +Calculated {rows} features გამოთვლილი ობიექტები: {rows} Calculation გამოთვლა Camera კამერა Camera Effect კამერის ეფექტი @@ -396,12 +436,14 @@ Case Field ველი სიმბოლოს რეგისტრის მ Cast Shadow ჩრდილის დაცემა Catalog კატალოგი catalog is a static STAC catalog's catalog.json or collection.json catalog არის სტატიკური STAC კატალოგის catalog.json ან collection.json +Catalogue (CSW) კატალოგი (CSW) Catalogue search (CSW) კატალოგში ძიება (CSW) Celestial Bodies ციური სხეულები Celestial Body ციური სხეული Celestial body: {body} ციური სხეული: {body} Cell size უჯრედის ზომა cell size ° უჯრედის ზომა ° +Cells უჯრედები Center ცენტრი Center Map Here რუკის აქ გაცენტრება Centered ცენტრში @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. ამ ანგ ChatGPT sign-in failed. Check the connection and local settings, then try again. ChatGPT-ში შესვლა ვერ მოხერხდა. შეამოწმეთ კავშირი და ადგილობრივი პარამეტრები, შემდეგ სცადეთ ხელახლა. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. ChatGPT-ის გამოყენება დროებით მიუწვდომელია. შეინარჩუნეთ შესვლა და მოგვიანებით სცადეთ. CHE CHE +Check შემოწმება Check Again ხელახლა შემოწმება Check for\nUpdates განახლებების\nშემოწმება Check for Updates განახლებების შემოწმება @@ -428,6 +471,7 @@ Check for Updates… განახლებების შემოწმე Check the address and the service type. შეამოწმეთ მისამართი და სერვისის ტიპი. Check the address and your connection. შეამოწმეთ მისამართი და კავშირი. Check the address. შეამოწმეთ მისამართი. +Check the clause, and count the features it takes. პირობის შემოწმება და მის მიერ შერჩეული ობიექტების დათვლა. Check the integration's client, permissions, workspace policy and route configuration. შეამოწმეთ ინტეგრაციის კლიენტი, ნებართვები, სამუშაო სივრცის პოლიტიკა და მარშრუტების კონფიგურაცია. Checking for updates… მიმდინარეობს განახლებების შემოწმება… China ჩინეთი @@ -455,6 +499,7 @@ Choose which layers a click identifies. აირჩიეთ, რომელ Choose which tabs the ribbon shows. აირჩიეთ, რომელი ჩანართები აჩვენოს ლენტმა. Circle წრე Circle Radius (km) წრის რადიუსი (კმ) +Class კლასი Class color კლასის ფერი Class Style კლასის სტილი Classes კლასები @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line დააწკაპ Click to edit the description დააწკაპუნეთ აღწერის რედაქტირებისთვის Click to place a point graphic. დააწკაპუნეთ წერტილოვანი გრაფიკის განსათავსებლად. Click to select the node დააწკაპუნეთ კვანძის ასარჩევად +Click to select, double-click to add, right-click for more დაწკაპუნება: მონიშვნა, ორმაგი დაწკაპუნება: დამატება, მარჯვენა დაწკაპუნება: მეტი Click to switch between decimal degrees and DMS დააწკაპუნეთ ათწილადი გრადუსებისა და DMS-ის გადასართავად Clip მოჭრა Clip a raster to the view, or resample it. რასტრის მოჭრა ხედზე ან მისი რესემპლირება. @@ -532,7 +578,7 @@ Close thread თემის დახურვა Close {name} {name}-ის დახურვა Closest უახლოესი Coded values, one per line: code=description კოდირებული მნიშვნელობები, თითო ხაზზე ერთი: კოდი=აღწერა -Collapse or expand the ribbon ლენტის ჩაკეცვა ან გაშლა +Collapse or Expand the Ribbon ლენტის აკეცვა ან გაშლა Collapse the ribbon (Ctrl+F1) ლენტის ჩაკეცვა (Ctrl+F1) Collection კოლექცია Collection {id}: choose dates and Find Items კოლექცია {id}: აირჩიეთ თარიღები და დააჭირეთ „ელემენტების პოვნა“ @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line ზემოთ მოცემული ველიდან კომბინაციები, თითო ხაზზე ერთი COMID {comid} COMID {comid} Comma მძიმე +Comma-separated (optional) მძიმით გამოყოფილი (არასავალდებულო) Command\nSearch ბრძანებების\nძიება command is required საჭიროა ბრძანება Command Search ბრძანებების ძიება @@ -572,10 +619,13 @@ Connect დაკავშირება Connect a folder to the Browser. საქაღალდის დაკავშირება ბრაუზერთან. Connect to S3 S3-თან დაკავშირება Connect, or pick a layer დაუკავშირდით ან აირჩიეთ ფენა +Connected to {kind} {title} დაკავშირებულია: {kind} {title} Connecting to {url}… {url}-თან დაკავშირება… Connection კავშირი +connection.new returned no id connection.new-მა იდენტიფიკატორი არ დააბრუნა Connections კავშირები -Connect… დაკავშირება… +Connections you add are listed here თქვენ მიერ დამატებული კავშირები აქ ჩამოითვლება +Connections… კავშირები… Constant მუდმივა Constant height მუდმივი სიმაღლე Constant… მუდმივა… @@ -583,11 +633,14 @@ Contingent Values პირობითი მნიშვნელობებ Continue გაგრძელება Continue with ChatGPT გაგრძელება ChatGPT-ით Contrast კონტრასტი +Control Point Fit მორგება საკონტროლო წერტილებით Conversion გარდაქმნა Convert გადაყვანა Convert a layer to another format ფენის გადაყვანა სხვა ფორმატში Convert the selected layer's labels into an annotation layer. არჩეული ფენის წარწერების გადაყვანა ანოტაციის ფენად. +Convert this layer's labels into an annotation layer. ამ ფენის წარწერების ანოტაციის ფენად გადაყვანა. Convert To Annotation ანოტაციად გადაყვანა +Convert to Annotation ანოტაციად გადაყვანა Converted data გადაყვანილი მონაცემები Convert… გადაყვანა… Coordinate კოორდინატი @@ -599,11 +652,15 @@ Copied the map to the clipboard რუკა დაკოპირდა გა Copied {path} დაკოპირდა {path} Copies the (selected) features of a layer to a new layer. ფენის (მონიშნული) ობიექტების ახალ ფენაში კოპირება. Copy კოპირება +Copy / Cut / Paste features ობიექტების კოპირება / ამოჭრა / ჩასმა Copy a model.run command that runs this model, for the Script pane or Python ამ მოდელის გამშვები model.run ბრძანების კოპირება სკრიპტის პანელისთვის ან Python-ისთვის Copy Address მისამართის კოპირება Copy address მისამართის კოპირება Copy an image of the map to the clipboard. რუკის გამოსახულების კოპირება გაცვლის ბუფერში. +Copy as CLI Command კოპირება CLI ბრძანებად +Copy as Command კოპირება ბრძანებად Copy As GeoJSON კოპირება GeoJSON-ად +Copy as Python კოპირება Python-ად Copy Command ბრძანების კოპირება Copy coordinates to the clipboard კოორდინატების კოპირება გაცვლის ბუფერში Copy details დეტალების კოპირება @@ -611,11 +668,13 @@ Copy Group ჯგუფის კოპირება Copy Layer ფენის კოპირება Copy Layers ფენების კოპირება Copy Path მისამართის კოპირება +Copy Run As Code გაშვების კოპირება კოდად Copy sign-in link შესვლის ბმულის კოპირება Copy the selected features (also as GeoJSON text). მონიშნული ობიექტების კოპირება (GeoJSON ტექსტადაც). Copy the selected features and delete them. მონიშნული ობიექტების კოპირება და წაშლა. Copy the selected layer's data source path. არჩეული ფენის მონაცემთა წყაროს მისამართის კოპირება. Copy this element (Ctrl+D, ⌘D on a Mac) ამ ელემენტის კოპირება (Ctrl+D, Mac-ზე ⌘D) +Copy this run as code, or open it in its tool ამ გაშვების კოპირება კოდად ან მისი გახსნა მის ხელსაწყოში Corrections შესწორებები Cost ღირებულება could not draw the map რუკის დახატვა ვერ მოხერხდა @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} დანამატების რ Count, sum, mean, min, max and standard deviation of a field, optionally per case field. ველის რაოდენობა, ჯამი, საშუალო, მინიმუმი, მაქსიმუმი და სტანდარტული გადახრა, საჭიროებისას სიმბოლოს რეგისტრის ველის მიხედვით. Countries, cities, rivers and lakes ქვეყნები, ქალაქები, მდინარეები და ტბები Country ქვეყანა +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. ითვლის ფენის წერტილებს მათი შემცველი A5 უჯრედების მიხედვით (უჯრედები ტოლი ფართობისაა, ამიტომ რაოდენობა სიმჭიდროვეა), ველის ჯამითა და საშუალოთი. Counts the points that fall inside each polygon. თითოეულ პოლიგონში მოხვედრილი წერტილების დათვლა. Counts, totals and top categories of what's in view. ხედში მოხვედრილის რაოდენობა, ჯამები და ყველაზე გავრცელებული კატეგორიები. CPU CPU @@ -647,12 +707,8 @@ Cross Section განივი ჭრილი Cross Section (2 clicks) განივი ჭრილი (2 დაწკაპუნება) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative კუმულაციური Current date მიმდინარე თარიღი Current Project მიმდინარე პროექტი @@ -680,13 +736,16 @@ Data type მონაცემთა ტიპი Data you add is listed here თქვენ მიერ დამატებული მონაცემები აქ ჩამოითვლება data.gov and CKAN catalogues data.gov და CKAN კატალოგები Database მონაცემთა ბაზა -Databases მონაცემთა ბაზები +Database connections are used by the desktop app მონაცემთა ბაზის კავშირებს იყენებს სამაგიდო აპლიკაცია +databases are read through DuckDB, a program the browser can't run მონაცემთა ბაზები იკითხება DuckDB-ით, პროგრამით, რომლის გაშვებაც ბრაუზერს არ შეუძლია Database… მონაცემთა ბაზა… Dataset მონაცემთა ნაკრები Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. მონაცემთა ნაკრებები Hugging Face-ზე სახელით ან მფლობელი/სახელით; დაათვალიერეთ ნაკრების ფაილები და დაამატეთ GeoTIFF, GeoParquet, PMTiles, LiDAR და სხვა ფაილები. Data… მონაცემები… Date თარიღი Dates თარიღები +Dates a cell needs უჯრედს საჭირო თარიღები +Dates a cell needs is a whole number, or empty for half „უჯრედს საჭირო თარიღები“ მთელი რიცხვია, ან ცარიელი ნახევრისთვის Day დღე Day and night shading for a time. დღისა და ღამის დაჩრდილვა მოცემული დროისთვის. Decimal ათწილადი @@ -715,7 +774,6 @@ Delete Notebook რვეულის წაშლა Delete Page გვერდის წაშლა Delete Presentation პრეზენტაციის წაშლა Delete Report ანგარიშის წაშლა -Delete selected features მონიშნული ობიექტების წაშლა Delete selected rows მონიშნული სტრიქონების წაშლა Delete Style სტილის წაშლა Delete Task ამოცანის წაშლა @@ -736,11 +794,15 @@ Desktop and command line only მხოლოდ დესკტოპ აპლ Details დეტალები Details of the selected layer. არჩეული ფენის დეტალები. Deviations გადახრები +DGGS DGGS Diagnostic\nMonitor დიაგნოსტიკური\nმონიტორი Diagnostic Monitor დიაგნოსტიკური მონიტორი Diagnostics დიაგნოსტიკა +Diagonal ↗ დიაგონალი ↗ +Diagonal ↘ დიაგონალი ↘ Diamond რომბი Difference, ratio or percent change between two rasters. სხვაობა, ფარდობა ან პროცენტული ცვლილება ორ რასტრს შორის. +Digit Digit Directions მარშრუტი Directions From Here მარშრუტი აქედან Directions To Here მარშრუტი აქამდე @@ -758,6 +820,7 @@ Dissolve Field გაერთიანების ველი Distance მანძილი Distance (km) მანძილი (კმ) Distance Coefficient P მანძილის კოეფიციენტი P +Distance units მანძილის ერთეულები Distances along the surface, heights, areas and volumes. მანძილები ზედაპირის გასწვრივ, სიმაღლეები, ფართობები და მოცულობები. DISTINCT DISTINCT Distinct განსხვავებული @@ -804,18 +867,23 @@ Drag to draw გადაათრიეთ დასახატად Drag to draw a freehand line. გადაათრიეთ თავისუფალი ხაზის დასახატად. Drag to draw a freehand polygon. გადაათრიეთ თავისუფალი პოლიგონის დასახატად. Drag to draw a rectangle. გადაათრიეთ მართკუთხედის დასახატად. +Drag to resize the message box გადაათრიეთ შეტყობინების ველის ზომის შესაცვლელად Drag tools from the palette onto the canvas. გადმოიტანეთ ხელსაწყოები პალიტრიდან ტილოზე. Drag vertices on the map; click an edge to add one გადაათრიეთ წვეროები რუკაზე; წვეროს დასამატებლად დააწკაპუნეთ წიბოზე Draw a halo around label text. წარწერის ტექსტის გარშემო შუქის დახატვა. Draw a labelled latitude/longitude grid over the map frame. რუკის ჩარჩოზე გრძედისა და განედის წარწერებიანი ბადის დახატვა. Draw a model: chain tools from input layers to the results to keep დახაზეთ მოდელი: დააკავშირეთ ხელსაწყოები ჯაჭვად შემავალი ფენებიდან შესანარჩუნებელ შედეგებამდე Draw a model: chain tools from input layers to the results to keep. დახაზეთ მოდელი: დააკავშირეთ ხელსაწყოები ჯაჭვად შემავალი ფენებიდან შესანარჩუნებელ შედეგებამდე. +Draw at its place in the stack, the bottom stroke first დახატვა სტეკში თავის ადგილას, ქვედა შტრიხიდან დაწყებული +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet ყველა ობიექტის ერთი დონის შტრიხების დახატვა შემდეგ დონემდე, რომ ხაზების შეხვედრისას გარსები ბირთვების ქვეშ შეუერთდეს Draw labels at every scale and distance. წარწერების დახატვა ყველა მასშტაბსა და მანძილზე. Draw new features in the selected layer. Double-click to finish lines and polygons. ახალი ობიექტების დახატვა არჩეულ ფენაში. ხაზებისა და პოლიგონების დასასრულებლად ორჯერ დააწკაპუნეთ. Draw new single-band rasters with the inverted grey stretch again. ახალი ერთარხიანი რასტრების კვლავ ინვერსიული ნაცრისფერი გაჭიმვით დახატვა. +Draw only polygon outlines, keeping their colors and widths. მხოლოდ პოლიგონების კონტურების დახატვა მათი ფერებისა და სისქის შენარჩუნებით. Draw points as bins coloured by how many fall in each. წერტილების დახატვა უჯრედებად, ფერით იმის მიხედვით, რამდენი მოხვდა თითოეულში. Draw polygons as outlines only. პოლიგონების დახატვა მხოლოდ კონტურით. Draw single-band rasters added from now on with this color scheme, stretch and resampling. ამიერიდან დამატებული ერთარხიანი რასტრების ამ ფერთა სქემით, გაჭიმვითა და რესემპლირებით დახატვა. +Draw strokes in levels შტრიხების დახატვა დონეებად Draw the layer at every scale. ფენის დახატვა ყველა მასშტაბში. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). რუკის დახატვა GPU-ზე (ბეტა; გლობუსი, ხედის ეფექტები და ფერთა აღქმის სიმულაცია რჩება CPU რენდერერზე). Draw the selected layer above others. არჩეული ფენის დახატვა სხვებზე ზემოთ. @@ -828,6 +896,7 @@ drawn by the server დახატულია სერვერის მი Drive მანქანა Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. OpenAerialMap-ზე გაზიარებული დრონისა და აეროგადაღების გამოსახულებები, ჯერ უახლესი; ემატება ღრუბლისთვის ოპტიმიზებულ GeoTIFF-ებად. Duplicate დუბლირება +Duplicate Connection კავშირის დუბლირება Duplicate Element ელემენტის დუბლირება Duplicate Layout განლაგების დუბლირება Duplicate Map რუკის დუბლირება @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name მაგ. pop_est / 100000 e.g. 0, 255, -9999 მაგ. 0, 255, -9999 e.g. continent = 'Europe' მაგ. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' მაგ. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 მაგ. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs გეოდამუშავების ყოველი გაშვება ხდება საფეხური და ერთმანეთს გამოსავლებით უკავშირდება Earth Index has no points in the view Earth Index-ს ამ ხედში წერტილები არ აქვს Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices წვეროების\nრედაქტირება Edit and run the notebook. რვეულის რედაქტირება და გაშვება. Edit Attribute ატრიბუტის რედაქტირება Edit Cell უჯრედის რედაქტირება +Edit Connection კავშირის რედაქტირება Edit Description აღწერის რედაქტირება Edit Graphic გრაფიკის რედაქტირება Edit Status რედაქტირების სტატუსი @@ -882,6 +953,7 @@ Editor\nTools რედაქტორის\nხელსაწყოები Editor Tools რედაქტორის ხელსაწყოები Edits since the project was saved. ცვლილებები პროექტის შენახვის შემდეგ. Edits the project ცვლის პროექტს +Edit… რედაქტირება… Effects ეფექტები Element ელემენტი Element Properties ელემენტის თვისებები @@ -898,21 +970,25 @@ Enable Lighting განათების ჩართვა Enable Plugin დანამატის ჩართვა Enabled ჩართულია Encloses each feature, or all features, in its convex hull. თითოეული ობიექტის ან ყველა ობიექტის შემოხვევა მისი ამოზნექილი გარსით. +Encoded polyline კოდირებული პოლიხაზი End დასასრული End date დასრულების თარიღი Endpoint ბოლო წერტილი Enhancement გაუმჯობესება Enter Enter Enter an API key. შეიყვანეთ API გასაღები. +Enter an EPSG code as a number, such as 32650 შეიყვანეთ EPSG კოდი რიცხვად, მაგალითად 32650 Enter the provider's address. შეიყვანეთ პროვაიდერის მისამართი. Enter to send, Shift+Enter for a new line გასაგზავნად Enter, ახალი ხაზისთვის Shift+Enter EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments გარემოები EPSG EPSG +EPSG code EPSG კოდი EPSG or ESRI code EPSG ან ESRI კოდი EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval ტოლი ინტერვალი Equirectangular (Plate Carrée) ბრტყელი კვადრატული (Plate Carrée) @@ -922,14 +998,18 @@ Error შეცდომა Error Inspector შეცდომების ინსპექტორი Error Inspector (validate) შეცდომების ინსპექტორი (ვალიდაცია) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) ევროპის ტოლფართობიანი (ETRS89 LAEA) Every band's value at a pixel. ყველა არხის მნიშვნელობა პიქსელში. Every editing tool. ყველა რედაქტირების ხელსაწყო. +Every label setting of this layer, in the Labeling pane. ამ ფენის წარწერის ყველა პარამეტრი წარწერების პანელში. Every layer, as FEMA draws them ყველა ფენა, ისე როგორც FEMA ხატავს +Every run, in order, as a script that opens the project ყველა გაშვება თანმიმდევრობით, სკრიპტის სახით, რომელიც პროექტს ხსნის every Socrata site, or one such as data.cityofnewyork.us ყველა Socrata საიტი ან ერთ-ერთი, მაგ. data.cityofnewyork.us Everywhere ყველგან +Existing field არსებული ველი Existing Template არსებული შაბლონი exit rotary გამოდით წრიული მოძრაობიდან exit roundabout გამოდით წრიული მოძრაობიდან @@ -948,6 +1028,7 @@ Export\nRaster რასტრის\nექსპორტი Export Animation ანიმაციის ექსპორტი Export Bookmarks სანიშნეების ექსპორტი Export Bookmarks… სანიშნეების ექსპორტი… +Export CSV… CSV-ის ექსპორტი… Export Features ობიექტების ექსპორტი Export Features (tool) ობიექტების ექსპორტი (ხელსაწყო) Export Features needs a vector layer (Data › Export Raster writes a raster) ობიექტების ექსპორტისთვის საჭიროა ვექტორული ფენა (რასტრს მონაცემები › რასტრის ექსპორტი წერს) @@ -996,6 +1077,7 @@ Face Culling წახნაგების გამორიცხვა Faces წახნაგები Factor კოეფიციენტი Failed ჩავარდა +FALSE FALSE Farthest ყველაზე შორეული Faster drawing: no antialiasing. უფრო სწრაფი ხატვა: გლუვების გარეშე. Favourite Data Source რჩეული მონაცემთა წყარო @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries მინდვრების საზღვრები Field density მინდვრების სიმჭიდროვე Field name ველის სახელი +Field Prefix ველის პრეფიქსი +Field to Sum and Average ველი ჯამისა და საშუალოსთვის Fields ველები Fields are drawn from zoom 9: zoom in to see them. მინდვრები იხატება მე-9 მასშტაბის დონიდან: სანახავად გაადიდეთ. Fields colored by score, drawn from zoom 9 მინდვრები შეფერილი ქულის მიხედვით, იხატება მე-9 მასშტაბის დონიდან @@ -1079,6 +1163,8 @@ Folder Connections საქაღალდის კავშირები folder connections need the desktop app საქაღალდის კავშირებისთვის საჭიროა დესკტოპ აპლიკაცია Folders საქაღალდეები Folder… საქაღალდე… +Follow the time slider დროის სლაიდერის მიყოლა +Font შრიფტი for a store that doesn't say საცავისთვის, რომელიც ამას არ უთითებს Forest ტყე Forget Earthdata Login Token Earthdata Login-ის ტოკენის დავიწყება @@ -1090,9 +1176,11 @@ France საფრანგეთი Free the prepared geometry. მომზადებული გეომეტრიის გათავისუფლება. Freehand თავისუფალი ხაზვა FROM FROM +From დან From Selection მონიშვნიდან From Service… სერვისიდან… From symbol სიმბოლოდან +From the address მისამართიდან from the address მისამართიდან from the service სერვისიდან From URL… URL-დან… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. ხედის ცენტრიდ Hide Group ჯგუფის დამალვა Hide labels when the viewer is closer than this. წარწერების დამალვა, როცა მაყურებელი ამაზე ახლოსაა. Hide labels when the viewer is farther than this. წარწერების დამალვა, როცა მაყურებელი ამაზე შორსაა. +Hide Layers ფენების დამალვა Hide other values სხვა მნიშვნელობების დამალვა Hide Steps საფეხურების დამალვა Hide the description აღწერის დამალვა @@ -1213,6 +1302,8 @@ Hold დაჭერა HOME HOME Home საწყისი Home › Add საწყისი › დამატება +Home › Add Data საწყისი › მონაცემების დამატება +Home › Inspect › Measure საწყისი › შემოწმება › გაზომვა Home › Navigate საწყისი › ნავიგაცია Home › Navigate › Bookmarks საწყისი › ნავიგაცია › სანიშნეები Home › Web Data საწყისი › ვებ მონაცემები @@ -1220,7 +1311,7 @@ Home › Web Data › United States საწყისი › ვებ მო Homepage საწყისი გვერდი Horizontal ჰორიზონტალური host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=… ან .duckdb ფაილი +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery ჰოსტინგული გამოსახულება Hour საათი How the layer's colours combine with the tile layers and basemap beneath. როგორ ერწყმის ფენის ფერები ქვემოთ მყოფ ფილების ფენებსა და საბაზისო რუკას. @@ -1248,6 +1339,7 @@ Imagery Sharing გამოსახულებების გაზიარ Imagery Tools გამოსახულების ხელსაწყოები ImageServer ImageServer Image… გამოსახულება… +Imperial იმპერიული Import იმპორტი Import\nProject პროექტის\nიმპორტი Import All ყველას იმპორტი @@ -1267,6 +1359,7 @@ Import Task დავალების იმპორტი Import Task… დავალების იმპორტი… Import Thumbnail მინიატურის იმპორტი Import… იმპორტი… +IN IN In Beyond შორს, მის მიღმა In memory მეხსიერებაში In Progress მიმდინარეობს @@ -1281,6 +1374,8 @@ Information ინფორმაცია INPUT შესავალი Input შესავალი Input Features შემავალი ობიექტები +Input Layer შემავალი ფენა +Input Points შემავალი წერტილები Input Polygons შემავალი პოლიგონები Input Rows შემავალი სტრიქონები Input Summary Features შემავალი შემაჯამებელი ობიექტები @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} დაინსტალირებუ Installed with Homebrew, winget, the AUR or COPR? Update it there. დაყენებულია Homebrew-ით, winget-ით, AUR-ით ან COPR-ით? განაახლეთ იქვე. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. დაყენდა {name} {version}. მოძებნეთ დანამატების ჩანართზე; მისი ხელსაწყოები ხელსაწყოების პანელშიც არის. Installing it downloads Python {version} and the packages it needs from the internet. ინსტალაციისას ინტერნეტიდან ჩამოიტვირთება Python {version} და მისთვის საჭირო პაკეტები. +Integer field: values are rounded. მთელრიცხვა ველი: მნიშვნელობები მრგვალდება. Interface font: {font} ინტერფეისის შრიფტი: {font} Interpolate a surface from points (IDW) ზედაპირის ინტერპოლაცია წერტილებიდან (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. ინტერპოლაციით ქმნის ორარხიან რასტრულ ზედაპირს (პროგნოზი და დისპერსია) წერტილოვანი ობიექტებიდან ჩვეულებრივი კრიგინგით. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. რასტრული ზედაპირის ინტერპოლაცია ობიექტების მნიშვნელობებიდან უკუმანძილური შეწონვით, ისე, როგორც QGIS-ში. Interpolation ინტერპოლაცია Interpolation Attribute ინტერპოლაციის ატრიბუტი @@ -1316,10 +1413,13 @@ Invalid: {error} არასწორია: {error} Invert ინვერსია Invert color scheme ფერთა სქემის ინვერსია Invert the selection. მონიშვნის ინვერსია. +IS IS IS NULL IS NULL it connects to an address on the internet ის უკავშირდება ინტერნეტის მისამართს It has no code: nothing in it runs. მასში კოდი არ არის: არაფერი არ ეშვება. +It is locked, or one of its surrounds is: unlock it first ის ან მისი ერთ-ერთი თანმხლები ელემენტი ჩაკეტილია: ჯერ განბლოკეთ it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) ის დაცულია თქვენთვის (დანამატები, სკრიპტები, დავალებები, მოდელები და GeoLibre-ის გამშვები) +it needs input: open it from its pane or button, or run it from a script or the assistant მას შემავალი მონაცემები სჭირდება: გახსენით მისი პანელიდან ან ღილაკიდან, ან გაუშვით სკრიპტიდან ან ასისტენტიდან it reads or writes files on this computer ის კითხულობს ან წერს ფაილებს ამ კომპიუტერში it replaces the open project ის ცვლის ღია პროექტს it runs a plugin's command ის უშვებს დანამატის ბრძანებას @@ -1332,20 +1432,30 @@ Italic დახრილი Item Pop-ups ელემენტის ამომხტარი ფანჯრები Item {id} ელემენტი {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. მისმა ფაიერვოლმა ({firewall}) დაბლოკა RustGIS. სერვისი შესაძლოა მხოლოდ ზოგიერთ აპლიკაციას უშვებდეს: დაუკავშირდით მის ადმინისტრატორს და გაუგზავნეთ ქვემოთ მოცემული დეტალები. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) მისი ხელსაწყო ახლა მიუწვდომელია (მოდულის, რომელიც არ არის ჩატვირთული, ან ნაკრების, რომელიც არ არის დაყენებული) Jobs სამუშაოები JOIN JOIN Join შეერთება +Join Features მისაერთებელი ობიექტები +Join Operation შეერთების ოპერაცია +Joins attributes from a join layer to a target layer based on their spatial relationship. მისაერთებელი ფენის ატრიბუტებს უერთებს სამიზნე ფენას მათი სივრცითი მიმართების საფუძველზე. +Journal figure, 1 column (90 mm) ჟურნალის ფიგურა, 1 სვეტი (90 მმ) +Journal figure, 1.5 columns (140 mm) ჟურნალის ფიგურა, 1.5 სვეტი (140 მმ) +Journal figure, 2 columns (190 mm) ჟურნალის ფიგურა, 2 სვეტი (190 მმ) Keep a history of every edit to the layer, to look back or restore. ფენის ყოველი რედაქტირების ისტორიის შენახვა, რომ უკან დაბრუნდეთ ან აღადგინოთ. Keep an archive of edits რედაქტირებების არქივის შენახვა keep current მიმდინარეს დატოვება +Keep it from being moved, resized, deleted or edited by mistake შემთხვევითი გადაადგილების, ზომის შეცვლის, წაშლის ან რედაქტირებისგან დაცვა Keep one value of each point's vector as its value field თითოეული წერტილის ვექტორის ერთი მნიშვნელობის შენარჩუნება მის მნიშვნელობის ველად Keep Result შედეგის შენარჩუნება Keep the labelled features fixed as the map moves. წარწერიანი ობიექტების ფიქსირება რუკის გადაადგილებისას. Keep the last drawing tool active after each graphic. ბოლო ხატვის ხელსაწყოს აქტიურად დატოვება ყოველი გრაფიკის შემდეგ. Keep the last tool active ბოლო ხელსაწყოს აქტიურად დატოვება +Keep this endpoint under Data Sources › Connections, without its password or keys ამ ბოლო წერტილის შენახვა მონაცემთა წყაროები › კავშირები განყოფილებაში, პაროლისა და გასაღებების გარეშე Keep this result ამ შედეგის შენარჩუნება Keep Tool ხელსაწყოს დაკავება Keep value მნიშვნელობის შენარჩუნება +Key Key Keyboard Shortcuts კლავიატურის მალსახმობები Keyboard shortcuts. კლავიატურის მალსახმობები. Keys from გასაღებების წყარო @@ -1356,13 +1466,20 @@ KMZ KMZ Label წარწერა Label Class წარწერის კლასი Label Distance Range წარწერის მანძილის დიაპაზონი +Label features in this layer ამ ფენის ობიექტებისთვის წარწერების დადება Label Field წარწერის ველი +Label only the features that match. წარწერები მხოლოდ შესაბამის ობიექტებს. Label Placement წარწერის განთავსება Label Range წარწერის დიაპაზონი Label the cities with their names ქალაქების წარწერა სახელებით Label Visibility Range წარწერის ხილვადობის დიაპაზონი Labeling წარწერები +Labeling Pane წარწერების პანელი +Labeling Properties… წარწერების თვისებები… Labels წარწერები +labels are drawn for feature layers, not rasters წარწერები იხატება ვექტორული ფენებისთვის და არა რასტრებისთვის +Labels are drawn for feature layers, not rasters. წარწერები იხატება ვექტორული ფენებისთვის და არა რასტრებისთვის. +Labels are off for this layer: the clause applies once they are on. ამ ფენის წარწერები გამორთულია: პირობა მათი ჩართვის შემდეგ ამოქმედდება. Lagoon ლაგუნა Lambert Conformal Conic ლამბერტის კონფორმული კონუსური Lambert Cylindrical Equal Area ლამბერტის ცილინდრული ტოლფართობიანი @@ -1383,12 +1500,14 @@ Layer Blend ფენების შერევა Layer Extrusion ფენის ამოწევა Layer File ფენის ფაილი Layer From\nSelection ფენა\nმონიშვნიდან +Layer not found ფენა ვერ მოიძებნა Layer Template ფენის შაბლონი Layer Templates ფენის შაბლონები layer {id} ფენა {id} Layers ფენები Layers ({n}) ფენები ({n}) Layers and Table Views ფენები და ცხრილების ხედები +Layers, a date each ფენები, თითოეულს თავისი თარიღი Layout განლაგება Layout\nFile განლაგების\nფაილი Layout after: {name} განლაგება მის შემდეგ: {name} @@ -1402,7 +1521,7 @@ Learning\nResources სასწავლო\nმასალები Left მარცხენა Legend ლეგენდა Legend label. Clear it to show the value again. ლეგენდის წარწერა. გაასუფთავეთ, რომ კვლავ გამოჩნდეს მნიშვნელობა. -Length {length} · gain {gain} m · loss {loss} m სიგრძე {length} · მატება {gain} მ · კლება {loss} მ +Length {length} · gain {gain} · loss {loss} სიგრძე {length} · აღმართი {gain} · დაღმართი {loss} Let the assistant hand tasks to agents on this computer ასისტენტს ამ კომპიუტერის აგენტებისთვის დავალებების გადაცემის ნება მიეცით Let your Agents Control RustGIS მიეცით თქვენს აგენტებს RustGIS-ის მართვის საშუალება Letter spacing ასოებს შორის ინტერვალი @@ -1439,6 +1558,7 @@ List by drawing order სია ხატვის რიგის მიხე List by selection სია მონიშვნის მიხედვით List Columns in File ფაილში სვეტების სია List Commands ბრძანებების სია +List Connections კავშირების სია List Data Sources მონაცემთა წყაროების სია List Database Columns მონაცემთა ბაზის სვეტების სია List Database Tables მონაცემთა ბაზის ცხრილების სია @@ -1475,6 +1595,11 @@ Locate… მოძებნა… Location მდებარეობა Locator ლოკატორი Lock ჩაკეტვა +Locked ჩაკეტილი +Locked: unlock it in the Format pane to move or change it ჩაკეტილია: გადასაადგილებლად ან შესაცვლელად განბლოკეთ ფორმატის პანელში +Log In to Connection კავშირში შესვლა +Log In to {name}… შესვლა: {name}… +Log In… შესვლა… Long გრძელი Longitude გრძედი Longitude (X) გრძედი (X) @@ -1493,6 +1618,7 @@ Manage მართვა Manage Bookmarks სანიშნეების მართვა Manage Edits რედაქტირებების მართვა Manage Templates შაბლონების მართვა +Mann–Kendall მან–კენდალი Map რუკა Map\nFrame რუკის\nჩარჩო Map (the current view) რუკა (მიმდინარე ხედი) @@ -1504,6 +1630,7 @@ Map File რუკის ფაილი Map Frame რუკის ჩარჩო Map Frames რუკის ჩარჩოები Map frames per second (live while the map moves) რუკის კადრები წამში (ცოცხლად, რუკის მოძრაობისას) +Map Labeling რუკის წარწერები Map Link რუკის ბმული Map name რუკის სახელი Map Page რუკის გვერდი @@ -1515,6 +1642,7 @@ Map style (JSON) რუკის სტილი (JSON) Map Surrounds რუკის გარშემო ელემენტები Map Thumbnail რუკის მინიატურა Map tiles of the whole world მთელი მსოფლიოს რუკის ფილები +Map Tool: {tool} რუკის ხელსაწყო: {tool} Map View რუკის ხედი map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} რუკა: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker მარკერი Mask Values მნიშვნელობების ნიღაბი Masking მასკირება +Match Option შესაბამისობის პარამეტრი Matching places შესაბამისი ადგილები Max მაქს. Max % მაქს. % +Max Neighbors მეზობლების მაქს. რაოდენობა Maximum zoom მაქსიმალური გადიდება MCP Relay MCP რელე MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCP რელე უკვე უსმენს 127.0.0.1:{port}-ს; ჯერ გააჩერეთ, რომ გამოიყენოთ პორტი {asked} @@ -1542,6 +1672,8 @@ Measure Distance მანძილის გაზომვა Measure geodesic distances and areas on the map. გეოდეზიური მანძილებისა და ფართობების გაზომვა რუკაზე. Measure on this body, with one of its basemaps. გაზომვა ამ სხეულზე, მისი ერთ-ერთი საბაზისო რუკით. Measure with an elevation raster გაზომვა სიმაღლის რასტრით +Measurement units საზომი ერთეულები +Measurement units must be metric or imperial საზომი ერთეულები უნდა იყოს მეტრული ან იმპერიული Measurements so far. აქამდე გაზომილი. Mensuration მენსურაცია Mensuration Tools მენსურაციის ხელსაწყოები @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. ერთ Messages appear here. შეტყობინებები აქ გამოჩნდება. Meters მეტრი Method მეთოდი +Metric მეტრული Midnight შუაღამე Miles მილი Miller Cylindrical მილერის ცილინდრული @@ -1560,21 +1693,43 @@ Min must be less than Max მინ. მაქს.-ზე ნაკლები Min must not be more than Max მინ. არ უნდა აღემატებოდეს მაქს.-ს Minimum Bounding Geometry მინიმალური შემომსაზღვრელი გეომეტრია Minimum Maximum მინიმუმი და მაქსიმუმი +Minus Minus Minutes წუთი minutes ago წუთის წინ Miter წვეტიანი Mobile Map მობილური რუკა Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model მოდელი Model\nBuilder მოდელების\nაგება model id მოდელის ID Model output მოდელის გამოსავალი ModelBuilder ModelBuilder Models მოდელები +Modified Mann–Kendall (Hamed & Rao) მოდიფიცირებული მან–კენდალი (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) მოდიფიცირებული მან–კენდალი (Yue & Wang) Modify შეცვლა Modify Selected Features მონიშნული ობიექტების შეცვლა Mollweide (equal-area world) მოლვაიდე (ტოლფართობიანი მსოფლიო) Month თვე +Months თვეები +Months are numbers from 1 to 12 (12, 1, 2 for December to February) თვეები რიცხვებია 1-დან 12-მდე (12, 1, 2 დეკემბრიდან თებერვლამდე) More მეტი More basemaps: მეტი საბაზისო რუკა: Mosaic images into an orthomosaic სურათების მოზაიკა ორთომოზაიკად @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. ხილული რასტ Most cloud cover (%) მაქს. ღრუბლიანობა (%) Mouse wheel თაგვის ბორბალი Move გადაადგილება -Move (m) გადაადგილება (მ) Move Bookmark სანიშნის გადატანა Move Down ქვემოთ ჩამოწევა Move down ქვემოთ ჩამოწევა Move Group ჯგუფის გადატანა +Move Label Class წარწერის კლასის გადატანა Move Task Step ამოცანის საფეხურის გადატანა +Move To Bottom ბოლოში გადატანა Move To Group ჯგუფში გადატანა +Move To Top თავში გადატანა Move To {group} გადატანა: {group} Move Up ზემოთ აწევა Move up ზემოთ აწევა @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets ჩემი ბაკეტები My OGC Layer ჩემი OGC ფენა +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name სახელი name the plugin whose panel to open: {"id": plugin id} მიუთითეთ მოდული, რომლის პანელიც უნდა გაიხსნას: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook ახალი\nრვეული New\nPresentation ახალი\nპრეზენტაცია New\nReport ახალი\nანგარიში New\nWorkspace ახალი\nსამუშაო სივრცე -New / Open / Save project პროექტის შექმნა / გახსნა / შენახვა New Bookmark ახალი სანიშნე New Bookmark… ახალი სანიშნე… +New Connection ახალი კავშირი +New Connection ▾ ახალი კავშირი ▾ New conversation ახალი საუბარი New Custom Template ახალი მორგებული შაბლონი New Custom Template… ახალი მორგებული შაბლონი… New features use this template's values ახალი ობიექტები ამ შაბლონის მნიშვნელობებს გამოიყენებს +New field ახალი ველი New Group ახალი ჯგუფი New job name ახალი სამუშაოს სახელი New Layout ახალი განლაგება @@ -1649,6 +1809,7 @@ New Notebook ახალი რვეული New Presentation ახალი პრეზენტაცია New Project ახალი პროექტი New project ახალი პროექტი +New Publication Layout ახალი განლაგება პუბლიკაციისთვის New Relationship… ახალი კავშირი… New Report ახალი ანგარიში New Selection ახალი მონიშვნა @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. სანი No catalogs below this one: Find Items lists its items ამის ქვემოთ კატალოგები არ არის: „ელემენტების პოვნა“ აჩვენებს მის ელემენტებს No COMID was returned for this point. ამ წერტილისთვის COMID არ დაბრუნდა. no command ბრძანება არ არის -No connections this session ამ სესიაში კავშირები არ არის +No data at the points picked. არჩეულ წერტილებში მონაცემები არ არის. No datasets match. შესაბამისი მონაცემთა ნაკრებები არ არის. No datasets with data RustGIS can add არ არის მონაცემთა ნაკრებები, რომლებსაც RustGIS დაამატებს +no date in the name სახელში თარიღი არ არის No edits since the project was saved პროექტის შენახვის შემდეგ რედაქტირება არ ყოფილა No field of this layer holds dates. ამ ფენის არცერთ ველში თარიღები არ არის. +No Fill შევსების გარეშე +No geometry to calculate გამოსათვლელი გეომეტრია არ არის No granules in this view and date range. ამ ხედსა და თარიღების დიაპაზონში გრანულები არ არის. no home folder საწყისი საქაღალდე არ არის no layer ფენა არ არის @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) კავშირები ა No relationships yet კავშირები ჯერ არ არის No reports yet. Publish › New Report lists the selected layer's records. ანგარიშები ჯერ არ არის. გამოქვეყნება › ახალი ანგარიში არჩეული ფენის ჩანაწერებს ჩამოთვლის. no row სტრიქონი არ არის +No serif font is installed, so this text is drawn in the sans-serif სერიფიანი შრიფტი არ არის დაყენებული, ამიტომ ეს ტექსტი უსერიფო შრიფტით იხატება no service or file links სერვისის ან ფაილის ბმულები არ არის No services match your search. თქვენს ძიებას არცერთი სერვისი არ შეესაბამება. No standalone tables დამოუკიდებელი ცხრილები არ არის @@ -1718,14 +1883,16 @@ Nominatim Nominatim None არცერთი None (public buckets) არცერთი (საჯარო ბაკეტები) none, a .duckdb file, or host=… dbname=… user=… არცერთი, .duckdb ფაილი ან host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD არცერთი, ან PGPASSWORD-დან ან MYSQL_PWD-დან +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD არცერთი, ან PGPASSWORD-დან, MYSQL_PWD-დან ან SQLCMDPASSWORD-დან None. Add one by address, or from a basemap plugin. არცერთი. დაამატეთ მისამართით ან საბაზისო რუკის დანამატიდან. North ჩრდილოეთი North\nArrow ჩრდილოეთის\nისარი North Polar Stereographic ჩრდილოეთის პოლარული სტერეოგრაფიული +NOT NOT not a picture სურათი არ არის not a RustGIS layout ეს RustGIS-ის განლაგება არ არის not a RustGIS project არ არის RustGIS-ის პროექტი +Not applied yet. ჯერ არ არის გამოყენებული. Not for texts: a text is edited in the Format pane. არა ტექსტებისთვის: ტექსტი ფორმატის პანელში რედაქტირდება. not needed საჭირო არ არის Not sandboxed იზოლაციის გარეშე @@ -1739,9 +1906,12 @@ Nothing found where the map is რუკის არეში არაფე Nothing here აქ არაფერია Nothing named {name} to rename გადასარქმევად {name} სახელით არაფერია Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. გაზიარებული ჯერ არაფერია. გამოქვეყნება › გაზიარება როგორც ქმნის ვებ რუკებს, ფენებს, სტილებს, სცენებს და სხვას. +Nothing to add here აქ დასამატებელი არაფერია +Nothing to test შესამოწმებელი არაფერია Nothing was found at this address. ამ მისამართზე არაფერი მოიძებნა. Nothing was recorded, so no task was made. არაფერი ჩაწერილა, ამიტომ ამოცანა არ შეიქმნა. Now ახლა +Nugget ნაგეტი NULL NULL Nulls Null-ები Observations (observer, date, species, count) დაკვირვებები (დამკვირვებელი, თარიღი, სახეობა, რაოდენობა) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. დესკტოპზე On-screen compass, zoom slider and pan pad. ეკრანზე კომპასი, გადიდების სლაიდერი და გადაწევის პანელი. One command per line: id {json}. Lines starting with # are comments. თითო ბრძანება თითო ხაზზე: id {json}. # ნიშნით დაწყებული ხაზები კომენტარებია. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. ერთი DuckDB SELECT: DuckDB Spatial-ის ST_ ფუნქციები, ფაილები და ვებ მისამართები (read_parquet('…')) და ქვემოთ მოცემული მონაცემთა ბაზის ცხრილები. Ctrl+Enter უშვებს მას. +One layer: a Zarr variable or a raster's bands ერთი ფენა: Zarr ცვლადი ან რასტრის არხები One page per feature of an index layer. ერთი გვერდი ინდექსური ფენის თითო ობიექტზე. One per line: code=name თითო ხაზზე ერთი: კოდი=სახელი One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. ერთი შტრიხი, სიმბოლოდან. დაამატეთ შტრიხები გარსებისთვის, პარალელური ხაზებისა და წანაცვლებებისთვის. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder საქაღალდის გახსნა Open In Geoprocessing… გეოდამუშავებაში გახსნა… Open in ModelBuilder ModelBuilder-ში გახსნა +Open in Tool ხელსაწყოში გახსნა Open Layout განლაგების გახსნა Open ModelBuilder ModelBuilder-ის გახსნა Open Notebook რვეულის გახსნა @@ -1797,6 +1969,7 @@ Open Presentation პრეზენტაციის გახსნა Open Project პროექტის გახსნა Open project პროექტის გახსნა Open Project (from text) პროექტის გახსნა (ტექსტიდან) +Open Project… პროექტის გახსნა… Open Report ანგარიშის გახსნა Open the attribute table of the selected layer. არჩეული ფენის ატრიბუტების ცხრილის გახსნა. Open the Basemaps tab საბაზისო რუკების ჩანართის გახსნა @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: ან გაუშვით Or run in PowerShell, for Claude Code or Codex: ან გაუშვით PowerShell-ში, Claude Code-ისთვის ან Codex-ისთვის: Or run, for Claude Code or Codex: ან Claude Code-ისთვის ან Codex-ისთვის გაუშვით: ORDER BY ORDER BY +Ordinary Kriging ჩვეულებრივი კრიგინგი Orientation ორიენტაცია Ortho Mapping ორთოგრაფიული რუკების შედგენა Other სხვა Other Data სხვა მონაცემები +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. ამ ფენის სხვა წარწერის კლასები (შექმნილი ბრძანებებით): {n}. თითოეული აწერს წარწერას იმ ობიექტებს, რომლებსაც მისი პირობა შეესაბამება, ამიტომ ობიექტმა შეიძლება წარწერა ერთზე მეტი კლასიდან მიიღოს. Out Beyond შორს, ამ ზღვარს მიღმა Outline კონტური Outline another frame's extent in this map frame. სხვა ჩარჩოს ფარგლების კონტურის დახატვა ამ რუკის ჩარჩოში. @@ -1851,6 +2026,7 @@ Output გამოსავალი Output Name გამოსავლის სახელი Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. 1000-ზე მეტი რასტრის, ვექტორის, LiDAR-ის, ჰიდროლოგიისა და რელიეფის ხელსაწყო, რომელიც ბრაუზერში მუშაობს; ერთჯერადი ჩამოტვირთვა დაახლოებით 25 მბ. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. 1000-ზე მეტი რასტრის, ვექტორის, LiDAR-ის, ჰიდროლოგიისა და რელიეფის ხელსაწყო; არასავალდებულო ჩამოტვირთვა დაახლოებით 20 მბ. +Overlapping გადაფარული Overlay გადაფარვა overlay გადაფარვა Overlay (a transparent layer kept on top, such as labels or roads) გადაფარვა (ზემოთ დარჩენილი გამჭვირვალე ფენა, მაგალითად წარწერები ან გზები) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture-ს არ აქვს {ty} Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Overture Maps-ის ღია მონაცემები: შენობები, ადგილები, ტრანსპორტი, ადმინისტრაციული ერთეულები, საბაზისო ფენები და მისამართები. რუკის არეალში ობიექტები ადგილზე იკითხება Overture-ის GeoParquet ფაილებიდან (გაადიდეთ ქალაქამდე ან უფრო მცირემდე); მთელი მსოფლიო მოდის რუკის ფილებად. Overture Maps… Overture Maps… +Overwrite existing values არსებული მნიშვნელობების გადაწერა Package პაკეტი Package a trained deep learning model. გაწვრთნილი ღრმა სწავლების მოდელის დაპაკეტება. Package Map რუკის დაპაკეტება @@ -1907,8 +2084,12 @@ Performance წარმადობა Phase ფაზა Photon Photon Pick a live value, such as the scale or date, then click the map to place it. აირჩიეთ ცოცხალი მნიშვნელობა, მაგალითად მასშტაბი ან თარიღი, შემდეგ განსათავსებლად დააწკაპუნეთ რუკაზე. +Pick a Point წერტილის არჩევა +Pick a point on the map to see the variable's values through time there. აირჩიეთ წერტილი რუკაზე, რომ ნახოთ იქ ცვლადის მნიშვნელობები დროში. Pick a route in the list, or click a grey route on the map. აირჩიეთ მარშრუტი სიაში ან დააწკაპუნეთ ნაცრისფერ მარშრუტზე რუკაზე. +Pick a single layer. აირჩიეთ ერთი ფენა. Pick it on the map რუკაზე არჩევა +Pick one… აირჩიეთ… Picture სურათი Pictures სურათები Pictures, attachments and chart სურათები, დანართები და დიაგრამა @@ -1966,8 +2147,11 @@ Portal პორტალი Portolan Portolan Portolan… Portolan… Portrait ვერტიკალური +Position მდებარეობა +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing პოსტდამუშავება power 2 ხარისხი 2 +Precision 6 (OSRM, Valhalla) სიზუსტე 6 (OSRM, Valhalla) Prepare მომზადება Prepare every layer's geometry for this projection. ამ პროექციისთვის ყველა ფენის გეომეტრიის მომზადება. Prepare Plugin Environment დანამატის გარემოს მომზადება @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. პროექტის project's path is required საჭიროა project-ის path Projection პროექცია Projections პროექციები +Properties თვისებები +Property თვისება Proportional Symbols პროპორციული სიმბოლოები Provider პროვაიდერი +Provinces: პროვინციები: Proximity სიახლოვე Public GIS portals of US counties and cities, by state. აშშ-ის საგრაფოებისა და ქალაქების საჯარო GIS პორტალები, შტატების მიხედვით. Public GIS portals of US federal agencies, by department. აშშ-ის ფედერალური სააგენტოების საჯარო GIS პორტალები, უწყებების მიხედვით. Public GIS portals of US states and the District of Columbia. აშშ-ის შტატებისა და კოლუმბიის ოლქის საჯარო GIS პორტალები. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. საჯარო ფენები ArcGIS Online-ზე ან სხვა ArcGIS პორტალზე: ობიექტების, რუკების, გამოსახულებებისა და ვექტორული ფილების სერვისები, ვებ რუკები და GeoJSON ფაილები. +Publication Layout განლაგება პუბლიკაციისთვის Publish გამოქვეყნება Publish › Output გამოქვეყნება › გამოსავალი Put the layer back as it was then ფენის დაბრუნება იმ მდგომარეობაში, როგორიც მაშინ იყო Python environment failed: {error} Python გარემო ვერ მომზადდა: {error} Python environment not prepared Python გარემო არ არის მომზადებული Python environment ready Python გარემო მზადაა +Python script Python სკრიპტი Qualitative თვისობრივი Quantile კვანტილი Query files, web addresses and databases with DuckDB Spatial; add results to the map. ფაილების, ვებ მისამართებისა და მონაცემთა ბაზების მოთხოვნა DuckDB Spatial-ით; შედეგების რუკაზე დამატება. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. პოლიგონების ამ Raise Priority პრიორიტეტის აწევა Random points in polygons შემთხვევითი წერტილები პოლიგონებში Range დიაპაზონი +Range (Degrees) რადიუსი (გრადუსი) +range: 0, 0.9 დიაპაზონი: 0, 0.9 Raster რასტრი Raster\nAnalysis რასტრული\nანალიზი Raster\nInformation რასტრის\nინფორმაცია @@ -2075,6 +2266,7 @@ Redo {action} გამეორება: {action} Red–Blue წითელი–ლურჯი Refine the search to see {n} more დააზუსტეთ ძიება, რომ კიდევ {n} ნახოთ Refresh განახლება +Refresh every connection's lists ყველა კავშირის სიების განახლება Refresh Map Series რუკების სერიის განახლება Refresh the map series from its index layer. რუკების სერიის განახლება მისი ინდექსური ფენიდან. Region რეგიონი @@ -2091,11 +2283,10 @@ Relationships კავშირები Release გამოშვება Reload გადატვირთვა Reload Web Layers ვებ ფენების გადატვირთვა -Remember (without the secret) დამახსოვრება (საიდუმლო გასაღების გარეშე) Remember the key on this computer გასაღების დამახსოვრება ამ კომპიუტერში -Remembered: დამახსოვრებული: Remove ამოშლა Remove All ყველას ამოშლა +Remove All Label Classes ყველა წარწერის კლასის ამოშლა Remove an offline area. ოფლაინ არეალის ამოშლა. Remove Bookmark სანიშნის ამოშლა Remove Connection კავშირის წაშლა @@ -2108,11 +2299,14 @@ Remove from Favourites რჩეულებიდან ამოშლა Remove From Group ჯგუფიდან ამოშლა Remove Group ჯგუფის ამოშლა Remove Keyframe საკვანძო კადრის ამოშლა +Remove Label Class წარწერის კლასის ამოშლა Remove Section სექციის ამოშლა Remove Table ცხრილის ამოშლა +Remove the clause. პირობის ამოშლა. Remove the last keyframe. ბოლო საკვანძო კადრის ამოშლა. Remove the layer from the map. ფენის ამოშლა რუკიდან. Remove the strokes and draw the symbol's line alone შტრიხების წაშლა და მხოლოდ სიმბოლოს ხაზის დახატვა +Remove this class's own strokes and draw it with the layer's ამ კლასის საკუთარი შტრიხების ამოშლა და მისი ფენის შტრიხებით დახატვა Remove this stop ამ გაჩერების ამოშლა Remove Tile Layer ფილების ფენის ამოშლა Remove token ტოკენის წაშლა @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). იმ Rename გადარქმევა Rename Bookmark სანიშნის გადარქმევა Rename Group ჯგუფის გადარქმევა +Rename it to save it: a connection has this name შესანახად გადაარქვით სახელი: ამ სახელით კავშირი უკვე არსებობს +Rename Label Class წარწერის კლასის გადარქმევა Rename Layer ფენის გადარქმევა Rename Layout განლაგების გადარქმევა Rename Map რუკის გადარქმევა @@ -2149,6 +2345,7 @@ Reset Default ნაგულისხმევის აღდგენა Reset Effects ეფექტების საწყისზე დაბრუნება Reset North ჩრდილოეთის აღდგენა Reset north ჩრდილოეთის აღდგენა +Resolution (0–30) გარჩევადობა (0–30) Response პასუხი Restore აღდგენა Restore From Archive არქივიდან აღდგენა @@ -2162,6 +2359,7 @@ RGB RGB Ribbon ლენტი Right მარჯვენა Right-click a source to add it here დააწკაპუნეთ წყაროზე მარჯვენა ღილაკით, რომ აქ დაამატოთ +Right-click for more მეტისთვის დააწკაპუნეთ მარჯვენა ღილაკით Robinson რობინსონი Rotate მობრუნება Rotate (°) მობრუნება (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) სანიმუშო მონაცემები (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. სანიმუშო მონაცემები © Natural Earth (საჯარო დომენი). RustGIS ლიცენზირებულია Apache-2.0-ით. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. სანიმუშო მონაცემები: Natural Earth (საჯარო დომენი). ლიცენზია: Apache-2.0. +Sans-serif უსერიფო Satellite Embedding Datasets სატელიტური ემბედინგების მონაცემთა ნაკრებები Satellite Embeddings სატელიტური ემბედინგები Satellite Embeddings… სატელიტური ემბედინგები… Saturation ნაჯერობა Save შენახვა Save As შენახვა როგორც +Save as Connection კავშირად შენახვა Save as Default ნაგულისხმევად შენახვა Save as Default Raster Style რასტრის ნაგულისხმევ სტილად შენახვა Save as Layer ფენად შენახვა Save As Presentation File… პრეზენტაციის ფაილად შენახვა… +Save as Python Script… Python სკრიპტად შენახვა… Save As Report File… ანგარიშის ფაილად შენახვა… Save As Report Template… ანგარიშის შაბლონად შენახვა… Save as {name} შენახვა როგორც {name} Save As… შენახვა როგორც… Save Color Ramps ფერთა შკალების შენახვა Save Data Source მონაცემთა წყაროს შენახვა +Save History As Python Script ისტორიის შენახვა Python სკრიპტად Save Layer File ფენის ფაილის შენახვა Save Layout File განლაგების ფაილის შენახვა Save Map File რუკის ფაილის შენახვა @@ -2254,6 +2456,7 @@ save or load a pane set by name პანელების ნაკრებ Save Project პროექტის შენახვა Save project პროექტის შენახვა Save Project As პროექტის შენახვა როგორც +Save Project As… პროექტის შენახვა როგორც… Save Project Template პროექტის შაბლონის შენახვა Save Style Items სტილის ელემენტების შენახვა Save Task Item ამოცანის ელემენტის შენახვა @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) მოდელის შენახვ Save the model in the project მოდელის შენახვა პროექტში Save the open layout as a layout file. ღია განლაგების შენახვა განლაგების ფაილად. Save the panes you have open, and switch back to them. თქვენ მიერ გახსნილი პანელების შენახვა და მათზე დაბრუნება. +Save the project too: the script opens its saved file, without changes made since. შეინახეთ პროექტიც: სკრიპტი ხსნის მის შენახულ ფაილს, შემდგომი ცვლილებების გარეშე. Save the project under a new name. პროექტის შენახვა ახალი სახელით. Save the project with your edits. პროექტის შენახვა თქვენი რედაქტირებით. Save the project's custom color ramps to a color ramp file. პროექტის მორგებული ფერთა შკალების შენახვა ფერთა შკალების ფაილში. @@ -2275,13 +2479,16 @@ Save Web Link Chart ვებ ბმულის დიაგრამის შ Save Web Map ვებ რუკის შენახვა Save Web Scene ვებ სცენის შენახვა Saved შენახულია +Saved as the connection {name} შენახულია როგორც კავშირი {name} Saved as the default style for new single-band rasters. შენახულია ახალი ერთარხიანი რასტრების ნაგულისხმევ სტილად. +Saved connection შენახული კავშირი Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). შენახული გასაღებები დაუშიფრავად ინახება აპლიკაციის პარამეტრებში (ვებზე ბრაუზერის საცავში). Saved models შენახული მოდელები Saved symbology: apply it to the selected layer, or save the layer's. შენახული სიმბოლიზაცია: გამოიყენეთ არჩეულ ფენაზე ან შეინახეთ ფენის სიმბოლიზაცია. Saved View შენახული ხედი Saved {name} შეინახა {name} Saved {name}. შენახულია {name}. +Saved {path} შენახულია: {path} saving failed unexpectedly შენახვა მოულოდნელად ჩავარდა Scale მასშტაბი Scale\nBar მასშტაბის\nხაზი @@ -2304,7 +2511,7 @@ Search (this portal needs words)… ძიება (ამ პორტალ Search Catalogue კატალოგში ძიება Search commands — e.g. buffer, basemap, labels ბრძანებების ძიება — მაგ. buffer, basemap, labels Search datasets, e.g. GEDI biomass მონაცემთა ნაკრებების ძიება, მაგ. GEDI biomass -Search Distance (km) ძიების მანძილი (კმ) +Search Distance ძიების მანძილი Search every command and its parameters ყველა ბრძანებისა და მისი პარამეტრების ძიება Search layers (e.g. temperature) ფენების ძიება (მაგ. temperature) Search layers, places, lat/lon… ფენების, ადგილების, განედ-გრძედის ძიება… @@ -2329,6 +2536,8 @@ Searching the folders… საქაღალდეებში ძიება Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. ძიება აქ მუშაობს, მაგრამ NASA-ს მონაცემთა სერვერები ვებ გვერდებს მათი ფაილების წაკითხვის უფლებას არ აძლევს (CORS არ არის), ამიტომ GeoTIFF-ებისა და დაცული მონაცემების ჩამოსატვირთად საჭიროა დესკტოპ აპლიკაცია. „ჩამოტვირთვა“ ხსნის ფაილს ახალ ჩანართში, სადაც Earthdata Login შესვლას მოგთხოვთ. Searching… ძიება… Searching… try again in a moment. ძიება… სცადეთ ცოტა ხანში. +Seasonal Mann–Kendall სეზონური მან–კენდალი +Seasons a year სეზონები წელიწადში Second standard parallel მეორე სტანდარტული პარალელი Seconds წამი Secret access key საიდუმლო წვდომის გასაღები @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator გამყოფი Sepia სეპია Sequential მიმდევრობითი +Serif სერიფიანი Server სერვერი Service Group სერვისის ჯგუფი Service group data exceeds 64 MB. სერვისის ჯგუფის მონაცემები 64 მბ-ს აღემატება. Service Import Requests სერვისის იმპორტის მოთხოვნები Service Layers სერვისის ფენები -Services სერვისები +Session token სესიის ტოკენი Set დაყენება Set Cell Size უჯრედის ზომის დაყენება Set Domain დომენის დაყენება @@ -2411,6 +2621,7 @@ Shaded დაჩრდილული Shaded Relief დაჩრდილული რელიეფი Shape ფორმა Shapefile Shapefile +Shapes ფიგურები Share გაზიარება Share As გაზიარება როგორც Share every layer and table as OGC API Features collections. ყველა ფენისა და ცხრილის გაზიარება OGC API Features კოლექციებად. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. დეტალები Show different features at different scales. სხვადასხვა ობიექტის ჩვენება სხვადასხვა მასშტაბზე. Show grid ბადის ჩვენება Show Group ჯგუფის ჩვენება +Show labels წარწერების ჩვენება Show latitude/longitude lines on the basemap. გრძედისა და განედის ხაზების ჩვენება საბაზისო რუკაზე. +Show Layers ფენების ჩვენება Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. ფენების ჩვენება დროში: მხოლოდ ობიექტები, რომელთა თარიღიც სლაიდერის ფანჯარაში ხვდება, და თარიღიანი გამოსახულებები მისი თარიღისთვის. Show Map რუკის ჩვენება Show Me მაჩვენე @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. ხელსაწყოების პანე Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. სახაზავების ჩვენება. დამხმარე ხაზის დასამატებლად გადაათრიეთ სახაზავიდან; ამოსაშლელად გადაათრიეთ იგი სახაზავზე უკან. Show the bands picked, read in place as the map needs them არჩეული არხების ჩვენება, ადგილზე წაკითხული რუკის საჭიროებისამებრ Show the description აღწერის ჩვენება +Show the Labeling pane for the selected layer. მონიშნული ფენისთვის წარწერების პანელის ჩვენება. Show the labels that don't fit, in red. წარწერების ჩვენება, რომლებიც არ ეტევა, წითლად. Show the layer only inside another layer's polygons. ფენის ჩვენება მხოლოდ სხვა ფენის პოლიგონებში. Show the layer only left of a divider, to compare it with what's beneath. ფენის ჩვენება მხოლოდ გამყოფის მარცხნივ, ქვემოთ მყოფთან შესადარებლად. @@ -2491,8 +2705,10 @@ Sign-in cancelled. შესვლა გაუქმდა. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. შესვლის ლოდინის დრო ამოიწურა. დაიწყეთ თავიდან და მიეცით ბრაუზერს ამ კომპიუტერის უკუკავშირის მისამართზე წვდომა. signed in შესულია Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. ამ კომპიუტერზე გამოსვლა შესრულდა, მაგრამ OpenAI-მ ეს არ დაადასტურა. RustGIS-ის გათიშვა ChatGPT-ის პარამეტრებში შეგიძლიათ. +Significance level მნიშვნელოვნობის დონე Signing in with ChatGPT works in the desktop app. ChatGPT-ით შესვლა მუშაობს სამუშაო მაგიდის აპში. Signing the asset (Planetary Computer)… რესურსის ხელმოწერა (Planetary Computer)… +Sill ზღურბლი Simplify გამარტივება Simulate the drive, or save the route as a layer. მგზავრობის სიმულაცია ან მარშრუტის ფენად შენახვა. Simulation სიმულაცია @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} უნარები AI ასისტე Slideshow, PDF or PNG სლაიდშოუ, PDF ან PNG slight left ოდნავ მარცხნივ slight right ოდნავ მარჯვნივ +Slope per დახრილობა ერთეულზე Slope, aspect, hillshade, NDVI, band math, thresholds. დახრილობა, ექსპოზიცია, დაჩრდილული რელიეფი, NDVI, არხების არითმეტიკა, ზღვრები. Slope, hillshade, NDVI and more. დახრილობა, დაჩრდილული რელიეფი, NDVI და სხვა. Small scales წვრილი მასშტაბები @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South სამხრეთი South Polar Stereographic სამხრეთის პოლარული სტერეოგრაფიული +Spatial Join სივრცითი შეერთება Speaker notes მომხსენებლის ჩანაწერები Spectral Analysis სპექტრული ანალიზი Spectral indices სპექტრული ინდექსები @@ -2530,6 +2748,7 @@ Split (click the line) გაყოფა (დააწკაპუნეთ ხ SQL SQL SQL Pane SQL პანელი SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL მოთხოვნები სრულდება DuckDB-ით, პროგრამით, რომელსაც დესკტოპის აპლიკაცია იძახებს: მოთხოვნისთვის გახსენით ეს პროექტი დესკტოპის აპლიკაციაში. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL, რომელიც ჭეშმარიტი უნდა იყოს, მაგ. pop > 0 Square კვადრატი Squares კვადრატები @@ -2588,6 +2807,7 @@ Start over at the end while playing. დაკვრისას ბოლოს Start With დასაწყისი Start-up გაშვებისას starting with a project file needs the desktop app პროექტის ფაილით გაშვებას დესკტოპის აპი სჭირდება +Statistic სტატისტიკა Statistics სტატისტიკა Statistics Field სტატისტიკის ველი Status სტატუსი @@ -2613,8 +2833,10 @@ Stretch\nType გაჭიმვის\nტიპი Stretch it over the map view, or give control points (one per line: column row lon lat). გაჭიმეთ რუკის ხედზე ან მიუთითეთ საკონტროლო წერტილები (თითო ხაზზე: სვეტი სტრიქონი გრძედი განედი). Stretch Type გაჭიმვის ტიპი Stretch type გაჭიმვის ტიპი +Stroke Levels შტრიხების დონეები Stroke {n} შტრიხი {n} Strokes შტრიხები +Strokes for შტრიხები კლასისთვის Style სტილი Style Items სტილის ელემენტები Style › Drawing სტილი › ხატვა @@ -2624,7 +2846,10 @@ Subreport: {name} ქვეანგარიში: {name} Subtypes ქვეტიპები Subtypes, one per line: code=name ქვეტიპები, თითო ხაზზე ერთი: კოდი=სახელი Suitability Modeler ვარგისიანობის მოდელერი +Summarise შეჯამება +Summarise over time შეჯამება დროში Summarise the conversation so far, so a long chat stays within the model's context შეაჯამეთ საუბარი აქამდე, რომ გრძელი ჩატი მოდელის კონტექსტში დაეტიოს +Summarise Zarr Over Time Zarr-ის შეჯამება დროში Summarising the earlier conversation to keep it within the model's context… წინა საუბარი ჯამდება, რომ მოდელის კონტექსტში დაეტიოს… Summarize შეჯამება Summarize\nWithin შეჯამება\nფარგლებში @@ -2658,6 +2883,8 @@ Table or layer ცხრილი ან ფენა Tables ცხრილები Tags ტეგები Take the data in view offline, to edit and sync back later. ხედში მოხვედრილი მონაცემების ოფლაინ წაღება რედაქტირებისთვის და მოგვიანებით უკან სინქრონიზაციისთვის. +Target Features სამიზნე ობიექტები +Target Records სამიზნე ჩანაწერები Task ამოცანა Task Item ამოცანის ელემენტი Task name ამოცანის სახელი @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. ტენესის საჯარო GIS მონაცემები (Tennessee GeoData, geodata.tn.gov): საკუთრების საზღვრები, ტრანსპორტი, ჯანდაცვა, ჰიდროლოგია და სხვა. Terrain რელიეფი Test Test +Test Connection კავშირის შემოწმება +Testing… მოწმდება… Text ტექსტი Text Symbol ტექსტის სიმბოლო That connection would create a loop. ეს კავშირი ციკლს შექმნიდა. The address fields are empty in every row. მისამართის ველები ყველა სტრიქონში ცარიელია. +The answer isn't UTF-8 text პასუხი არ არის UTF-8 ტექსტი the answer isn't UTF-8 text პასუხი არ არის UTF-8 ტექსტი The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. ასისტენტი უნარის გამოყენებისას მის ინსტრუქციებს მიჰყვება. რაც არ უნდა ეწეროს მათში, ბრძანებები, რომლებსაც თქვენი თანხმობა სჭირდება, მაინც გკითხავთ. The assistant is busy; stop it or wait for it to finish. ასისტენტი დაკავებულია; შეაჩერეთ ან დაელოდეთ დასრულებას. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. საბაზისო რუკ the capture failed unexpectedly გადაღება მოულოდნელად ჩავარდა The connection failed; trying again in {seconds} s. კავშირი ვერ დამყარდა; ხელახალი ცდა {seconds} წმ-ში. The country list couldn't be read ({e}): type a country's code ქვეყნების სიის წაკითხვა ვერ მოხერხდა ({e}): შეიყვანეთ ქვეყნის კოდი +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. მიმდინარე სიმაღლე ძალაში რჩება, სანამ შეყვანის ველებს არედაქტირებთ. მნიშვნელობისა და ერთეულების გამოსაყენებლად დააწკაპუნეთ „სიმაღლის გამოყენება“. The dataset has nothing RustGIS can add მონაცემთა ნაკრებში არაფერია, რისი დამატებაც RustGIS-ს შეუძლია The environment (AWS_ACCESS_KEY_ID…) გარემო (AWS_ACCESS_KEY_ID…) The features where the map is ობიექტები რუკის არეალში @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. Flow The installed GeoLibre runner lists no tools. დაინსტალირებული GeoLibre-ის გამშვები ხელსაწყოებს არ ჩამოთვლის. The job covers the map view სამუშაო მოიცავს რუკის ხედს The key is kept for this session only. გასაღები ინახება მხოლოდ ამ სესიისთვის. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. ფენა აჩვენებს ბიჯს, რომელზეც დროის სლაიდერი დგას, და რთავს სლაიდერს ცვლადის თარიღებზე. The layer's file couldn't be read: {error} ფენის ფაილის წაკითხვა ვერ მოხერხდა: {error} +The layer's strokes ფენის შტრიხები the layout export failed unexpectedly განლაგების ექსპორტი მოულოდნელად ჩავარდა The link doesn't say its STAC API: Find Items searches {api} ბმული არ მიუთითებს თავის STAC API-ს: „ელემენტების პოვნა“ ეძებს {api}-ში The list of plugins to browse. Leave it empty for the RustGIS plugin registry. დანამატების სია დასათვალიერებლად. დატოვეთ ცარიელი RustGIS-ის დანამატების რეესტრისთვის. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. პროექტის მინ The projection the map opens in. პროექცია, რომელშიც რუკა იხსნება. The provider's content filter stopped the reply. პროვაიდერის შინაარსის ფილტრმა პასუხი შეაჩერა. The public server is free and shared by many: it answers light use, and may ask you to wait. საჯარო სერვერი უფასოა და ბევრი იყენებს: ის მსუბუქ გამოყენებას ემსახურება და შეიძლება მოგთხოვოთ მოცდა. +The range is two numbers, low and high (0, 0.9) დიაპაზონი ორი რიცხვია, ქვედა და ზედა (0, 0.9) The raster's data couldn't be read. რასტრის მონაცემების წაკითხვა ვერ მოხერხდა. The raster's data couldn't be read: {path} რასტრის მონაცემების წაკითხვა ვერ მოხერხდა: {path} the release isn't known გამოშვება უცნობია @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Shapefile-ის ფაილებ The share of each cell covered by fields, for the zoomed-out view თითოეული უჯრის მინდვრებით დაფარული წილი, დაშორებული ხედისთვის The sign-in expired or was revoked. Sign in again. შესვლის ვადა ამოიწურა ან წვდომა გაუქმდა. ხელახლა შედით. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. შესვლის მონაცემები ინახება აპის პარამეტრების საქაღალდეში, მხოლოდ თქვენი მომხმარებლის ანგარიშისთვის, და არასოდეს პროექტის ფაილში. +The significance level is a number, such as 0.05 მნიშვნელოვნობის დონე რიცხვია, მაგალითად 0.05 The start-up project {path} didn't open: {error} საწყისი პროექტი {path} ვერ გაიხსნა: {error} The start-up project {path} isn't a RustGIS project file. საწყისი პროექტი {path} არ არის RustGIS-ის პროექტის ფაილი. The step's layer {name} isn't in the map. საფეხურის ფენა {name} რუკაზე არ არის. The store has no variables with two or more dimensions. საცავში არ არის ორი ან მეტი განზომილების ცვლადები. the store is gone საცავი აღარ არსებობს +The summary couldn't start: {error} შეჯამების დაწყება ვერ მოხერხდა: {error} +The summary failed unexpectedly შეჯამება მოულოდნელად ჩაიშალა +The summary finished after another project was opened, so it wasn't added შეჯამება დასრულდა სხვა პროექტის გახსნის შემდეგ, ამიტომ არ დაემატა The task's steps changed, so the step being edited was closed: edit it again. ამოცანის საფეხურები შეიცვალა, ამიტომ რედაქტირებადი საფეხური დაიხურა: დაარედაქტირეთ ხელახლა. the time slider is off დროის სლაიდერი გამორთულია The token expires on {date}. ტოკენს ვადა გასდის {date}. +The trend couldn't start: {error} ტრენდის ანალიზის დაწყება ვერ მოხერხდა: {error} +The trend failed unexpectedly ტრენდის ანალიზი მოულოდნელად ჩაიშალა +The trend finished after another project was opened, so it wasn't added ტრენდის ანალიზი დასრულდა სხვა პროექტის გახსნის შემდეგ, ამიტომ არ დაემატა +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. მნიშვნელობები, რომლებიც შავიდან თეთრამდე ჩანს, ერთნაირი ყველა თარიღისა და ფილისთვის (არხის საკუთარ ერთეულებში). ცარიელი: თითოეული ფაილის საკუთარი გაჭიმვა. the view has no extent ხედს ფარგლები არ აქვს The view is larger than one read: its centre was read (zoom in for the rest) ხედი ერთ წაკითხვაზე დიდია: წაკითხულია მისი ცენტრი (დანარჩენისთვის გაადიდეთ) The web app always runs the latest release: reload the page to get it. ვებ აპლიკაცია ყოველთვის უახლეს რელიზს უშვებს: განახლებისთვის გადატვირთეთ გვერდი. the window's screenshot didn't come back in time, so the map was drawn offscreen ფანჯრის ეკრანის ანაბეჭდი დროულად არ დაბრუნდა, ამიტომ რუკა ეკრანის გარეთ დაიხატა the window's screenshot didn't hold the map, so it was drawn offscreen ფანჯრის ეკრანის ანაბეჭდი რუკას არ შეიცავდა, ამიტომ ის ეკრანის გარეთ დაიხატა Theme თემა +There is no expression defined. გამოსახულება არ არის განსაზღვრული. Thermal თერმული +Thesis figure, full page (160 × 225 mm) დისერტაციის ფიგურა, სრული გვერდი (160 × 225 მმ) +Thesis figure, half page (160 × 110 mm) დისერტაციის ფიგურა, ნახევარი გვერდი (160 × 110 მმ) Thinking… მიმდინარეობს ფიქრი… +Third-party licences მესამე მხარის ლიცენზიები +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins ამ ვერსიას დანამატების ჩამოტვირთვა არ შეუძლია this build can't reach AI providers ამ ვერსიას AI პროვაიდერებთან კავშირი არ შეუძლია this build saves projects with Save As ეს ვერსია პროექტებს ინახავს „შენახვა როგორც“-ით This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. ეს ChatGPT ანგარიში უკვე შენახულია. შემდეგ ჯერზე ხელახლა დამატების ნაცვლად აირჩიეთ სიიდან. +This class is drawn with the layer's strokes. Changing them here gives it its own. ეს კლასი ფენის შტრიხებით იხატება. აქ მათი შეცვლა მას საკუთარ შტრიხებს მისცემს. +This class is hidden, so it labels nothing. ეს კლასი დამალულია, ამიტომ არაფერს აწერს წარწერას. +This clause no longer reads, so no feature is labelled: {error} ეს პირობა აღარ იკითხება, ამიტომ არცერთ ობიექტს წარწერა არ აქვს: {error} This map has no layers yet. ამ რუკას ჯერ ფენები არ აქვს. This map's thumbnail, from an image. ამ რუკის მინიატურა სურათიდან. This map's thumbnail, from the view. ამ რუკის მინიატურა ხედიდან. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. ამ მოთხოვნას ერთჯერადი დაშვების ვარიანტი არ აქვს. This request uses an unsupported capability. Check the parameter and change the request. ეს მოთხოვნა მხარდაუჭერელ შესაძლებლობას იყენებს. შეამოწმეთ პარამეტრი და შეცვალეთ მოთხოვნა. This result is kept ეს შედეგი შენარჩუნებულია +This run has no tool form to open ამ გაშვებას გასახსნელი ხელსაწყოს ფორმა არ აქვს +This session only (optional) მხოლოდ ეს სესია (არასავალდებულო) This step ეს ნაბიჯი This step names no tool to show. ეს საფეხური საჩვენებელ ხელსაწყოს არ ასახელებს. Thumbnail მინიატურა @@ -2789,15 +3041,21 @@ Tilt Map რუკის დახრა Tilt-shift ტილტ-შიფტი Tilt-shift blur and film grain. ტილტ-შიფტის გაფუჭება და ფირის მარცვალი. Time დრო +Time\nSeries დროითი\nმწკრივი Time\nSlider დროის\nსლაიდერი Time Fields დროის ველები +Time Series დროითი მწკრივი +Time series დროითი მწკრივი +Time Series at a Point დროითი მწკრივი წერტილში Time Slider დროის სლაიდერი Time Slider Settings დროის სლაიდერის პარამეტრები Tint by elevation ელევაციით შეფერვა Tissot's Indicatrix ტისოს ინდიკატრისა Title სათაური +To მდე Toggle Dark Mode მუქი რეჟიმის გადართვა Toggle GPU Rendering GPU რენდერინგის გადართვა +Toggle Imperial Measurements იმპერიული ერთეულების გადართვა Tolerance (km) ტოლერანტობა (კმ) Tolls ფასიანი გზები Tool Gallery ხელსაწყოების გალერეა @@ -2828,7 +3086,10 @@ Train Deep Learning Model ღრმა სწავლების მოდე Trained models გაწვრთნილი მოდელები Transparency გამჭვირვალობა Traverse გავლა +Trend Analysis ტრენდის ანალიზი +Trend over a series of rasters ტრენდი რასტრების სერიაზე Triangle სამკუთხედი +TRUE TRUE Try again in a moment. სცადეთ ხელახლა ცოტა ხანში. Try again later. სცადეთ მოგვიანებით. Turn editing on or off for the map. რუკის რედაქტირების ჩართვა ან გამორთვა. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map შეიყვანეთ გ Type a scale, such as 1:12,345, 12345 or 25k, and press Enter შეიყვანეთ მასშტაბი, მაგალითად 1:12,345, 12345 ან 25k, და დააჭირეთ Enter-ს Type an address or coordinates and press Enter, or click the map. შეიყვანეთ მისამართი ან კოორდინატები და დააჭირეთ Enter-ს, ან დააწკაპუნეთ რუკაზე. Type numbers separated by commas შეიყვანეთ მძიმით გამოყოფილი რიცხვები +Type the keys to log in for this session, then Save. შეიყვანეთ გასაღებები ამ სესიაში შესასვლელად, შემდეგ დააჭირეთ „შენახვას“. +Type the password or keys to log in for this session, then Save. შეიყვანეთ პაროლი ან გასაღებები ამ სესიაში შესასვლელად, შემდეგ დააჭირეთ „შენახვას“. Typed below ქვემოთ შეყვანილი UM UM Unavailable: {reason} მიუწვდომელია: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider დროის სლაიდერიდან მო Unbind from Time Slider დროის სლაიდერიდან მოხსნა Undo გაუქმება Undo (Ctrl+Z) გაუქმება (Ctrl+Z) -Undo / Redo გაუქმება / გამეორება Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. გაუქმება ჩაწერილ საფეხურს არ შლის: ჩაწერის შეჩერების შემდეგ წაშალეთ ის „საფეხურების რედაქტირებაში“. Undo the last change. ბოლო ცვლილების გაუქმება. Undo {action} გაუქმება: {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools GeoLibre-ის ხელსაწყოების Uninstall Plugin დანამატის წაშლა Uninstalled {id}. {id} წაიშალა. Unique Values უნიკალური მნიშვნელობები +Unit ერთეული United States აშშ Units ერთეულები Units per step ერთეული ნაბიჯზე @@ -2877,6 +3140,7 @@ Up a level ერთი დონით ზემოთ Update განახლება Update Bookmark სანიშნის განახლება Update Job სამუშაოს განახლება +Update Label Class წარწერის კლასის განახლება Update to {version} განახლება {version}-მდე Upload File ფაილის ატვირთვა Upload File… ფაილის ატვირთვა… @@ -2895,6 +3159,7 @@ Use another account სხვა ანგარიშის გამოყე Use Current View მიმდინარე ხედის გამოყენება Use each feature's line color თითოეული ობიექტის ხაზის ფერის გამოყენება Use each feature's line width თითოეული ობიექტის ხაზის სისქის გამოყენება +Use the Layer's Strokes ფენის შტრიხების გამოყენება USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan გამოიყენება ChatGPT გეგმა Utilities კომუნალური ქსელები Valid combinations, one per line: a, b სწორი კომბინაციები, თითო ხაზზე ერთი: a, b Valid expression · {n} matching rows სწორი გამოსახულება · შესატყვისი სტრიქონები: {n} +Valid: {n} of {of} features match სწორია: შეესაბამება {n} ობიექტი {of}-დან Validate ვალიდაცია Validate Model მოდელის შემოწმება value (empty: no-data) მნიშვნელობა (ცარიელი: მონაცემების გარეშე) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Vantor-ის კატასტროფების ღია გამოსახულებები: აირჩიეთ მოვლენა და ფაზა (მანამდე ან შემდეგ), შემდეგ „ელემენტების პოვნა“. Vantor… Vantor… Variable ცვლადი +Variogram Model ვარიოგრამის მოდელი Vector and raster data ვექტორული და რასტრული მონაცემები Vector Layer ვექტორული ფენა +vector tiles ვექტორული ფილები Vector tiles (MVT) ვექტორული ფილები (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. ვექტორები, რომლებიც გეოსივრცულმა საბაზისო მოდელმა შექმნა თითოეული პიქსელისთვის ან გამოსახულების ფრაგმენტისთვის, ნაპოვნი რუკის არეში და ნაჩვენები ფერებად. Version {version} ვერსია {version} @@ -2933,6 +3201,9 @@ Video file or URL ვიდეო ფაილი ან URL Video Page ვიდეოს გვერდი Videos ვიდეოები Video… ვიდეო… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. ვიეტნამის ეროვნული კოორდინატთა სისტემა (VN-2000) 3-გრადუსიანი განივი მერკატორით და ჰელმერტის 7-პარამეტრიანი გადაწევით WGS 84-ში. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). ვიეტნამის ეროვნული კოორდინატთა სისტემა (VN-2000), UTM ზონა 48N (102°E–108°E, ცენტრალური მერიდიანი 105°E). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). ვიეტნამის ეროვნული კოორდინატთა სისტემა (VN-2000), UTM ზონა 49N (108°E–114°E, ცენტრალური მერიდიანი 111°E). View ხედი View Clipping ხედის მოჭრა View Effects ხედის ეფექტები @@ -2952,6 +3223,9 @@ Visibility Range ხილვადობის დიაპაზონი Visible ხილული Visible Layers ხილული ფენები Visual Effect ვიზუალური ეფექტი +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (ვიეტნამი) +VN-2000 / UTM zone 48N VN-2000 / UTM zone 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zone 49N Volume მოცულობა Waiting for the browser… ბრაუზერის მოლოდინი… Waiting for your answer ელოდება თქვენს პასუხს @@ -2970,6 +3244,7 @@ Web Link Chart ვებ ბმულის დიაგრამა Web maps ვებ რუკები Web Mercator ვებ მერკატორი Web Scene ვებ სცენა +Web service (detected) ვებსერვისი (აღმოჩენილი) Web Style ვებ სტილი Web Tile Layers ვებ ფილების ფენები Web Tool ვებ ხელსაწყო @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. სად მდებარეო Where new features get their elevation (the z field). საიდან იღებს ახალი ობიექტები სიმაღლეს (z ველი). Where the map opens: the full extent or a view you saved. სად იხსნება რუკა: სრულ ფარგლებში ან თქვენ მიერ შენახულ ხედში. Where tools work სად მუშაობს ხელსაწყოები +Whether the Select tool picks these layers' features on the map ირჩევს თუ არა მონიშვნის ხელსაწყო ამ ფენების ობიექტებს რუკაზე Which 5 countries have the largest GDP? რომელ 5 ქვეყანას აქვს ყველაზე დიდი მშპ? Whitebox and GeoLibre tools Whitebox-ისა და GeoLibre-ის ხელსაწყოები Whole map მთელი რუკა @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. მსოფლიო, ტო World, equal-area: true sizes, shapes bend towards the edges. მსოფლიო, ტოლფართობიანი: ნამდვილი ზომები, ფორმები კიდეებისკენ იღუნება. Worldview მსოფლიო ხედვა Write a plugin დანამატის დაწერა +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. ჩაწერს სენის დახრილობას, ტესტის Z-სა და p-მნიშვნელობას, და დახრილობას იქ, სადაც ტრენდი მნიშვნელოვანია. X (mm) X (მმ) X column X სვეტი X Field X ველი @@ -3050,7 +3327,6 @@ Yours თქვენი YYYY-MM-DD წწწწ-თთ-დდ YYYY-MM-DD, for the layers that change with time წწწწ-თთ-დდ, დროში ცვალებადი ფენებისთვის YYYY-MM-DD, for time layers წწწწ-თთ-დდ, დროის ფენებისთვის -z (m) z (მ) Z factor Z კოეფიციენტი Zarr Zarr Zarr Variables Zarr ცვლადები @@ -3061,7 +3337,6 @@ Zoom Box გადიდების ჩარჩო Zoom Frame გადიდების ჩარჩო Zoom In გადიდება Zoom in გადიდება -Zoom in / out გადიდება / დაპატარავება Zoom In Here აქ გადიდება Zoom in on the centre of the map. რუკის ცენტრზე გადიდება. Zoom in · finish a sketch გადიდება · ესკიზის დასრულება @@ -3073,6 +3348,7 @@ Zoom To\nLayer გადიდება\nფენაზე Zoom to a box (Explore) გადიდება ჩარჩოზე (შესწავლა) Zoom To Extent გადიდება ფარგლებზე Zoom To Layer გადიდება ფენაზე +Zoom To Layers გადიდება ფენებზე Zoom To Map View გადიდება რუკის ხედზე Zoom To Selection გადიდება მონიშვნაზე Zoom to selection გადიდება მონიშვნაზე @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. გადიდება ფენის ფარ Zoom to the selected features. გადიდება მონიშნულ ობიექტებზე. Zoom to the whole world. გადიდება მთელ მსოფლიოზე. Zoom to this bookmark ამ სანიშნეზე გადიდება +Zoom To Tile Layer გადიდება ფილების ფენაზე Zoom to Tokyo გადიდება ტოკიოზე Zoom To World გადიდება მსოფლიოზე +Zoom {z} · Bearing {b}° · Pitch {p}° მასშტაბი {z} · აზიმუტი {b}° · დახრა {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. მასშტაბი: რუკის მასშტაბის დონე, როგორც მას ვებრუკები ითვლის. აზიმუტი: მიმართულება, საითაც რუკის ზედა მხარეა, საათის ისრის მიმართულებით ჩრდილოეთიდან. დახრა: გადახრა პირდაპირ ქვემოთ ხედვიდან. დააწკაპუნეთ აზიმუტისა და დახრის აღსადგენად. {agent} was stopped by you. {agent} თქვენ შეაჩერეთ. {id} changed since it was installed; install it again from the Plugin Manager. {id} შეიცვალა ინსტალაციის შემდეგ; დააყენეთ ხელახლა დანამატების მენეჯერიდან. {names}: needs Node.js (npx) for its ACP adapter. {names}: მის ACP ადაპტერს Node.js (npx) სჭირდება. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ab3a44b9d..8fa9d0d51 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6,6 +6,7 @@ (filtered) (필터링됨) + Add Stop + 경유지 추가 + Input + 입력 ++ New expression + 새 표현식 + Output + 출력 /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh 3D 메시 <없음> A 3D globe seen from space. 우주에서 본 3D 지구본. A blank map with a basemap 배경지도가 있는 빈 지도 +A call for the rustgis Python package rustgis Python 패키지용 호출 A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. 카탈로그(CSW 2.0.2) 주소: 단어와 원하면 지도 영역으로 레코드를 검색한 다음, 연결된 서비스와 파일을 추가합니다. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. ChatGPT 요금제 또는 앱 사용 한도에 도달했습니다. 다시 시도하기 전에 주간 사용량을 확인하세요. A ChatGPT sign-in can only be used with the OpenAI API. ChatGPT 로그인은 OpenAI API에서만 사용할 수 있습니다. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). GeoLens A globe scene. 지구본 장면. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place 위도와 경도(40.7, -74.0 또는 40°42'N 74°0'W), 레이어, 북마크 또는 레이어 테이블의 값. Enter는 강조 표시된 행을 선택하고, 장소 검색은 텍스트를 장소로 찾습니다 A layer the model reads 모델이 읽는 레이어 +A line for the Script pane 스크립트 창용 한 줄 A magnifying lens that follows the pointer. 포인터를 따라 움직이는 돋보기. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). 지도, 피처 또는 커버리지 서비스 주소: WMS, WMTS, WFS, WCS, OGC API Features 또는 Tiles, 또는 ArcGIS FeatureServer, MapServer, ImageServer 또는 VectorTileServer(찾아보려면 서비스 디렉터리). a name, or owner/name such as giswqs/geospatial 이름 또는 giswqs/geospatial 같은 소유자/이름 +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. 다시 사용할 수 있는 이름이 지정된 엔드포인트입니다. 비밀번호와 키는 RustGIS를 닫을 때까지 유지되며 저장되지 않습니다. A node cannot connect to itself. 노드는 자기 자신에 연결할 수 없습니다. A notebook of text and command cells. 텍스트 셀과 명령 셀로 이루어진 노트북. +A password or token in the address was left out: connections never keep secrets. 주소에 포함된 비밀번호 또는 토큰은 제외되었습니다. 연결은 비밀 정보를 보관하지 않습니다. A place or an address 장소 또는 주소 A project keeps at least one map 프로젝트에는 지도가 하나 이상 있어야 합니다 A projected coordinate system. 투영 좌표계. A report listing the selected layer's records, page after page. 선택한 레이어의 레코드를 페이지별로 나열하는 보고서. A result that only feeds other tools is removed once the model has run; add an output to keep it. 다른 도구에만 전달되는 결과는 모델 실행 후 제거됩니다. 유지하려면 출력을 추가하세요. A result the model keeps, under a name 모델이 이름을 붙여 유지하는 결과 +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) rustgis-cli 명령(POSIX 셸용: macOS, Linux, WSL, Git Bash) A service import or refresh is already running. 서비스 가져오기 또는 새로 고침이 이미 진행 중입니다. +A service's layers stay in their service group. 서비스의 레이어는 해당 서비스 그룹 안에 유지됩니다. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. 태스크 하나에는 최대 200단계까지 담을 수 있습니다. 기록을 중지해 태스크를 만든 다음 나머지는 다른 태스크로 기록하세요. A template with the first selected feature's values 첫 번째 선택 피처의 값을 사용하는 템플릿 +A tile layer per grid tile, each following the time slider through its dates. 그리드 타일마다 타일 레이어 하나를 만들며, 각각 날짜에 따라 시간 슬라이더를 따릅니다. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. 타일 서비스 주소: {z}/{x}/{y}가 포함된 래스터 타일(WMS는 {-y}, {q}, {bbox-epsg-3857}), 벡터 타일(.pbf) 또는 TileJSON, MapLibre 스타일, PMTiles 아카이브(웹 주소 또는 이 컴퓨터의 파일), 또는 이 컴퓨터의 MBTiles 파일. A token is set. 토큰이 설정되어 있습니다. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. 값, 필드 또는 이 둘을 + − × ÷ 로 결합한 식입니다. 선택한 행에 적용되며, 선택한 행이 없으면 모든 행에 적용됩니다. a vector a pixel 픽셀마다 벡터 하나 a vector an image patch 이미지 패치마다 벡터 하나 A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. 통합 메타데이터가 있는 Zarr 저장소(형식 2 또는 3)의 웹 주소 또는 이 컴퓨터의 폴더. 변수의 지도는 시간 등 다른 차원에서 선택한 단계에 대해 읽습니다. +A Zarr variable's values through time at points clicked on the map. 지도에서 클릭한 포인트에서 Zarr 변수 값의 시간에 따른 변화. +A2 landscape sheet A2 가로 용지 +A2 portrait sheet A2 세로 용지 +A3 landscape sheet A3 가로 용지 +A3 portrait sheet A3 세로 용지 +A4 landscape sheet A4 가로 용지 +A4 portrait sheet A4 세로 용지 +A5 Grid A5 격자 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. 지정한 해상도의 A5 등면적 오각형 셀을 지도 범위, 레이어 또는 전 세계에 걸쳐 ID가 있는 폴리곤으로 만듭니다. About 정보 About plugins 플러그인 정보 About RustGIS RustGIS 정보 @@ -65,6 +83,7 @@ Add 추가 Add\nData 데이터\n추가 Add ({n}) 추가({n}) Add a bar chart of a layer's numeric field. 레이어의 숫자 필드로 막대 차트를 추가합니다. +Add a circle. 원을 추가합니다. Add a colorbar of a raster layer's colour scale in the map frame. 맵 프레임에 있는 래스터 레이어 색상 척도의 컬러바를 추가합니다. Add a description. 설명을 추가합니다. Add a field. 필드를 추가합니다. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. 지도를 인쇄하고 내 Add a page to the presentation. 프레젠테이션에 페이지를 추가합니다. Add a PNG or JPEG picture to the page. 페이지에 PNG 또는 JPEG 그림을 추가합니다. Add a presentation file. 프레젠테이션 파일을 추가합니다. +Add a rectangle, to frame or highlight part of the page. 페이지의 일부를 둘러싸거나 강조하는 사각형을 추가합니다. Add a report file, or a report template for the selected layer. 보고서 파일 또는 선택한 레이어의 보고서 템플릿을 추가합니다. Add a scale bar for the map frame. 맵 프레임용 축척 막대를 추가합니다. add a second map first (Data › New Map) 먼저 두 번째 지도를 추가하세요(데이터 › 새 지도) Add a second map frame first 먼저 두 번째 맵 프레임을 추가하세요 +Add a straight line. 직선을 추가합니다. Add a text element. 텍스트 요소를 추가합니다. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. 웹 배경지도를 다른 배경지도를 바꾸지 않고 그 위에 겹쳐 혼합합니다. 표시 중인 배경지도를 클릭하면 제거됩니다. +Add a Zarr variable first (Add Data › Zarr…). 먼저 Zarr 변수를 추가하세요(데이터 추가 › Zarr…). Add Administrative Boundaries 행정 경계 추가 +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. 화살표를 추가합니다. 위쪽의 둥근 핸들로 회전하거나 속성에서 각도를 설정하세요. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. NASA GeoTIFF를 지도에 추가하려면 Earthdata Login 토큰(위의 Earthdata Login)을 추가하세요. Add an elevation raster first. 먼저 고도 래스터를 추가하세요. Add an elevation raster to the map first 먼저 지도에 고도 래스터를 추가하세요 +Add an ellipse. 타원을 추가합니다. Add an empty layer with a ready-made schema, to draw into. 그리기용으로 미리 만든 스키마를 가진 빈 레이어를 추가합니다. Add an input for each layer the model reads. 모델이 읽는 레이어마다 입력을 추가합니다. Add an output to keep a result under a name. 결과를 이름으로 유지하려면 출력을 추가합니다. Add and Open 추가 및 열기 Add and Open Notebook 노트북 추가 및 열기 Add Archive 아카이브 추가 +Add as Time Series 시계열로 추가 add at least two keyframes 키프레임을 두 개 이상 추가하세요 Add Cell 셀 추가 Add Color Ramp File… 색상 램프 파일 추가… @@ -132,6 +157,7 @@ Add Graphics Layer 그래픽 레이어 추가 Add Input 입력 추가 Add Job 작업 추가 Add Keyframe 키프레임 추가 +Add Label Class 라벨 클래스 추가 Add Layer File 레이어 파일 추가 Add Open Data 오픈 데이터 추가 Add Output 출력 추가 @@ -147,9 +173,11 @@ Add Rule 규칙 추가 Add sample countries 샘플 국가 추가 Add Sample Data 샘플 데이터 추가 Add Satellite Embeddings 위성 임베딩 추가 +Add Selected ({n}) 선택 항목 추가({n}) Add Service Layer 서비스 레이어 추가 Add skill… 스킬 추가… Add STAC Asset STAC 에셋 추가 +Add STAC Data Cube STAC 데이터 큐브 추가 Add STAC Footprints STAC 풋프린트 추가 Add Step 단계 추가 Add Stroke 선 추가 @@ -171,6 +199,7 @@ Add to Favourites 즐겨찾기에 추가 Add to Map 지도에 추가 Add vector data. 벡터 데이터를 추가합니다. Add view 뷰 추가 +Add With Options… 옵션을 지정해 추가… Add Zarr Variable Zarr 변수 추가 Add Zarr Variable… Zarr 변수 추가… Add ▾ 추가 ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay 에이전트에 전체 액세스 권한이 있습니다: 스크립트, 태스크, 모델, 플러그인을 실행할 수 있습니다. 클릭하면 MCP 릴레이를 중지합니다 Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. 연결된 이 컴퓨터의 에이전트(Claude Code, Codex, Cursor 등)는 RustGIS의 도구로 여기 열린 지도에서 작업하며, 파일을 열고 저장하고 웹을 사용할 수 있습니다. 스크립트와 플러그인 관리는 계속 꺼져 있습니다. Agents then run commands, change files and use the web on this computer without asking you. 에이전트가 묻지 않고 이 컴퓨터에서 명령을 실행하고, 파일을 변경하고, 웹을 사용합니다. +Aggregate Points to A5 포인트를 A5로 집계 Aggregation 집계 Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Fields of the World 모델이 Sentinel-2 영상에서 찾은 전 세계 농지 경계, 연도별(제2판, CC BY 4.0). 이는 모델 예측이며 측량된 필지가 아닙니다. AI AI @@ -221,15 +251,18 @@ All steps done. 모든 단계를 완료했습니다. All tables with geometry 지오메트리가 있는 모든 테이블 Allow 허용 Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. 로그인할 때 ChatGPT 요금제 사용을 허용하세요. 워크스페이스에서 허용하지 않으면 관리자에게 문의하거나 API 키를 사용하는 제공자를 선택하세요. +Allow Feature Selection 피처 선택 허용 Allow only certain combinations of values across fields. 필드 간 특정 값 조합만 허용합니다. Allow such actions for this conversation 이 대화에서 이러한 작업 허용 Allow this action? 이 작업을 허용하시겠습니까? Allow this agent's RustGIS action? 이 에이전트의 RustGIS 작업을 허용하시겠습니까? Along Selected Line 선택한 라인을 따라 +Alt+ Alt+ Alternating bar 교대 막대 Altitude 고도 Always allow 항상 허용 -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3 또는 엔드포인트로 지정하는 S3 호환 서비스(MinIO, Cloudflare R2, Wasabi…). 키는 RustGIS를 닫을 때까지 보관됩니다. 기억된 연결은 이름, 엔드포인트, 리전을 저장하며 비밀 키는 절대 저장하지 않습니다. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3 또는 엔드포인트로 지정하는 S3 호환 서비스(MinIO, Cloudflare R2, Wasabi…). 키는 RustGIS를 닫을 때까지 유지됩니다. 저장된 연결에는 이름, 엔드포인트, 리전이 유지되며 비밀 정보는 유지되지 않습니다. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: 이 컴퓨터의 에이전트가 다음을 요청합니다: An AWS profile AWS 프로필 @@ -248,6 +281,7 @@ API key API 키 Apply 적용 Apply a function to a raster 래스터에 함수 적용 Apply Control Points 기준점 적용 +Apply elevation 고도 적용 Apply Style 스타일 적용 Apply to layers 레이어에 적용 Apply to the selected layer 선택한 레이어에 적용 @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° 사용 범위: {lon}°, {lat}°에 Area {area} · Perimeter {perimeter} 면적 {area} · 둘레 {perimeter} Areas (name, land use, area) 영역(이름, 토지 이용, 면적) Arrange 정렬 +Arrow 화살표 +Arrowheads 화살촉 Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. 데이터에 대해 질문하거나 지도로 할 일을 설명하세요. 어시스턴트가 RustGIS 명령을 대신 실행하며, 모든 변경은 실행 취소할 수 있습니다. Ask about the map, or tell it what to do… 지도에 대해 묻거나 할 일을 알려 주세요… Ask about your data, or tell the assistant what to do with the map. 데이터에 대해 묻거나 지도로 할 일을 어시스턴트에게 알려 주세요. @@ -275,12 +311,13 @@ Asking NASA's CMR… NASA CMR에 문의하는 중… Asking the catalog… 카탈로그에 요청하는 중… Asking the NLDI… NLDI에 문의하는 중… Assistant settings 어시스턴트 설정 +At the end 끝 +At the start 시작 Atlas 아틀라스 Attachments 첨부 파일 Attribute\nTable 속성\n테이블 Attribute Rules 속성 규칙 Attribute Table 속성 테이블 -Attribute table 속성 테이블 Attributes 속성 Attribution 저작자 표시 Aurora 오로라 @@ -295,11 +332,9 @@ Automatic (system language) 자동(시스템 언어) Automatic (system time zone and region) 자동(시스템 시간대 및 지역) Automatic ({kind}) 자동({kind}) Avoid 회피 -AWS AWS AWS, anonymous AWS, 익명 Azimuth 방위각 Back 뒤로 -Back to Explore; cancel a sketch 탐색으로 돌아가기; 스케치 취소 Back to the Map 지도로 돌아가기 Back to the map 지도로 돌아가기 Back to tools 도구로 돌아가기 @@ -314,8 +349,6 @@ Basemap 배경지도 Basemaps 배경지도 Basin from hydrolocation 수문 위치에서 유역 구하기 Bearing 방위 -Bearing {b}° · Pitch {p}° 방위 {b}° · 기울기 {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. 방위: 지도 위쪽이 향하는 방향으로, 북쪽에서 시계 방향으로 잽니다. 기울기: 바로 위에서 내려다보는 상태에서 기울어진 정도입니다. 클릭하면 둘 다 재설정합니다. Before the event 이벤트 이전 Below 아래 Below 1:5,000,000 show everything; beyond it only what matches: 1:5,000,000보다 크면 모두 표시하고, 그보다 작으면 일치하는 항목만 표시: @@ -328,10 +361,12 @@ Bilinear 쌍선형 Bind To Time Slider 시간 슬라이더에 연결 Bind to Time Slider 시간 슬라이더에 연결 Blank 빈 지도 +Blank for Amazon S3 Amazon S3이면 비워 두기 Blank Letter page 빈 Letter 페이지 Blank Map 빈 지도 Blank Page 빈 페이지 Blend: {mode} 혼합: {mode} +Block Feature Selection 피처 선택 차단 Blue 파랑 Bold 굵게 Bold label text. 라벨 텍스트를 굵게 표시합니다. @@ -339,6 +374,7 @@ Bookmark Description 북마크 설명 Bookmarks 북마크 Bookmarks Pane 북마크 창 Boolean 불리언 +Both ends 양쪽 끝 Bottom 아래쪽 Boundaries… 경계… Bounding\nGeometry 경계\n지오메트리 @@ -346,6 +382,7 @@ Brightness 밝기 Brightness, contrast, saturation and sepia for the whole map. 지도 전체의 밝기, 대비, 채도 및 세피아. Bring this bookmark's map into view to update it 업데이트하려면 이 북마크의 지도를 표시하세요 Browse 둘러보기 +Browse Connection 연결 둘러보기 Browse it in the Browser 브라우저에서 탐색 Browse Plugins 플러그인 둘러보기 Browse the geoprocessing toolbox. 지오프로세싱 도구 상자를 탐색합니다. @@ -357,6 +394,7 @@ Browser 브라우저 Browser\nPane 브라우저\n창 Browser View 브라우저 뷰 Bucket 버킷 +Buckets 버킷 Buffer 버퍼 Buffer A Point 포인트 버퍼 Buffer the cities by 200 km 도시 주변에 200km 버퍼 만들기 @@ -381,7 +419,9 @@ Calculate Field 필드 계산 Calculate field 필드 계산 Calculate field values. 필드 값을 계산합니다. Calculate fields and enforce conditions on every edit. 편집할 때마다 필드를 계산하고 조건을 적용합니다. +Calculate Geometry 지오메트리 계산 Calculate Geometry Attributes 지오메트리 속성 계산 +Calculated {rows} features 피처 {rows}개를 계산했습니다 Calculation 계산 Camera 카메라 Camera Effect 카메라 효과 @@ -396,12 +436,14 @@ Case Field 케이스 필드 Cast Shadow 그림자 드리우기 Catalog 카탈로그 catalog is a static STAC catalog's catalog.json or collection.json catalog는 정적 STAC 카탈로그의 catalog.json 또는 collection.json입니다 +Catalogue (CSW) 카탈로그(CSW) Catalogue search (CSW) 카탈로그 검색(CSW) Celestial Bodies 천체 Celestial Body 천체 Celestial body: {body} 천체: {body} Cell size 셀 크기 cell size ° 셀 크기 ° +Cells 셀 Center 중심 Center Map Here 여기를 지도 중심으로 Centered 가운데 @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. 이 계정 또는 ChatGPT sign-in failed. Check the connection and local settings, then try again. ChatGPT 로그인에 실패했습니다. 연결과 로컬 설정을 확인한 후 다시 시도하세요. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. ChatGPT 사용 정보를 일시적으로 확인할 수 없습니다. 로그인 상태를 유지하고 나중에 다시 시도하세요. CHE CHE +Check 확인 Check Again 다시 확인 Check for\nUpdates 업데이트\n확인 Check for Updates 업데이트 확인 @@ -428,6 +471,7 @@ Check for Updates… 업데이트 확인… Check the address and the service type. 주소와 서비스 유형을 확인하세요. Check the address and your connection. 주소와 연결을 확인하세요. Check the address. 주소를 확인하세요. +Check the clause, and count the features it takes. 절을 확인하고 해당하는 피처 수를 셉니다. Check the integration's client, permissions, workspace policy and route configuration. 통합의 클라이언트, 권한, 워크스페이스 정책 및 경로 구성을 확인하세요. Checking for updates… 업데이트 확인 중… China 중국 @@ -455,6 +499,7 @@ Choose which layers a click identifies. 클릭으로 식별할 레이어를 선 Choose which tabs the ribbon shows. 리본에 표시할 탭을 선택합니다. Circle 원 Circle Radius (km) 원 반경(km) +Class 클래스 Class color 클래스 색상 Class Style 클래스 스타일 Classes 클래스 @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line 클릭하여 버텍스 Click to edit the description 클릭하여 설명 편집 Click to place a point graphic. 클릭하여 포인트 그래픽을 배치합니다. Click to select the node 클릭하여 노드 선택 +Click to select, double-click to add, right-click for more 클릭하면 선택, 두 번 클릭하면 추가, 마우스 오른쪽 버튼을 클릭하면 추가 작업 Click to switch between decimal degrees and DMS 클릭하여 십진수 도와 도분초(DMS) 간 전환 Clip 자르기 Clip a raster to the view, or resample it. 래스터를 뷰에 맞춰 자르거나 리샘플링합니다. @@ -532,7 +578,7 @@ Close thread 스레드 닫기 Close {name} {name} 닫기 Closest 가장 가까움 Coded values, one per line: code=description 코드 값, 한 줄에 하나씩: code=설명 -Collapse or expand the ribbon 리본 축소 또는 확장 +Collapse or Expand the Ribbon 리본 축소 또는 확장 Collapse the ribbon (Ctrl+F1) 리본 축소 (Ctrl+F1) Collection 컬렉션 Collection {id}: choose dates and Find Items 컬렉션 {id}: 날짜를 선택하고 항목 찾기 @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line 위 상자에 있는 값의 조합, 한 줄에 하나씩 COMID {comid} COMID {comid} Comma 쉼표 +Comma-separated (optional) 쉼표로 구분(선택 사항) Command\nSearch 명령\n검색 command is required command가 필요합니다 Command Search 명령 검색 @@ -572,10 +619,13 @@ Connect 연결 Connect a folder to the Browser. 브라우저에 폴더를 연결합니다. Connect to S3 S3에 연결 Connect, or pick a layer 연결하거나 레이어 선택 +Connected to {kind} {title} {kind} {title}에 연결했습니다 Connecting to {url}… {url}에 연결하는 중… Connection 연결 +connection.new returned no id connection.new가 ID를 반환하지 않았습니다 Connections 연결 -Connect… 연결… +Connections you add are listed here 추가한 연결이 여기에 표시됩니다 +Connections… 연결… Constant 상수 Constant height 일정 높이 Constant… 상수… @@ -583,11 +633,14 @@ Contingent Values 조건부 값 Continue 계속 Continue with ChatGPT ChatGPT로 계속 Contrast 대비 +Control Point Fit 기준점 맞춤 Conversion 변환 Convert 변환 Convert a layer to another format 레이어를 다른 형식으로 변환 Convert the selected layer's labels into an annotation layer. 선택한 레이어의 라벨을 주석 레이어로 변환합니다. +Convert this layer's labels into an annotation layer. 이 레이어의 라벨을 주석 레이어로 변환합니다. Convert To Annotation 주석으로 변환 +Convert to Annotation 주석으로 변환 Converted data 변환된 데이터 Convert… 변환… Coordinate 좌표 @@ -599,11 +652,15 @@ Copied the map to the clipboard 지도를 클립보드에 복사했습니다 Copied {path} {path} 복사됨 Copies the (selected) features of a layer to a new layer. 레이어의 (선택한) 피처를 새 레이어로 복사합니다. Copy 복사 +Copy / Cut / Paste features 피처 복사/잘라내기/붙여넣기 Copy a model.run command that runs this model, for the Script pane or Python 이 모델을 실행하는 model.run 명령을 복사합니다(스크립트 창 또는 Python용) Copy Address 주소 복사 Copy address 주소 복사 Copy an image of the map to the clipboard. 지도 이미지를 클립보드에 복사합니다. +Copy as CLI Command CLI 명령으로 복사 +Copy as Command 명령으로 복사 Copy As GeoJSON GeoJSON으로 복사 +Copy as Python Python으로 복사 Copy Command 명령 복사 Copy coordinates to the clipboard 좌표를 클립보드에 복사 Copy details 세부 정보 복사 @@ -611,11 +668,13 @@ Copy Group 그룹 복사 Copy Layer 레이어 복사 Copy Layers 레이어 복사 Copy Path 경로 복사 +Copy Run As Code 실행을 코드로 복사 Copy sign-in link 로그인 링크 복사 Copy the selected features (also as GeoJSON text). 선택한 피처를 복사합니다(GeoJSON 텍스트로도). Copy the selected features and delete them. 선택한 피처를 복사하고 삭제합니다. Copy the selected layer's data source path. 선택한 레이어의 데이터 원본 경로를 복사합니다. Copy this element (Ctrl+D, ⌘D on a Mac) 이 요소를 복제합니다(Ctrl+D, Mac에서는 ⌘D) +Copy this run as code, or open it in its tool 이 실행을 코드로 복사하거나 해당 도구에서 엽니다 Corrections 보정 Cost 비용 could not draw the map 지도를 그릴 수 없습니다 @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} 플러그인 레지스트리를 불 Count, sum, mean, min, max and standard deviation of a field, optionally per case field. 필드의 개수, 합계, 평균, 최소값, 최대값 및 표준 편차를 계산하며, 선택적으로 케이스 필드별로 계산합니다. Countries, cities, rivers and lakes 국가, 도시, 하천 및 호수 Country 국가 +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. 레이어의 포인트를 포함하는 A5 셀별로 개수를 세고(셀은 면적이 같으므로 개수가 곧 밀도입니다), 필드의 합계와 평균도 구합니다. Counts the points that fall inside each polygon. 각 폴리곤 안에 있는 포인트 수를 셉니다. Counts, totals and top categories of what's in view. 보기 내 항목의 개수, 합계 및 상위 범주. CPU CPU @@ -647,12 +707,8 @@ Cross Section 횡단면 Cross Section (2 clicks) 횡단면(2회 클릭) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative 누적 Current date 현재 날짜 Current Project 현재 프로젝트 @@ -680,13 +736,16 @@ Data type 데이터 유형 Data you add is listed here 추가한 데이터가 여기에 표시됩니다 data.gov and CKAN catalogues data.gov 및 CKAN 카탈로그 Database 데이터베이스 -Databases 데이터베이스 +Database connections are used by the desktop app 데이터베이스 연결은 데스크톱 앱에서 사용됩니다 +databases are read through DuckDB, a program the browser can't run 데이터베이스는 DuckDB를 통해 읽는데, 브라우저에서는 DuckDB를 실행할 수 없습니다 Database… 데이터베이스… Dataset 데이터 세트 Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Hugging Face의 데이터 세트를 이름 또는 소유자/이름으로 찾고, 데이터 세트의 파일을 둘러보며 GeoTIFF, GeoParquet, PMTiles, LiDAR 등의 파일을 추가합니다. Data… 데이터… Date 날짜 Dates 날짜 +Dates a cell needs 셀에 필요한 날짜 수 +Dates a cell needs is a whole number, or empty for half 셀에 필요한 날짜 수는 정수이며, 절반으로 하려면 비워 둡니다 Day 일 Day and night shading for a time. 특정 시각의 낮과 밤 음영. Decimal 십진수 @@ -715,7 +774,6 @@ Delete Notebook 노트북 삭제 Delete Page 페이지 삭제 Delete Presentation 프레젠테이션 삭제 Delete Report 보고서 삭제 -Delete selected features 선택한 피처 삭제 Delete selected rows 선택한 행 삭제 Delete Style 스타일 삭제 Delete Task 태스크 삭제 @@ -736,11 +794,15 @@ Desktop and command line only 데스크톱 앱과 명령줄 전용 Details 세부 정보 Details of the selected layer. 선택한 레이어의 세부 정보입니다. Deviations 표준편차 수 +DGGS DGGS Diagnostic\nMonitor 진단\n모니터 Diagnostic Monitor 진단 모니터 Diagnostics 진단 정보 +Diagonal ↗ 대각선 ↗ +Diagonal ↘ 대각선 ↘ Diamond 마름모 Difference, ratio or percent change between two rasters. 두 래스터 간의 차이, 비율 또는 변화율. +Digit Digit Directions 길찾기 Directions From Here 여기에서 출발 Directions To Here 여기로 도착 @@ -758,6 +820,7 @@ Dissolve Field 디졸브 필드 Distance 거리 Distance (km) 거리(km) Distance Coefficient P 거리 계수 P +Distance units 거리 단위 Distances along the surface, heights, areas and volumes. 표면을 따른 거리, 높이, 면적 및 부피. DISTINCT DISTINCT Distinct 고유 @@ -804,18 +867,23 @@ Drag to draw 드래그하여 그리기 Drag to draw a freehand line. 드래그하여 자유곡선 라인을 그립니다. Drag to draw a freehand polygon. 드래그하여 자유곡선 폴리곤을 그립니다. Drag to draw a rectangle. 드래그하여 사각형을 그립니다. +Drag to resize the message box 드래그하여 메시지 상자 크기 조정 Drag tools from the palette onto the canvas. 팔레트에서 캔버스로 도구를 끌어 놓으세요. Drag vertices on the map; click an edge to add one 지도에서 버텍스 드래그, 가장자리를 클릭하여 버텍스 추가 Draw a halo around label text. 라벨 텍스트 주위에 후광을 그립니다. Draw a labelled latitude/longitude grid over the map frame. 맵 프레임 위에 라벨이 있는 위도/경도 격자를 그립니다. Draw a model: chain tools from input layers to the results to keep 모델 그리기: 입력 레이어부터 유지할 결과까지 도구를 연결합니다 Draw a model: chain tools from input layers to the results to keep. 모델 그리기: 입력 레이어부터 유지할 결과까지 도구를 연결합니다. +Draw at its place in the stack, the bottom stroke first 쌓인 순서대로, 아래쪽 선부터 그립니다 +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet 모든 피처의 선을 레벨별로 그려, 라인이 만나는 곳에서 외곽선이 중심선 아래에서 이어지도록 합니다 Draw labels at every scale and distance. 모든 축척과 거리에서 라벨을 그립니다. Draw new features in the selected layer. Double-click to finish lines and polygons. 선택한 레이어에 새 피처를 그립니다. 라인과 폴리곤은 더블클릭하여 완료합니다. Draw new single-band rasters with the inverted grey stretch again. 새 단일 밴드 래스터를 다시 반전 회색조 스트레치로 그립니다. +Draw only polygon outlines, keeping their colors and widths. 색상과 두께를 유지한 채 폴리곤 외곽선만 그립니다. Draw points as bins coloured by how many fall in each. 포인트를 각 칸에 들어가는 개수에 따라 색칠한 빈(bin)으로 그립니다. Draw polygons as outlines only. 폴리곤을 외곽선만 그립니다. Draw single-band rasters added from now on with this color scheme, stretch and resampling. 앞으로 추가하는 단일 밴드 래스터를 이 색상표, 스트레치, 리샘플링으로 그립니다. +Draw strokes in levels 선을 레벨별로 그리기 Draw the layer at every scale. 모든 축척에서 레이어를 그립니다. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). GPU에서 지도를 그립니다(베타; 지구본, 보기 효과, 색각 시뮬레이션은 CPU 렌더러에서 유지됩니다). Draw the selected layer above others. 선택한 레이어를 다른 레이어 위에 그립니다. @@ -828,6 +896,7 @@ drawn by the server 서버에서 그림 Drive 자동차 Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. OpenAerialMap에 공유된 드론 및 항공 영상(최신순), 클라우드 최적화 GeoTIFF로 추가됩니다. Duplicate 복제 +Duplicate Connection 연결 복제 Duplicate Element 요소 복제 Duplicate Layout 레이아웃 복제 Duplicate Map 지도 복제 @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name 예: pop_est / 1000000 e.g. 0, 255, -9999 예: 0, 255, -9999 e.g. continent = 'Europe' 예: continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' 예: pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 예: pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs 각 지오프로세싱 실행이 하나의 단계가 되어 출력으로 연결됩니다 Earth Index has no points in the view Earth Index에 뷰 안의 포인트가 없습니다 Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices 버텍스\n편집 Edit and run the notebook. 노트북을 편집하고 실행합니다. Edit Attribute 속성 편집 Edit Cell 셀 편집 +Edit Connection 연결 편집 Edit Description 설명 편집 Edit Graphic 그래픽 편집 Edit Status 편집 상태 @@ -882,6 +953,7 @@ Editor\nTools 편집기\n도구 Editor Tools 편집기 도구 Edits since the project was saved. 프로젝트를 저장한 이후의 편집 내용입니다. Edits the project 프로젝트 편집 +Edit… 편집… Effects 효과 Element 요소 Element Properties 요소 속성 @@ -898,21 +970,25 @@ Enable Lighting 조명 사용 Enable Plugin 플러그인 사용 Enabled 사용 Encloses each feature, or all features, in its convex hull. 각 피처 또는 모든 피처를 볼록 껍질로 둘러쌉니다. +Encoded polyline 인코딩된 폴리라인 End 끝 End date 종료 날짜 Endpoint 엔드포인트 Enhancement 향상 Enter Enter Enter an API key. API 키를 입력하세요. +Enter an EPSG code as a number, such as 32650 EPSG 코드를 숫자로 입력하세요(예: 32650) Enter the provider's address. 공급자의 주소를 입력하세요. Enter to send, Shift+Enter for a new line Enter로 보내기, Shift+Enter로 줄 바꿈 EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments 환경 EPSG EPSG +EPSG code EPSG 코드 EPSG or ESRI code EPSG 또는 ESRI 코드 EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth 이퀄 어스 도법 Equal Interval 등간격 Equirectangular (Plate Carrée) 정거원통도법 (Plate Carrée) @@ -922,14 +998,18 @@ Error 오류 Error Inspector 오류 검사기 Error Inspector (validate) 오류 검사기(검증) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) 유럽 정적도법 (ETRS89 LAEA) Every band's value at a pixel. 픽셀의 모든 밴드 값. Every editing tool. 모든 편집 도구. +Every label setting of this layer, in the Labeling pane. 이 레이어의 모든 라벨 설정(라벨링 창). Every layer, as FEMA draws them FEMA가 그리는 대로 모든 레이어 +Every run, in order, as a script that opens the project 모든 실행을 순서대로, 프로젝트를 여는 스크립트로 출력 every Socrata site, or one such as data.cityofnewyork.us 모든 Socrata 사이트 또는 data.cityofnewyork.us 같은 특정 사이트 Everywhere 모든 곳 +Existing field 기존 필드 Existing Template 기존 템플릿 exit rotary 로터리 진출 exit roundabout 회전교차로 진출 @@ -948,6 +1028,7 @@ Export\nRaster 래스터\n내보내기 Export Animation 애니메이션 내보내기 Export Bookmarks 북마크 내보내기 Export Bookmarks… 북마크 내보내기… +Export CSV… CSV 내보내기… Export Features 피처 내보내기 Export Features (tool) 피처 내보내기(도구) Export Features needs a vector layer (Data › Export Raster writes a raster) 피처 내보내기에는 벡터 레이어가 필요합니다(래스터는 데이터 › 래스터 내보내기로 저장합니다) @@ -996,6 +1077,7 @@ Face Culling 면 컬링 Faces 면 Factor 계수 Failed 실패 +FALSE FALSE Farthest 가장 멂 Faster drawing: no antialiasing. 더 빠른 그리기: 안티앨리어싱 없음. Favourite Data Source 즐겨찾는 데이터 원본 @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries 농지 경계 Field density 농지 밀도 Field name 필드 이름 +Field Prefix 필드 접두사 +Field to Sum and Average 합계와 평균을 구할 필드 Fields 필드 Fields are drawn from zoom 9: zoom in to see them. 농지는 줌 9부터 그려집니다: 확대하여 확인하세요. Fields colored by score, drawn from zoom 9 점수로 색칠된 농지, 줌 9부터 그려짐 @@ -1079,6 +1163,8 @@ Folder Connections 폴더 연결 folder connections need the desktop app 폴더 연결을 사용하려면 데스크톱 앱이 필요합니다 Folders 폴더 Folder… 폴더… +Follow the time slider 시간 슬라이더 따르기 +Font 글꼴 for a store that doesn't say 지정하지 않은 저장소의 경우 Forest 숲 Forget Earthdata Login Token Earthdata Login 토큰 삭제 @@ -1090,9 +1176,11 @@ France 프랑스 Free the prepared geometry. 준비된 지오메트리를 해제합니다. Freehand 자유곡선 FROM FROM +From 시작 From Selection 선택에서 From Service… 서비스에서… From symbol 심볼에서 +From the address 주소에서 from the address 주소에서 from the service 서비스에서 From URL… URL에서… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. 뷰 중심에서 멀리 있는 피처 Hide Group 그룹 숨기기 Hide labels when the viewer is closer than this. 보는 위치가 이보다 가까우면 라벨을 숨깁니다. Hide labels when the viewer is farther than this. 보는 위치가 이보다 멀면 라벨을 숨깁니다. +Hide Layers 레이어 숨기기 Hide other values 기타 값 숨기기 Hide Steps 단계 숨기기 Hide the description 설명 숨기기 @@ -1213,6 +1302,8 @@ Hold 보류 HOME HOME Home 홈 Home › Add 홈 › 추가 +Home › Add Data 홈 › 데이터 추가 +Home › Inspect › Measure 홈 › 검사 › 측정 Home › Navigate 홈 › 탐색 Home › Navigate › Bookmarks 홈 › 탐색 › 북마크 Home › Web Data 홈 › 웹 데이터 @@ -1220,7 +1311,7 @@ Home › Web Data › United States 홈 › 웹 데이터 › 미국 Homepage 홈페이지 Horizontal 가로 host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=… 또는 .duckdb 파일 +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery 호스팅 이미지 Hour 시 How the layer's colours combine with the tile layers and basemap beneath. 레이어의 색상이 아래의 타일 레이어 및 배경지도와 혼합되는 방식. @@ -1248,6 +1339,7 @@ Imagery Sharing 영상 공유 Imagery Tools 영상 도구 ImageServer ImageServer Image… 이미지… +Imperial 야드파운드법 Import 가져오기 Import\nProject 프로젝트\n가져오기 Import All 모두 가져오기 @@ -1267,6 +1359,7 @@ Import Task 태스크 가져오기 Import Task… 태스크 가져오기… Import Thumbnail 썸네일 가져오기 Import… 가져오기… +IN IN In Beyond 다음보다 확대 시 In memory 메모리 내 In Progress 진행 중 @@ -1281,6 +1374,8 @@ Information 정보 INPUT 입력 Input 입력 Input Features 입력 피처 +Input Layer 입력 레이어 +Input Points 입력 포인트 Input Polygons 입력 폴리곤 Input Rows 입력 행 Input Summary Features 입력 요약 피처 @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} 설치된 플러그인 위치: {folder} Installed with Homebrew, winget, the AUR or COPR? Update it there. Homebrew, winget, AUR 또는 COPR로 설치했다면 그곳에서 업데이트하세요. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version}을(를) 설치했습니다. 플러그인 탭에서 찾을 수 있으며, 도구는 도구 창에도 있습니다. Installing it downloads Python {version} and the packages it needs from the internet. 설치하면 인터넷에서 Python {version}과 필요한 패키지를 내려받습니다. +Integer field: values are rounded. 정수 필드: 값이 반올림됩니다. Interface font: {font} 인터페이스 글꼴: {font} Interpolate a surface from points (IDW) 포인트로 표면 보간(IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. 정규 크리깅을 사용해 포인트 피처에서 2밴드 래스터 표면(예측값과 분산)을 보간합니다. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. QGIS와 같은 방식으로 역거리 가중법을 사용해 피처 값에서 래스터 표면을 보간합니다. Interpolation 보간 Interpolation Attribute 보간 속성 @@ -1316,10 +1413,13 @@ Invalid: {error} 유효하지 않음: {error} Invert 반전 Invert color scheme 색상표 반전 Invert the selection. 선택을 반전합니다. +IS IS IS NULL IS NULL it connects to an address on the internet 인터넷의 주소에 연결하는 It has no code: nothing in it runs. 코드가 없습니다. 실행되는 것은 없습니다. +It is locked, or one of its surrounds is: unlock it first 이 요소 또는 주변 요소 중 하나가 잠겨 있습니다. 먼저 잠금을 해제하세요 it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) 사용자만 실행할 수 있는(플러그인, 스크립트, 작업, 모델, GeoLibre 실행기) +it needs input: open it from its pane or button, or run it from a script or the assistant 입력이 필요합니다. 해당 창이나 버튼에서 열거나 스크립트 또는 어시스턴트에서 실행하세요 it reads or writes files on this computer 이 컴퓨터의 파일을 읽거나 쓰는 it replaces the open project 열려 있는 프로젝트를 바꾸는 it runs a plugin's command 플러그인의 명령을 실행하는 @@ -1332,20 +1432,30 @@ Italic 기울임꼴 Item Pop-ups 항목 팝업 Item {id} 항목 {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. 방화벽({firewall})이 RustGIS를 차단했습니다. 이 서비스는 일부 애플리케이션만 허용할 수 있습니다: 관리자에게 연락하여 아래 세부 정보를 보내세요. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) 해당 도구를 지금 사용할 수 없습니다(플러그인이 로드되지 않았거나 도구 모음이 설치되지 않음) Jobs 작업 JOIN JOIN Join 조인 +Join Features 조인 피처 +Join Operation 조인 작업 +Joins attributes from a join layer to a target layer based on their spatial relationship. 공간 관계를 기준으로 조인 레이어의 속성을 대상 레이어에 조인합니다. +Journal figure, 1 column (90 mm) 학술지 그림, 1단(90 mm) +Journal figure, 1.5 columns (140 mm) 학술지 그림, 1.5단(140 mm) +Journal figure, 2 columns (190 mm) 학술지 그림, 2단(190 mm) Keep a history of every edit to the layer, to look back or restore. 레이어의 모든 편집 기록을 보관하여 되돌아보거나 복원합니다. Keep an archive of edits 편집 아카이브 보관 keep current 현재 값 유지 +Keep it from being moved, resized, deleted or edited by mistake 실수로 이동, 크기 조정, 삭제 또는 편집되지 않도록 합니다 Keep one value of each point's vector as its value field 각 포인트 벡터의 값 하나를 값 필드로 유지 Keep Result 결과 유지 Keep the labelled features fixed as the map moves. 지도가 움직여도 라벨이 표시된 피처를 고정합니다. Keep the last drawing tool active after each graphic. 그래픽을 그린 후에도 마지막 그리기 도구를 활성 상태로 유지합니다. Keep the last tool active 마지막 도구 유지 +Keep this endpoint under Data Sources › Connections, without its password or keys 이 엔드포인트를 비밀번호나 키 없이 데이터 원본 › 연결에 보관합니다 Keep this result 이 결과 유지 Keep Tool 도구 유지 Keep value 값 유지 +Key Key Keyboard Shortcuts 키보드 단축키 Keyboard shortcuts. 키보드 단축키. Keys from 키 출처 @@ -1356,13 +1466,20 @@ KMZ KMZ Label 라벨 Label Class 라벨 클래스 Label Distance Range 라벨 거리 범위 +Label features in this layer 이 레이어의 피처에 라벨 표시 Label Field 라벨 필드 +Label only the features that match. 일치하는 피처에만 라벨을 표시합니다. Label Placement 라벨 배치 Label Range 라벨 범위 Label the cities with their names 도시에 이름 라벨 표시 Label Visibility Range 라벨 가시 범위 Labeling 라벨링 +Labeling Pane 라벨링 창 +Labeling Properties… 라벨링 속성… Labels 라벨 +labels are drawn for feature layers, not rasters 라벨은 래스터가 아닌 피처 레이어에 그려집니다 +Labels are drawn for feature layers, not rasters. 라벨은 래스터가 아닌 피처 레이어에 그려집니다. +Labels are off for this layer: the clause applies once they are on. 이 레이어의 라벨이 꺼져 있습니다. 라벨을 켜면 절이 적용됩니다. Lagoon 라군 Lambert Conformal Conic 램버트 정각 원추도법 Lambert Cylindrical Equal Area 램버트 원통 정적도법 @@ -1383,12 +1500,14 @@ Layer Blend 레이어 혼합 Layer Extrusion 레이어 돌출 Layer File 레이어 파일 Layer From\nSelection 선택에서\n레이어 +Layer not found 레이어를 찾을 수 없습니다 Layer Template 레이어 템플릿 Layer Templates 레이어 템플릿 layer {id} 레이어 {id} Layers 레이어 Layers ({n}) 레이어({n}) Layers and Table Views 레이어 및 테이블 뷰 +Layers, a date each 레이어(각각 날짜 하나) Layout 레이아웃 Layout\nFile 레이아웃\n파일 Layout after: {name} 다음 레이아웃: {name} @@ -1402,7 +1521,7 @@ Learning\nResources 학습\n자료 Left 왼쪽 Legend 범례 Legend label. Clear it to show the value again. 범례 라벨입니다. 지우면 값이 다시 표시됩니다. -Length {length} · gain {gain} m · loss {loss} m 길이 {length} · 상승 {gain} m · 하강 {loss} m +Length {length} · gain {gain} · loss {loss} 길이 {length} · 상승 {gain} · 하강 {loss} Let the assistant hand tasks to agents on this computer 어시스턴트가 이 컴퓨터의 에이전트에게 작업을 맡기도록 허용 Let your Agents Control RustGIS 에이전트가 RustGIS를 제어하게 하기 Letter spacing 자간 @@ -1439,6 +1558,7 @@ List by drawing order 그리기 순서별 목록 List by selection 선택별 목록 List Columns in File 파일의 열 목록 List Commands 명령 목록 +List Connections 연결 목록 List Data Sources 데이터 원본 목록 List Database Columns 데이터베이스 열 목록 List Database Tables 데이터베이스 테이블 목록 @@ -1475,6 +1595,11 @@ Locate… 찾기… Location 위치 Locator 로케이터 Lock 잠금 +Locked 잠김 +Locked: unlock it in the Format pane to move or change it 잠김: 이동하거나 변경하려면 서식 창에서 잠금을 해제하세요 +Log In to Connection 연결에 로그인 +Log In to {name}… {name}에 로그인… +Log In… 로그인… Long 정수 Longitude 경도 Longitude (X) 경도(X) @@ -1493,6 +1618,7 @@ Manage 관리 Manage Bookmarks 북마크 관리 Manage Edits 편집 관리 Manage Templates 템플릿 관리 +Mann–Kendall Mann–Kendall Map 지도 Map\nFrame 맵\n프레임 Map (the current view) 지도(현재 뷰) @@ -1504,6 +1630,7 @@ Map File 지도 파일 Map Frame 맵 프레임 Map Frames 맵 프레임 Map frames per second (live while the map moves) 지도 초당 프레임 수(지도가 움직이는 동안 실시간) +Map Labeling 지도 라벨링 Map Link 지도 링크 Map name 지도 이름 Map Page 지도 페이지 @@ -1515,6 +1642,7 @@ Map style (JSON) 지도 스타일(JSON) Map Surrounds 지도 주변 요소 Map Thumbnail 지도 썸네일 Map tiles of the whole world 전 세계 지도 타일 +Map Tool: {tool} 지도 도구: {tool} Map View 지도 뷰 map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} 지도: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker 마커 Mask Values 마스크 값 Masking 마스킹 +Match Option 일치 옵션 Matching places 일치하는 장소 Max 최대 Max % 최대 % +Max Neighbors 최대 이웃 수 Maximum zoom 최대 줌 MCP Relay MCP 릴레이 MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCP 릴레이가 이미 127.0.0.1:{port}에서 대기 중입니다. 포트 {asked}를 쓰려면 먼저 중지하세요 @@ -1542,6 +1672,8 @@ Measure Distance 거리 측정 Measure geodesic distances and areas on the map. 지도에서 측지 거리와 면적을 측정합니다. Measure on this body, with one of its basemaps. 이 천체에서 해당 배경지도 중 하나로 측정합니다. Measure with an elevation raster 고도 래스터로 측정 +Measurement units 측정 단위 +Measurement units must be metric or imperial 측정 단위는 metric 또는 imperial이어야 합니다 Measurements so far. 지금까지의 측정값. Mensuration 영상 측량 Mensuration Tools 영상 측량 도구 @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. 필드 값 Messages appear here. 메시지가 여기에 표시됩니다. Meters 미터 Method 방법 +Metric 미터법 Midnight 자정 Miles 마일 Miller Cylindrical 밀러 원통도법 @@ -1560,21 +1693,43 @@ Min must be less than Max 최소는 최대보다 작아야 합니다 Min must not be more than Max 최소는 최대보다 클 수 없습니다 Minimum Bounding Geometry 최소 경계 지오메트리 Minimum Maximum 최소-최대 +Minus Minus Minutes 분 minutes ago 분 전 Miter 마이터 Mobile Map 모바일 지도 Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model 모델 Model\nBuilder 모델\n빌더 model id 모델 ID Model output 모델 출력 ModelBuilder ModelBuilder Models 모델 +Modified Mann–Kendall (Hamed & Rao) 수정 Mann–Kendall(Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) 수정 Mann–Kendall(Yue & Wang) Modify 수정 Modify Selected Features 선택한 피처 수정 Mollweide (equal-area world) 몰바이데 도법 (정적 세계) Month 월 +Months 월 +Months are numbers from 1 to 12 (12, 1, 2 for December to February) 월은 1부터 12까지의 숫자입니다(12월부터 2월까지는 12, 1, 2) More 더 보기 More basemaps: 더 많은 배경지도: Mosaic images into an orthomosaic 영상을 정사 모자이크로 합성 @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. 표시된 래스터를 정사 Most cloud cover (%) 최대 구름량(%) Mouse wheel 마우스 휠 Move 이동 -Move (m) 이동(m) Move Bookmark 북마크 이동 Move Down 아래로 이동 Move down 아래로 이동 Move Group 그룹 이동 +Move Label Class 라벨 클래스 이동 Move Task Step 태스크 단계 이동 +Move To Bottom 맨 아래로 이동 Move To Group 그룹으로 이동 +Move To Top 맨 위로 이동 Move To {group} {group}(으)로 이동 Move Up 위로 이동 Move up 위로 이동 @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets 내 버킷 My OGC Layer 내 OGC 레이어 +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name 이름 name the plugin whose panel to open: {"id": plugin id} 패널을 열 플러그인을 지정하세요: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook 새\n노트북 New\nPresentation 새\n프레젠테이션 New\nReport 새\n보고서 New\nWorkspace 새\n작업 공간 -New / Open / Save project 새 프로젝트 / 열기 / 저장 New Bookmark 새 북마크 New Bookmark… 새 북마크… +New Connection 새 연결 +New Connection ▾ 새 연결 ▾ New conversation 새 대화 New Custom Template 새 사용자 지정 템플릿 New Custom Template… 새 사용자 지정 템플릿… New features use this template's values 새 피처는 이 템플릿의 값을 사용합니다 +New field 새 필드 New Group 새 그룹 New job name 새 작업 이름 New Layout 새 레이아웃 @@ -1649,6 +1809,7 @@ New Notebook 새 노트북 New Presentation 새 프레젠테이션 New Project 새 프로젝트 New project 새 프로젝트 +New Publication Layout 새 출판용 레이아웃 New Relationship… 새 관계… New Report 새 보고서 New Selection 새 선택 @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. 아직 북마 No catalogs below this one: Find Items lists its items 이 아래에는 카탈로그가 없습니다: 항목 찾기로 항목을 나열합니다 No COMID was returned for this point. 이 포인트에 대한 COMID가 반환되지 않았습니다. no command 명령 없음 -No connections this session 이 세션의 연결이 없습니다 +No data at the points picked. 선택한 포인트에 데이터가 없습니다. No datasets match. 일치하는 데이터 세트가 없습니다. No datasets with data RustGIS can add RustGIS가 추가할 수 있는 데이터가 있는 데이터 세트가 없습니다 +no date in the name 이름에 날짜 없음 No edits since the project was saved 프로젝트를 저장한 이후 편집 내용이 없습니다 No field of this layer holds dates. 이 레이어에는 날짜를 담은 필드가 없습니다. +No Fill 채우기 없음 +No geometry to calculate 계산할 지오메트리가 없습니다 No granules in this view and date range. 이 뷰와 날짜 범위에 그래뉼이 없습니다. no home folder 홈 폴더 없음 no layer 레이어 없음 @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) 관계 없음(데이터 디자 No relationships yet 아직 관계가 없습니다 No reports yet. Publish › New Report lists the selected layer's records. 아직 보고서가 없습니다. 게시 › 새 보고서로 선택한 레이어의 레코드를 나열합니다. no row 행 없음 +No serif font is installed, so this text is drawn in the sans-serif 세리프 글꼴이 설치되어 있지 않아 이 텍스트는 산세리프로 그려집니다 no service or file links 서비스 또는 파일 링크가 없습니다 No services match your search. 검색과 일치하는 서비스가 없습니다. No standalone tables 독립 테이블 없음 @@ -1718,14 +1883,16 @@ Nominatim Nominatim None 없음 None (public buckets) 없음(공개 버킷) none, a .duckdb file, or host=… dbname=… user=… 없음, .duckdb 파일 또는 host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD 없음 또는 PGPASSWORD나 MYSQL_PWD에서 가져옴 +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD 없음 또는 PGPASSWORD, MYSQL_PWD, SQLCMDPASSWORD에서 가져옴 None. Add one by address, or from a basemap plugin. 없음. 주소로 추가하거나 배경지도 플러그인에서 추가하세요. North 북 North\nArrow 방위\n표 North Polar Stereographic 북극 평사도법 +NOT NOT not a picture 그림이 아닙니다 not a RustGIS layout RustGIS 레이아웃이 아닙니다 not a RustGIS project RustGIS 프로젝트가 아닙니다 +Not applied yet. 아직 적용되지 않았습니다. Not for texts: a text is edited in the Format pane. 텍스트에는 사용할 수 없음: 텍스트는 서식 창에서 편집합니다. not needed 필요 없음 Not sandboxed 샌드박스 밖 @@ -1739,9 +1906,12 @@ Nothing found where the map is 지도 위치에서 아무것도 찾지 못했습 Nothing here 항목이 없습니다 Nothing named {name} to rename 이름을 바꿀 {name}(이)라는 항목이 없습니다 Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. 아직 공유한 항목이 없습니다. 게시 › 다른 형식으로 공유로 웹 지도, 레이어, 스타일, 장면 등을 만듭니다. +Nothing to add here 여기에 추가할 항목이 없습니다 +Nothing to test 테스트할 항목이 없습니다 Nothing was found at this address. 이 주소에서 아무것도 찾지 못했습니다. Nothing was recorded, so no task was made. 기록된 내용이 없어 태스크를 만들지 않았습니다. Now 지금 +Nugget 너깃 NULL NULL Nulls Null 값 Observations (observer, date, species, count) 관측(관찰자, 날짜, 종, 개수) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. 데스크톱에서는 {vars} On-screen compass, zoom slider and pan pad. 화면 나침반, 확대/축소 슬라이더 및 이동 패드. One command per line: id {json}. Lines starting with # are comments. 한 줄에 명령 하나: id {json}. #으로 시작하는 줄은 주석입니다. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. DuckDB SELECT 하나: DuckDB Spatial의 ST_ 함수, 파일과 웹 주소(read_parquet('…')), 아래 데이터베이스의 테이블을 사용할 수 있습니다. Ctrl+Enter로 실행합니다. +One layer: a Zarr variable or a raster's bands 레이어 하나: Zarr 변수 또는 래스터의 밴드 One page per feature of an index layer. 인덱스 레이어의 피처마다 한 페이지. One per line: code=name 한 줄에 하나씩: code=이름 One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. 심볼에서 가져온 선 하나. 외곽선, 평행선, 오프셋을 위해 선을 추가하세요. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder 폴더 열기 Open In Geoprocessing… 지오프로세싱에서 열기… Open in ModelBuilder ModelBuilder에서 열기 +Open in Tool 도구에서 열기 Open Layout 레이아웃 열기 Open ModelBuilder ModelBuilder 열기 Open Notebook 노트북 열기 @@ -1797,6 +1969,7 @@ Open Presentation 프레젠테이션 열기 Open Project 프로젝트 열기 Open project 프로젝트 열기 Open Project (from text) 프로젝트 열기(텍스트에서) +Open Project… 프로젝트 열기… Open Report 보고서 열기 Open the attribute table of the selected layer. 선택한 레이어의 속성 테이블을 엽니다. Open the Basemaps tab 배경지도 탭 열기 @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: 또는 POSIX 셸에서 실행 Or run in PowerShell, for Claude Code or Codex: 또는 PowerShell에서 실행(Claude Code 또는 Codex용): Or run, for Claude Code or Codex: 또는 Claude Code나 Codex에서는 다음을 실행하세요: ORDER BY ORDER BY +Ordinary Kriging 정규 크리깅 Orientation 방향 Ortho Mapping 정사 매핑 Other 기타 Other Data 기타 데이터 +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. 이 레이어의 다른 라벨 클래스(명령으로 생성): {n}. 각 클래스는 자체 절과 일치하는 피처에 라벨을 표시하므로, 한 피처에 여러 클래스의 라벨이 붙을 수 있습니다. Out Beyond 다음보다 축소 시 Outline 외곽선 Outline another frame's extent in this map frame. 이 맵 프레임에 다른 프레임의 범위를 외곽선으로 표시합니다. @@ -1851,6 +2026,7 @@ Output 출력 Output Name 출력 이름 Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. 브라우저에서 실행되는 래스터, 벡터, LiDAR, 수문, 지형 도구 1,000개 이상 (최초 1회 약 25MB 다운로드). Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. 래스터, 벡터, LiDAR, 수문, 지형 도구 1,000개 이상 (약 20MB의 선택적 다운로드). +Overlapping 겹침 Overlay 중첩 overlay 중첩 Overlay (a transparent layer kept on top, such as labels or roads) 중첩(레이블이나 도로처럼 맨 위에 유지되는 투명 레이어) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is 지도 영역에 Overture {ty} 데이 Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Overture Maps의 오픈 데이터: 건물, 장소, 교통, 행정 구역, 기본 레이어, 주소. 지도 영역의 피처는 Overture의 GeoParquet 파일에서 그 자리에서 읽고(도시 이하로 확대), 전 세계는 지도 타일로 제공됩니다. Overture Maps… Overture Maps… +Overwrite existing values 기존 값 덮어쓰기 Package 패키지 Package a trained deep learning model. 학습된 딥러닝 모델을 패키지로 만듭니다. Package Map 지도 패키지 @@ -1907,8 +2084,12 @@ Performance 성능 Phase 단계 Photon Photon Pick a live value, such as the scale or date, then click the map to place it. 축척이나 날짜 같은 동적 값을 고른 다음 지도를 클릭하여 배치합니다. +Pick a Point 포인트 선택 +Pick a point on the map to see the variable's values through time there. 지도에서 포인트를 선택하면 그 위치에서 변수 값의 시간에 따른 변화를 볼 수 있습니다. Pick a route in the list, or click a grey route on the map. 목록에서 경로를 고르거나 지도에서 회색 경로를 클릭하세요. +Pick a single layer. 레이어를 하나 선택하세요. Pick it on the map 지도에서 선택 +Pick one… 하나 선택… Picture 그림 Pictures 그림 Pictures, attachments and chart 그림, 첨부 파일 및 차트 @@ -1966,8 +2147,11 @@ Portal 포털 Portolan Portolan Portolan… Portolan… Portrait 세로 +Position 위치 +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing 후처리 power 2 거듭제곱 2 +Precision 6 (OSRM, Valhalla) 정밀도 6(OSRM, Valhalla) Prepare 준비 Prepare every layer's geometry for this projection. 이 투영에 맞게 모든 레이어의 지오메트리를 준비합니다. Prepare Plugin Environment 플러그인 환경 준비 @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. 프로젝트 템플릿: project's path is required 프로젝트 경로가 필요합니다 Projection 투영 Projections 투영 +Properties 속성 +Property 속성 Proportional Symbols 비례 심볼 Provider 공급자 +Provinces: 성: Proximity 근접 Public GIS portals of US counties and cities, by state. 미국 카운티와 도시의 공개 GIS 포털(주별). Public GIS portals of US federal agencies, by department. 미국 연방 기관의 공개 GIS 포털(부처별). Public GIS portals of US states and the District of Columbia. 미국 주와 컬럼비아 특별구의 공개 GIS 포털. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. ArcGIS Online 또는 다른 ArcGIS 포털의 공개 레이어: 피처, 지도, 이미지, 벡터 타일 서비스, 웹 지도, GeoJSON 파일. +Publication Layout 출판용 레이아웃 Publish 게시 Publish › Output 게시 › 출력 Put the layer back as it was then 레이어를 그 시점의 상태로 되돌리기 Python environment failed: {error} Python 환경 준비 실패: {error} Python environment not prepared Python 환경이 준비되지 않음 Python environment ready Python 환경 준비됨 +Python script Python 스크립트 Qualitative 정성적 Quantile 분위수 Query files, web addresses and databases with DuckDB Spatial; add results to the map. DuckDB Spatial로 파일, 웹 주소, 데이터베이스를 쿼리하고 결과를 지도에 추가합니다. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. 높이 또는 필드 값으로 폴리곤 Raise Priority 우선순위 높이기 Random points in polygons 폴리곤 내 무작위 포인트 Range 범위 +Range (Degrees) 범위(도) +range: 0, 0.9 범위: 0, 0.9 Raster 래스터 Raster\nAnalysis 래스터\n분석 Raster\nInformation 래스터\n정보 @@ -2075,6 +2266,7 @@ Redo {action} {action} 다시 실행 Red–Blue 빨강–파랑 Refine the search to see {n} more 검색을 좁히면 {n}개를 더 볼 수 있습니다 Refresh 새로 고침 +Refresh every connection's lists 모든 연결의 목록 새로 고침 Refresh Map Series 지도 시리즈 새로 고침 Refresh the map series from its index layer. 인덱스 레이어로 지도 시리즈를 새로 고칩니다. Region 리전 @@ -2091,11 +2283,10 @@ Relationships 관계 Release 릴리스 Reload 다시 불러오기 Reload Web Layers 웹 레이어 다시 불러오기 -Remember (without the secret) 기억(비밀 키 제외) Remember the key on this computer 이 컴퓨터에 키 기억 -Remembered: 기억된 연결: Remove 제거 Remove All 모두 제거 +Remove All Label Classes 모든 라벨 클래스 제거 Remove an offline area. 오프라인 영역을 제거합니다. Remove Bookmark 북마크 제거 Remove Connection 연결 제거 @@ -2108,11 +2299,14 @@ Remove from Favourites 즐겨찾기에서 제거 Remove From Group 그룹에서 제거 Remove Group 그룹 제거 Remove Keyframe 키프레임 제거 +Remove Label Class 라벨 클래스 제거 Remove Section 섹션 제거 Remove Table 테이블 제거 +Remove the clause. 절을 제거합니다. Remove the last keyframe. 마지막 키프레임을 제거합니다. Remove the layer from the map. 지도에서 레이어를 제거합니다. Remove the strokes and draw the symbol's line alone 선을 제거하고 심볼의 선만 그립니다 +Remove this class's own strokes and draw it with the layer's 이 클래스 자체의 선을 제거하고 레이어의 선으로 그립니다 Remove this stop 이 경유지 제거 Remove Tile Layer 타일 레이어 제거 Remove token 토큰 제거 @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). 허용 Rename 이름 바꾸기 Rename Bookmark 북마크 이름 바꾸기 Rename Group 그룹 이름 바꾸기 +Rename it to save it: a connection has this name 저장하려면 이름을 바꾸세요. 이 이름의 연결이 이미 있습니다 +Rename Label Class 라벨 클래스 이름 바꾸기 Rename Layer 레이어 이름 바꾸기 Rename Layout 레이아웃 이름 바꾸기 Rename Map 지도 이름 바꾸기 @@ -2149,6 +2345,7 @@ Reset Default 기본값으로 재설정 Reset Effects 효과 재설정 Reset North 북쪽 재설정 Reset north 북쪽 재설정 +Resolution (0–30) 해상도(0–30) Response 응답 Restore 복원 Restore From Archive 아카이브에서 복원 @@ -2162,6 +2359,7 @@ RGB RGB Ribbon 리본 Right 오른쪽 Right-click a source to add it here 원본을 마우스 오른쪽 버튼으로 클릭하여 여기에 추가 +Right-click for more 마우스 오른쪽 버튼을 클릭하면 추가 작업 Robinson 로빈슨 도법 Rotate 회전 Rotate (°) 회전(°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) 샘플 데이터(Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. 샘플 데이터 © Natural Earth(퍼블릭 도메인). RustGIS는 Apache-2.0 라이선스를 따릅니다. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. 샘플 데이터: Natural Earth(퍼블릭 도메인). Apache-2.0 라이선스. +Sans-serif 산세리프 Satellite Embedding Datasets 위성 임베딩 데이터 세트 Satellite Embeddings 위성 임베딩 Satellite Embeddings… 위성 임베딩… Saturation 채도 Save 저장 Save As 다른 이름으로 저장 +Save as Connection 연결로 저장 Save as Default 기본값으로 저장 Save as Default Raster Style 기본 래스터 스타일로 저장 Save as Layer 레이어로 저장 Save As Presentation File… 프레젠테이션 파일로 저장… +Save as Python Script… Python 스크립트로 저장… Save As Report File… 보고서 파일로 저장… Save As Report Template… 보고서 템플릿으로 저장… Save as {name} {name}(으)로 저장 Save As… 다른 이름으로 저장… Save Color Ramps 색상 램프 저장 Save Data Source 데이터 원본 저장 +Save History As Python Script 기록을 Python 스크립트로 저장 Save Layer File 레이어 파일 저장 Save Layout File 레이아웃 파일 저장 Save Map File 지도 파일 저장 @@ -2254,6 +2456,7 @@ save or load a pane set by name 이름으로 창 세트 저장 또는 불러오 Save Project 프로젝트 저장 Save project 프로젝트 저장 Save Project As 프로젝트를 다른 이름으로 저장 +Save Project As… 프로젝트를 다른 이름으로 저장… Save Project Template 프로젝트 템플릿 저장 Save Style Items 스타일 항목 저장 Save Task Item 태스크 항목 저장 @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) 모델을 파일(*.model.json)로 저장 Save the model in the project 모델을 프로젝트에 저장 Save the open layout as a layout file. 열려 있는 레이아웃을 레이아웃 파일로 저장합니다. Save the panes you have open, and switch back to them. 열려 있는 창 구성을 저장하고 다시 전환합니다. +Save the project too: the script opens its saved file, without changes made since. 프로젝트도 저장하세요. 스크립트는 저장된 파일을 열므로 그 이후의 변경 내용은 포함되지 않습니다. Save the project under a new name. 프로젝트를 새 이름으로 저장합니다. Save the project with your edits. 편집 내용과 함께 프로젝트를 저장합니다. Save the project's custom color ramps to a color ramp file. 프로젝트의 사용자 지정 색상 램프를 색상 램프 파일에 저장합니다. @@ -2275,13 +2479,16 @@ Save Web Link Chart 웹 링크 차트 저장 Save Web Map 웹 지도 저장 Save Web Scene 웹 장면 저장 Saved 저장됨 +Saved as the connection {name} 연결 {name}(으)로 저장했습니다 Saved as the default style for new single-band rasters. 새 단일 밴드 래스터의 기본 스타일로 저장했습니다. +Saved connection 저장된 연결 Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). 저장된 키는 앱 환경설정에 암호화되지 않은 상태로 보관됩니다(웹에서는 브라우저 저장소). Saved models 저장된 모델 Saved symbology: apply it to the selected layer, or save the layer's. 저장된 심볼: 선택한 레이어에 적용하거나 레이어의 심볼을 저장합니다. Saved View 저장된 보기 Saved {name} {name} 저장됨 Saved {name}. {name}을(를) 저장했습니다. +Saved {path} {path}을(를) 저장했습니다 saving failed unexpectedly 저장이 예기치 않게 실패했습니다 Scale 축척 Scale\nBar 축척\n막대 @@ -2304,7 +2511,7 @@ Search (this portal needs words)… 검색(이 포털은 검색어가 필요합 Search Catalogue 카탈로그 검색 Search commands — e.g. buffer, basemap, labels 명령 검색 — 예: buffer, basemap, labels Search datasets, e.g. GEDI biomass 데이터 세트 검색(예: GEDI biomass) -Search Distance (km) 검색 거리(km) +Search Distance 검색 거리 Search every command and its parameters 모든 명령과 매개변수 검색 Search layers (e.g. temperature) 레이어 검색(예: temperature) Search layers, places, lat/lon… 레이어, 장소, 위도/경도 검색… @@ -2329,6 +2536,8 @@ Searching the folders… 폴더를 검색하는 중… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. 여기에서도 검색할 수 있지만, NASA의 데이터 서버는 웹 페이지가 파일을 읽도록 허용하지 않으므로(CORS 없음) GeoTIFF와 보호된 데이터 다운로드에는 데스크톱 앱이 필요합니다. 다운로드하면 새 탭에서 파일이 열리고, Earthdata Login이 로그인을 요청합니다. Searching… 검색하는 중… Searching… try again in a moment. 검색하는 중… 잠시 후 다시 시도하세요. +Seasonal Mann–Kendall 계절 Mann–Kendall +Seasons a year 연간 계절 수 Second standard parallel 제2 표준 위선 Seconds 초 Secret access key 비밀 액세스 키 @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator 구분 기호 Sepia 세피아 Sequential 순차 +Serif 세리프 Server 서버 Service Group 서비스 그룹 Service group data exceeds 64 MB. 서비스 그룹 데이터가 64 MB를 초과합니다. Service Import Requests 서비스 가져오기 요청 Service Layers 서비스 레이어 -Services 서비스 +Session token 세션 토큰 Set 설정 Set Cell Size 셀 크기 설정 Set Domain 도메인 설정 @@ -2411,6 +2621,7 @@ Shaded 음영 Shaded Relief 음영 기복 Shape 모양 Shapefile Shapefile +Shapes 도형 Share 공유 Share As 다른 형식으로 공유 Share every layer and table as OGC API Features collections. 모든 레이어와 테이블을 OGC API Features 컬렉션으로 공유합니다. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. 브라우저 뷰에서 항 Show different features at different scales. 축척에 따라 다른 피처를 표시합니다. Show grid 격자 표시 Show Group 그룹 표시 +Show labels 라벨 표시 Show latitude/longitude lines on the basemap. 배경지도에 위도/경도 선을 표시합니다. +Show Layers 레이어 표시 Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. 시간에 따라 레이어를 표시합니다: 날짜가 슬라이더 기간에 속하는 피처와 해당 날짜의 날짜가 있는 영상만 표시합니다. Show Map 지도 표시 Show Me 보여 주기 @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. 도구 창을 표시하거나 숨깁니다. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. 눈금자를 표시합니다. 눈금자에서 드래그하여 안내선을 추가하고, 안내선을 눈금자로 다시 드래그하면 제거됩니다. Show the bands picked, read in place as the map needs them 선택한 밴드를 표시하며, 지도에 필요한 만큼 제자리에서 읽습니다 Show the description 설명 표시 +Show the Labeling pane for the selected layer. 선택한 레이어의 라벨링 창을 표시합니다. Show the labels that don't fit, in red. 맞지 않는 라벨을 빨간색으로 표시합니다. Show the layer only inside another layer's polygons. 다른 레이어의 폴리곤 안쪽에서만 레이어를 표시합니다. Show the layer only left of a divider, to compare it with what's beneath. 구분선 왼쪽에만 레이어를 표시하여 아래 내용과 비교합니다. @@ -2491,8 +2705,10 @@ Sign-in cancelled. 로그인이 취소되었습니다. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. 로그인 시간이 초과되었습니다. 다시 시작하고 브라우저가 이 컴퓨터의 루프백 주소에 접근하도록 허용하세요. signed in 로그인됨 Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. 이 컴퓨터에서 로그아웃했지만 OpenAI가 확인하지 않았습니다. ChatGPT 설정에서 RustGIS 연결을 해제할 수 있습니다. +Significance level 유의 수준 Signing in with ChatGPT works in the desktop app. ChatGPT 로그인은 데스크톱 앱에서 작동합니다. Signing the asset (Planetary Computer)… 에셋에 서명하는 중(Planetary Computer)… +Sill 문턱값 Simplify 단순화 Simulate the drive, or save the route as a layer. 주행을 시뮬레이션하거나 경로를 레이어로 저장합니다. Simulation 시뮬레이션 @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} AI 어시스턴트용 스킬: {names} Slideshow, PDF or PNG 슬라이드쇼, PDF 또는 PNG slight left 약간 왼쪽 slight right 약간 오른쪽 +Slope per 기울기 단위 Slope, aspect, hillshade, NDVI, band math, thresholds. 경사, 향, 음영기복, NDVI, 밴드 연산, 임계값. Slope, hillshade, NDVI and more. 경사, 음영기복, NDVI 등. Small scales 소축척 @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South 남 South Polar Stereographic 남극 평사도법 +Spatial Join 공간 조인 Speaker notes 발표자 노트 Spectral Analysis 분광 분석 Spectral indices 분광 지수 @@ -2530,6 +2748,7 @@ Split (click the line) 분할(라인 클릭) SQL SQL SQL Pane SQL 창 SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL 쿼리는 데스크톱 앱이 호출하는 프로그램인 DuckDB로 실행됩니다: 쿼리하려면 이 프로젝트를 데스크톱 앱에서 여세요. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 참이어야 하는 SQL, 예: pop > 0 Square 정사각형 Squares 정사각형 @@ -2588,6 +2807,7 @@ Start over at the end while playing. 재생 중 끝에 도달하면 처음부터 Start With 시작 시 Start-up 시작 starting with a project file needs the desktop app 프로젝트 파일로 시작하려면 데스크톱 앱이 필요합니다 +Statistic 통계량 Statistics 통계 Statistics Field 통계 필드 Status 상태 @@ -2613,8 +2833,10 @@ Stretch\nType 스트레치\n유형 Stretch it over the map view, or give control points (one per line: column row lon lat). 지도 뷰에 맞춰 늘이거나 기준점을 입력하세요(한 줄에 하나씩: 열 행 경도 위도). Stretch Type 스트레치 유형 Stretch type 스트레치 유형 +Stroke Levels 선 레벨 Stroke {n} 선 {n} Strokes 선 +Strokes for 선 적용 대상 Style 스타일 Style Items 스타일 항목 Style › Drawing 스타일 › 그리기 @@ -2624,7 +2846,10 @@ Subreport: {name} 하위 보고서: {name} Subtypes 하위 유형 Subtypes, one per line: code=name 하위 유형, 한 줄에 하나씩: code=이름 Suitability Modeler 적합성 모델러 +Summarise 요약 +Summarise over time 시간에 따라 요약 Summarise the conversation so far, so a long chat stays within the model's context 지금까지의 대화를 요약해 긴 채팅이 모델의 컨텍스트 안에 들어가도록 합니다 +Summarise Zarr Over Time 시간에 따라 Zarr 요약 Summarising the earlier conversation to keep it within the model's context… 모델의 컨텍스트에 맞도록 이전 대화를 요약하는 중… Summarize 요약 Summarize\nWithin 범위 내\n요약 @@ -2658,6 +2883,8 @@ Table or layer 테이블 또는 레이어 Tables 테이블 Tags 태그 Take the data in view offline, to edit and sync back later. 보기 내 데이터를 오프라인으로 가져가 편집하고 나중에 다시 동기화합니다. +Target Features 대상 피처 +Target Records 대상 레코드 Task 태스크 Task Item 태스크 항목 Task name 태스크 이름 @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. 테네시주의 공개 GIS 데이터(Tennessee GeoData, geodata.tn.gov): 필지 경계, 교통, 보건, 수문 등. Terrain 지형 Test Test +Test Connection 연결 테스트 +Testing… 테스트하는 중… Text 텍스트 Text Symbol 텍스트 심볼 That connection would create a loop. 이 연결은 순환을 만듭니다. The address fields are empty in every row. 모든 행에서 주소 필드가 비어 있습니다. +The answer isn't UTF-8 text 응답이 UTF-8 텍스트가 아닙니다 the answer isn't UTF-8 text 응답이 UTF-8 텍스트가 아닙니다 The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. 어시스턴트는 스킬을 사용할 때 그 지침을 따릅니다. 지침 내용과 관계없이 승인이 필요한 명령은 계속 확인을 요청합니다. The assistant is busy; stop it or wait for it to finish. 어시스턴트가 작업 중입니다. 중지하거나 완료될 때까지 기다리세요. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. 배경지도 플러그인의 공 the capture failed unexpectedly 캡처가 예기치 않게 실패했습니다 The connection failed; trying again in {seconds} s. 연결에 실패했습니다. {seconds}초 후 다시 시도합니다. The country list couldn't be read ({e}): type a country's code 국가 목록을 읽을 수 없습니다({e}): 국가 코드를 입력하세요 +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. 입력을 편집하는 동안에는 현재 고도가 그대로 적용됩니다. 값과 단위를 적용하려면 고도 적용을 클릭하세요. The dataset has nothing RustGIS can add 이 데이터 세트에는 RustGIS가 추가할 수 있는 것이 없습니다 The environment (AWS_ACCESS_KEY_ID…) 환경 변수(AWS_ACCESS_KEY_ID…) The features where the map is 지도 영역의 피처 @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. 흐 The installed GeoLibre runner lists no tools. 설치된 GeoLibre 실행기에 도구가 없습니다. The job covers the map view 작업 범위는 지도 뷰입니다 The key is kept for this session only. 키는 이 세션 동안만 보관됩니다. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. 레이어에는 시간 슬라이더의 현재 단계가 표시되며, 변수의 날짜 범위로 슬라이더가 켜집니다. The layer's file couldn't be read: {error} 레이어 파일을 읽을 수 없습니다: {error} +The layer's strokes 레이어의 선 the layout export failed unexpectedly 레이아웃 내보내기가 예기치 않게 실패했습니다 The link doesn't say its STAC API: Find Items searches {api} 링크에 STAC API가 지정되지 않았습니다: 항목 찾기는 {api}을(를) 검색합니다 The list of plugins to browse. Leave it empty for the RustGIS plugin registry. 둘러볼 플러그인 목록입니다. 비워 두면 RustGIS 플러그인 레지스트리를 사용합니다. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. 지도 뷰로 만든 프로젝트 The projection the map opens in. 지도를 열 때의 투영법. The provider's content filter stopped the reply. 제공자의 콘텐츠 필터가 응답을 중단했습니다. The public server is free and shared by many: it answers light use, and may ask you to wait. 공개 서버는 무료이며 많은 사람이 함께 사용합니다. 가벼운 사용에는 응답하지만 기다리라고 요청할 수 있습니다. +The range is two numbers, low and high (0, 0.9) 범위는 최솟값과 최댓값 두 숫자입니다(0, 0.9) The raster's data couldn't be read. 래스터 데이터를 읽을 수 없습니다. The raster's data couldn't be read: {path} 래스터 데이터를 읽을 수 없습니다: {path} the release isn't known 릴리스를 알 수 없습니다 @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Shapefile의 파일은 하나의 zip The share of each cell covered by fields, for the zoomed-out view 축소된 뷰를 위한, 각 셀에서 농지가 차지하는 비율 The sign-in expired or was revoked. Sign in again. 로그인이 만료되었거나 취소되었습니다. 다시 로그인하세요. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. 로그인 정보는 앱의 설정 폴더에 사용자 계정 전용으로 저장되며 프로젝트 파일에는 저장되지 않습니다. +The significance level is a number, such as 0.05 유의 수준은 숫자입니다(예: 0.05) The start-up project {path} didn't open: {error} 시작 프로젝트 {path}을(를) 열지 못했습니다: {error} The start-up project {path} isn't a RustGIS project file. 시작 프로젝트 {path}은(는) RustGIS 프로젝트 파일이 아닙니다. The step's layer {name} isn't in the map. 단계의 레이어 {name}이(가) 지도에 없습니다. The store has no variables with two or more dimensions. 저장소에 2차원 이상의 변수가 없습니다. the store is gone 저장소가 없어졌습니다 +The summary couldn't start: {error} 요약을 시작할 수 없습니다: {error} +The summary failed unexpectedly 요약이 예기치 않게 실패했습니다 +The summary finished after another project was opened, so it wasn't added 다른 프로젝트를 연 뒤에 요약이 완료되어 추가되지 않았습니다 The task's steps changed, so the step being edited was closed: edit it again. 태스크 단계가 변경되어 편집 중인 단계를 닫았습니다. 다시 편집하세요. the time slider is off 시간 슬라이더가 꺼져 있습니다 The token expires on {date}. 토큰은 {date}에 만료됩니다. +The trend couldn't start: {error} 추세 분석을 시작할 수 없습니다: {error} +The trend failed unexpectedly 추세 분석이 예기치 않게 실패했습니다 +The trend finished after another project was opened, so it wasn't added 다른 프로젝트를 연 뒤에 추세 분석이 완료되어 추가되지 않았습니다 +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. 검은색에서 흰색으로 표시할 값으로, 모든 날짜와 타일에 동일하게 적용됩니다(밴드 자체 단위). 비워 두면 각 파일 자체의 스트레치를 사용합니다. the view has no extent 뷰에 범위가 없습니다 The view is larger than one read: its centre was read (zoom in for the rest) 뷰가 한 번에 읽을 수 있는 크기보다 커서 중심부를 읽었습니다(나머지는 확대하세요) The web app always runs the latest release: reload the page to get it. 웹 앱은 항상 최신 릴리스로 실행됩니다. 페이지를 새로 고치면 받을 수 있습니다. the window's screenshot didn't come back in time, so the map was drawn offscreen 창 스크린샷이 제때 돌아오지 않아 지도를 화면 밖에서 그렸습니다 the window's screenshot didn't hold the map, so it was drawn offscreen 창 스크린샷에 지도가 없어 화면 밖에서 그렸습니다 Theme 테마 +There is no expression defined. 정의된 표현식이 없습니다. Thermal 열화상 +Thesis figure, full page (160 × 225 mm) 학위 논문 그림, 전체 페이지(160 × 225 mm) +Thesis figure, half page (160 × 110 mm) 학위 논문 그림, 반 페이지(160 × 110 mm) Thinking… 생각 중… +Third-party licences 타사 라이선스 +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins 이 빌드는 플러그인을 다운로드할 수 없습니다 this build can't reach AI providers 이 빌드는 AI 공급자에 연결할 수 없습니다 this build saves projects with Save As 이 빌드에서는 다른 이름으로 저장으로 프로젝트를 저장합니다 This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. 이 ChatGPT 계정은 이미 저장되어 있습니다. 다음에는 다시 추가하지 말고 목록에서 선택하세요. +This class is drawn with the layer's strokes. Changing them here gives it its own. 이 클래스는 레이어의 선으로 그려집니다. 여기서 변경하면 이 클래스 자체의 선이 됩니다. +This class is hidden, so it labels nothing. 이 클래스는 숨겨져 있어 아무것도 라벨을 표시하지 않습니다. +This clause no longer reads, so no feature is labelled: {error} 이 절을 더 이상 해석할 수 없어 라벨이 표시되는 피처가 없습니다: {error} This map has no layers yet. 이 지도에는 아직 레이어가 없습니다. This map's thumbnail, from an image. 이미지로 만든 이 지도의 썸네일. This map's thumbnail, from the view. 뷰로 만든 이 지도의 썸네일. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. 이 요청에는 1회 허용 옵션이 없습니다. This request uses an unsupported capability. Check the parameter and change the request. 이 요청은 지원되지 않는 기능을 사용합니다. 매개변수를 확인하고 요청을 변경하세요. This result is kept 이 결과는 유지됩니다 +This run has no tool form to open 이 실행에는 열 수 있는 도구 양식이 없습니다 +This session only (optional) 이 세션에만 해당(선택 사항) This step 이 단계 This step names no tool to show. 이 단계에는 표시할 도구가 지정되어 있지 않습니다. Thumbnail 썸네일 @@ -2789,15 +3041,21 @@ Tilt Map 지도 기울이기 Tilt-shift 틸트 시프트 Tilt-shift blur and film grain. 틸트 시프트 블러 및 필름 그레인. Time 시간 +Time\nSeries 시계열\n그래프 Time\nSlider 시간\n슬라이더 Time Fields 시간 필드 +Time Series 시계열 +Time series 시계열 +Time Series at a Point 포인트의 시계열 Time Slider 시간 슬라이더 Time Slider Settings 시간 슬라이더 설정 Tint by elevation 고도별 색조 Tissot's Indicatrix 티소 지시곡선 Title 제목 +To 종료 Toggle Dark Mode 다크 모드 전환 Toggle GPU Rendering GPU 렌더링 전환 +Toggle Imperial Measurements 야드파운드법 측정 전환 Tolerance (km) 허용 오차(km) Tolls 유료 도로 Tool Gallery 도구 갤러리 @@ -2828,7 +3086,10 @@ Train Deep Learning Model 딥러닝 모델 훈련 Trained models 훈련된 모델 Transparency 투명도 Traverse 트래버스 +Trend Analysis 추세 분석 +Trend over a series of rasters 일련의 래스터에 대한 추세 Triangle 삼각형 +TRUE TRUE Try again in a moment. 잠시 후 다시 시도하세요. Try again later. 나중에 다시 시도하세요. Turn editing on or off for the map. 지도의 편집을 켜거나 끕니다. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map 경도와 위도를 입력하거 Type a scale, such as 1:12,345, 12345 or 25k, and press Enter 1:12,345, 12345 또는 25k 같은 축척을 입력하고 Enter를 누르세요 Type an address or coordinates and press Enter, or click the map. 주소나 좌표를 입력하고 Enter를 누르거나 지도를 클릭하세요. Type numbers separated by commas 쉼표로 구분된 숫자 입력 +Type the keys to log in for this session, then Save. 이 세션에 로그인할 키를 입력한 다음 저장하세요. +Type the password or keys to log in for this session, then Save. 이 세션에 로그인할 비밀번호 또는 키를 입력한 다음 저장하세요. Typed below 아래에 입력 UM UM Unavailable: {reason} 사용할 수 없음: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider 시간 슬라이더에서 연결 해제 Unbind from Time Slider 시간 슬라이더에서 연결 해제 Undo 실행 취소 Undo (Ctrl+Z) 실행 취소(Ctrl+Z) -Undo / Redo 실행 취소 / 다시 실행 Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. 실행 취소로는 기록된 단계가 제거되지 않습니다. 기록을 중지한 후 단계 편집에서 삭제하세요. Undo the last change. 마지막 변경을 실행 취소합니다. Undo {action} {action} 실행 취소 @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools GeoLibre 도구 제거 Uninstall Plugin 플러그인 제거 Uninstalled {id}. {id}을(를) 제거했습니다. Unique Values 고유 값 +Unit 단위 United States 미국 Units 단위 Units per step 단계당 단위 @@ -2877,6 +3140,7 @@ Up a level 상위 수준으로 Update 업데이트 Update Bookmark 북마크 업데이트 Update Job 작업 업데이트 +Update Label Class 라벨 클래스 업데이트 Update to {version} {version}(으)로 업데이트 Upload File 파일 업로드 Upload File… 파일 업로드… @@ -2895,6 +3159,7 @@ Use another account 다른 계정 사용 Use Current View 현재 보기 사용 Use each feature's line color 각 피처의 선 색상 사용 Use each feature's line width 각 피처의 선 두께 사용 +Use the Layer's Strokes 레이어의 선 사용 USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan ChatGPT 요금제 사용 중 Utilities 공공시설 Valid combinations, one per line: a, b 유효한 조합, 한 줄에 하나씩: a, b Valid expression · {n} matching rows 유효한 표현식 · 일치하는 행 {n}개 +Valid: {n} of {of} features match 유효: 피처 {of}개 중 {n}개 일치 Validate 검증 Validate Model 모델 검증 value (empty: no-data) 값(비우면 NoData) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Vantor의 공개 재해 영상: 이벤트와 단계(이전 또는 이후)를 선택한 다음 항목을 찾으세요. Vantor… Vantor… Variable 변수 +Variogram Model 베리오그램 모델 Vector and raster data 벡터 및 래스터 데이터 Vector Layer 벡터 레이어 +vector tiles 벡터 타일 Vector tiles (MVT) 벡터 타일(MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. 지리공간 기반 모델이 픽셀 또는 이미지 패치마다 만든 벡터로, 지도 위치에서 찾아 색상으로 표시합니다. Version {version} 버전 {version} @@ -2933,6 +3201,9 @@ Video file or URL 동영상 파일 또는 URL Video Page 동영상 페이지 Videos 동영상 Video… 동영상… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. 베트남 국가 좌표계(VN-2000). 3도 횡메르카토르 도법을 사용하며 WGS 84로의 7변수 Helmert 변환을 적용합니다. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). 베트남 국가 좌표계(VN-2000) UTM 48N 구역(102°E–108°E, 중앙 자오선 105°E). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). 베트남 국가 좌표계(VN-2000) UTM 49N 구역(108°E–114°E, 중앙 자오선 111°E). View 보기 View Clipping 뷰 클리핑 View Effects 보기 효과 @@ -2952,6 +3223,9 @@ Visibility Range 가시 범위 Visible 표시 Visible Layers 표시 레이어 Visual Effect 시각 효과 +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3(베트남) +VN-2000 / UTM zone 48N VN-2000 / UTM 48N 구역 +VN-2000 / UTM zone 49N VN-2000 / UTM 49N 구역 Volume 부피 Waiting for the browser… 브라우저를 기다리는 중… Waiting for your answer 응답을 기다리는 중 @@ -2970,6 +3244,7 @@ Web Link Chart 웹 링크 차트 Web maps 웹 지도 Web Mercator 웹 메르카토르 Web Scene 웹 장면 +Web service (detected) 웹 서비스(감지됨) Web Style 웹 스타일 Web Tile Layers 웹 타일 레이어 Web Tool 웹 도구 @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. 피처를 기준으로 라벨이 Where new features get their elevation (the z field). 새 피처가 고도를 가져오는 위치(z 필드). Where the map opens: the full extent or a view you saved. 지도가 열리는 위치: 전체 범위 또는 저장한 보기. Where tools work 도구가 작동하는 범위 +Whether the Select tool picks these layers' features on the map 선택 도구로 지도에서 이 레이어들의 피처를 선택할 수 있는지 여부 Which 5 countries have the largest GDP? GDP가 가장 큰 5개 국가는 어디인가요? Whitebox and GeoLibre tools Whitebox 및 GeoLibre 도구 Whole map 전체 지도 @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. 세계, 정적: 실제 면적, World, equal-area: true sizes, shapes bend towards the edges. 세계, 정적: 실제 면적, 가장자리로 갈수록 형태가 휩니다. Worldview 국경 Write a plugin 플러그인 만들기 +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Sen 기울기, 검정의 Z 값과 p 값, 그리고 추세가 유의한 곳의 기울기를 기록합니다. X (mm) X(mm) X column X 열 X Field X 필드 @@ -3050,7 +3327,6 @@ Yours 내 스킬 YYYY-MM-DD YYYY-MM-DD YYYY-MM-DD, for the layers that change with time YYYY-MM-DD, 시간에 따라 변하는 레이어용 YYYY-MM-DD, for time layers YYYY-MM-DD, 시간 레이어용 -z (m) z(m) Z factor Z 계수 Zarr Zarr Zarr Variables Zarr 변수 @@ -3061,7 +3337,6 @@ Zoom Box 영역 확대/축소 Zoom Frame 프레임 확대/축소 Zoom In 확대 Zoom in 확대 -Zoom in / out 확대 / 축소 Zoom In Here 여기에서 확대 Zoom in on the centre of the map. 지도 중심을 기준으로 확대합니다. Zoom in · finish a sketch 확대 · 스케치 완료 @@ -3073,6 +3348,7 @@ Zoom To\nLayer 레이어로\n확대 Zoom to a box (Explore) 상자로 확대(탐색) Zoom To Extent 범위로 확대 이동 Zoom To Layer 레이어로 확대 +Zoom To Layers 레이어로 확대 Zoom To Map View 지도 뷰로 확대 Zoom To Selection 선택 항목으로 확대 Zoom to selection 선택 항목으로 확대 @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. 레이어 범위로 확대합니다. Zoom to the selected features. 선택한 피처로 확대합니다. Zoom to the whole world. 전 세계로 확대/축소합니다. Zoom to this bookmark 이 북마크로 확대 이동 +Zoom To Tile Layer 타일 레이어로 확대 Zoom to Tokyo 도쿄로 확대 Zoom To World 세계로 확대/축소 +Zoom {z} · Bearing {b}° · Pitch {p}° 줌 {z} · 방위 {b}° · 기울기 {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. 줌: 웹 지도 기준의 지도 줌 레벨입니다. 방위: 지도 위쪽이 향하는 방향으로, 북쪽에서 시계 방향으로 잽니다. 기울기: 바로 위에서 내려다보는 상태에서 기울어진 정도입니다. 클릭하면 방위와 기울기를 재설정합니다. {agent} was stopped by you. {agent}을(를) 중지했습니다. {id} changed since it was installed; install it again from the Plugin Manager. {id}이(가) 설치 후 변경되었습니다. 플러그인 관리자에서 다시 설치하세요. {names}: needs Node.js (npx) for its ACP adapter. {names}: ACP 어댑터에 Node.js(npx)가 필요합니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 05bf360cd..9ed9cc322 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6,6 +6,7 @@ (filtered) (gefilterd) + Add Stop + Tussenstop toevoegen + Input + Invoer ++ New expression + Nieuwe expressie + Output + Uitvoer /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh 3D-mesh A 3D globe seen from space. Een 3D-globe, gezien vanuit de ruimte. A blank map with a basemap Een lege kaart met een basiskaart +A call for the rustgis Python package Een aanroep voor het Python-pakket rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. Het adres van een catalogus (CSW 2.0.2): doorzoek de records op woorden en, als je wilt, op het kaartgebied, en voeg dan de gekoppelde diensten en bestanden toe. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. Een gebruikslimiet van het ChatGPT-abonnement of de app is bereikt. Bekijk het wekelijkse gebruik voordat je het opnieuw probeert. A ChatGPT sign-in can only be used with the OpenAI API. Een ChatGPT-aanmelding kan alleen met de OpenAI-API worden gebruikt. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). De data A globe scene. Een wereldbolscène. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Een breedte- en lengtegraad (40.7, -74.0 of 40°42'N 74°0'W), een laag, een bladwijzer of een waarde in de tabel van een laag. Enter kiest de gemarkeerde rij; Plaatsen zoeken zoekt de tekst op als plaats A layer the model reads Een laag die het model leest +A line for the Script pane Een regel voor het scriptvenster A magnifying lens that follows the pointer. Een vergrootglas dat de aanwijzer volgt. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). Het adres van een kaart-, object- of coveragedienst: WMS, WMTS, WFS, WCS, OGC API Features of Tiles, of een ArcGIS FeatureServer, MapServer, ImageServer of VectorTileServer (of een dienstenmap, om te bladeren). a name, or owner/name such as giswqs/geospatial een naam, of eigenaar/naam zoals giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Een benoemd eindpunt dat u opnieuw kunt gebruiken. Wachtwoorden en sleutels worden bewaard tot RustGIS sluit, nooit opgeslagen. A node cannot connect to itself. Een knooppunt kan niet met zichzelf verbonden worden. A notebook of text and command cells. Een notebook met tekst- en opdrachtcellen. +A password or token in the address was left out: connections never keep secrets. Een wachtwoord of token in het adres is weggelaten: verbindingen bewaren nooit geheimen. A place or an address Een plaats of een adres A project keeps at least one map Een project houdt minstens één kaart A projected coordinate system. Een geprojecteerd coördinatensysteem. A report listing the selected layer's records, page after page. Een rapport met de records van de geselecteerde laag, pagina na pagina. A result that only feeds other tools is removed once the model has run; add an output to keep it. Een resultaat dat alleen andere gereedschappen voedt, wordt verwijderd zodra het model gedraaid heeft; voeg een uitvoer toe om het te bewaren. A result the model keeps, under a name Een resultaat dat het model onder een naam bewaart +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) Een rustgis-cli-opdracht, voor een POSIX-shell (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Er wordt al een service geïmporteerd of vernieuwd. +A service's layers stay in their service group. De lagen van een service blijven in hun servicegroep. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Een taak bevat hoogstens 200 stappen: stop de opname om hem te maken en neem de rest op als een andere taak. A template with the first selected feature's values Een sjabloon met de waarden van het eerste geselecteerde object +A tile layer per grid tile, each following the time slider through its dates. Een tegellaag per rastertegel, die elk de tijdschuifregelaar door zijn datums volgt. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. Het adres van een tegeldienst: rastertegels met {z}/{x}/{y} (of {-y}, {q}, {bbox-epsg-3857} voor WMS), vectortegels (.pbf) of TileJSON, een MapLibre-stijl, een PMTiles-archief (een webadres of een bestand op deze computer) of een MBTiles-bestand op deze computer. A token is set. Er is een token ingesteld. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Een waarde, een veld of twee daarvan verbonden door + − × ÷. Geldt voor de geselecteerde rijen, of voor alle rijen als er geen geselecteerd zijn. a vector a pixel één vector per pixel a vector an image patch één vector per beelduitsnede A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Een Zarr-opslag (formaat 2 of 3) via het webadres, met geconsolideerde metadata, of een map op deze computer. De kaart van een variabele wordt gelezen voor de gekozen stappen van de andere dimensies, zoals tijd. +A Zarr variable's values through time at points clicked on the map. De waarden van een Zarr-variabele door de tijd op punten die op de kaart worden aangeklikt. +A2 landscape sheet A2-blad liggend +A2 portrait sheet A2-blad staand +A3 landscape sheet A3-blad liggend +A3 portrait sheet A3-blad staand +A4 landscape sheet A4-blad liggend +A4 portrait sheet A4-blad staand +A5 Grid A5-raster +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. De oppervlaktegetrouwe vijfhoekige cellen van A5 op een resolutie, over het kaartbereik, een laag of de hele wereld, als polygonen met hun id’s. About Over About plugins Over plugins About RustGIS Over RustGIS @@ -65,6 +83,7 @@ Add Toevoegen Add\nData Gegevens\ntoevoegen Add ({n}) Toevoegen ({n}) Add a bar chart of a layer's numeric field. Een staafdiagram van een numeriek veld van een laag toevoegen. +Add a circle. Een cirkel toevoegen. Add a colorbar of a raster layer's colour scale in the map frame. Een kleurenbalk van de kleurschaal van een rasterlaag aan het kaartkader toevoegen. Add a description. Een beschrijving toevoegen. Add a field. Een veld toevoegen. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Een paginalay-out toevoege Add a page to the presentation. Een pagina aan de presentatie toevoegen. Add a PNG or JPEG picture to the page. Een PNG- of JPEG-afbeelding aan de pagina toevoegen. Add a presentation file. Een presentatiebestand toevoegen. +Add a rectangle, to frame or highlight part of the page. Een rechthoek toevoegen, om een deel van de pagina te omkaderen of te markeren. Add a report file, or a report template for the selected layer. Een rapportbestand toevoegen, of een rapportsjabloon voor de geselecteerde laag. Add a scale bar for the map frame. Een schaalbalk voor het kaartkader toevoegen. add a second map first (Data › New Map) voeg eerst een tweede kaart toe (Gegevens › Nieuwe kaart) Add a second map frame first Voeg eerst een tweede kaartkader toe +Add a straight line. Een rechte lijn toevoegen. Add a text element. Een tekstelement toevoegen. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Een webbasiskaart boven de andere toevoegen in plaats van ze te vervangen, om hem ermee te mengen. Klik op een zichtbare om hem weg te halen. +Add a Zarr variable first (Add Data › Zarr…). Voeg eerst een Zarr-variabele toe (Gegevens toevoegen › Zarr…). Add Administrative Boundaries Bestuurlijke grenzen toevoegen +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Een pijl toevoegen. Draai hem met de ronde greep erboven, of stel de hoek in bij de eigenschappen. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. Voeg een Earthdata Login-token toe (Earthdata Login, hierboven) om NASA-GeoTIFF's op de kaart te zetten. Add an elevation raster first. Voeg eerst een hoogteraster toe. Add an elevation raster to the map first Voeg eerst een hoogteraster aan de kaart toe +Add an ellipse. Een ellips toevoegen. Add an empty layer with a ready-made schema, to draw into. Een lege laag met een kant-en-klaar schema toevoegen, om in te tekenen. Add an input for each layer the model reads. Voeg een invoer toe voor elke laag die het model leest. Add an output to keep a result under a name. Voeg een uitvoer toe om een resultaat onder een naam te bewaren. Add and Open Toevoegen en openen Add and Open Notebook Notebook toevoegen en openen Add Archive Archief toevoegen +Add as Time Series Toevoegen als tijdreeks add at least two keyframes voeg minstens twee sleutelframes toe Add Cell Cel toevoegen Add Color Ramp File… Kleurverloopbestand toevoegen… @@ -132,6 +157,7 @@ Add Graphics Layer Grafische laag toevoegen Add Input Invoer toevoegen Add Job Job toevoegen Add Keyframe Sleutelframe toevoegen +Add Label Class Labelklasse toevoegen Add Layer File Laagbestand toevoegen Add Open Data Open data toevoegen Add Output Uitvoer toevoegen @@ -147,9 +173,11 @@ Add Rule Regel toevoegen Add sample countries Voorbeeldlanden toevoegen Add Sample Data Voorbeeldgegevens toevoegen Add Satellite Embeddings Satellietembeddings toevoegen +Add Selected ({n}) Geselecteerde toevoegen ({n}) Add Service Layer Dienstlaag toevoegen Add skill… Skill toevoegen… Add STAC Asset STAC-asset toevoegen +Add STAC Data Cube STAC-datakubus toevoegen Add STAC Footprints STAC-footprints toevoegen Add Step Stap toevoegen Add Stroke Streek toevoegen @@ -171,6 +199,7 @@ Add to Favourites Aan favorieten toevoegen Add to Map Aan kaart toevoegen Add vector data. Vectorgegevens toevoegen. Add view Weergave toevoegen +Add With Options… Toevoegen met opties… Add Zarr Variable Zarr-variabele toevoegen Add Zarr Variable… Zarr-variabele toevoegen… Add ▾ Toevoegen ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Agents hebben volledige toegang: ze mogen scripts, taken, modellen en plug-ins uitvoeren. Klik om de MCP-relay te stoppen Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Agents op deze computer die verbinden (Claude Code, Codex, Cursor en andere) werken met de tools van RustGIS aan de kaart die hier openstaat: ze kunnen bestanden openen en opslaan en het web gebruiken. Scripts en pluginbeheer blijven uit. Agents then run commands, change files and use the web on this computer without asking you. Agents voeren dan zonder te vragen opdrachten uit, wijzigen bestanden en gebruiken het web op deze computer. +Aggregate Points to A5 Punten aggregeren naar A5 Aggregation Aggregatie Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Landbouwperceelgrenzen die het Fields of the World-model wereldwijd in Sentinel-2-beelden vond, per jaar (2nd Edition, CC BY 4.0). Dit zijn modelvoorspellingen, geen ingemeten percelen. AI AI @@ -221,15 +251,18 @@ All steps done. Alle stappen voltooid. All tables with geometry Alle tabellen met geometrie Allow Toestaan Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Sta het gebruik van het ChatGPT-abonnement toe bij het aanmelden. Als je workspace dat niet toestaat, vraag het de beheerder of kies een provider met een API-sleutel. +Allow Feature Selection Objectselectie toestaan Allow only certain combinations of values across fields. Alleen bepaalde combinaties van waarden over velden toestaan. Allow such actions for this conversation Zulke acties toestaan voor dit gesprek Allow this action? Deze actie toestaan? Allow this agent's RustGIS action? De RustGIS-actie van deze agent toestaan? Along Selected Line Langs geselecteerde lijn +Alt+ Alt+ Alternating bar Afwisselende balk Altitude Hoogte Always allow Altijd toestaan -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3, of een S3-compatibele dienst (MinIO, Cloudflare R2, Wasabi…) via het eindpunt. Sleutels worden bewaard tot RustGIS sluit; een onthouden verbinding behoudt naam, eindpunt en regio, nooit de geheime sleutel. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3, of een S3-compatibele dienst (MinIO, Cloudflare R2, Wasabi…) via het eindpunt. Sleutels worden bewaard tot RustGIS sluit; een opgeslagen verbinding behoudt naam, eindpunt en regio, nooit de geheime sleutel. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Een agent op deze computer vraagt om: An AWS profile Een AWS-profiel @@ -248,6 +281,7 @@ API key API-sleutel Apply Toepassen Apply a function to a raster Een functie op een raster toepassen Apply Control Points Controlepunten toepassen +Apply elevation Hoogte toepassen Apply Style Stijl toepassen Apply to layers Op lagen toepassen Apply to the selected layer Toepassen op de geselecteerde laag @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Toepassingsgebied: binnen {r}° va Area {area} · Perimeter {perimeter} Oppervlakte {area} · Omtrek {perimeter} Areas (name, land use, area) Gebieden (naam, landgebruik, oppervlakte) Arrange Schikken +Arrow Pijl +Arrowheads Pijlpunten Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Stel een vraag over je gegevens, of beschrijf wat er met de kaart moet gebeuren. De assistent voert RustGIS-opdrachten voor je uit; elke wijziging kan ongedaan worden gemaakt. Ask about the map, or tell it what to do… Stel een vraag over de kaart, of zeg wat er moet gebeuren… Ask about your data, or tell the assistant what to do with the map. Stel een vraag over je gegevens, of vertel de assistent wat er met de kaart moet gebeuren. @@ -275,12 +311,13 @@ Asking NASA's CMR… NASA's CMR raadplegen… Asking the catalog… Catalogus bevragen… Asking the NLDI… De NLDI raadplegen… Assistant settings Instellingen van de assistent +At the end Aan het einde +At the start Aan het begin Atlas Atlas Attachments Bijlagen Attribute\nTable Attributen-\ntabel Attribute Rules Attribuutregels Attribute Table Attributentabel -Attribute table Attributentabel Attributes Attributen Attribution Bronvermelding Aurora Aurora @@ -295,11 +332,9 @@ Automatic (system language) Automatisch (systeemtaal) Automatic (system time zone and region) Automatisch (tijdzone en regio van het systeem) Automatic ({kind}) Automatisch ({kind}) Avoid Vermijden -AWS AWS AWS, anonymous AWS, anoniem Azimuth Azimut Back Terug -Back to Explore; cancel a sketch Terug naar Verkennen; schets annuleren Back to the Map Terug naar de kaart Back to the map Terug naar de kaart Back to tools Terug naar gereedschappen @@ -314,8 +349,6 @@ Basemap Basiskaart Basemaps Basiskaarten Basin from hydrolocation Stroomgebied vanaf hydrolocatie Bearing Richting -Bearing {b}° · Pitch {p}° Richting {b}° · Kanteling {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Richting: de kant waar de bovenkant van de kaart naar wijst, met de klok mee vanaf het noorden. Kanteling: de helling ten opzichte van recht naar beneden kijken. Klik om beide te herstellen. Before the event Voor de gebeurtenis Below Onder Below 1:5,000,000 show everything; beyond it only what matches: Onder 1:5.000.000 alles tonen; daarboven alleen wat overeenkomt: @@ -328,10 +361,12 @@ Bilinear Bilineair Bind To Time Slider Aan tijdschuifregelaar koppelen Bind to Time Slider Aan tijdschuifregelaar koppelen Blank Leeg +Blank for Amazon S3 Leeg voor Amazon S3 Blank Letter page Lege Letter-pagina Blank Map Lege kaart Blank Page Lege pagina Blend: {mode} Menging: {mode} +Block Feature Selection Objectselectie blokkeren Blue Blauw Bold Vet Bold label text. Labeltekst vet. @@ -339,6 +374,7 @@ Bookmark Description Beschrijving van bladwijzer Bookmarks Bladwijzers Bookmarks Pane Bladwijzervenster Boolean Booleaans +Both ends Beide uiteinden Bottom Onder Boundaries… Grenzen… Bounding\nGeometry Begrenzende\ngeometrie @@ -346,6 +382,7 @@ Brightness Helderheid Brightness, contrast, saturation and sepia for the whole map. Helderheid, contrast, verzadiging en sepia voor de hele kaart. Bring this bookmark's map into view to update it Breng de kaart van deze bladwijzer in beeld om hem bij te werken Browse Bladeren +Browse Connection Verbinding doorbladeren Browse it in the Browser Doorbladeren in de browser Browse Plugins Plugins bekijken Browse the geoprocessing toolbox. Door de geoverwerkingsgereedschappen bladeren. @@ -357,6 +394,7 @@ Browser Browser Browser\nPane Browser-\nvenster Browser View Browserweergave Bucket Bucket +Buckets Buckets Buffer Buffer Buffer A Point Buffer rond een punt Buffer the cities by 200 km Een buffer van 200 km rond de steden maken @@ -381,7 +419,9 @@ Calculate Field Veld berekenen Calculate field Veld berekenen Calculate field values. Veldwaarden berekenen. Calculate fields and enforce conditions on every edit. Velden berekenen en voorwaarden afdwingen bij elke bewerking. +Calculate Geometry Geometrie berekenen Calculate Geometry Attributes Geometrie-attributen berekenen +Calculated {rows} features {rows} objecten berekend Calculation Berekening Camera Camera Camera Effect Camera-effect @@ -396,12 +436,14 @@ Case Field Groeperingsveld Cast Shadow Schaduw werpen Catalog Catalogus catalog is a static STAC catalog's catalog.json or collection.json catalog is de catalog.json of collection.json van een statische STAC-catalogus +Catalogue (CSW) Catalogus (CSW) Catalogue search (CSW) Catalogus doorzoeken (CSW) Celestial Bodies Hemellichamen Celestial Body Hemellichaam Celestial body: {body} Hemellichaam: {body} Cell size Celgrootte cell size ° celgrootte ° +Cells Cellen Center Midden Center Map Here Kaart hier centreren Centered Gecentreerd @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. Gebruik van een C ChatGPT sign-in failed. Check the connection and local settings, then try again. Aanmelden bij ChatGPT is mislukt. Controleer de verbinding en lokale instellingen en probeer het opnieuw. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. ChatGPT-gebruik is tijdelijk niet beschikbaar. Blijf aangemeld en probeer het later opnieuw. CHE CHE +Check Controleren Check Again Opnieuw controleren Check for\nUpdates Updates\nzoeken Check for Updates Naar updates zoeken @@ -428,6 +471,7 @@ Check for Updates… Naar updates zoeken… Check the address and the service type. Controleer het adres en het diensttype. Check the address and your connection. Controleer het adres en je verbinding. Check the address. Controleer het adres. +Check the clause, and count the features it takes. De clausule controleren en de objecten tellen die ze selecteert. Check the integration's client, permissions, workspace policy and route configuration. Controleer de client, machtigingen, het werkruimtebeleid en de routeconfiguratie van de integratie. Checking for updates… Zoeken naar updates… China China @@ -455,6 +499,7 @@ Choose which layers a click identifies. Kiezen welke lagen een klik identificeer Choose which tabs the ribbon shows. Kiezen welke tabbladen het lint toont. Circle Cirkel Circle Radius (km) Cirkelstraal (km) +Class Klasse Class color Klassekleur Class Style Stijl per klasse Classes Klassen @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Klik om hoekpunten toe Click to edit the description Klik om de beschrijving te bewerken Click to place a point graphic. Klik om een puntelement te plaatsen. Click to select the node Klik om het knooppunt te selecteren +Click to select, double-click to add, right-click for more Klik om te selecteren, dubbelklik om toe te voegen, rechtsklik voor meer Click to switch between decimal degrees and DMS Klik om te wisselen tussen decimale graden en GMS Clip Clippen Clip a raster to the view, or resample it. Een raster clippen tot de weergave, of het herbemonsteren. @@ -532,7 +578,7 @@ Close thread Thread sluiten Close {name} {name} sluiten Closest Dichtstbij Coded values, one per line: code=description Gecodeerde waarden, één per regel: code=beschrijving -Collapse or expand the ribbon Lint samenvouwen of uitvouwen +Collapse or Expand the Ribbon Lint samenvouwen of uitvouwen Collapse the ribbon (Ctrl+F1) Lint samenvouwen (Ctrl+F1) Collection Collectie Collection {id}: choose dates and Find Items Collectie {id}: kies datums en Items zoeken @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Combinaties uit het vak hierboven, één per regel COMID {comid} COMID {comid} Comma Komma +Comma-separated (optional) Kommagescheiden (optioneel) Command\nSearch Opdracht\nzoeken command is required opdracht is vereist Command Search Opdracht zoeken @@ -572,10 +619,13 @@ Connect Verbinden Connect a folder to the Browser. Een map met de browser verbinden. Connect to S3 Verbinden met S3 Connect, or pick a layer Verbind, of kies een laag +Connected to {kind} {title} Verbonden met {kind} {title} Connecting to {url}… Verbinden met {url}… Connection Verbinding +connection.new returned no id connection.new gaf geen id terug Connections Verbindingen -Connect… Verbinden… +Connections you add are listed here Verbindingen die u toevoegt, worden hier weergegeven +Connections… Verbindingen… Constant Constante Constant height Constante hoogte Constant… Constante… @@ -583,11 +633,14 @@ Contingent Values Afhankelijke waarden Continue Doorgaan Continue with ChatGPT Doorgaan met ChatGPT Contrast Contrast +Control Point Fit Passing op controlepunten Conversion Conversie Convert Converteren Convert a layer to another format Een laag naar een ander formaat converteren Convert the selected layer's labels into an annotation layer. De labels van de geselecteerde laag converteren naar een annotatielaag. +Convert this layer's labels into an annotation layer. De labels van deze laag converteren naar een annotatielaag. Convert To Annotation Converteren naar annotatie +Convert to Annotation Converteren naar annotatie Converted data Geconverteerde gegevens Convert… Converteren… Coordinate Coördinaat @@ -599,11 +652,15 @@ Copied the map to the clipboard De kaart is naar het klembord gekopieerd Copied {path} {path} gekopieerd Copies the (selected) features of a layer to a new layer. Kopieert de (geselecteerde) objecten van een laag naar een nieuwe laag. Copy Kopiëren +Copy / Cut / Paste features Objecten kopiëren / knippen / plakken Copy a model.run command that runs this model, for the Script pane or Python Een model.run-opdracht kopiëren die dit model uitvoert, voor het scriptvenster of Python Copy Address Adres kopiëren Copy address Adres kopiëren Copy an image of the map to the clipboard. Een afbeelding van de kaart naar het klembord kopiëren. +Copy as CLI Command Kopiëren als CLI-opdracht +Copy as Command Kopiëren als opdracht Copy As GeoJSON Kopiëren als GeoJSON +Copy as Python Kopiëren als Python Copy Command Opdracht kopiëren Copy coordinates to the clipboard Coördinaten naar het klembord kopiëren Copy details Details kopiëren @@ -611,11 +668,13 @@ Copy Group Groep kopiëren Copy Layer Laag kopiëren Copy Layers Lagen kopiëren Copy Path Pad kopiëren +Copy Run As Code Uitvoering kopiëren als code Copy sign-in link Aanmeldlink kopiëren Copy the selected features (also as GeoJSON text). De geselecteerde objecten kopiëren (ook als GeoJSON-tekst). Copy the selected features and delete them. De geselecteerde objecten kopiëren en verwijderen. Copy the selected layer's data source path. Het gegevensbronpad van de geselecteerde laag kopiëren. Copy this element (Ctrl+D, ⌘D on a Mac) Dit element kopiëren (Ctrl+D, ⌘D op een Mac) +Copy this run as code, or open it in its tool Deze uitvoering kopiëren als code, of openen in het bijbehorende gereedschap Corrections Correcties Cost Kosten could not draw the map kan de kaart niet tekenen @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Kan het pluginregister niet laden: {e Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Aantal, som, gemiddelde, min, max en standaardafwijking van een veld, optioneel per groeperingsveld. Countries, cities, rivers and lakes Landen, steden, rivieren en meren Country Land +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Telt de punten van een laag per A5-cel waarin ze liggen (cellen van gelijke oppervlakte, dus het aantal is een dichtheid), met de som en het gemiddelde van een veld. Counts the points that fall inside each polygon. Telt de punten die binnen elke polygoon vallen. Counts, totals and top categories of what's in view. Aantallen, totalen en topcategorieën van wat in beeld is. CPU CPU @@ -647,12 +707,8 @@ Cross Section Dwarsdoorsnede Cross Section (2 clicks) Dwarsdoorsnede (2 klikken) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Cumulatief Current date Huidige datum Current Project Huidig project @@ -680,13 +736,16 @@ Data type Gegevenstype Data you add is listed here Gegevens die je toevoegt, worden hier vermeld data.gov and CKAN catalogues data.gov- en CKAN-catalogi Database Database -Databases Databases +Database connections are used by the desktop app Databaseverbindingen worden gebruikt door de desktop-app +databases are read through DuckDB, a program the browser can't run databases worden gelezen via DuckDB, een programma dat de browser niet kan uitvoeren Database… Database… Dataset Dataset Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Datasets op Hugging Face op naam of eigenaar/naam; blader door de bestanden van een dataset en voeg GeoTIFF's, GeoParquet, PMTiles, LiDAR en andere bestanden toe. Data… Gegevens… Date Datum Dates Datums +Dates a cell needs Benodigde datums per cel +Dates a cell needs is a whole number, or empty for half Benodigde datums per cel is een geheel getal, of leeg voor de helft Day Dag Day and night shading for a time. Dag- en nachtschaduw voor een tijdstip. Decimal Decimaal @@ -715,7 +774,6 @@ Delete Notebook Notebook verwijderen Delete Page Pagina verwijderen Delete Presentation Presentatie verwijderen Delete Report Rapport verwijderen -Delete selected features Geselecteerde objecten verwijderen Delete selected rows Geselecteerde rijen verwijderen Delete Style Stijl verwijderen Delete Task Taak verwijderen @@ -736,11 +794,15 @@ Desktop and command line only Alleen desktopapp en opdrachtregel Details Details Details of the selected layer. Details van de geselecteerde laag. Deviations Afwijkingen +DGGS DGGS Diagnostic\nMonitor Diagnostische\nmonitor Diagnostic Monitor Diagnostische monitor Diagnostics Diagnostiek +Diagonal ↗ Diagonaal ↗ +Diagonal ↘ Diagonaal ↘ Diamond Ruit Difference, ratio or percent change between two rasters. Verschil, verhouding of procentuele verandering tussen twee rasters. +Digit Digit Directions Routebeschrijving Directions From Here Route vanaf hier Directions To Here Route naar hier @@ -758,6 +820,7 @@ Dissolve Field Samenvoegveld Distance Afstand Distance (km) Afstand (km) Distance Coefficient P Afstandscoëfficiënt P +Distance units Afstandseenheden Distances along the surface, heights, areas and volumes. Afstanden over het oppervlak, hoogten, oppervlakten en volumes. DISTINCT DISTINCT Distinct Uniek @@ -804,18 +867,23 @@ Drag to draw Sleep om te tekenen Drag to draw a freehand line. Sleep om een lijn uit de vrije hand te tekenen. Drag to draw a freehand polygon. Sleep om een polygoon uit de vrije hand te tekenen. Drag to draw a rectangle. Sleep om een rechthoek te tekenen. +Drag to resize the message box Sleep om het berichtvak van grootte te veranderen Drag tools from the palette onto the canvas. Sleep gereedschappen uit het palet naar het canvas. Drag vertices on the map; click an edge to add one Sleep hoekpunten op de kaart; klik op een rand om er een toe te voegen Draw a halo around label text. Een halo rond de labeltekst tekenen. Draw a labelled latitude/longitude grid over the map frame. Een gelabeld raster van breedte- en lengtegraden over het kaartkader tekenen. Draw a model: chain tools from input layers to the results to keep Teken een model: koppel gereedschappen van invoerlagen tot de te bewaren resultaten Draw a model: chain tools from input layers to the results to keep. Teken een model: koppel gereedschappen van invoerlagen tot de te bewaren resultaten. +Draw at its place in the stack, the bottom stroke first Tekenen op de plek in de stapel, de onderste streek eerst +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet De streken van alle objecten van één niveau vóór het volgende tekenen, zodat omlijningen onder de kernen samenkomen waar lijnen elkaar raken Draw labels at every scale and distance. Labels op elke schaal en afstand tekenen. Draw new features in the selected layer. Double-click to finish lines and polygons. Nieuwe objecten tekenen in de geselecteerde laag. Dubbelklik om lijnen en polygonen te voltooien. Draw new single-band rasters with the inverted grey stretch again. Nieuwe rasters met één band weer met de omgekeerde grijsstretch tekenen. +Draw only polygon outlines, keeping their colors and widths. Alleen polygoonomtrekken tekenen, met behoud van hun kleuren en breedtes. Draw points as bins coloured by how many fall in each. Punten tekenen als vakken gekleurd naar het aantal in elk vak. Draw polygons as outlines only. Polygonen alleen als omtrek tekenen. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Rasters met één band die vanaf nu worden toegevoegd met dit kleurenschema, deze stretch en deze resampling tekenen. +Draw strokes in levels Streken in niveaus tekenen Draw the layer at every scale. De laag op elke schaal tekenen. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). De kaart op de GPU tekenen (bèta; de wereldbol, weergave-effecten en kleurenzichtsimulatie blijven op de CPU-renderer). Draw the selected layer above others. De geselecteerde laag boven andere tekenen. @@ -828,6 +896,7 @@ drawn by the server getekend door de server Drive Rijden Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. Drone- en luchtbeelden gedeeld op OpenAerialMap, nieuwste eerst, toegevoegd als cloud-geoptimaliseerde GeoTIFF's. Duplicate Dupliceren +Duplicate Connection Verbinding dupliceren Duplicate Element Element dupliceren Duplicate Layout Lay-out dupliceren Duplicate Map Kaart dupliceren @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name Bijv. pop_est / 1000000 o e.g. 0, 255, -9999 bv. 0, 255, -9999 e.g. continent = 'Europe' bijv. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' bijv. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 bijv. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Elke geoverwerkingsrun wordt een stap, gekoppeld via de uitvoer Earth Index has no points in the view Earth Index heeft geen punten in de weergave Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Hoekpunten\nbewerken Edit and run the notebook. Het notebook bewerken en uitvoeren. Edit Attribute Attribuut bewerken Edit Cell Cel bewerken +Edit Connection Verbinding bewerken Edit Description Beschrijving bewerken Edit Graphic Grafisch element bewerken Edit Status Bewerkingsstatus @@ -882,6 +953,7 @@ Editor\nTools Editor-\ngereedschappen Editor Tools Editorgereedschappen Edits since the project was saved. Bewerkingen sinds het project is opgeslagen. Edits the project Bewerkt het project +Edit… Bewerken… Effects Effecten Element Element Element Properties Elementeigenschappen @@ -898,21 +970,25 @@ Enable Lighting Belichting inschakelen Enable Plugin Plugin inschakelen Enabled Ingeschakeld Encloses each feature, or all features, in its convex hull. Omsluit elk object, of alle objecten, met de convexe omhulling. +Encoded polyline Gecodeerde polylijn End Einde End date Einddatum Endpoint Eindpunt Enhancement Verbetering Enter Enter Enter an API key. Voer een API-sleutel in. +Enter an EPSG code as a number, such as 32650 Voer een EPSG-code in als getal, zoals 32650 Enter the provider's address. Voer het adres van de aanbieder in. Enter to send, Shift+Enter for a new line Enter om te verzenden, Shift+Enter voor een nieuwe regel EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Omgevingen EPSG EPSG +EPSG code EPSG-code EPSG or ESRI code EPSG- of ESRI-code EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Gelijke interval Equirectangular (Plate Carrée) Equirectangulair (Plate Carrée) @@ -922,14 +998,18 @@ Error Fout Error Inspector Foutinspecteur Error Inspector (validate) Foutinspecteur (valideren) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Europa oppervlaktegetrouw (ETRS89 LAEA) Every band's value at a pixel. De waarde van elke band op een pixel. Every editing tool. Alle bewerkingsgereedschappen. +Every label setting of this layer, in the Labeling pane. Alle labelinstellingen van deze laag, in het labelvenster. Every layer, as FEMA draws them Elke laag, zoals FEMA ze tekent +Every run, in order, as a script that opens the project Elke uitvoering, op volgorde, als script dat het project opent every Socrata site, or one such as data.cityofnewyork.us elke Socrata-site, of één zoals data.cityofnewyork.us Everywhere Overal +Existing field Bestaand veld Existing Template Bestaand sjabloon exit rotary verlaat het verkeersplein exit roundabout verlaat de rotonde @@ -948,6 +1028,7 @@ Export\nRaster Raster\nexporteren Export Animation Animatie exporteren Export Bookmarks Bladwijzers exporteren Export Bookmarks… Bladwijzers exporteren… +Export CSV… CSV exporteren… Export Features Objecten exporteren Export Features (tool) Objecten exporteren (gereedschap) Export Features needs a vector layer (Data › Export Raster writes a raster) Objecten exporteren vereist een vectorlaag (Gegevens › Raster exporteren schrijft een raster) @@ -996,6 +1077,7 @@ Face Culling Face culling Faces Vlakken Factor Factor Failed Mislukt +FALSE FALSE Farthest Verst Faster drawing: no antialiasing. Sneller tekenen: geen anti-aliasing. Favourite Data Source Favoriete gegevensbron @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Perceelgrenzen Field density Perceeldichtheid Field name Veldnaam +Field Prefix Veldvoorvoegsel +Field to Sum and Average Veld voor som en gemiddelde Fields Velden Fields are drawn from zoom 9: zoom in to see them. Percelen worden getekend vanaf zoomniveau 9: zoom in om ze te zien. Fields colored by score, drawn from zoom 9 Percelen gekleurd op score, getekend vanaf zoomniveau 9 @@ -1079,6 +1163,8 @@ Folder Connections Mapverbindingen folder connections need the desktop app mapverbindingen vereisen de desktopapp Folders Mappen Folder… Map… +Follow the time slider De tijdschuifregelaar volgen +Font Lettertype for a store that doesn't say voor een opslag die het niet aangeeft Forest Bos Forget Earthdata Login Token Earthdata Login-token vergeten @@ -1090,9 +1176,11 @@ France Frankrijk Free the prepared geometry. De voorbereide geometrie vrijgeven. Freehand Uit de vrije hand FROM FROM +From Van From Selection Uit selectie From Service… Uit dienst… From symbol Uit symbool +From the address Uit het adres from the address uit het adres from the service van de dienst From URL… Uit URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Objecten ver van het midden van de wee Hide Group Groep verbergen Hide labels when the viewer is closer than this. Labels verbergen wanneer de kijker dichterbij is. Hide labels when the viewer is farther than this. Labels verbergen wanneer de kijker verder weg is. +Hide Layers Lagen verbergen Hide other values Andere waarden verbergen Hide Steps Stappen verbergen Hide the description De beschrijving verbergen @@ -1213,6 +1302,8 @@ Hold Vasthouden HOME HOME Home Start Home › Add Start › Toevoegen +Home › Add Data Start › Gegevens toevoegen +Home › Inspect › Measure Start › Inspecteren › Meten Home › Navigate Start › Navigeren Home › Navigate › Bookmarks Start › Navigeren › Bladwijzers Home › Web Data Start › Webgegevens @@ -1220,7 +1311,7 @@ Home › Web Data › United States Start › Webgegevens › Verenigde Staten Homepage Website Horizontal Horizontaal host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=…, of een .duckdb-bestand +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Gehoste beelden Hour Uur How the layer's colours combine with the tile layers and basemap beneath. Hoe de kleuren van de laag zich mengen met de tegellagen en de basiskaart eronder. @@ -1248,6 +1339,7 @@ Imagery Sharing Beelden delen Imagery Tools Beeldgereedschappen ImageServer ImageServer Image… Afbeelding… +Imperial Imperiaal Import Importeren Import\nProject Project\nimporteren Import All Alles importeren @@ -1267,6 +1359,7 @@ Import Task Taak importeren Import Task… Taak importeren… Import Thumbnail Miniatuur importeren Import… Importeren… +IN IN In Beyond Ingezoomd voorbij In memory In het geheugen In Progress Bezig @@ -1281,6 +1374,8 @@ Information Informatie INPUT INVOER Input Invoer Input Features Invoerobjecten +Input Layer Invoerlaag +Input Points Invoerpunten Input Polygons Invoerpolygonen Input Rows Invoerrijen Input Summary Features Invoerobjecten voor samenvatting @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Geïnstalleerde plugins worden bewaard in Installed with Homebrew, winget, the AUR or COPR? Update it there. Geïnstalleerd met Homebrew, winget, de AUR of COPR? Werk het daar bij. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} geïnstalleerd. Je vindt de plugin op het tabblad Plugins; de gereedschappen staan ook in het gereedschappenvenster. Installing it downloads Python {version} and the packages it needs from the internet. Bij het installeren worden Python {version} en de benodigde pakketten van internet gedownload. +Integer field: values are rounded. Geheel-getalveld: waarden worden afgerond. Interface font: {font} Interfacelettertype: {font} Interpolate a surface from points (IDW) Een oppervlak interpoleren uit punten (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Interpoleert een rasteroppervlak met 2 banden (voorspelling en variantie) uit puntobjecten met ordinary kriging. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. Interpoleert een rasteroppervlak uit objectwaarden met inverse afstandsweging, zoals QGIS dat doet. Interpolation Interpolatie Interpolation Attribute Interpolatieattribuut @@ -1316,10 +1413,13 @@ Invalid: {error} Ongeldig: {error} Invert Omkeren Invert color scheme Kleurenschema omkeren Invert the selection. De selectie omkeren. +IS IS IS NULL IS NULL it connects to an address on the internet het maakt verbinding met een adres op internet It has no code: nothing in it runs. Het bevat geen code: er wordt niets uitgevoerd. +It is locked, or one of its surrounds is: unlock it first Het is vergrendeld, of een van de kaartelementen ervan: ontgrendel het eerst it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) het is aan jou voorbehouden (plugins, scripts, taken, modellen en de GeoLibre-runner) +it needs input: open it from its pane or button, or run it from a script or the assistant het heeft invoer nodig: open het via het venster of de knop, of voer het uit vanuit een script of de assistent it reads or writes files on this computer het leest of schrijft bestanden op deze computer it replaces the open project het vervangt het geopende project it runs a plugin's command het voert een opdracht van een plugin uit @@ -1332,20 +1432,30 @@ Italic Cursief Item Pop-ups Item-pop-ups Item {id} Item {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. De firewall ({firewall}) heeft RustGIS geblokkeerd. De dienst staat misschien alleen bepaalde toepassingen toe: neem contact op met de beheerder en stuur de onderstaande details. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Het gereedschap is nu niet beschikbaar (van een plug-in die niet geladen is, of van een suite die niet geïnstalleerd is) Jobs Jobs JOIN JOIN Join Lijnverbinding +Join Features Join-objecten +Join Operation Join-bewerking +Joins attributes from a join layer to a target layer based on their spatial relationship. Koppelt attributen uit een join-laag aan een doellaag op basis van hun ruimtelijke relatie. +Journal figure, 1 column (90 mm) Tijdschriftfiguur, 1 kolom (90 mm) +Journal figure, 1.5 columns (140 mm) Tijdschriftfiguur, 1,5 kolom (140 mm) +Journal figure, 2 columns (190 mm) Tijdschriftfiguur, 2 kolommen (190 mm) Keep a history of every edit to the layer, to look back or restore. Een geschiedenis van elke bewerking aan de laag bijhouden, om terug te kijken of te herstellen. Keep an archive of edits Een archief van bewerkingen bijhouden keep current huidige behouden +Keep it from being moved, resized, deleted or edited by mistake Voorkomen dat het per ongeluk wordt verplaatst, van grootte veranderd, verwijderd of bewerkt Keep one value of each point's vector as its value field Eén waarde van de vector van elk punt bewaren als waardeveld Keep Result Resultaat bewaren Keep the labelled features fixed as the map moves. De gelabelde objecten vasthouden terwijl de kaart beweegt. Keep the last drawing tool active after each graphic. Het laatste tekengereedschap actief houden na elk grafisch element. Keep the last tool active Het laatste gereedschap actief houden +Keep this endpoint under Data Sources › Connections, without its password or keys Dit eindpunt bewaren onder Gegevensbronnen › Verbindingen, zonder wachtwoord of sleutels Keep this result Dit resultaat bewaren Keep Tool Gereedschap vasthouden Keep value Waarde bewaren +Key Key Keyboard Shortcuts Sneltoetsen Keyboard shortcuts. Sneltoetsen. Keys from Sleutels uit @@ -1356,13 +1466,20 @@ KMZ KMZ Label Label Label Class Labelklasse Label Distance Range Labelafstandsbereik +Label features in this layer Objecten in deze laag labelen Label Field Labelveld +Label only the features that match. Alleen de overeenkomende objecten labelen. Label Placement Labelplaatsing Label Range Labelbereik Label the cities with their names De steden labelen met hun namen Label Visibility Range Labelzichtbaarheidsbereik Labeling Labels +Labeling Pane Labelvenster +Labeling Properties… Labeleigenschappen… Labels Labels +labels are drawn for feature layers, not rasters labels worden getekend voor objectlagen, niet voor rasters +Labels are drawn for feature layers, not rasters. Labels worden getekend voor objectlagen, niet voor rasters. +Labels are off for this layer: the clause applies once they are on. Labels staan uit voor deze laag: de clausule geldt zodra ze aan staan. Lagoon Lagune Lambert Conformal Conic Lambert conforme kegelprojectie Lambert Cylindrical Equal Area Lambert cilindrisch oppervlaktegetrouw @@ -1383,12 +1500,14 @@ Layer Blend Laagmenging Layer Extrusion Laagextrusie Layer File Laagbestand Layer From\nSelection Laag uit\nselectie +Layer not found Laag niet gevonden Layer Template Laagsjabloon Layer Templates Laagsjablonen layer {id} laag {id} Layers Lagen Layers ({n}) Lagen ({n}) Layers and Table Views Lagen en tabelweergaven +Layers, a date each Lagen, elk een datum Layout Lay-out Layout\nFile Lay-out-\nbestand Layout after: {name} Lay-out na: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Leer-\nbronnen Left Links Legend Legenda Legend label. Clear it to show the value again. Legendalabel. Wis het om de waarde weer te tonen. -Length {length} · gain {gain} m · loss {loss} m Lengte {length} · stijging {gain} m · daling {loss} m +Length {length} · gain {gain} · loss {loss} Lengte {length} · stijging {gain} · daling {loss} Let the assistant hand tasks to agents on this computer Laat de assistent taken overdragen aan agents op deze computer Let your Agents Control RustGIS Laat je agents RustGIS besturen Letter spacing Letterafstand @@ -1439,6 +1558,7 @@ List by drawing order Weergeven op tekenvolgorde List by selection Weergeven per selectie List Columns in File Kolommen in bestand weergeven List Commands Opdrachten weergeven +List Connections Verbindingen weergeven List Data Sources Gegevensbronnen weergeven List Database Columns Databasekolommen weergeven List Database Tables Databasetabellen weergeven @@ -1475,6 +1595,11 @@ Locate… Zoeken… Location Locatie Locator Locator Lock Vergrendelen +Locked Vergrendeld +Locked: unlock it in the Format pane to move or change it Vergrendeld: ontgrendel het in het opmaakvenster om het te verplaatsen of te wijzigen +Log In to Connection Aanmelden bij verbinding +Log In to {name}… Aanmelden bij {name}… +Log In… Aanmelden… Long Lang geheel getal Longitude Lengtegraad Longitude (X) Lengtegraad (X) @@ -1493,6 +1618,7 @@ Manage Beheren Manage Bookmarks Bladwijzers beheren Manage Edits Bewerkingen beheren Manage Templates Sjablonen beheren +Mann–Kendall Mann–Kendall Map Kaart Map\nFrame Kaart-\nkader Map (the current view) Kaart (de huidige weergave) @@ -1504,6 +1630,7 @@ Map File Kaartbestand Map Frame Kaartkader Map Frames Kaartkaders Map frames per second (live while the map moves) Kaartframes per seconde (live terwijl de kaart beweegt) +Map Labeling Kaartlabels Map Link Kaartlink Map name Kaartnaam Map Page Kaartpagina @@ -1515,6 +1642,7 @@ Map style (JSON) Kaartstijl (JSON) Map Surrounds Kaartelementen Map Thumbnail Kaartminiatuur Map tiles of the whole world Kaarttegels van de hele wereld +Map Tool: {tool} Kaartgereedschap: {tool} Map View Kaartweergave map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Kaart: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Markering Mask Values Waarden maskeren Masking Maskeren +Match Option Overeenkomstoptie Matching places Overeenkomende plaatsen Max Max Max % Max % +Max Neighbors Max. buren Maximum zoom Maximaal zoomniveau MCP Relay MCP-relay MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCP-relay luistert al op 127.0.0.1:{port}; stop hem eerst om poort {asked} te gebruiken @@ -1542,6 +1672,8 @@ Measure Distance Afstand meten Measure geodesic distances and areas on the map. Geodetische afstanden en oppervlakten op de kaart meten. Measure on this body, with one of its basemaps. Meten op dit hemellichaam, met een van zijn basiskaarten. Measure with an elevation raster Meten met een hoogteraster +Measurement units Meeteenheden +Measurement units must be metric or imperial Meeteenheden moeten metrisch of imperiaal zijn Measurements so far. Metingen tot nu toe. Mensuration Mensuratie Mensuration Tools Mensuratiegereedschappen @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Voegt polyg Messages appear here. Berichten verschijnen hier. Meters Meter Method Methode +Metric Metrisch Midnight Middernacht Miles Mijl Miller Cylindrical Miller cilindrisch @@ -1560,21 +1693,43 @@ Min must be less than Max Min moet kleiner zijn dan Max Min must not be more than Max Min mag niet groter zijn dan Max Minimum Bounding Geometry Minimale begrenzende geometrie Minimum Maximum Minimum maximum +Minus Minus Minutes Minuten minutes ago minuten geleden Miter Verstek Mobile Map Mobiele kaart Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Model Model\nBuilder Model-\nBuilder model id model-id Model output Modeluitvoer ModelBuilder ModelBuilder Models Modellen +Modified Mann–Kendall (Hamed & Rao) Gemodificeerde Mann–Kendall (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Gemodificeerde Mann–Kendall (Yue & Wang) Modify Wijzigen Modify Selected Features Geselecteerde objecten wijzigen Mollweide (equal-area world) Mollweide (oppervlaktegetrouwe wereld) Month Maand +Months Maanden +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Maanden zijn getallen van 1 tot 12 (12, 1, 2 voor december tot februari) More Meer More basemaps: Meer basiskaarten: Mosaic images into an orthomosaic Beelden tot een orthomozaïek samenvoegen @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. De zichtbare rasters tot een ort Most cloud cover (%) Maximale bewolking (%) Mouse wheel Muiswiel Move Verplaatsen -Move (m) Verplaatsen (m) Move Bookmark Bladwijzer verplaatsen Move Down Omlaag Move down Omlaag Move Group Groep verplaatsen +Move Label Class Labelklasse verplaatsen Move Task Step Taakstap verplaatsen +Move To Bottom Helemaal omlaag Move To Group Naar groep verplaatsen +Move To Top Helemaal omhoog Move To {group} Naar {group} verplaatsen Move Up Omhoog Move up Omhoog @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Mijn buckets My OGC Layer Mijn OGC-laag +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Naam name the plugin whose panel to open: {"id": plugin id} geef de plugin waarvan het paneel moet openen: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Nieuw\nnotebook New\nPresentation Nieuwe\npresentatie New\nReport Nieuw\nrapport New\nWorkspace Nieuwe\nwerkruimte -New / Open / Save project Project nieuw / openen / opslaan New Bookmark Nieuwe bladwijzer New Bookmark… Nieuwe bladwijzer… +New Connection Nieuwe verbinding +New Connection ▾ Nieuwe verbinding ▾ New conversation Nieuw gesprek New Custom Template Nieuw aangepast sjabloon New Custom Template… Nieuw aangepast sjabloon… New features use this template's values Nieuwe objecten gebruiken de waarden van dit sjabloon +New field Nieuw veld New Group Nieuwe groep New job name Naam van nieuwe job New Layout Nieuwe lay-out @@ -1649,6 +1809,7 @@ New Notebook Nieuw notebook New Presentation Nieuwe presentatie New Project Nieuw project New project Nieuw project +New Publication Layout Nieuwe publicatielay-out New Relationship… Nieuwe relatie… New Report Nieuw rapport New Selection Nieuwe selectie @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Nog geen bladw No catalogs below this one: Find Items lists its items Geen catalogi onder deze: Items zoeken toont de items No COMID was returned for this point. Voor dit punt is geen COMID teruggegeven. no command geen opdracht -No connections this session Geen verbindingen in deze sessie +No data at the points picked. Geen gegevens op de gekozen punten. No datasets match. Geen datasets gevonden. No datasets with data RustGIS can add Geen datasets met gegevens die RustGIS kan toevoegen +no date in the name geen datum in de naam No edits since the project was saved Geen bewerkingen sinds het project is opgeslagen No field of this layer holds dates. Geen veld van deze laag bevat datums. +No Fill Geen vulling +No geometry to calculate Geen geometrie om te berekenen No granules in this view and date range. Geen granules in deze weergave en periode. no home folder geen thuismap no layer geen laag @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) Geen relaties (Gegevensontwerp No relationships yet Nog geen relaties No reports yet. Publish › New Report lists the selected layer's records. Nog geen rapporten. Publiceren › Nieuw rapport geeft de records van de geselecteerde laag weer. no row geen rij +No serif font is installed, so this text is drawn in the sans-serif Er is geen schreeflettertype geïnstalleerd, dus deze tekst wordt schreefloos getekend no service or file links geen dienst- of bestandskoppelingen No services match your search. Geen diensten gevonden voor je zoekopdracht. No standalone tables Geen zelfstandige tabellen @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Geen None (public buckets) Geen (openbare buckets) none, a .duckdb file, or host=… dbname=… user=… geen, een .duckdb-bestand, of host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD geen, of uit PGPASSWORD of MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD geen, of uit PGPASSWORD, MYSQL_PWD of SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. Geen. Voeg er een toe via een adres of vanuit een basiskaartplugin. North Noord North\nArrow Noord-\npijl North Polar Stereographic Polair stereografisch noord +NOT NOT not a picture geen afbeelding not a RustGIS layout geen RustGIS-lay-out not a RustGIS project geen RustGIS-project +Not applied yet. Nog niet toegepast. Not for texts: a text is edited in the Format pane. Niet voor teksten: een tekst wordt bewerkt in het opmaakvenster. not needed niet nodig Not sandboxed Niet in sandbox @@ -1739,9 +1906,12 @@ Nothing found where the map is Niets gevonden waar de kaart is Nothing here Niets hier Nothing named {name} to rename Niets met de naam {name} om te hernoemen Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Nog niets gedeeld. Publiceren › Delen als maakt webkaarten, lagen, stijlen, scènes en meer. +Nothing to add here Hier valt niets toe te voegen +Nothing to test Niets om te testen Nothing was found at this address. Op dit adres is niets gevonden. Nothing was recorded, so no task was made. Er is niets opgenomen, dus er is geen taak gemaakt. Now Nu +Nugget Nugget NULL NULL Nulls Null-waarden Observations (observer, date, species, count) Waarnemingen (waarnemer, datum, soort, aantal) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. Op de desktop kan de sleutel On-screen compass, zoom slider and pan pad. Kompas, zoomschuif en pankussen op het scherm. One command per line: id {json}. Lines starting with # are comments. Eén opdracht per regel: id {json}. Regels die met # beginnen zijn commentaar. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Eén DuckDB-SELECT: de ST_-functies van DuckDB Spatial, bestanden en webadressen (read_parquet('…')), en de tabellen van de database hieronder. Ctrl+Enter voert hem uit. +One layer: a Zarr variable or a raster's bands Eén laag: een Zarr-variabele of de banden van een raster One page per feature of an index layer. Eén pagina per object van een indexlaag. One per line: code=name Eén per regel: code=naam One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Eén streek, uit het symbool. Voeg streken toe voor omlijningen, parallelle lijnen en verschuivingen. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Map openen Open In Geoprocessing… Openen in Geoverwerking… Open in ModelBuilder Openen in ModelBuilder +Open in Tool Openen in gereedschap Open Layout Lay-out openen Open ModelBuilder ModelBuilder openen Open Notebook Notebook openen @@ -1797,6 +1969,7 @@ Open Presentation Presentatie openen Open Project Project openen Open project Project openen Open Project (from text) Project openen (uit tekst) +Open Project… Project openen… Open Report Rapport openen Open the attribute table of the selected layer. De attributentabel van de geselecteerde laag openen. Open the Basemaps tab Het tabblad Basiskaarten openen @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Of voer uit in een POSIX-shel Or run in PowerShell, for Claude Code or Codex: Of voer uit in PowerShell, voor Claude Code of Codex: Or run, for Claude Code or Codex: Of voer uit, voor Claude Code of Codex: ORDER BY ORDER BY +Ordinary Kriging Ordinary kriging Orientation Oriëntatie Ortho Mapping Orthokartering Other Overig Other Data Overige gegevens +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Andere labelklassen op deze laag (gemaakt met opdrachten): {n}. Elke klasse labelt de objecten waarop haar eigen clausule past, dus een object kan een label van meer dan één klasse krijgen. Out Beyond Uitgezoomd voorbij Outline Omtrek Outline another frame's extent in this map frame. Het bereik van een ander kader in dit kaartkader omlijnen. @@ -1851,6 +2026,7 @@ Output Uitvoer Output Name Uitvoernaam Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Meer dan 1.000 raster-, vector-, LiDAR-, hydrologie- en terreingereedschappen die in de browser draaien, na een eenmalige download van ongeveer 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Meer dan 1.000 raster-, vector-, LiDAR-, hydrologie- en terreingereedschappen als optionele download van ongeveer 20 MB. +Overlapping Overlappend Overlay Overlay overlay overlay Overlay (a transparent layer kept on top, such as labels or roads) Overlay (een transparante laag die bovenop blijft, zoals labels of wegen) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture heeft geen {ty}-gegevens waa Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. De open data van Overture Maps: gebouwen, plaatsen, vervoer, bestuurlijke eenheden, basislagen en adressen. Waar de kaart is, worden de objecten ter plekke gelezen uit de GeoParquet-bestanden van Overture (zoom in tot een stad of kleiner); de hele wereld komt als kaarttegels. Overture Maps… Overture Maps… +Overwrite existing values Bestaande waarden overschrijven Package Pakket Package a trained deep learning model. Een getraind deep learning-model verpakken. Package Map Kaart verpakken @@ -1907,8 +2084,12 @@ Performance Prestaties Phase Fase Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Kies een live waarde, zoals de schaal of de datum, en klik op de kaart om hem te plaatsen. +Pick a Point Punt kiezen +Pick a point on the map to see the variable's values through time there. Kies een punt op de kaart om daar de waarden van de variabele door de tijd te zien. Pick a route in the list, or click a grey route on the map. Kies een route in de lijst, of klik op een grijze route op de kaart. +Pick a single layer. Kies één laag. Pick it on the map Op de kaart aanwijzen +Pick one… Kies er een… Picture Afbeelding Pictures Afbeeldingen Pictures, attachments and chart Afbeeldingen, bijlagen en diagram @@ -1966,8 +2147,11 @@ Portal Portal Portolan Portolan Portolan… Portolan… Portrait Staand +Position Positie +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Nabewerking power 2 macht 2 +Precision 6 (OSRM, Valhalla) Precisie 6 (OSRM, Valhalla) Prepare Voorbereiden Prepare every layer's geometry for this projection. De geometrie van elke laag voorbereiden voor deze projectie. Prepare Plugin Environment Pluginomgeving voorbereiden @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Projectsjablonen: projecte project's path is required het pad van project is verplicht Projection Projectie Projections Projecties +Properties Eigenschappen +Property Eigenschap Proportional Symbols Proportionele symbolen Provider Aanbieder +Provinces: Provincies: Proximity Nabijheid Public GIS portals of US counties and cities, by state. Openbare GIS-portals van Amerikaanse county's en steden, per staat. Public GIS portals of US federal agencies, by department. Openbare GIS-portals van Amerikaanse federale instanties, per departement. Public GIS portals of US states and the District of Columbia. Openbare GIS-portals van de Amerikaanse staten en het District of Columbia. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. Openbare lagen op ArcGIS Online of een ander ArcGIS-portal: object-, kaart-, beeld- en vectortegeldiensten, webkaarten en GeoJSON-bestanden. +Publication Layout Publicatielay-out Publish Publiceren Publish › Output Publiceren › Uitvoer Put the layer back as it was then De laag terugzetten zoals ze toen was Python environment failed: {error} Python-omgeving mislukt: {error} Python environment not prepared Python-omgeving niet voorbereid Python environment ready Python-omgeving gereed +Python script Python-script Qualitative Kwalitatief Quantile Kwantiel Query files, web addresses and databases with DuckDB Spatial; add results to the map. Bestanden, webadressen en databases bevragen met DuckDB Spatial; resultaten aan de kaart toevoegen. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Polygonen verhogen met een hoogte of een Raise Priority Prioriteit verhogen Random points in polygons Willekeurige punten in polygonen Range Bereik +Range (Degrees) Bereik (graden) +range: 0, 0.9 bereik: 0, 0.9 Raster Raster Raster\nAnalysis Raster-\nanalyse Raster\nInformation Raster-\ninformatie @@ -2075,6 +2266,7 @@ Redo {action} {action} opnieuw Red–Blue Rood–Blauw Refine the search to see {n} more Verfijn de zoekopdracht om {n} meer te zien Refresh Vernieuwen +Refresh every connection's lists De lijsten van alle verbindingen vernieuwen Refresh Map Series Kaartreeks vernieuwen Refresh the map series from its index layer. De kaartreeks vernieuwen vanuit de indexlaag. Region Regio @@ -2091,11 +2283,10 @@ Relationships Relaties Release Release Reload Opnieuw laden Reload Web Layers Weblagen opnieuw laden -Remember (without the secret) Onthouden (zonder geheime sleutel) Remember the key on this computer Sleutel op deze computer onthouden -Remembered: Onthouden: Remove Verwijderen Remove All Alles verwijderen +Remove All Label Classes Alle labelklassen verwijderen Remove an offline area. Een offline gebied verwijderen. Remove Bookmark Bladwijzer verwijderen Remove Connection Verbinding verwijderen @@ -2108,11 +2299,14 @@ Remove from Favourites Uit favorieten verwijderen Remove From Group Uit groep verwijderen Remove Group Groep verwijderen Remove Keyframe Sleutelframe verwijderen +Remove Label Class Labelklasse verwijderen Remove Section Sectie verwijderen Remove Table Tabel verwijderen +Remove the clause. De clausule verwijderen. Remove the last keyframe. Het laatste sleutelframe verwijderen. Remove the layer from the map. De laag van de kaart verwijderen. Remove the strokes and draw the symbol's line alone De streken verwijderen en alleen de lijn van het symbool tekenen +Remove this class's own strokes and draw it with the layer's De eigen streken van deze klasse verwijderen en haar tekenen met die van de laag Remove this stop Deze tussenstop verwijderen Remove Tile Layer Tegellaag verwijderen Remove token Token verwijderen @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Verwijd Rename Hernoemen Rename Bookmark Bladwijzer hernoemen Rename Group Groep hernoemen +Rename it to save it: a connection has this name Hernoem haar om haar op te slaan: een verbinding heeft deze naam al +Rename Label Class Labelklasse hernoemen Rename Layer Laag hernoemen Rename Layout Lay-out hernoemen Rename Map Kaart hernoemen @@ -2149,6 +2345,7 @@ Reset Default Standaard herstellen Reset Effects Effecten herstellen Reset North Noorden herstellen Reset north Noorden herstellen +Resolution (0–30) Resolutie (0–30) Response Antwoord Restore Herstellen Restore From Archive Herstellen uit archief @@ -2162,6 +2359,7 @@ RGB RGB Ribbon Lint Right Rechts Right-click a source to add it here Rechtsklik op een bron om die hier toe te voegen +Right-click for more Rechtsklik voor meer Robinson Robinson Rotate Roteren Rotate (°) Roteren (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Voorbeeldgegevens (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Voorbeeldgegevens © Natural Earth (publiek domein). RustGIS valt onder de Apache-2.0-licentie. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Voorbeeldgegevens: Natural Earth (publiek domein). Licentie Apache-2.0. +Sans-serif Schreefloos Satellite Embedding Datasets Datasets met satellietembeddings Satellite Embeddings Satellietembeddings Satellite Embeddings… Satellietembeddings… Saturation Verzadiging Save Opslaan Save As Opslaan als +Save as Connection Opslaan als verbinding Save as Default Opslaan als standaard Save as Default Raster Style Opslaan als standaard rasterstijl Save as Layer Opslaan als laag Save As Presentation File… Opslaan als presentatiebestand… +Save as Python Script… Opslaan als Python-script… Save As Report File… Opslaan als rapportbestand… Save As Report Template… Opslaan als rapportsjabloon… Save as {name} Opslaan als {name} Save As… Opslaan als… Save Color Ramps Kleurverlopen opslaan Save Data Source Gegevensbron opslaan +Save History As Python Script Geschiedenis opslaan als Python-script Save Layer File Laagbestand opslaan Save Layout File Lay-outbestand opslaan Save Map File Kaartbestand opslaan @@ -2254,6 +2456,7 @@ save or load a pane set by name een vensterset op naam opslaan of laden Save Project Project opslaan Save project Project opslaan Save Project As Project opslaan als +Save Project As… Project opslaan als… Save Project Template Projectsjabloon opslaan Save Style Items Stijlitems opslaan Save Task Item Taakitem opslaan @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Het model opslaan als bestand (*.model.j Save the model in the project Het model in het project opslaan Save the open layout as a layout file. De geopende lay-out opslaan als lay-outbestand. Save the panes you have open, and switch back to them. De geopende vensters opslaan en er later naar terugschakelen. +Save the project too: the script opens its saved file, without changes made since. Sla ook het project op: het script opent het opgeslagen bestand, zonder wijzigingen die sindsdien zijn gemaakt. Save the project under a new name. Het project onder een nieuwe naam opslaan. Save the project with your edits. Het project met je bewerkingen opslaan. Save the project's custom color ramps to a color ramp file. De aangepaste kleurverlopen van het project opslaan in een kleurverloopbestand. @@ -2275,13 +2479,16 @@ Save Web Link Chart Web-koppelingsdiagram opslaan Save Web Map Webkaart opslaan Save Web Scene Webscène opslaan Saved Opgeslagen +Saved as the connection {name} Opgeslagen als de verbinding {name} Saved as the default style for new single-band rasters. Opgeslagen als standaardstijl voor nieuwe rasters met één band. +Saved connection Opgeslagen verbinding Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Opgeslagen sleutels worden onversleuteld bewaard in de voorkeuren van de app (op het web in de opslag van de browser). Saved models Opgeslagen modellen Saved symbology: apply it to the selected layer, or save the layer's. Opgeslagen symbologie: toepassen op de geselecteerde laag, of die van de laag opslaan. Saved View Opgeslagen weergave Saved {name} {name} opgeslagen Saved {name}. {name} opgeslagen. +Saved {path} {path} opgeslagen saving failed unexpectedly opslaan onverwacht mislukt Scale Schaal Scale\nBar Schaal-\nbalk @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Zoeken (dit portal heeft zoekwoorden nodig) Search Catalogue Catalogus doorzoeken Search commands — e.g. buffer, basemap, labels Opdrachten zoeken — bijv. buffer, basiskaart, labels Search datasets, e.g. GEDI biomass Datasets zoeken, bijv. GEDI biomass -Search Distance (km) Zoekafstand (km) +Search Distance Zoekafstand Search every command and its parameters Alle opdrachten en hun parameters doorzoeken Search layers (e.g. temperature) Lagen zoeken (bijv. temperatuur) Search layers, places, lat/lon… Lagen, plaatsen, lat/lon zoeken… @@ -2329,6 +2536,8 @@ Searching the folders… De mappen doorzoeken… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. Zoeken werkt hier, maar de gegevensservers van NASA laten webpagina's hun bestanden niet lezen (geen CORS), dus voor GeoTIFF's en downloads van beschermde gegevens is de desktop-app nodig. Downloaden opent een bestand in een nieuw tabblad, waar Earthdata Login je vraagt om je aan te melden. Searching… Zoeken… Searching… try again in a moment. Bezig met zoeken… probeer het zo opnieuw. +Seasonal Mann–Kendall Seizoensgebonden Mann–Kendall +Seasons a year Seizoenen per jaar Second standard parallel Tweede standaardparallel Seconds Seconden Secret access key Geheime toegangssleutel @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Scheidingsteken Sepia Sepia Sequential Sequentieel +Serif Schreef Server Server Service Group Servicegroep Service group data exceeds 64 MB. De gegevens van de servicegroep overschrijden 64 MB. Service Import Requests Importaanvragen voor service Service Layers Dienstlagen -Services Diensten +Session token Sessietoken Set Instellen Set Cell Size Celgrootte instellen Set Domain Domein instellen @@ -2411,6 +2621,7 @@ Shaded Schaduw Shaded Relief Schaduwreliëf Shape Vorm Shapefile Shapefile +Shapes Vormen Share Delen Share As Delen als Share every layer and table as OGC API Features collections. Alle lagen en tabellen delen als OGC API Features-collecties. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Details tonen bij het aanw Show different features at different scales. Verschillende objecten tonen op verschillende schalen. Show grid Raster tonen Show Group Groep tonen +Show labels Labels tonen Show latitude/longitude lines on the basemap. Lengte- en breedtelijnen op de basiskaart tonen. +Show Layers Lagen tonen Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Lagen door de tijd tonen: alleen de objecten waarvan de datum in het venster van de schuifregelaar valt, en gedateerde beelden voor de datum. Show Map Kaart tonen Show Me Laat zien @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Het gereedschappenvenster tonen of verbergen. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Linialen tonen. Sleep vanaf een liniaal om een hulplijn toe te voegen; sleep een hulplijn terug op de liniaal om hem te verwijderen. Show the bands picked, read in place as the map needs them De gekozen banden tonen, ter plekke gelezen wanneer de kaart ze nodig heeft Show the description De beschrijving tonen +Show the Labeling pane for the selected layer. Het labelvenster voor de geselecteerde laag tonen. Show the labels that don't fit, in red. De labels die niet passen in rood tonen. Show the layer only inside another layer's polygons. De laag alleen binnen de polygonen van een andere laag tonen. Show the layer only left of a divider, to compare it with what's beneath. De laag alleen links van een scheidingslijn tonen, om te vergelijken met wat eronder ligt. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Aanmelding geannuleerd. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. De aanmeldtijd is verstreken. Begin opnieuw en geef de browser toegang tot het loopback-adres van deze computer. signed in aangemeld Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Afgemeld op deze computer, maar OpenAI heeft het niet bevestigd. Je kunt RustGIS ontkoppelen in de instellingen van ChatGPT. +Significance level Significantieniveau Signing in with ChatGPT works in the desktop app. Aanmelden met ChatGPT werkt in de desktop-app. Signing the asset (Planetary Computer)… Asset ondertekenen (Planetary Computer)… +Sill Sill Simplify Vereenvoudigen Simulate the drive, or save the route as a layer. De rit simuleren, of de route als laag opslaan. Simulation Simulatie @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} Skills voor de AI-assistent: {names} Slideshow, PDF or PNG Diavoorstelling, PDF of PNG slight left flauw linksaf slight right flauw rechtsaf +Slope per Helling per Slope, aspect, hillshade, NDVI, band math, thresholds. Helling, expositie, reliëfschaduw, NDVI, bandberekening, drempelwaarden. Slope, hillshade, NDVI and more. Helling, reliëfschaduw, NDVI en meer. Small scales Kleine schalen @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Zuid South Polar Stereographic Polair stereografisch zuid +Spatial Join Ruimtelijke join Speaker notes Sprekersnotities Spectral Analysis Spectrale analyse Spectral indices Spectrale indices @@ -2530,6 +2748,7 @@ Split (click the line) Splitsen (klik op de lijn) SQL SQL SQL Pane SQL-venster SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL-query's lopen via DuckDB, een programma dat de desktopapp aanroept: open dit project in de desktopapp om het te bevragen. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL die waar moet zijn, bijv. pop > 0 Square Vierkant Squares Vierkanten @@ -2588,6 +2807,7 @@ Start over at the end while playing. Tijdens het afspelen aan het einde opnieuw Start With Starten met Start-up Opstarten starting with a project file needs the desktop app starten met een projectbestand vereist de desktop-app +Statistic Statistiek Statistics Statistieken Statistics Field Statistiekveld Status Status @@ -2613,8 +2833,10 @@ Stretch\nType Stretch-\ntype Stretch it over the map view, or give control points (one per line: column row lon lat). Over de kaartweergave uitrekken, of controlepunten opgeven (één per regel: kolom rij lon lat). Stretch Type Stretchtype Stretch type Stretchtype +Stroke Levels Streekniveaus Stroke {n} Streek {n} Strokes Streken +Strokes for Streken voor Style Stijl Style Items Stijlitems Style › Drawing Stijl › Tekenen @@ -2624,7 +2846,10 @@ Subreport: {name} Subrapport: {name} Subtypes Subtypen Subtypes, one per line: code=name Subtypen, één per regel: code=naam Suitability Modeler Geschiktheidsmodelleerder +Summarise Samenvatten +Summarise over time Samenvatten over de tijd Summarise the conversation so far, so a long chat stays within the model's context Vat het gesprek tot nu toe samen, zodat een lange chat binnen de context van het model blijft +Summarise Zarr Over Time Zarr samenvatten over de tijd Summarising the earlier conversation to keep it within the model's context… Het eerdere gesprek wordt samengevat zodat het binnen de context van het model blijft… Summarize Samenvatten Summarize\nWithin Samenvatten\nbinnen @@ -2658,6 +2883,8 @@ Table or layer Tabel of laag Tables Tabellen Tags Tags Take the data in view offline, to edit and sync back later. De gegevens in beeld offline nemen, om te bewerken en later terug te synchroniseren. +Target Features Doelobjecten +Target Records Doelrecords Task Taak Task Item Taakitem Task name Taaknaam @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Openbare GIS-gegevens van Tennessee (Tennessee GeoData, geodata.tn.gov): perceelgrenzen, vervoer, gezondheid, hydrologie en meer. Terrain Terrein Test Test +Test Connection Verbinding testen +Testing… Bezig met testen… Text Tekst Text Symbol Tekstsymbool That connection would create a loop. Die verbinding zou een lus maken. The address fields are empty in every row. De adresvelden zijn in elke rij leeg. +The answer isn't UTF-8 text Het antwoord is geen UTF-8-tekst the answer isn't UTF-8 text het antwoord is geen UTF-8-tekst The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. De assistent volgt de instructies van een skill wanneer hij die gebruikt. Wat ze ook zeggen, opdrachten die jouw toestemming nodig hebben, blijven erom vragen. The assistant is busy; stop it or wait for it to finish. De assistent is bezig; stop hem of wacht tot hij klaar is. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. Het tabblad van de plugin Basiskaa the capture failed unexpectedly het vastleggen is onverwacht mislukt The connection failed; trying again in {seconds} s. De verbinding is mislukt; nieuwe poging over {seconds} s. The country list couldn't be read ({e}): type a country's code De landenlijst kon niet worden gelezen ({e}): typ een landcode +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. De huidige hoogte blijft van kracht terwijl u de invoer bewerkt. Klik op ‘Hoogte toepassen’ om de waarde en eenheden toe te passen. The dataset has nothing RustGIS can add De dataset bevat niets wat RustGIS kan toevoegen The environment (AWS_ACCESS_KEY_ID…) De omgeving (AWS_ACCESS_KEY_ID…) The features where the map is De objecten waar de kaart is @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. De s The installed GeoLibre runner lists no tools. De geïnstalleerde GeoLibre-runner vermeldt geen gereedschappen. The job covers the map view De job beslaat de kaartweergave The key is kept for this session only. De sleutel wordt alleen voor deze sessie bewaard. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. De laag toont de stap waarop de tijdschuifregelaar staat, en zet de regelaar aan over de datums van de variabele. The layer's file couldn't be read: {error} Het bestand van de laag kon niet worden gelezen: {error} +The layer's strokes De streken van de laag the layout export failed unexpectedly het exporteren van de lay-out is onverwacht mislukt The link doesn't say its STAC API: Find Items searches {api} De link noemt geen STAC-API: Items zoeken doorzoekt {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. De lijst met plugins om door te bladeren. Laat leeg voor het RustGIS-pluginregister. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. De miniatuur van het project, uit de The projection the map opens in. De projectie waarin de kaart opent. The provider's content filter stopped the reply. Het inhoudsfilter van de provider heeft het antwoord gestopt. The public server is free and shared by many: it answers light use, and may ask you to wait. De openbare server is gratis en wordt door velen gedeeld: hij is bedoeld voor licht gebruik en kan je vragen te wachten. +The range is two numbers, low and high (0, 0.9) Het bereik is twee getallen, laag en hoog (0, 0.9) The raster's data couldn't be read. De rastergegevens konden niet worden gelezen. The raster's data couldn't be read: {path} De rastergegevens konden niet worden gelezen: {path} the release isn't known de release is onbekend @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. De bestanden van de Shapefile worden The share of each cell covered by fields, for the zoomed-out view Het aandeel van elke cel dat door percelen wordt bedekt, voor de uitgezoomde weergave The sign-in expired or was revoked. Sign in again. De aanmelding is verlopen of ingetrokken. Meld je opnieuw aan. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. De aanmelding wordt bewaard in de instellingenmap van de app, alleen voor uw gebruikersaccount, en nooit in een projectbestand. +The significance level is a number, such as 0.05 Het significantieniveau is een getal, zoals 0.05 The start-up project {path} didn't open: {error} Het startproject {path} kon niet worden geopend: {error} The start-up project {path} isn't a RustGIS project file. Het startproject {path} is geen RustGIS-projectbestand. The step's layer {name} isn't in the map. De laag {name} van de stap staat niet in de kaart. The store has no variables with two or more dimensions. De opslag heeft geen variabelen met twee of meer dimensies. the store is gone de opslag is verdwenen +The summary couldn't start: {error} De samenvatting kon niet starten: {error} +The summary failed unexpectedly De samenvatting is onverwacht mislukt +The summary finished after another project was opened, so it wasn't added De samenvatting was klaar nadat een ander project was geopend, dus ze is niet toegevoegd The task's steps changed, so the step being edited was closed: edit it again. De stappen van de taak zijn gewijzigd, dus de stap die werd bewerkt is gesloten: bewerk hem opnieuw. the time slider is off de tijdschuifregelaar staat uit The token expires on {date}. Het token verloopt op {date}. +The trend couldn't start: {error} De trend kon niet starten: {error} +The trend failed unexpectedly De trend is onverwacht mislukt +The trend finished after another project was opened, so it wasn't added De trend was klaar nadat een ander project was geopend, dus hij is niet toegevoegd +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. De waarden die van zwart tot wit worden getoond, gelijk voor elke datum en tegel (in de eigen eenheden van de band). Leeg: de eigen stretch van elk bestand. the view has no extent de weergave heeft geen bereik The view is larger than one read: its centre was read (zoom in for the rest) De weergave is groter dan één leesbewerking: het midden is gelezen (zoom in voor de rest) The web app always runs the latest release: reload the page to get it. De webapp draait altijd de nieuwste release: herlaad de pagina om die te krijgen. the window's screenshot didn't come back in time, so the map was drawn offscreen de schermafbeelding van het venster kwam niet op tijd terug, dus is de kaart buiten beeld getekend the window's screenshot didn't hold the map, so it was drawn offscreen de schermafbeelding van het venster bevatte de kaart niet, dus is ze buiten beeld getekend Theme Thema +There is no expression defined. Er is geen expressie gedefinieerd. Thermal Thermisch +Thesis figure, full page (160 × 225 mm) Scriptiefiguur, hele pagina (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Scriptiefiguur, halve pagina (160 × 110 mm) Thinking… Denkt na… +Third-party licences Licenties van derden +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins deze build kan geen plugins downloaden this build can't reach AI providers deze build kan geen AI-providers bereiken this build saves projects with Save As deze versie slaat projecten op met Opslaan als This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Dit ChatGPT-account was al opgeslagen. Kies het de volgende keer in de lijst in plaats van het opnieuw toe te voegen. +This class is drawn with the layer's strokes. Changing them here gives it its own. Deze klasse wordt getekend met de streken van de laag. Als u ze hier wijzigt, krijgt ze eigen streken. +This class is hidden, so it labels nothing. Deze klasse is verborgen, dus ze labelt niets. +This clause no longer reads, so no feature is labelled: {error} Deze clausule is niet meer leesbaar, dus geen enkel object krijgt een label: {error} This map has no layers yet. Deze kaart heeft nog geen lagen. This map's thumbnail, from an image. De miniatuur van deze kaart, uit een afbeelding. This map's thumbnail, from the view. De miniatuur van deze kaart, uit de weergave. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Dit verzoek heeft geen optie om eenmalig toe te staan. This request uses an unsupported capability. Check the parameter and change the request. Dit verzoek gebruikt een niet-ondersteunde functie. Controleer de parameter en wijzig het verzoek. This result is kept Dit resultaat wordt bewaard +This run has no tool form to open Deze uitvoering heeft geen gereedschapsformulier om te openen +This session only (optional) Alleen deze sessie (optioneel) This step Deze stap This step names no tool to show. Deze stap noemt geen gereedschap om te tonen. Thumbnail Miniatuur @@ -2789,15 +3041,21 @@ Tilt Map Kaart kantelen Tilt-shift Tilt-shift Tilt-shift blur and film grain. Tilt-shift-vervaging en filmkorrel. Time Tijd +Time\nSeries Tijd-\nreeks Time\nSlider Tijd-\nregelaar Time Fields Tijdvelden +Time Series Tijdreeks +Time series Tijdreeks +Time Series at a Point Tijdreeks op een punt Time Slider Tijdschuifregelaar Time Slider Settings Instellingen tijdschuifregelaar Tint by elevation Kleuren op hoogte Tissot's Indicatrix Indicatrix van Tissot Title Titel +To Tot Toggle Dark Mode Donkere modus aan/uit Toggle GPU Rendering GPU-weergave in-/uitschakelen +Toggle Imperial Measurements Imperiale maateenheden omschakelen Tolerance (km) Tolerantie (km) Tolls Tolwegen Tool Gallery Gereedschapsgalerij @@ -2828,7 +3086,10 @@ Train Deep Learning Model Deep learning-model trainen Trained models Getrainde modellen Transparency Transparantie Traverse Polygoontrek +Trend Analysis Trendanalyse +Trend over a series of rasters Trend over een reeks rasters Triangle Driehoek +TRUE TRUE Try again in a moment. Probeer het zo meteen opnieuw. Try again later. Probeer het later opnieuw. Turn editing on or off for the map. Bewerken voor de kaart in- of uitschakelen. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map typ een lengte- en breedtegraad, Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Typ een schaal, zoals 1:12,345, 12345 of 25k, en druk op Enter Type an address or coordinates and press Enter, or click the map. Typ een adres of coördinaten en druk op Enter, of klik op de kaart. Type numbers separated by commas Typ getallen gescheiden door komma's +Type the keys to log in for this session, then Save. Typ de sleutels om u voor deze sessie aan te melden, en daarna Opslaan. +Type the password or keys to log in for this session, then Save. Typ het wachtwoord of de sleutels om u voor deze sessie aan te melden, en daarna Opslaan. Typed below Hieronder getypt UM UM Unavailable: {reason} Niet beschikbaar: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Loskoppelen van tijdschuifregelaar Unbind from Time Slider Loskoppelen van tijdschuifregelaar Undo Ongedaan maken Undo (Ctrl+Z) Ongedaan maken (Ctrl+Z) -Undo / Redo Ongedaan maken / Opnieuw Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Ongedaan maken verwijdert de opgenomen stap niet: verwijder hem in Stappen bewerken zodra je de opname stopt. Undo the last change. De laatste wijziging ongedaan maken. Undo {action} {action} ongedaan maken @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools GeoLibre-gereedschappen verwijderen Uninstall Plugin Plugin verwijderen Uninstalled {id}. {id} verwijderd. Unique Values Unieke waarden +Unit Eenheid United States Verenigde Staten Units Eenheden Units per step Eenheden per stap @@ -2877,6 +3140,7 @@ Up a level Een niveau omhoog Update Bijwerken Update Bookmark Bladwijzer bijwerken Update Job Job bijwerken +Update Label Class Labelklasse bijwerken Update to {version} Bijwerken naar {version} Upload File Bestand uploaden Upload File… Bestand uploaden… @@ -2895,6 +3159,7 @@ Use another account Ander account gebruiken Use Current View Huidige weergave gebruiken Use each feature's line color De lijnkleur van elk object gebruiken Use each feature's line width De lijndikte van elk object gebruiken +Use the Layer's Strokes Streken van de laag gebruiken USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan ChatGPT-abonnement in gebruik Utilities Nutsvoorzieningen Valid combinations, one per line: a, b Geldige combinaties, één per regel: a, b Valid expression · {n} matching rows Geldige expressie · {n} overeenkomende rijen +Valid: {n} of {of} features match Geldig: {n} van {of} objecten komen overeen Validate Valideren Validate Model Model valideren value (empty: no-data) waarde (leeg: no-data) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Vantors open beelden van rampen: kies een gebeurtenis en een fase (ervoor of erna), en dan Items zoeken. Vantor… Vantor… Variable Variabele +Variogram Model Variogrammodel Vector and raster data Vector- en rastergegevens Vector Layer Vectorlaag +vector tiles vectortegels Vector tiles (MVT) Vectortegels (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Vectoren die een geospatiaal foundation model voor elke pixel of beelduitsnede maakte, gevonden waar de kaart is en weergegeven als kleuren. Version {version} Versie {version} @@ -2933,6 +3201,9 @@ Video file or URL Videobestand of URL Video Page Videopagina Videos Video's Video… Video… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Nationaal coördinatensysteem van Vietnam (VN-2000) met 3-graden Transverse Mercator en een 7-parameter Helmert-transformatie naar WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Nationaal coördinatensysteem van Vietnam (VN-2000) UTM-zone 48N (102°E–108°E, centrale meridiaan 105°E). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Nationaal coördinatensysteem van Vietnam (VN-2000) UTM-zone 49N (108°E–114°E, centrale meridiaan 111°E). View Beeld View Clipping Weergave clippen View Effects Weergave-effecten @@ -2952,6 +3223,9 @@ Visibility Range Zichtbaarheidsbereik Visible Zichtbaar Visible Layers Zichtbare lagen Visual Effect Visueel effect +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vietnam) +VN-2000 / UTM zone 48N VN-2000 / UTM-zone 48N +VN-2000 / UTM zone 49N VN-2000 / UTM-zone 49N Volume Volume Waiting for the browser… Wachten op de browser… Waiting for your answer Wacht op je antwoord @@ -2970,6 +3244,7 @@ Web Link Chart Web-koppelingsdiagram Web maps Webkaarten Web Mercator Web Mercator Web Scene Webscène +Web service (detected) Webservice (gedetecteerd) Web Style Webstijl Web Tile Layers Webtegellagen Web Tool Webtool @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Waar labels staan ten opzichte van Where new features get their elevation (the z field). Waar nieuwe objecten hun hoogte vandaan halen (het z-veld). Where the map opens: the full extent or a view you saved. Waar de kaart opent: de volledige extent of een opgeslagen weergave. Where tools work Waar gereedschappen werken +Whether the Select tool picks these layers' features on the map Of het gereedschap Selecteren de objecten van deze lagen op de kaart kiest Which 5 countries have the largest GDP? Welke 5 landen hebben het hoogste bbp? Whitebox and GeoLibre tools Whitebox- en GeoLibre-gereedschappen Whole map Hele kaart @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Wereld, oppervlaktegetrouw: wa World, equal-area: true sizes, shapes bend towards the edges. Wereld, oppervlaktegetrouw: ware grootten, vormen buigen naar de randen. Worldview Grenzen Write a plugin Een plugin schrijven +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Schrijft de Sen-helling, de Z en p-waarde van de test, en de helling waar de trend significant is. X (mm) X (mm) X column X-kolom X Field X-veld @@ -3050,7 +3327,6 @@ Yours Eigen YYYY-MM-DD JJJJ-MM-DD YYYY-MM-DD, for the layers that change with time JJJJ-MM-DD, voor de lagen die met de tijd veranderen YYYY-MM-DD, for time layers JJJJ-MM-DD, voor tijdlagen -z (m) z (m) Z factor Z-factor Zarr Zarr Zarr Variables Zarr-variabelen @@ -3061,7 +3337,6 @@ Zoom Box Zoomkader Zoom Frame Zoomkader Zoom In Inzoomen Zoom in Inzoomen -Zoom in / out In- / uitzoomen Zoom In Here Hier inzoomen Zoom in on the centre of the map. Inzoomen op het midden van de kaart. Zoom in · finish a sketch Inzoomen · schets voltooien @@ -3073,6 +3348,7 @@ Zoom To\nLayer Zoomen\nnaar laag Zoom to a box (Explore) Zoomen naar een kader (Verkennen) Zoom To Extent Zoomen naar bereik Zoom To Layer Zoomen naar laag +Zoom To Layers Zoomen naar lagen Zoom To Map View Zoomen naar kaartweergave Zoom To Selection Zoomen naar selectie Zoom to selection Zoomen naar selectie @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Zoomen naar het bereik van de laag. Zoom to the selected features. Zoomen naar de geselecteerde objecten. Zoom to the whole world. Zoomen naar de hele wereld. Zoom to this bookmark Zoomen naar deze bladwijzer +Zoom To Tile Layer Zoomen naar tegellaag Zoom to Tokyo Zoomen naar Tokio Zoom To World Zoomen naar wereld +Zoom {z} · Bearing {b}° · Pitch {p}° Zoom {z} · Richting {b}° · Kanteling {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Zoom: het zoomniveau van de kaart, zoals webkaarten het tellen. Richting: de kant waar de bovenkant van de kaart naar wijst, met de klok mee vanaf het noorden. Kanteling: de helling ten opzichte van recht naar beneden kijken. Klik om richting en kanteling te herstellen. {agent} was stopped by you. Je hebt {agent} gestopt. {id} changed since it was installed; install it again from the Plugin Manager. {id} is gewijzigd sinds de installatie; installeer het opnieuw via Pluginbeheer. {names}: needs Node.js (npx) for its ACP adapter. {names}: heeft Node.js (npx) nodig voor de ACP-adapter. diff --git a/crates/ui-egui/src/i18n/pt.tsv b/crates/ui-egui/src/i18n/pt.tsv index 2d318d19c..facb6e7a9 100644 --- a/crates/ui-egui/src/i18n/pt.tsv +++ b/crates/ui-egui/src/i18n/pt.tsv @@ -6,6 +6,7 @@ (filtered) (filtrada) + Add Stop + Adicionar parada + Input + Entrada ++ New expression + Nova expressão + Output + Saída /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh Malha 3D A 3D globe seen from space. Um globo 3D visto do espaço. A blank map with a basemap Um mapa em branco com um mapa base +A call for the rustgis Python package Uma chamada para o pacote Python rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. O endereço de um catálogo (CSW 2.0.2): pesquise seus registros por palavras e, se quiser, pela área do mapa, e depois adicione os serviços e arquivos a que eles se vinculam. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. Um limite de uso do plano ChatGPT ou do aplicativo foi atingido. Consulte o uso semanal antes de tentar novamente. A ChatGPT sign-in can only be used with the OpenAI API. Um início de sessão do ChatGPT só pode ser usado com a API da OpenAI. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). Os conj A globe scene. Uma cena de globo. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Uma latitude e longitude (40.7, -74.0 ou 40°42'N 74°0'W), uma camada, um marcador ou um valor da tabela de uma camada. Enter escolhe a linha destacada; Pesquisar locais procura o texto como um local A layer the model reads Uma camada que o modelo lê +A line for the Script pane Uma linha para o painel Script A magnifying lens that follows the pointer. Uma lupa que acompanha o ponteiro. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). O endereço de um serviço de mapas, feições ou coberturas: WMS, WMTS, WFS, WCS, OGC API Features ou Tiles, ou um ArcGIS FeatureServer, MapServer, ImageServer ou VectorTileServer (ou um diretório de serviços, para navegar). a name, or owner/name such as giswqs/geospatial um nome, ou proprietário/nome como giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Um endpoint com nome que você pode reutilizar. Senhas e chaves são mantidas até o RustGIS fechar, nunca são salvas. A node cannot connect to itself. Um nó não pode se conectar a si mesmo. A notebook of text and command cells. Um notebook de células de texto e de comandos. +A password or token in the address was left out: connections never keep secrets. Uma senha ou token no endereço foi omitido: as conexões nunca guardam segredos. A place or an address Um local ou um endereço A project keeps at least one map Um projeto mantém pelo menos um mapa A projected coordinate system. Um sistema de coordenadas projetadas. A report listing the selected layer's records, page after page. Um relatório que lista os registros da camada selecionada, página após página. A result that only feeds other tools is removed once the model has run; add an output to keep it. Um resultado que só alimenta outras ferramentas é removido depois que o modelo é executado; adicione uma saída para mantê-lo. A result the model keeps, under a name Um resultado que o modelo mantém, com um nome +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) Um comando do rustgis-cli, para um shell POSIX (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Já está em curso uma importação ou atualização de serviço. +A service's layers stay in their service group. As camadas de um serviço permanecem no grupo do serviço. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Uma tarefa comporta no máximo 200 etapas: pare a gravação para criá-la e depois grave o restante como outra tarefa. A template with the first selected feature's values Um modelo com os valores da primeira feição selecionada +A tile layer per grid tile, each following the time slider through its dates. Uma camada de blocos por bloco da grade, cada uma seguindo o controle deslizante de tempo pelas suas datas. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. O endereço de um serviço de blocos: blocos raster com {z}/{x}/{y} (ou {-y}, {q}, {bbox-epsg-3857} para WMS), blocos vetoriais (.pbf) ou TileJSON, um estilo MapLibre, um arquivo PMTiles (um endereço web ou um arquivo neste computador) ou um arquivo MBTiles neste computador. A token is set. Há um token definido. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Um valor, um campo ou dois deles unidos por + − × ÷. Aplica-se às linhas selecionadas, ou a todas as linhas quando nenhuma estiver selecionada. a vector a pixel um vetor por pixel a vector an image patch um vetor por recorte de imagem A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Um armazenamento Zarr (formato 2 ou 3) pelo seu endereço web, com metadados consolidados, ou uma pasta neste computador. O mapa de uma variável é lido para os passos escolhidos das suas outras dimensões, como o tempo. +A Zarr variable's values through time at points clicked on the map. Os valores de uma variável Zarr ao longo do tempo em pontos clicados no mapa. +A2 landscape sheet Folha A2 paisagem +A2 portrait sheet Folha A2 retrato +A3 landscape sheet Folha A3 paisagem +A3 portrait sheet Folha A3 retrato +A4 landscape sheet Folha A4 paisagem +A4 portrait sheet Folha A4 retrato +A5 Grid Grade A5 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. As células pentagonais de áreas iguais do A5 em uma resolução, sobre a extensão do mapa, uma camada ou o mundo, como polígonos com seus identificadores. About Sobre About plugins Sobre plugins About RustGIS Sobre o RustGIS @@ -65,6 +83,7 @@ Add Adicionar Add\nData Adicionar\ndados Add ({n}) Adicionar ({n}) Add a bar chart of a layer's numeric field. Adicionar um gráfico de barras de um campo numérico de uma camada. +Add a circle. Adicionar um círculo. Add a colorbar of a raster layer's colour scale in the map frame. Adicionar ao quadro de mapa uma barra de cores da escala de cores de uma camada raster. Add a description. Adicionar uma descrição. Add a field. Adicionar um campo. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Adicionar um layout de pá Add a page to the presentation. Adicionar uma página à apresentação. Add a PNG or JPEG picture to the page. Adicionar uma imagem PNG ou JPEG à página. Add a presentation file. Adicionar um arquivo de apresentação. +Add a rectangle, to frame or highlight part of the page. Adicionar um retângulo, para enquadrar ou destacar parte da página. Add a report file, or a report template for the selected layer. Adicionar um arquivo de relatório ou um modelo de relatório para a camada selecionada. Add a scale bar for the map frame. Adicionar uma barra de escala para o quadro de mapa. add a second map first (Data › New Map) adicione primeiro um segundo mapa (Dados › Novo mapa) Add a second map frame first Adicione primeiro um segundo quadro de mapa +Add a straight line. Adicionar uma linha reta. Add a text element. Adicionar um elemento de texto. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Adicionar um mapa base web sobre os outros em vez de substituí-los, para mesclá-lo com eles. Clique em um que esteja visível para removê-lo. +Add a Zarr variable first (Add Data › Zarr…). Adicione primeiro uma variável Zarr (Adicionar dados › Zarr…). Add Administrative Boundaries Adicionar limites administrativos +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Adicionar uma seta. Gire-a com a alça redonda acima dela ou defina o ângulo nas propriedades. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. Adicione um token do Earthdata Login (Earthdata Login, acima) para colocar GeoTIFFs da NASA no mapa. Add an elevation raster first. Adicione primeiro um raster de elevação. Add an elevation raster to the map first Adicione primeiro um raster de elevação ao mapa +Add an ellipse. Adicionar uma elipse. Add an empty layer with a ready-made schema, to draw into. Adicionar uma camada vazia com um esquema pronto, para desenhar nela. Add an input for each layer the model reads. Adicione uma entrada para cada camada que o modelo lê. Add an output to keep a result under a name. Adicione uma saída para manter um resultado com um nome. Add and Open Adicionar e abrir Add and Open Notebook Adicionar e abrir notebook Add Archive Adicionar arquivamento +Add as Time Series Adicionar como série temporal add at least two keyframes adicione pelo menos dois quadros-chave Add Cell Adicionar célula Add Color Ramp File… Adicionar arquivo de rampa de cores… @@ -132,6 +157,7 @@ Add Graphics Layer Adicionar camada de gráficos Add Input Adicionar entrada Add Job Adicionar trabalho Add Keyframe Adicionar quadro-chave +Add Label Class Adicionar classe de rótulo Add Layer File Adicionar arquivo de camada Add Open Data Adicionar dados abertos Add Output Adicionar saída @@ -147,9 +173,11 @@ Add Rule Adicionar regra Add sample countries Adicionar países de exemplo Add Sample Data Adicionar dados de exemplo Add Satellite Embeddings Adicionar embeddings de satélite +Add Selected ({n}) Adicionar selecionados ({n}) Add Service Layer Adicionar camada de serviço Add skill… Adicionar habilidade… Add STAC Asset Adicionar asset STAC +Add STAC Data Cube Adicionar cubo de dados STAC Add STAC Footprints Adicionar footprints STAC Add Step Adicionar etapa Add Stroke Adicionar traço @@ -171,6 +199,7 @@ Add to Favourites Adicionar aos favoritos Add to Map Adicionar ao mapa Add vector data. Adicionar dados vetoriais. Add view Adicionar vista +Add With Options… Adicionar com opções… Add Zarr Variable Adicionar variável Zarr Add Zarr Variable… Adicionar variável Zarr… Add ▾ Adicionar ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Os agentes têm acesso total: podem executar scripts, tarefas, modelos e plugins. Clique para parar o relé MCP Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Os agentes deste computador que se conectarem (Claude Code, Codex, Cursor e outros) trabalham no mapa aberto aqui com as ferramentas do RustGIS: podem abrir e salvar arquivos e usar a web. Scripts e o gerenciamento de plugins continuam desativados. Agents then run commands, change files and use the web on this computer without asking you. Os agentes então executam comandos, alteram arquivos e usam a web neste computador sem perguntar a você. +Aggregate Points to A5 Agregar pontos em A5 Aggregation Agregação Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Limites de campos agrícolas que o modelo Fields of the World encontrou em imagens Sentinel-2, no mundo todo, um ano por vez (2ª edição, CC BY 4.0). São previsões do modelo, não parcelas levantadas. AI IA @@ -221,15 +251,18 @@ All steps done. Todas as etapas concluídas. All tables with geometry Todas as tabelas com geometria Allow Permitir Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Permita o uso do plano do ChatGPT ao iniciar sessão. Se o seu espaço de trabalho não o permitir, peça ao respetivo administrador ou escolha um fornecedor com chave de API. +Allow Feature Selection Permitir a seleção de feições Allow only certain combinations of values across fields. Permitir somente certas combinações de valores entre campos. Allow such actions for this conversation Permitir ações desse tipo nesta conversa Allow this action? Permitir esta ação? Allow this agent's RustGIS action? Permitir a ação do RustGIS deste agente? Along Selected Line Ao longo da linha selecionada +Alt+ Alt+ Alternating bar Barra alternada Altitude Altitude Always allow Sempre permitir -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3, ou um serviço compatível com S3 (MinIO, Cloudflare R2, Wasabi…) pelo seu endpoint. As chaves são mantidas até o RustGIS fechar; uma conexão lembrada mantém o nome, o endpoint e a região, nunca o segredo. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3, ou um serviço compatível com S3 (MinIO, Cloudflare R2, Wasabi…) pelo seu endpoint. As chaves são mantidas até o RustGIS fechar; uma conexão salva mantém o nome, o endpoint e a região, nunca o segredo. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Um agente deste computador pede para: An AWS profile Um perfil da AWS @@ -248,6 +281,7 @@ API key Chave de API Apply Aplicar Apply a function to a raster Aplicar uma função a um raster Apply Control Points Aplicar pontos de controle +Apply elevation Aplicar elevação Apply Style Aplicar estilo Apply to layers Aplicar às camadas Apply to the selected layer Aplicar à camada selecionada @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Área de uso: até {r}° de {lon} Area {area} · Perimeter {perimeter} Área {area} · Perímetro {perimeter} Areas (name, land use, area) Áreas (nome, uso do solo, área) Arrange Organizar +Arrow Seta +Arrowheads Pontas de seta Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Faça uma pergunta sobre seus dados ou descreva o que fazer com o mapa. O assistente executa comandos do RustGIS para você; toda alteração pode ser desfeita. Ask about the map, or tell it what to do… Pergunte sobre o mapa ou diga o que fazer… Ask about your data, or tell the assistant what to do with the map. Pergunte sobre seus dados ou diga ao assistente o que fazer com o mapa. @@ -275,12 +311,13 @@ Asking NASA's CMR… Consultando o CMR da NASA… Asking the catalog… Consultando o catálogo… Asking the NLDI… Consultando o NLDI… Assistant settings Configurações do assistente +At the end No fim +At the start No início Atlas Atlas Attachments Anexos Attribute\nTable Tabela de\natributos Attribute Rules Regras de atributo Attribute Table Tabela de atributos -Attribute table Tabela de atributos Attributes Atributos Attribution Atribuição Aurora Aurora @@ -295,11 +332,9 @@ Automatic (system language) Automático (idioma do sistema) Automatic (system time zone and region) Automático (fuso horário e região do sistema) Automatic ({kind}) Automático ({kind}) Avoid Evitar -AWS AWS AWS, anonymous AWS, anônimo Azimuth Azimute Back Voltar -Back to Explore; cancel a sketch Voltar a Explorar; cancelar um esboço Back to the Map Voltar ao mapa Back to the map Voltar ao mapa Back to tools Voltar às ferramentas @@ -314,8 +349,6 @@ Basemap Mapa base Basemaps Mapas base Basin from hydrolocation Bacia a partir de hidrolocalização Bearing Orientação -Bearing {b}° · Pitch {p}° Rumo {b}° · Inclinação {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Rumo: a direção para a qual o topo do mapa aponta, no sentido horário a partir do norte. Inclinação: o desvio em relação à vista de cima, na vertical. Clique para redefinir ambos. Before the event Antes do evento Below Abaixo Below 1:5,000,000 show everything; beyond it only what matches: Abaixo de 1:5.000.000 mostrar tudo; além disso, somente o que corresponder: @@ -328,10 +361,12 @@ Bilinear Bilinear Bind To Time Slider Vincular ao controle deslizante de tempo Bind to Time Slider Vincular ao controle deslizante de tempo Blank Em branco +Blank for Amazon S3 Em branco para Amazon S3 Blank Letter page Página Carta em branco Blank Map Mapa em branco Blank Page Página em branco Blend: {mode} Mesclagem: {mode} +Block Feature Selection Bloquear a seleção de feições Blue Azul Bold Negrito Bold label text. Texto do rótulo em negrito. @@ -339,6 +374,7 @@ Bookmark Description Descrição do marcador Bookmarks Marcadores Bookmarks Pane Painel Marcadores Boolean Booleano +Both ends Ambas as extremidades Bottom Inferior Boundaries… Limites… Bounding\nGeometry Geometria\ndelimitadora @@ -346,6 +382,7 @@ Brightness Brilho Brightness, contrast, saturation and sepia for the whole map. Brilho, contraste, saturação e sépia para o mapa inteiro. Bring this bookmark's map into view to update it Exiba o mapa deste marcador para atualizá-lo Browse Explorar +Browse Connection Navegar na conexão Browse it in the Browser Explorá-lo no Navegador Browse Plugins Explorar plugins Browse the geoprocessing toolbox. Explorar a caixa de ferramentas de geoprocessamento. @@ -357,6 +394,7 @@ Browser Navegador Browser\nPane Painel\nNavegador Browser View Visualização do navegador Bucket Bucket +Buckets Buckets Buffer Buffer Buffer A Point Buffer de um ponto Buffer the cities by 200 km Criar um buffer de 200 km ao redor das cidades @@ -381,7 +419,9 @@ Calculate Field Calcular campo Calculate field Calcular campo Calculate field values. Calcular os valores de um campo. Calculate fields and enforce conditions on every edit. Calcular campos e impor condições em cada edição. +Calculate Geometry Calcular geometria Calculate Geometry Attributes Calcular atributos de geometria +Calculated {rows} features {rows} feições calculadas Calculation Cálculo Camera Câmera Camera Effect Efeito de câmera @@ -396,12 +436,14 @@ Case Field Campo de caso Cast Shadow Projetar sombra Catalog Catálogo catalog is a static STAC catalog's catalog.json or collection.json catalog é o catalog.json ou collection.json de um catálogo STAC estático +Catalogue (CSW) Catálogo (CSW) Catalogue search (CSW) Pesquisa em catálogo (CSW) Celestial Bodies Corpos celestes Celestial Body Corpo celeste Celestial body: {body} Corpo celeste: {body} Cell size Tamanho da célula cell size ° tamanho da célula ° +Cells Células Center Centro Center Map Here Centralizar o mapa aqui Centered Centralizado @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. O uso do plano Ch ChatGPT sign-in failed. Check the connection and local settings, then try again. Falha ao entrar no ChatGPT. Verifique a conexão e as configurações locais e tente novamente. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. O uso do ChatGPT está temporariamente indisponível. Mantenha seu login e tente mais tarde. CHE CHE +Check Verificar Check Again Verificar novamente Check for\nUpdates Procurar\natualizações Check for Updates Procurar atualizações @@ -428,6 +471,7 @@ Check for Updates… Procurar atualizações… Check the address and the service type. Verifique o endereço e o tipo de serviço. Check the address and your connection. Verifique o endereço e sua conexão. Check the address. Verifique o endereço. +Check the clause, and count the features it takes. Verificar a cláusula e contar as feições que ela abrange. Check the integration's client, permissions, workspace policy and route configuration. Verifique o cliente, as permissões, a política do espaço de trabalho e a configuração de rotas da integração. Checking for updates… Procurando atualizações… China China @@ -455,6 +499,7 @@ Choose which layers a click identifies. Escolher quais camadas um clique identif Choose which tabs the ribbon shows. Escolher quais guias a faixa de opções mostra. Circle Círculo Circle Radius (km) Raio do círculo (km) +Class Classe Class color Cor da classe Class Style Estilo por classe Classes Classes @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Clique para adicionar v Click to edit the description Clique para editar a descrição Click to place a point graphic. Clique para posicionar um gráfico de ponto. Click to select the node Clique para selecionar o nó +Click to select, double-click to add, right-click for more Clique para selecionar, clique duplo para adicionar, clique direito para mais opções Click to switch between decimal degrees and DMS Clique para alternar entre graus decimais e GMS Clip Recortar Clip a raster to the view, or resample it. Recortar um raster pela visualização ou reamostrá-lo. @@ -532,7 +578,7 @@ Close thread Fechar thread Close {name} Fechar {name} Closest Mais próxima Coded values, one per line: code=description Valores codificados, um por linha: código=descrição -Collapse or expand the ribbon Recolher ou expandir a faixa de opções +Collapse or Expand the Ribbon Recolher ou expandir a faixa de opções Collapse the ribbon (Ctrl+F1) Recolher a faixa de opções (Ctrl+F1) Collection Coleção Collection {id}: choose dates and Find Items Coleção {id}: escolha as datas e Encontrar itens @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Combinações da caixa acima, uma por linha COMID {comid} COMID {comid} Comma Vírgula +Comma-separated (optional) Separados por vírgulas (opcional) Command\nSearch Pesquisar\ncomandos command is required command é obrigatório Command Search Pesquisa de comandos @@ -572,10 +619,13 @@ Connect Conectar Connect a folder to the Browser. Conectar uma pasta ao Navegador. Connect to S3 Conectar ao S3 Connect, or pick a layer Conecte ou escolha uma camada +Connected to {kind} {title} Conectado a {kind} {title} Connecting to {url}… Conectando a {url}… Connection Conexão +connection.new returned no id connection.new não retornou nenhum id Connections Conexões -Connect… Conectar… +Connections you add are listed here As conexões que você adicionar aparecem aqui +Connections… Conexões… Constant Constante Constant height Altura constante Constant… Constante… @@ -583,11 +633,14 @@ Contingent Values Valores contingentes Continue Continuar Continue with ChatGPT Continuar com o ChatGPT Contrast Contraste +Control Point Fit Ajuste por pontos de controle Conversion Conversão Convert Converter Convert a layer to another format Converter uma camada para outro formato Convert the selected layer's labels into an annotation layer. Converter os rótulos da camada selecionada em uma camada de anotação. +Convert this layer's labels into an annotation layer. Converter os rótulos desta camada em uma camada de anotação. Convert To Annotation Converter em anotação +Convert to Annotation Converter em anotação Converted data Dados convertidos Convert… Converter… Coordinate Coordenada @@ -599,11 +652,15 @@ Copied the map to the clipboard Mapa copiado para a área de transferência Copied {path} Copiado {path} Copies the (selected) features of a layer to a new layer. Copia as feições (selecionadas) de uma camada para uma nova camada. Copy Copiar +Copy / Cut / Paste features Copiar / Recortar / Colar feições Copy a model.run command that runs this model, for the Script pane or Python Copiar um comando model.run que executa este modelo, para o painel Script ou Python Copy Address Copiar endereço Copy address Copiar endereço Copy an image of the map to the clipboard. Copiar uma imagem do mapa para a área de transferência. +Copy as CLI Command Copiar como comando da CLI +Copy as Command Copiar como comando Copy As GeoJSON Copiar como GeoJSON +Copy as Python Copiar como Python Copy Command Copiar comando Copy coordinates to the clipboard Copiar as coordenadas para a área de transferência Copy details Copiar detalhes @@ -611,11 +668,13 @@ Copy Group Copiar grupo Copy Layer Copiar camada Copy Layers Copiar camadas Copy Path Copiar caminho +Copy Run As Code Copiar execução como código Copy sign-in link Copiar link de login Copy the selected features (also as GeoJSON text). Copiar as feições selecionadas (também como texto GeoJSON). Copy the selected features and delete them. Copiar as feições selecionadas e excluí-las. Copy the selected layer's data source path. Copiar o caminho da fonte de dados da camada selecionada. Copy this element (Ctrl+D, ⌘D on a Mac) Copiar este elemento (Ctrl+D, ⌘D em um Mac) +Copy this run as code, or open it in its tool Copiar esta execução como código ou abri-la na sua ferramenta Corrections Correções Cost Custo could not draw the map não foi possível desenhar o mapa @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Não foi possível carregar o registr Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Contagem, soma, média, mínimo, máximo e desvio padrão de um campo, opcionalmente por campo de caso. Countries, cities, rivers and lakes Países, cidades, rios e lagos Country País +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Conta os pontos de uma camada pela célula A5 que os contém (células de áreas iguais, então a contagem é uma densidade), com a soma e a média de um campo. Counts the points that fall inside each polygon. Conta os pontos que ficam dentro de cada polígono. Counts, totals and top categories of what's in view. Contagens, totais e principais categorias do que está à vista. CPU CPU @@ -647,12 +707,8 @@ Cross Section Seção transversal Cross Section (2 clicks) Seção transversal (2 cliques) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Cumulativo Current date Data atual Current Project Projeto atual @@ -680,13 +736,16 @@ Data type Tipo de dados Data you add is listed here Os dados que você adicionar aparecem aqui data.gov and CKAN catalogues Catálogos do data.gov e CKAN Database Banco de dados -Databases Bancos de dados +Database connections are used by the desktop app As conexões de banco de dados são usadas pelo aplicativo de desktop +databases are read through DuckDB, a program the browser can't run os bancos de dados são lidos pelo DuckDB, um programa que o navegador não consegue executar Database… Banco de dados… Dataset Conjunto de dados Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Conjuntos de dados no Hugging Face por nome ou proprietário/nome; navegue pelos arquivos de um conjunto de dados e adicione GeoTIFFs, GeoParquet, PMTiles, LiDAR e outros arquivos. Data… Dados… Date Data Dates Datas +Dates a cell needs Datas que uma célula precisa +Dates a cell needs is a whole number, or empty for half Datas que uma célula precisa é um número inteiro, ou vazio para a metade Day Dia Day and night shading for a time. Sombreamento de dia e noite para um horário. Decimal Decimal @@ -715,7 +774,6 @@ Delete Notebook Excluir notebook Delete Page Excluir página Delete Presentation Excluir apresentação Delete Report Excluir relatório -Delete selected features Excluir as feições selecionadas Delete selected rows Excluir as linhas selecionadas Delete Style Excluir estilo Delete Task Excluir tarefa @@ -736,11 +794,15 @@ Desktop and command line only Somente aplicativo desktop e linha de comando Details Detalhes Details of the selected layer. Detalhes da camada selecionada. Deviations Desvios +DGGS DGGS Diagnostic\nMonitor Monitor de\ndiagnóstico Diagnostic Monitor Monitor de diagnóstico Diagnostics Diagnóstico +Diagonal ↗ Diagonal ↗ +Diagonal ↘ Diagonal ↘ Diamond Losango Difference, ratio or percent change between two rasters. Diferença, razão ou variação percentual entre dois rasters. +Digit Digit Directions Rotas Directions From Here Rotas a partir daqui Directions To Here Rotas até aqui @@ -758,6 +820,7 @@ Dissolve Field Campo de dissolução Distance Distância Distance (km) Distância (km) Distance Coefficient P Coeficiente de distância P +Distance units Unidades de distância Distances along the surface, heights, areas and volumes. Distâncias ao longo da superfície, alturas, áreas e volumes. DISTINCT DISTINCT Distinct Distintos @@ -804,18 +867,23 @@ Drag to draw Arraste para desenhar Drag to draw a freehand line. Arraste para desenhar uma linha à mão livre. Drag to draw a freehand polygon. Arraste para desenhar um polígono à mão livre. Drag to draw a rectangle. Arraste para desenhar um retângulo. +Drag to resize the message box Arraste para redimensionar a caixa de mensagem Drag tools from the palette onto the canvas. Arraste ferramentas da paleta para a tela. Drag vertices on the map; click an edge to add one Arraste vértices no mapa; clique em uma aresta para adicionar um Draw a halo around label text. Desenhar um halo ao redor do texto do rótulo. Draw a labelled latitude/longitude grid over the map frame. Desenhar uma grade rotulada de latitude/longitude sobre o quadro de mapa. Draw a model: chain tools from input layers to the results to keep Desenhar um modelo: encadear ferramentas das camadas de entrada até os resultados a manter Draw a model: chain tools from input layers to the results to keep. Desenhar um modelo: encadear ferramentas das camadas de entrada até os resultados a manter. +Draw at its place in the stack, the bottom stroke first Desenhar na sua posição da pilha, primeiro o traço inferior +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Desenhar os traços de um nível de todas as feições antes do próximo, para que as bordas se unam sob os núcleos onde as linhas se encontram Draw labels at every scale and distance. Desenhar rótulos em qualquer escala e distância. Draw new features in the selected layer. Double-click to finish lines and polygons. Desenhar novas feições na camada selecionada. Clique duplo para concluir linhas e polígonos. Draw new single-band rasters with the inverted grey stretch again. Desenhar novamente os novos rasters de banda única com o realce em cinza invertido. +Draw only polygon outlines, keeping their colors and widths. Desenhar apenas os contornos dos polígonos, mantendo suas cores e larguras. Draw points as bins coloured by how many fall in each. Desenhar pontos como compartimentos coloridos pela quantidade que cai em cada um. Draw polygons as outlines only. Desenhar polígonos somente como contornos. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Desenhar os rasters de banda única adicionados a partir de agora com este esquema de cores, realce e reamostragem. +Draw strokes in levels Desenhar traços por níveis Draw the layer at every scale. Desenhar a camada em todas as escalas. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Desenhar o mapa na GPU (beta; o globo, os efeitos de visualização e a simulação de visão de cores continuam no renderizador de CPU). Draw the selected layer above others. Desenhar a camada selecionada acima das outras. @@ -828,6 +896,7 @@ drawn by the server desenhado pelo servidor Drive Carro Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. Imagens de drones e aéreas compartilhadas no OpenAerialMap, as mais recentes primeiro, adicionadas como GeoTIFFs otimizados para a nuvem. Duplicate Duplicar +Duplicate Connection Duplicar conexão Duplicate Element Duplicar elemento Duplicate Layout Duplicar layout Duplicate Map Duplicar mapa @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name ex.: pop_est / 1000000 ou e.g. 0, 255, -9999 ex.: 0, 255, -9999 e.g. continent = 'Europe' ex.: continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' ex.: pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 ex.: pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Cada execução de geoprocessamento se torna uma etapa, encadeada por suas saídas Earth Index has no points in the view O Earth Index não tem pontos na vista Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Editar\nvértices Edit and run the notebook. Editar e executar o notebook. Edit Attribute Editar atributo Edit Cell Editar célula +Edit Connection Editar conexão Edit Description Editar descrição Edit Graphic Editar gráfico Edit Status Status da edição @@ -882,6 +953,7 @@ Editor\nTools Ferramentas\nde edição Editor Tools Ferramentas de edição Edits since the project was saved. Edições desde que o projeto foi salvo. Edits the project Edita o projeto +Edit… Editar… Effects Efeitos Element Elemento Element Properties Propriedades do elemento @@ -898,21 +970,25 @@ Enable Lighting Ativar iluminação Enable Plugin Ativar plugin Enabled Ativado Encloses each feature, or all features, in its convex hull. Envolve cada feição, ou todas as feições, em seu fecho convexo. +Encoded polyline Polilinha codificada End Fim End date Data final Endpoint Endpoint Enhancement Realce Enter Enter Enter an API key. Informe uma chave de API. +Enter an EPSG code as a number, such as 32650 Digite um código EPSG como número, por exemplo 32650 Enter the provider's address. Informe o endereço do provedor. Enter to send, Shift+Enter for a new line Enter para enviar, Shift+Enter para uma nova linha EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Ambientes EPSG EPSG +EPSG code Código EPSG EPSG or ESRI code Código EPSG ou ESRI EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Intervalo igual Equirectangular (Plate Carrée) Equirretangular (Plate Carrée) @@ -922,14 +998,18 @@ Error Erro Error Inspector Inspetor de erros Error Inspector (validate) Inspetor de erros (validar) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Europa de áreas iguais (ETRS89 LAEA) Every band's value at a pixel. O valor de cada banda em um pixel. Every editing tool. Todas as ferramentas de edição. +Every label setting of this layer, in the Labeling pane. Todas as configurações de rótulos desta camada, no painel Rotulagem. Every layer, as FEMA draws them Todas as camadas, como a FEMA as desenha +Every run, in order, as a script that opens the project Todas as execuções, em ordem, como um script que abre o projeto every Socrata site, or one such as data.cityofnewyork.us todos os sites do Socrata, ou um como data.cityofnewyork.us Everywhere Em todo lugar +Existing field Campo existente Existing Template Modelo existente exit rotary saia da rotatória exit roundabout saia da rotatória @@ -948,6 +1028,7 @@ Export\nRaster Exportar\nraster Export Animation Exportar animação Export Bookmarks Exportar marcadores Export Bookmarks… Exportar marcadores… +Export CSV… Exportar CSV… Export Features Exportar feições Export Features (tool) Exportar feições (ferramenta) Export Features needs a vector layer (Data › Export Raster writes a raster) Exportar feições requer uma camada vetorial (Dados › Exportar raster grava um raster) @@ -996,6 +1077,7 @@ Face Culling Remoção de faces Faces Faces Factor Fator Failed Falhou +FALSE FALSE Farthest Mais distante Faster drawing: no antialiasing. Desenho mais rápido: sem suavização de serrilhado. Favourite Data Source Fonte de dados favorita @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Limites de campos agrícolas Field density Densidade de campos agrícolas Field name Nome do campo +Field Prefix Prefixo de campo +Field to Sum and Average Campo para somar e calcular a média Fields Campos Fields are drawn from zoom 9: zoom in to see them. Os campos agrícolas são desenhados a partir do zoom 9: aproxime para vê-los. Fields colored by score, drawn from zoom 9 Campos agrícolas coloridos por pontuação, desenhados a partir do zoom 9 @@ -1079,6 +1163,8 @@ Folder Connections Conexões de pasta folder connections need the desktop app conexões de pasta requerem o aplicativo desktop Folders Pastas Folder… Pasta… +Follow the time slider Seguir o controle deslizante de tempo +Font Fonte for a store that doesn't say para um armazenamento que não o indica Forest Floresta Forget Earthdata Login Token Esquecer token do Earthdata Login @@ -1090,9 +1176,11 @@ France França Free the prepared geometry. Liberar a geometria preparada. Freehand Mão livre FROM FROM +From De From Selection Da seleção From Service… De serviço… From symbol Do símbolo +From the address Do endereço from the address do endereço from the service do serviço From URL… De URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Ocultar feições distantes do centro Hide Group Ocultar grupo Hide labels when the viewer is closer than this. Ocultar os rótulos quando o observador estiver mais perto que isto. Hide labels when the viewer is farther than this. Ocultar os rótulos quando o observador estiver mais longe que isto. +Hide Layers Ocultar camadas Hide other values Ocultar outros valores Hide Steps Ocultar etapas Hide the description Ocultar a descrição @@ -1213,6 +1302,8 @@ Hold Em espera HOME HOME Home Início Home › Add Início › Adicionar +Home › Add Data Início › Adicionar dados +Home › Inspect › Measure Início › Inspecionar › Medir Home › Navigate Início › Navegar Home › Navigate › Bookmarks Início › Navegar › Marcadores Home › Web Data Início › Dados web @@ -1220,7 +1311,7 @@ Home › Web Data › United States Início › Dados web › Estados Unidos Homepage Página inicial Horizontal Horizontal host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=…, ou um arquivo .duckdb +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Imagens hospedadas Hour Hora How the layer's colours combine with the tile layers and basemap beneath. Como as cores da camada se combinam com as camadas de blocos e o mapa base abaixo. @@ -1248,6 +1339,7 @@ Imagery Sharing Compartilhamento de imagens Imagery Tools Ferramentas de imagens ImageServer ImageServer Image… Imagem… +Imperial Imperial Import Importar Import\nProject Importar\nprojeto Import All Importar tudo @@ -1267,6 +1359,7 @@ Import Task Importar tarefa Import Task… Importar tarefa… Import Thumbnail Importar miniatura Import… Importar… +IN IN In Beyond Limite ao aproximar In memory Na memória In Progress Em andamento @@ -1281,6 +1374,8 @@ Information Informações INPUT ENTRADA Input Entrada Input Features Feições de entrada +Input Layer Camada de entrada +Input Points Pontos de entrada Input Polygons Polígonos de entrada Input Rows Linhas de entrada Input Summary Features Feições de resumo de entrada @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Os plugins instalados ficam em {folder} Installed with Homebrew, winget, the AUR or COPR? Update it there. Instalou com Homebrew, winget, AUR ou COPR? Atualize por lá. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} instalado. Você o encontra na guia Plugins; as ferramentas dele também estão no painel Ferramentas. Installing it downloads Python {version} and the packages it needs from the internet. A instalação baixa da internet o Python {version} e os pacotes de que ele precisa. +Integer field: values are rounded. Campo inteiro: os valores são arredondados. Interface font: {font} Fonte da interface: {font} Interpolate a surface from points (IDW) Interpolar uma superfície a partir de pontos (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Interpola uma superfície raster de 2 bandas (Predição e Variância) a partir de feições de ponto usando krigagem ordinária. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. Interpola uma superfície raster a partir dos valores das feições por ponderação pelo inverso da distância, como faz o QGIS. Interpolation Interpolação Interpolation Attribute Atributo de interpolação @@ -1316,10 +1413,13 @@ Invalid: {error} Inválida: {error} Invert Inverter Invert color scheme Inverter esquema de cores Invert the selection. Inverter a seleção. +IS IS IS NULL IS NULL it connects to an address on the internet conecta-se a um endereço na internet It has no code: nothing in it runs. Não tem código: nada nele é executado. +It is locked, or one of its surrounds is: unlock it first Está bloqueado, ou um dos seus elementos complementares está: desbloqueie-o primeiro it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) é reservado a você (plugins, scripts, tarefas, modelos e o executor do GeoLibre) +it needs input: open it from its pane or button, or run it from a script or the assistant precisa de entrada: abra-o pelo seu painel ou botão, ou execute-o por um script ou pelo assistente it reads or writes files on this computer lê ou grava arquivos neste computador it replaces the open project substitui o projeto aberto it runs a plugin's command executa um comando de um plugin @@ -1332,20 +1432,30 @@ Italic Itálico Item Pop-ups Pop-ups de itens Item {id} Item {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. O firewall dele ({firewall}) bloqueou o RustGIS. O serviço pode permitir apenas alguns aplicativos: entre em contato com o administrador e envie os detalhes abaixo. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) A ferramenta não está disponível agora (de um plugin não carregado ou de um conjunto não instalado) Jobs Trabalhos JOIN JOIN Join Junção +Join Features Feições de junção +Join Operation Operação de junção +Joins attributes from a join layer to a target layer based on their spatial relationship. Junta atributos de uma camada de junção a uma camada de destino com base na relação espacial. +Journal figure, 1 column (90 mm) Figura de periódico, 1 coluna (90 mm) +Journal figure, 1.5 columns (140 mm) Figura de periódico, 1,5 coluna (140 mm) +Journal figure, 2 columns (190 mm) Figura de periódico, 2 colunas (190 mm) Keep a history of every edit to the layer, to look back or restore. Manter um histórico de cada edição da camada, para consultar ou restaurar. Keep an archive of edits Manter um arquivamento das edições keep current manter atual +Keep it from being moved, resized, deleted or edited by mistake Evitar que seja movido, redimensionado, excluído ou editado por engano Keep one value of each point's vector as its value field Manter um valor do vetor de cada ponto como seu campo de valor Keep Result Manter resultado Keep the labelled features fixed as the map moves. Manter fixas as feições rotuladas quando o mapa se move. Keep the last drawing tool active after each graphic. Manter a última ferramenta de desenho ativa após cada gráfico. Keep the last tool active Manter a última ferramenta ativa +Keep this endpoint under Data Sources › Connections, without its password or keys Manter este endpoint em Fontes de dados › Conexões, sem a senha nem as chaves Keep this result Manter este resultado Keep Tool Manter ferramenta Keep value Manter valor +Key Key Keyboard Shortcuts Atalhos de teclado Keyboard shortcuts. Atalhos de teclado. Keys from Chaves de @@ -1356,13 +1466,20 @@ KMZ KMZ Label Rótulo Label Class Classe de rótulo Label Distance Range Intervalo de distância dos rótulos +Label features in this layer Rotular as feições desta camada Label Field Campo de rótulo +Label only the features that match. Rotular apenas as feições que correspondem. Label Placement Posicionamento dos rótulos Label Range Intervalo de rótulos Label the cities with their names Rotular as cidades com seus nomes Label Visibility Range Intervalo de visibilidade dos rótulos Labeling Rotulagem +Labeling Pane Painel Rotulagem +Labeling Properties… Propriedades de rotulagem… Labels Rótulos +labels are drawn for feature layers, not rasters os rótulos são desenhados para camadas de feições, não para rasters +Labels are drawn for feature layers, not rasters. Os rótulos são desenhados para camadas de feições, não para rasters. +Labels are off for this layer: the clause applies once they are on. Os rótulos estão desativados nesta camada: a cláusula se aplica quando forem ativados. Lagoon Lagoa Lambert Conformal Conic Cônica conforme de Lambert Lambert Cylindrical Equal Area Cilíndrica de áreas iguais de Lambert @@ -1383,12 +1500,14 @@ Layer Blend Mesclagem de camadas Layer Extrusion Extrusão da camada Layer File Arquivo de camada Layer From\nSelection Camada da\nseleção +Layer not found Camada não encontrada Layer Template Modelo de camada Layer Templates Modelos de camada layer {id} camada {id} Layers Camadas Layers ({n}) Camadas ({n}) Layers and Table Views Camadas e visualizações de tabela +Layers, a date each Camadas, uma por data Layout Layout Layout\nFile Arquivo de\nlayout Layout after: {name} Layout depois: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Recursos de\naprendizagem Left Esquerda Legend Legenda Legend label. Clear it to show the value again. Rótulo da legenda. Limpe-o para mostrar o valor novamente. -Length {length} · gain {gain} m · loss {loss} m Comprimento {length} · ganho {gain} m · perda {loss} m +Length {length} · gain {gain} · loss {loss} Comprimento {length} · subida {gain} · descida {loss} Let the assistant hand tasks to agents on this computer Permitir que o assistente passe tarefas para agentes deste computador Let your Agents Control RustGIS Deixe seus agentes controlarem o RustGIS Letter spacing Espaçamento entre letras @@ -1439,6 +1558,7 @@ List by drawing order Listar por ordem de desenho List by selection Listar por seleção List Columns in File Listar colunas do arquivo List Commands Listar comandos +List Connections Listar conexões List Data Sources Listar fontes de dados List Database Columns Listar colunas do banco de dados List Database Tables Listar tabelas do banco de dados @@ -1475,6 +1595,11 @@ Locate… Localizar… Location Localização Locator Localizador Lock Bloquear +Locked Bloqueado +Locked: unlock it in the Format pane to move or change it Bloqueado: desbloqueie-o no painel Formato para movê-lo ou alterá-lo +Log In to Connection Entrar na conexão +Log In to {name}… Entrar em {name}… +Log In… Entrar… Long Inteiro longo Longitude Longitude Longitude (X) Longitude (X) @@ -1493,6 +1618,7 @@ Manage Gerenciar Manage Bookmarks Gerenciar marcadores Manage Edits Gerenciar edições Manage Templates Gerenciar modelos +Mann–Kendall Mann–Kendall Map Mapa Map\nFrame Quadro\nde mapa Map (the current view) Mapa (a visualização atual) @@ -1504,6 +1630,7 @@ Map File Arquivo de mapa Map Frame Quadro de mapa Map Frames Quadros de mapa Map frames per second (live while the map moves) Quadros por segundo do mapa (ao vivo enquanto o mapa se move) +Map Labeling Rotulagem do mapa Map Link Link do mapa Map name Nome do mapa Map Page Página de mapa @@ -1515,6 +1642,7 @@ Map style (JSON) Estilo de mapa (JSON) Map Surrounds Elementos complementares do mapa Map Thumbnail Miniatura do mapa Map tiles of the whole world Blocos de mapa do mundo inteiro +Map Tool: {tool} Ferramenta de mapa: {tool} Map View Visualização do mapa map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Mapa: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Marcador Mask Values Valores de máscara Masking Mascaramento +Match Option Opção de correspondência Matching places Locais correspondentes Max Máx Max % Máx % +Max Neighbors Máximo de vizinhos Maximum zoom Zoom máximo MCP Relay Relé MCP MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} O relé MCP já está a ouvir em 127.0.0.1:{port}; pare-o primeiro para usar a porta {asked} @@ -1542,6 +1672,8 @@ Measure Distance Medir distância Measure geodesic distances and areas on the map. Medir distâncias e áreas geodésicas no mapa. Measure on this body, with one of its basemaps. Medir neste corpo, com um de seus mapas base. Measure with an elevation raster Medir com um raster de elevação +Measurement units Unidades de medida +Measurement units must be metric or imperial As unidades de medida devem ser métricas ou imperiais Measurements so far. Medições até agora. Mensuration Mensuração Mensuration Tools Ferramentas de mensuração @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Mescla os p Messages appear here. As mensagens aparecem aqui. Meters Metros Method Método +Metric Métrico Midnight Meia-noite Miles Milhas Miller Cylindrical Cilíndrica de Miller @@ -1560,21 +1693,43 @@ Min must be less than Max Mín deve ser menor que Máx Min must not be more than Max Mín não pode ser maior que Máx Minimum Bounding Geometry Geometria delimitadora mínima Minimum Maximum Mínimo máximo +Minus Minus Minutes Minutos minutes ago minutos atrás Miter Meia-esquadria Mobile Map Mapa móvel Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Modelo Model\nBuilder Model\nBuilder model id id do modelo Model output Saída do modelo ModelBuilder ModelBuilder Models Modelos +Modified Mann–Kendall (Hamed & Rao) Mann–Kendall modificado (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Mann–Kendall modificado (Yue & Wang) Modify Modificar Modify Selected Features Modificar feições selecionadas Mollweide (equal-area world) Mollweide (mundo de áreas iguais) Month Mês +Months Meses +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Os meses são números de 1 a 12 (12, 1, 2 para dezembro a fevereiro) More Mais More basemaps: Mais mapas base: Mosaic images into an orthomosaic Mosaicar imagens em um ortomosaico @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Mosaicar os rasters visíveis em Most cloud cover (%) Cobertura de nuvens máxima (%) Mouse wheel Roda do mouse Move Mover -Move (m) Mover (m) Move Bookmark Mover marcador Move Down Mover para baixo Move down Mover para baixo Move Group Mover grupo +Move Label Class Mover classe de rótulo Move Task Step Mover etapa de tarefa +Move To Bottom Mover para o fim Move To Group Mover para o grupo +Move To Top Mover para o início Move To {group} Mover para {group} Move Up Mover para cima Move up Mover para cima @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Meus buckets My OGC Layer Minha camada OGC +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Nome name the plugin whose panel to open: {"id": plugin id} informe o plugin cujo painel abrir: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Novo\nnotebook New\nPresentation Nova\napresentação New\nReport Novo\nrelatório New\nWorkspace Novo espaço\nde trabalho -New / Open / Save project Novo / Abrir / Salvar projeto New Bookmark Novo marcador New Bookmark… Novo marcador… +New Connection Nova conexão +New Connection ▾ Nova conexão ▾ New conversation Nova conversa New Custom Template Novo modelo personalizado New Custom Template… Novo modelo personalizado… New features use this template's values Novas feições usam os valores deste modelo +New field Novo campo New Group Novo grupo New job name Nome do novo trabalho New Layout Novo layout @@ -1649,6 +1809,7 @@ New Notebook Novo notebook New Presentation Nova apresentação New Project Novo projeto New project Novo projeto +New Publication Layout Novo layout de publicação New Relationship… Novo relacionamento… New Report Novo relatório New Selection Nova seleção @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Ainda não há No catalogs below this one: Find Items lists its items Nenhum catálogo abaixo deste: Encontrar itens lista seus itens No COMID was returned for this point. Nenhum COMID foi retornado para este ponto. no command nenhum comando -No connections this session Nenhuma conexão nesta sessão +No data at the points picked. Não há dados nos pontos escolhidos. No datasets match. Nenhum conjunto de dados corresponde. No datasets with data RustGIS can add Nenhum conjunto de dados com dados que o RustGIS possa adicionar +no date in the name sem data no nome No edits since the project was saved Nenhuma edição desde que o projeto foi salvo No field of this layer holds dates. Nenhum campo desta camada contém datas. +No Fill Sem preenchimento +No geometry to calculate Não há geometria para calcular No granules in this view and date range. Nenhum grânulo nesta vista e intervalo de datas. no home folder nenhuma pasta pessoal no layer nenhuma camada @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) Nenhum relacionamento (Design d No relationships yet Ainda não há relacionamentos No reports yet. Publish › New Report lists the selected layer's records. Ainda não há relatórios. Publicar › Novo relatório lista os registros da camada selecionada. no row nenhuma linha +No serif font is installed, so this text is drawn in the sans-serif Nenhuma fonte serifada está instalada, então este texto é desenhado na fonte sem serifa no service or file links nenhum link para serviços ou arquivos No services match your search. Nenhum serviço corresponde à sua pesquisa. No standalone tables Nenhuma tabela independente @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Nenhum None (public buckets) Nenhuma (buckets públicos) none, a .duckdb file, or host=… dbname=… user=… nenhum, um arquivo .duckdb ou host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD nenhuma, ou de PGPASSWORD ou MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD nenhuma, ou de PGPASSWORD, MYSQL_PWD ou SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. Nenhuma. Adicione uma pelo endereço ou a partir de um plugin de mapas base. North Norte North\nArrow Seta de\nnorte North Polar Stereographic Estereográfica polar norte +NOT NOT not a picture não é uma imagem not a RustGIS layout não é um layout do RustGIS not a RustGIS project não é um projeto do RustGIS +Not applied yet. Ainda não aplicado. Not for texts: a text is edited in the Format pane. Não para textos: um texto é editado no painel Formato. not needed não é necessária Not sandboxed Fora da sandbox @@ -1739,9 +1906,12 @@ Nothing found where the map is Nada encontrado onde o mapa está Nothing here Nada aqui Nothing named {name} to rename Não há nada chamado {name} para renomear Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Nada compartilhado ainda. Publicar › Compartilhar como cria mapas web, camadas, estilos, cenas e mais. +Nothing to add here Nada para adicionar aqui +Nothing to test Nada para testar Nothing was found at this address. Nada foi encontrado neste endereço. Nothing was recorded, so no task was made. Nada foi gravado, portanto nenhuma tarefa foi criada. Now Agora +Nugget Efeito pepita (nugget) NULL NULL Nulls Nulos Observations (observer, date, species, count) Observações (observador, data, espécie, contagem) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. No desktop, a chave também p On-screen compass, zoom slider and pan pad. Bússola na tela, controle deslizante de zoom e painel de deslocamento. One command per line: id {json}. Lines starting with # are comments. Um comando por linha: id {json}. Linhas que começam com # são comentários. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Um único SELECT do DuckDB: as funções ST_ do DuckDB Spatial, arquivos e endereços web (read_parquet('…')) e as tabelas do banco de dados abaixo. Ctrl+Enter o executa. +One layer: a Zarr variable or a raster's bands Uma camada: uma variável Zarr ou as bandas de um raster One page per feature of an index layer. Uma página por feição de uma camada de índice. One per line: code=name Um por linha: código=nome One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Um traço, do símbolo. Adicione traços para contornos, linhas paralelas e deslocamentos. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Abrir pasta Open In Geoprocessing… Abrir no Geoprocessamento… Open in ModelBuilder Abrir no ModelBuilder +Open in Tool Abrir na ferramenta Open Layout Abrir layout Open ModelBuilder Abrir ModelBuilder Open Notebook Abrir notebook @@ -1797,6 +1969,7 @@ Open Presentation Abrir apresentação Open Project Abrir projeto Open project Abrir projeto Open Project (from text) Abrir projeto (de texto) +Open Project… Abrir projeto… Open Report Abrir relatório Open the attribute table of the selected layer. Abrir a tabela de atributos da camada selecionada. Open the Basemaps tab Abrir a guia Mapas base @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Ou execute em um shell POSIX, Or run in PowerShell, for Claude Code or Codex: Ou execute no PowerShell, para o Claude Code ou o Codex: Or run, for Claude Code or Codex: Ou execute, para o Claude Code ou o Codex: ORDER BY ORDER BY +Ordinary Kriging Krigagem ordinária Orientation Orientação Ortho Mapping Ortomapeamento Other Outro Other Data Outros dados +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Outras classes de rótulo nesta camada (criadas com comandos): {n}. Cada uma rotula as feições que correspondem à sua própria cláusula, então uma feição pode receber rótulos de mais de uma. Out Beyond Limite ao afastar Outline Contorno Outline another frame's extent in this map frame. Contornar a extensão de outro quadro neste quadro de mapa. @@ -1851,6 +2026,7 @@ Output Saída Output Name Nome de saída Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Mais de 1.000 ferramentas raster, vetoriais, LiDAR, hidrológicas e de terreno que rodam no navegador, após um download único de cerca de 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Mais de 1.000 ferramentas raster, vetoriais, LiDAR, hidrológicas e de terreno, em um download opcional de cerca de 20 MB. +Overlapping Sobrepostas Overlay Sobreposição overlay sobreposição Overlay (a transparent layer kept on top, such as labels or roads) Sobreposição (uma camada transparente mantida no topo, como rótulos ou estradas) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is O Overture não tem dados de {ty} ond Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Os dados abertos do Overture Maps: edificações, locais, transporte, divisões administrativas, camadas base e endereços. Onde o mapa está, suas feições são lidas no local a partir dos arquivos GeoParquet do Overture (aproxime até uma cidade ou menos); o mundo inteiro vem como blocos de mapa. Overture Maps… Overture Maps… +Overwrite existing values Sobrescrever os valores existentes Package Pacote Package a trained deep learning model. Empacotar um modelo de aprendizado profundo treinado. Package Map Empacotar mapa @@ -1907,8 +2084,12 @@ Performance Desempenho Phase Fase Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Escolha um valor dinâmico, como a escala ou a data, e clique no mapa para posicioná-lo. +Pick a Point Escolher um ponto +Pick a point on the map to see the variable's values through time there. Escolha um ponto no mapa para ver ali os valores da variável ao longo do tempo. Pick a route in the list, or click a grey route on the map. Escolha uma rota na lista ou clique em uma rota cinza no mapa. +Pick a single layer. Escolha uma única camada. Pick it on the map Escolher no mapa +Pick one… Escolha um… Picture Imagem Pictures Imagens Pictures, attachments and chart Imagens, anexos e gráfico @@ -1966,8 +2147,11 @@ Portal Portal Portolan Portolan Portolan… Portolan… Portrait Retrato +Position Posição +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Pós-processamento power 2 potência 2 +Precision 6 (OSRM, Valhalla) Precisão 6 (OSRM, Valhalla) Prepare Preparar Prepare every layer's geometry for this projection. Preparar a geometria de todas as camadas para esta projeção. Prepare Plugin Environment Preparar o ambiente de plugins @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Modelos de projeto: inicia project's path is required o caminho de project é obrigatório Projection Projeção Projections Projeções +Properties Propriedades +Property Propriedade Proportional Symbols Símbolos proporcionais Provider Provedor +Provinces: Províncias: Proximity Proximidade Public GIS portals of US counties and cities, by state. Portais SIG públicos de condados e cidades dos EUA, por estado. Public GIS portals of US federal agencies, by department. Portais SIG públicos de agências federais dos EUA, por departamento. Public GIS portals of US states and the District of Columbia. Portais SIG públicos dos estados dos EUA e do Distrito de Colúmbia. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. Camadas públicas no ArcGIS Online ou em outro portal ArcGIS: serviços de feições, mapas, imagens e blocos vetoriais, mapas web e arquivos GeoJSON. +Publication Layout Layout de publicação Publish Publicar Publish › Output Publicar › Saída Put the layer back as it was then Restaurar a camada como estava naquele momento Python environment failed: {error} Falha no ambiente Python: {error} Python environment not prepared Ambiente Python não preparado Python environment ready Ambiente Python pronto +Python script Script Python Qualitative Qualitativa Quantile Quantil Query files, web addresses and databases with DuckDB Spatial; add results to the map. Consultar arquivos, endereços web e bancos de dados com o DuckDB Spatial; adicionar os resultados ao mapa. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Elevar polígonos por uma altura ou um ca Raise Priority Aumentar prioridade Random points in polygons Pontos aleatórios em polígonos Range Intervalo +Range (Degrees) Alcance (graus) +range: 0, 0.9 intervalo: 0, 0.9 Raster Raster Raster\nAnalysis Análise\nraster Raster\nInformation Informações\ndo raster @@ -2075,6 +2266,7 @@ Redo {action} Refazer {action} Red–Blue Vermelho–Azul Refine the search to see {n} more Refine a pesquisa para ver mais {n} Refresh Atualizar +Refresh every connection's lists Atualizar as listas de todas as conexões Refresh Map Series Atualizar série de mapas Refresh the map series from its index layer. Atualizar a série de mapas a partir da camada de índice. Region Região @@ -2091,11 +2283,10 @@ Relationships Relacionamentos Release Versão Reload Recarregar Reload Web Layers Recarregar camadas web -Remember (without the secret) Lembrar (sem o segredo) Remember the key on this computer Lembrar a chave neste computador -Remembered: Lembradas: Remove Remover Remove All Remover todas +Remove All Label Classes Remover todas as classes de rótulo Remove an offline area. Remover uma área offline. Remove Bookmark Remover marcador Remove Connection Remover conexão @@ -2108,11 +2299,14 @@ Remove from Favourites Remover dos favoritos Remove From Group Remover do grupo Remove Group Remover grupo Remove Keyframe Remover quadro-chave +Remove Label Class Remover classe de rótulo Remove Section Remover seção Remove Table Remover tabela +Remove the clause. Remover a cláusula. Remove the last keyframe. Remover o último quadro-chave. Remove the layer from the map. Remover a camada do mapa. Remove the strokes and draw the symbol's line alone Remover os traços e desenhar apenas a linha do símbolo +Remove this class's own strokes and draw it with the layer's Remover os traços próprios desta classe e desenhá-la com os da camada Remove this stop Remover esta parada Remove Tile Layer Remover camada de blocos Remove token Remover token @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Remove Rename Renomear Rename Bookmark Renomear marcador Rename Group Renomear grupo +Rename it to save it: a connection has this name Renomeie para salvar: já existe uma conexão com este nome +Rename Label Class Renomear classe de rótulo Rename Layer Renomear camada Rename Layout Renomear layout Rename Map Renomear mapa @@ -2149,6 +2345,7 @@ Reset Default Redefinir padrão Reset Effects Redefinir efeitos Reset North Redefinir norte Reset north Redefinir norte +Resolution (0–30) Resolução (0–30) Response Resposta Restore Restaurar Restore From Archive Restaurar do arquivamento @@ -2162,6 +2359,7 @@ RGB RGB Ribbon Faixa de opções Right Direita Right-click a source to add it here Clique com o botão direito em uma fonte para adicioná-la aqui +Right-click for more Clique direito para mais opções Robinson Robinson Rotate Girar Rotate (°) Girar (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Dados de exemplo (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Dados de exemplo © Natural Earth (domínio público). O RustGIS é licenciado sob Apache-2.0. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Dados de exemplo: Natural Earth (domínio público). Licenciado sob Apache-2.0. +Sans-serif Sem serifa Satellite Embedding Datasets Conjuntos de dados de embeddings de satélite Satellite Embeddings Embeddings de satélite Satellite Embeddings… Embeddings de satélite… Saturation Saturação Save Salvar Save As Salvar como +Save as Connection Salvar como conexão Save as Default Salvar como padrão Save as Default Raster Style Salvar como estilo raster padrão Save as Layer Salvar como camada Save As Presentation File… Salvar como arquivo de apresentação… +Save as Python Script… Salvar como script Python… Save As Report File… Salvar como arquivo de relatório… Save As Report Template… Salvar como modelo de relatório… Save as {name} Salvar como {name} Save As… Salvar como… Save Color Ramps Salvar rampas de cores Save Data Source Salvar fonte de dados +Save History As Python Script Salvar histórico como script Python Save Layer File Salvar arquivo de camada Save Layout File Salvar arquivo de layout Save Map File Salvar arquivo de mapa @@ -2254,6 +2456,7 @@ save or load a pane set by name salvar ou carregar um conjunto de painéis pelo Save Project Salvar projeto Save project Salvar projeto Save Project As Salvar projeto como +Save Project As… Salvar projeto como… Save Project Template Salvar modelo de projeto Save Style Items Salvar itens de estilo Save Task Item Salvar item de tarefa @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Salvar o modelo como arquivo (*.model.js Save the model in the project Salvar o modelo no projeto Save the open layout as a layout file. Salvar o layout aberto como arquivo de layout. Save the panes you have open, and switch back to them. Salvar os painéis que você tem abertos e voltar a eles. +Save the project too: the script opens its saved file, without changes made since. Salve também o projeto: o script abre o arquivo salvo, sem as alterações feitas desde então. Save the project under a new name. Salvar o projeto com outro nome. Save the project with your edits. Salvar o projeto com suas edições. Save the project's custom color ramps to a color ramp file. Salvar as rampas de cores personalizadas do projeto em um arquivo de rampas de cores. @@ -2275,13 +2479,16 @@ Save Web Link Chart Salvar gráfico de vínculos web Save Web Map Salvar mapa web Save Web Scene Salvar cena web Saved Salvo +Saved as the connection {name} Salvo como a conexão {name} Saved as the default style for new single-band rasters. Salvo como estilo padrão para novos rasters de banda única. +Saved connection Conexão salva Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). As chaves salvas ficam sem criptografia nas preferências do aplicativo (no armazenamento do navegador, na web). Saved models Modelos salvos Saved symbology: apply it to the selected layer, or save the layer's. Simbologia salva: aplique-a à camada selecionada ou salve a da camada. Saved View Vista guardada Saved {name} {name} salvo Saved {name}. {name} salvo. +Saved {path} Salvo {path} saving failed unexpectedly o salvamento falhou inesperadamente Scale Escala Scale\nBar Barra de\nescala @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Pesquisar (este portal precisa de palavras) Search Catalogue Pesquisar catálogo Search commands — e.g. buffer, basemap, labels Pesquisar comandos — ex.: buffer, mapa base, rótulos Search datasets, e.g. GEDI biomass Pesquisar conjuntos de dados, ex.: GEDI biomass -Search Distance (km) Distância de pesquisa (km) +Search Distance Distância de pesquisa Search every command and its parameters Pesquisar todos os comandos e seus parâmetros Search layers (e.g. temperature) Pesquisar camadas (ex.: temperature) Search layers, places, lat/lon… Pesquisar camadas, locais, lat/lon… @@ -2329,6 +2536,8 @@ Searching the folders… Pesquisando as pastas… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. A pesquisa funciona aqui, mas os servidores de dados da NASA não permitem que páginas web leiam seus arquivos (sem CORS), então GeoTIFFs e downloads de dados protegidos requerem o aplicativo de desktop. Baixar abre um arquivo em uma nova aba, onde o Earthdata Login pede que você entre. Searching… Pesquisando… Searching… try again in a moment. Pesquisando… tente novamente em instantes. +Seasonal Mann–Kendall Mann–Kendall sazonal +Seasons a year Estações por ano Second standard parallel Segundo paralelo padrão Seconds Segundos Secret access key Chave de acesso secreta @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Separador Sepia Sépia Sequential Sequencial +Serif Serifada Server Servidor Service Group Grupo de serviço Service group data exceeds 64 MB. Os dados do grupo de serviço excedem 64 MB. Service Import Requests Pedidos de importação do serviço Service Layers Camadas de serviço -Services Serviços +Session token Token de sessão Set Definir Set Cell Size Definir tamanho da célula Set Domain Definir domínio @@ -2411,6 +2621,7 @@ Shaded Sombreado Shaded Relief Relevo sombreado Shape Forma Shapefile Shapefile +Shapes Formas Share Compartilhar Share As Compartilhar como Share every layer and table as OGC API Features collections. Compartilhar todas as camadas e tabelas como coleções OGC API Features. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Mostrar detalhes ao passar Show different features at different scales. Mostrar feições diferentes em escalas diferentes. Show grid Mostrar grade Show Group Mostrar grupo +Show labels Mostrar rótulos Show latitude/longitude lines on the basemap. Mostrar linhas de latitude/longitude no mapa base. +Show Layers Mostrar camadas Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Mostrar camadas ao longo do tempo: apenas as feições cuja data cai na janela do controle deslizante, e imagens datadas para a sua data. Show Map Mostrar mapa Show Me Mostre-me @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Mostrar ou ocultar o painel Ferramentas. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Mostrar as réguas. Arraste a partir de uma régua para adicionar uma guia; arraste uma guia de volta para a régua para removê-la. Show the bands picked, read in place as the map needs them Mostrar as bandas escolhidas, lidas no local conforme o mapa precisar Show the description Mostrar a descrição +Show the Labeling pane for the selected layer. Mostrar o painel Rotulagem da camada selecionada. Show the labels that don't fit, in red. Mostrar em vermelho os rótulos que não cabem. Show the layer only inside another layer's polygons. Mostrar a camada somente dentro dos polígonos de outra camada. Show the layer only left of a divider, to compare it with what's beneath. Mostrar a camada somente à esquerda de um divisor, para compará-la com o que está abaixo. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Login cancelado. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. O tempo de login esgotou. Recomece e permita que o navegador acesse o endereço de loopback deste computador. signed in conectado Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Sessão terminada neste computador, mas a OpenAI não a confirmou. Pode desligar o RustGIS nas definições do ChatGPT. +Significance level Nível de significância Signing in with ChatGPT works in the desktop app. O login com o ChatGPT funciona no aplicativo de desktop. Signing the asset (Planetary Computer)… Assinando o asset (Planetary Computer)… +Sill Patamar (sill) Simplify Simplificar Simulate the drive, or save the route as a layer. Simular o trajeto ou salvar a rota como camada. Simulation Simulação @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} Habilidades para o Assistente de IA: {names Slideshow, PDF or PNG Apresentação de slides, PDF ou PNG slight left curva suave à esquerda slight right curva suave à direita +Slope per Inclinação por Slope, aspect, hillshade, NDVI, band math, thresholds. Declividade, aspecto, sombreamento, NDVI, álgebra de bandas, limiares. Slope, hillshade, NDVI and more. Declividade, sombreamento, NDVI e mais. Small scales Escalas pequenas @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Sul South Polar Stereographic Estereográfica polar sul +Spatial Join Junção espacial Speaker notes Notas do apresentador Spectral Analysis Análise espectral Spectral indices Índices espectrais @@ -2530,6 +2748,7 @@ Split (click the line) Dividir (clique na linha) SQL SQL SQL Pane Painel SQL SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. As consultas SQL são executadas pelo DuckDB, um programa que o aplicativo desktop chama: abra este projeto no aplicativo desktop para consultá-lo. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL que deve ser verdadeiro, ex.: pop > 0 Square Quadrado Squares Quadrados @@ -2588,6 +2807,7 @@ Start over at the end while playing. Recomeçar ao chegar ao fim durante a repro Start With Iniciar com Start-up Arranque starting with a project file needs the desktop app iniciar com um arquivo de projeto requer o aplicativo para desktop +Statistic Estatística Statistics Estatísticas Statistics Field Campo de estatísticas Status Status @@ -2613,8 +2833,10 @@ Stretch\nType Tipo de\nrealce Stretch it over the map view, or give control points (one per line: column row lon lat). Esticá-lo sobre a visualização do mapa ou informar pontos de controle (um por linha: column row lon lat). Stretch Type Tipo de realce Stretch type Tipo de realce +Stroke Levels Níveis de traço Stroke {n} Traço {n} Strokes Traços +Strokes for Traços de Style Estilo Style Items Itens de estilo Style › Drawing Estilo › Desenho @@ -2624,7 +2846,10 @@ Subreport: {name} Sub-relatório: {name} Subtypes Subtipos Subtypes, one per line: code=name Subtipos, um por linha: código=nome Suitability Modeler Modelador de adequação +Summarise Resumir +Summarise over time Resumir ao longo do tempo Summarise the conversation so far, so a long chat stays within the model's context Resumir a conversa até agora, para que uma conversa longa caiba no contexto do modelo +Summarise Zarr Over Time Resumir Zarr ao longo do tempo Summarising the earlier conversation to keep it within the model's context… A resumir a conversa anterior para caber no contexto do modelo… Summarize Resumir Summarize\nWithin Resumir\ndentro @@ -2658,6 +2883,8 @@ Table or layer Tabela ou camada Tables Tabelas Tags Etiquetas Take the data in view offline, to edit and sync back later. Levar os dados à vista para o modo offline, para editar e sincronizar depois. +Target Features Feições de destino +Target Records Registros de destino Task Tarefa Task Item Item de tarefa Task name Nome da tarefa @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Dados SIG públicos do Tennessee (Tennessee GeoData, geodata.tn.gov): limites de propriedades, transporte, saúde, hidrologia e mais. Terrain Terreno Test Test +Test Connection Testar conexão +Testing… Testando… Text Texto Text Symbol Símbolo de texto That connection would create a loop. Essa conexão criaria um ciclo. The address fields are empty in every row. Os campos de endereço estão vazios em todas as linhas. +The answer isn't UTF-8 text A resposta não é texto UTF-8 the answer isn't UTF-8 text a resposta não é texto UTF-8 The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. O assistente segue as instruções de uma habilidade quando a usa. Seja o que for que digam, os comandos que precisam da sua aprovação continuam pedindo. The assistant is busy; stop it or wait for it to finish. O assistente está ocupado; interrompa-o ou aguarde até que termine. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. A guia de mapas base públicos do the capture failed unexpectedly a captura falhou inesperadamente The connection failed; trying again in {seconds} s. A ligação falhou; nova tentativa dentro de {seconds} s. The country list couldn't be read ({e}): type a country's code Não foi possível ler a lista de países ({e}): digite o código de um país +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. A elevação atual continua valendo enquanto você edita os valores. Clique em Aplicar elevação para aplicar o valor e as unidades. The dataset has nothing RustGIS can add O conjunto de dados não tem nada que o RustGIS possa adicionar The environment (AWS_ACCESS_KEY_ID…) O ambiente (AWS_ACCESS_KEY_ID…) The features where the map is As feições onde o mapa está @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. O fl The installed GeoLibre runner lists no tools. O executor do GeoLibre instalado não lista nenhuma ferramenta. The job covers the map view O trabalho abrange a visualização do mapa The key is kept for this session only. A chave é mantida apenas durante esta sessão. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. A camada mostra o passo em que o controle deslizante de tempo está e ativa o controle sobre as datas da variável. The layer's file couldn't be read: {error} Não foi possível ler o arquivo da camada: {error} +The layer's strokes Os traços da camada the layout export failed unexpectedly a exportação do layout falhou inesperadamente The link doesn't say its STAC API: Find Items searches {api} O link não indica sua STAC API: Encontrar itens pesquisa em {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. A lista de plugins para explorar. Deixe vazio para usar o registro de plugins do RustGIS. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. A miniatura do projeto, a partir da The projection the map opens in. A projeção em que o mapa abre. The provider's content filter stopped the reply. O filtro de conteúdo do fornecedor interrompeu a resposta. The public server is free and shared by many: it answers light use, and may ask you to wait. O servidor público é gratuito e compartilhado por muitos: atende a um uso leve e pode pedir que você espere. +The range is two numbers, low and high (0, 0.9) O intervalo são dois números, mínimo e máximo (0, 0.9) The raster's data couldn't be read. Não foi possível ler os dados do raster. The raster's data couldn't be read: {path} Não foi possível ler os dados do raster: {path} the release isn't known a versão não é conhecida @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Os arquivos do Shapefile são baixado The share of each cell covered by fields, for the zoomed-out view A fração de cada célula coberta por campos agrícolas, para a vista afastada The sign-in expired or was revoked. Sign in again. O login expirou ou foi revogado. Entre novamente. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. O login fica na pasta de configurações do aplicativo, apenas para a sua conta de usuário, e nunca em um arquivo de projeto. +The significance level is a number, such as 0.05 O nível de significância é um número, por exemplo 0.05 The start-up project {path} didn't open: {error} O projeto inicial {path} não abriu: {error} The start-up project {path} isn't a RustGIS project file. O projeto inicial {path} não é um arquivo de projeto do RustGIS. The step's layer {name} isn't in the map. A camada {name} da etapa não está no mapa. The store has no variables with two or more dimensions. O armazenamento não tem variáveis com duas ou mais dimensões. the store is gone o armazenamento não existe mais +The summary couldn't start: {error} Não foi possível iniciar o resumo: {error} +The summary failed unexpectedly O resumo falhou inesperadamente +The summary finished after another project was opened, so it wasn't added O resumo terminou depois que outro projeto foi aberto, então não foi adicionado The task's steps changed, so the step being edited was closed: edit it again. As etapas da tarefa mudaram, então a etapa em edição foi fechada: edite-a novamente. the time slider is off o controle deslizante de tempo está desativado The token expires on {date}. O token expira em {date}. +The trend couldn't start: {error} Não foi possível iniciar a tendência: {error} +The trend failed unexpectedly A tendência falhou inesperadamente +The trend finished after another project was opened, so it wasn't added A tendência terminou depois que outro projeto foi aberto, então não foi adicionada +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. Os valores mostrados do preto ao branco, iguais para cada data e bloco (nas unidades da banda). Vazio: o realce próprio de cada arquivo. the view has no extent a visualização não tem extensão The view is larger than one read: its centre was read (zoom in for the rest) A vista é maior que uma leitura: seu centro foi lido (aproxime para o restante) The web app always runs the latest release: reload the page to get it. O aplicativo web sempre executa a versão mais recente: recarregue a página para obtê-la. the window's screenshot didn't come back in time, so the map was drawn offscreen a captura de tela da janela não voltou a tempo, então o mapa foi desenhado fora da tela the window's screenshot didn't hold the map, so it was drawn offscreen a captura de tela da janela não continha o mapa, então ele foi desenhado fora da tela Theme Tema +There is no expression defined. Não há nenhuma expressão definida. Thermal Térmica +Thesis figure, full page (160 × 225 mm) Figura de tese, página inteira (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Figura de tese, meia página (160 × 110 mm) Thinking… Pensando… +Third-party licences Licenças de terceiros +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins esta versão não pode baixar plugins this build can't reach AI providers esta versão não consegue acessar provedores de IA this build saves projects with Save As esta compilação salva os projetos com Salvar como This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Esta conta do ChatGPT já estava guardada. Da próxima vez, escolha-a na lista em vez de a adicionar novamente. +This class is drawn with the layer's strokes. Changing them here gives it its own. Esta classe é desenhada com os traços da camada. Alterá-los aqui dá a ela os seus próprios. +This class is hidden, so it labels nothing. Esta classe está oculta, então não rotula nada. +This clause no longer reads, so no feature is labelled: {error} Esta cláusula não pode mais ser lida, então nenhuma feição é rotulada: {error} This map has no layers yet. Este mapa ainda não tem camadas. This map's thumbnail, from an image. A miniatura deste mapa, a partir de uma imagem. This map's thumbnail, from the view. A miniatura deste mapa, a partir da visualização. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Esta solicitação não tem a opção de permitir uma única vez. This request uses an unsupported capability. Check the parameter and change the request. Esta solicitação usa um recurso não compatível. Verifique o parâmetro e altere a solicitação. This result is kept Este resultado é mantido +This run has no tool form to open Esta execução não tem um formulário de ferramenta para abrir +This session only (optional) Somente esta sessão (opcional) This step Este passo This step names no tool to show. Esta etapa não indica nenhuma ferramenta para mostrar. Thumbnail Miniatura @@ -2789,15 +3041,21 @@ Tilt Map Inclinar mapa Tilt-shift Tilt-shift Tilt-shift blur and film grain. Desfoque tilt-shift e granulação de filme. Time Tempo +Time\nSeries Série\ntemporal Time\nSlider Controle deslizante\nde tempo Time Fields Campos de tempo +Time Series Série temporal +Time series Série temporal +Time Series at a Point Série temporal em um ponto Time Slider Controle deslizante de tempo Time Slider Settings Configurações do controle deslizante de tempo Tint by elevation Colorir por elevação Tissot's Indicatrix Indicatriz de Tissot Title Título +To Até Toggle Dark Mode Alternar modo escuro Toggle GPU Rendering Alternar renderização na GPU +Toggle Imperial Measurements Alternar medidas imperiais Tolerance (km) Tolerância (km) Tolls Pedágios Tool Gallery Galeria de ferramentas @@ -2828,7 +3086,10 @@ Train Deep Learning Model Treinar modelo de aprendizado profundo Trained models Modelos treinados Transparency Transparência Traverse Poligonal +Trend Analysis Análise de tendência +Trend over a series of rasters Tendência sobre uma série de rasters Triangle Triângulo +TRUE TRUE Try again in a moment. Tente novamente em instantes. Try again later. Tente novamente mais tarde. Turn editing on or off for the map. Ativar ou desativar a edição do mapa. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map digite uma longitude e uma latit Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Digite uma escala, como 1:12,345, 12345 ou 25k, e pressione Enter Type an address or coordinates and press Enter, or click the map. Digite um endereço ou coordenadas e pressione Enter, ou clique no mapa. Type numbers separated by commas Digite números separados por vírgulas +Type the keys to log in for this session, then Save. Digite as chaves para entrar nesta sessão e depois Salvar. +Type the password or keys to log in for this session, then Save. Digite a senha ou as chaves para entrar nesta sessão e depois Salvar. Typed below Digitadas abaixo UM UM Unavailable: {reason} Indisponível: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Desvincular do controle deslizante de tempo Unbind from Time Slider Desvincular do controle deslizante de tempo Undo Desfazer Undo (Ctrl+Z) Desfazer (Ctrl+Z) -Undo / Redo Desfazer / Refazer Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Desfazer não remove a etapa gravada: exclua-a em Editar etapas depois de parar a gravação. Undo the last change. Desfazer a última alteração. Undo {action} Desfazer {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools Desinstalar ferramentas GeoLibre Uninstall Plugin Desinstalar plugin Uninstalled {id}. {id} desinstalado. Unique Values Valores únicos +Unit Unidade United States Estados Unidos Units Unidades Units per step Unidades por passo @@ -2877,6 +3140,7 @@ Up a level Subir um nível Update Atualizar Update Bookmark Atualizar marcador Update Job Atualizar trabalho +Update Label Class Atualizar classe de rótulo Update to {version} Atualizar para {version} Upload File Carregar arquivo Upload File… Carregar arquivo… @@ -2895,6 +3159,7 @@ Use another account Usar outra conta Use Current View Usar a vista atual Use each feature's line color Usar a cor de linha de cada feição Use each feature's line width Usar a largura de linha de cada feição +Use the Layer's Strokes Usar os traços da camada USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan A usar o plano do ChatGPT Utilities Utilitários Valid combinations, one per line: a, b Combinações válidas, uma por linha: a, b Valid expression · {n} matching rows Expressão válida · {n} linhas correspondentes +Valid: {n} of {of} features match Válida: {n} de {of} feições correspondem Validate Validar Validate Model Validar modelo value (empty: no-data) valor (vazio: sem dados) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Imagens abertas de desastres da Vantor: escolha um evento e uma fase (antes ou depois), depois Encontrar itens. Vantor… Vantor… Variable Variável +Variogram Model Modelo de variograma Vector and raster data Dados vetoriais e raster Vector Layer Camada vetorial +vector tiles blocos vetoriais Vector tiles (MVT) Blocos vetoriais (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Vetores que um modelo de fundação geoespacial gerou para cada pixel ou recorte de imagem, encontrados onde o mapa está e mostrados como cores. Version {version} Versão {version} @@ -2933,6 +3201,9 @@ Video file or URL Arquivo de vídeo ou URL Video Page Página de vídeo Videos Vídeos Video… Vídeo… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Sistema de coordenadas nacional do Vietnã (VN-2000) com Transversa de Mercator de 3 graus e uma transformação de Helmert de 7 parâmetros para WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Sistema de coordenadas nacional do Vietnã (VN-2000), zona UTM 48N (102°E–108°E, meridiano central 105°E). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Sistema de coordenadas nacional do Vietnã (VN-2000), zona UTM 49N (108°E–114°E, meridiano central 111°E). View Exibir View Clipping Recorte da visualização View Effects Efeitos de visualização @@ -2952,6 +3223,9 @@ Visibility Range Intervalo de visibilidade Visible Visível Visible Layers Camadas visíveis Visual Effect Efeito visual +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vietnã) +VN-2000 / UTM zone 48N VN-2000 / UTM zona 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zona 49N Volume Volume Waiting for the browser… Aguardando o navegador… Waiting for your answer Aguardando sua resposta @@ -2970,6 +3244,7 @@ Web Link Chart Gráfico de vínculos web Web maps Mapas web Web Mercator Web Mercator Web Scene Cena web +Web service (detected) Serviço web (detectado) Web Style Estilo web Web Tile Layers Camadas de blocos web Web Tool Ferramenta web @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Onde os rótulos ficam em relação Where new features get their elevation (the z field). De onde as novas feições obtêm sua elevação (o campo z). Where the map opens: the full extent or a view you saved. Onde o mapa abre: a extensão completa ou uma vista guardada. Where tools work Onde as ferramentas atuam +Whether the Select tool picks these layers' features on the map Se a ferramenta Selecionar escolhe as feições destas camadas no mapa Which 5 countries have the largest GDP? Quais 5 países têm o maior PIB? Whitebox and GeoLibre tools Ferramentas Whitebox e GeoLibre Whole map Mapa inteiro @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Mundo, equivalente: tamanhos r World, equal-area: true sizes, shapes bend towards the edges. Mundo, equivalente: tamanhos reais, as formas se curvam para as bordas. Worldview Fronteiras Write a plugin Escrever um plugin +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Grava a inclinação de Sen, o Z e o valor-p do teste, e a inclinação onde a tendência é significativa. X (mm) X (mm) X column Coluna X X Field Campo X @@ -3050,7 +3327,6 @@ Yours Suas YYYY-MM-DD AAAA-MM-DD YYYY-MM-DD, for the layers that change with time AAAA-MM-DD, para as camadas que mudam com o tempo YYYY-MM-DD, for time layers AAAA-MM-DD, para camadas temporais -z (m) z (m) Z factor Fator Z Zarr Zarr Zarr Variables Variáveis Zarr @@ -3061,7 +3337,6 @@ Zoom Box Zoom por retângulo Zoom Frame Quadro de zoom Zoom In Aproximar Zoom in Aproximar -Zoom in / out Aproximar / afastar Zoom In Here Aproximar aqui Zoom in on the centre of the map. Aproximar no centro do mapa. Zoom in · finish a sketch Aproximar · concluir um esboço @@ -3073,6 +3348,7 @@ Zoom To\nLayer Zoom para\na camada Zoom to a box (Explore) Zoom para um retângulo (Explorar) Zoom To Extent Zoom para a extensão Zoom To Layer Zoom para a camada +Zoom To Layers Zoom para as camadas Zoom To Map View Zoom para a visualização do mapa Zoom To Selection Zoom para a seleção Zoom to selection Zoom para a seleção @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Zoom para a extensão da camada. Zoom to the selected features. Zoom para as feições selecionadas. Zoom to the whole world. Zoom para o mundo inteiro. Zoom to this bookmark Zoom para este marcador +Zoom To Tile Layer Zoom para a camada de blocos Zoom to Tokyo Aproximar de Tóquio Zoom To World Zoom para o mundo +Zoom {z} · Bearing {b}° · Pitch {p}° Zoom {z} · Rumo {b}° · Inclinação {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Zoom: o nível de zoom do mapa, como os mapas web o contam. Rumo: a direção para a qual o topo do mapa aponta, no sentido horário a partir do norte. Inclinação: o desvio em relação à vista de cima, na vertical. Clique para redefinir o rumo e a inclinação. {agent} was stopped by you. Você interrompeu o {agent}. {id} changed since it was installed; install it again from the Plugin Manager. {id} mudou desde que foi instalado; instale-o novamente pelo Gerenciador de plugins. {names}: needs Node.js (npx) for its ACP adapter. {names}: precisa do Node.js (npx) para o adaptador ACP. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8ac6f0405..4029ab449 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6,6 +6,7 @@ (filtered) (отфильтрован) + Add Stop + Добавить остановку + Input + Вход ++ New expression + Новое выражение + Output + Выход /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh 3D-сетка <Нет> A 3D globe seen from space. Трёхмерный глобус, вид из космоса. A blank map with a basemap Пустая карта с базовой картой +A call for the rustgis Python package Вызов для Python-пакета rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. Адрес каталога (CSW 2.0.2): ищите его записи по словам и, при желании, по области карты, затем добавляйте сервисы и файлы, на которые они ссылаются. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. Достигнут лимит использования тарифа ChatGPT или приложения. Проверьте использование за неделю перед повторной попыткой. A ChatGPT sign-in can only be used with the OpenAI API. Вход через ChatGPT можно использовать только с API OpenAI. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). Наб A globe scene. Сцена-глобус. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Широта и долгота (40.7, -74.0 или 40°42'N 74°0'W), слой, закладка или значение в таблице слоя. Enter выбирает выделенную строку; «Поиск мест» ищет текст как место A layer the model reads Слой, который читает модель +A line for the Script pane Строка для панели скриптов A magnifying lens that follows the pointer. Увеличительная лупа, следующая за указателем. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). Адрес сервиса карт, объектов или покрытий: WMS, WMTS, WFS, WCS, OGC API Features или Tiles либо ArcGIS FeatureServer, MapServer, ImageServer или VectorTileServer (или каталог сервисов для просмотра). a name, or owner/name such as giswqs/geospatial имя или владелец/имя, например giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Именованная конечная точка для повторного использования. Пароли и ключи хранятся до закрытия RustGIS и никогда не сохраняются. A node cannot connect to itself. Узел не может соединяться сам с собой. A notebook of text and command cells. Блокнот из текстовых и командных ячеек. +A password or token in the address was left out: connections never keep secrets. Пароль или токен в адресе пропущен: подключения никогда не хранят секреты. A place or an address Место или адрес A project keeps at least one map В проекте остаётся хотя бы одна карта A projected coordinate system. Система координат проекции. A report listing the selected layer's records, page after page. Отчёт со списком записей выбранного слоя, страница за страницей. A result that only feeds other tools is removed once the model has run; add an output to keep it. Результат, который только передаётся другим инструментам, удаляется после запуска модели; добавьте выход, чтобы сохранить его. A result the model keeps, under a name Результат, который модель сохраняет под именем +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) Команда rustgis-cli для оболочки POSIX (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Импорт или обновление сервиса уже выполняется. +A service's layers stay in their service group. Слои сервиса остаются в группе своего сервиса. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Задача вмещает не более 200 шагов: остановите запись, чтобы создать её, затем запишите остальное как другую задачу. A template with the first selected feature's values Шаблон со значениями первого выбранного объекта +A tile layer per grid tile, each following the time slider through its dates. Слой тайлов на каждый тайл сетки; каждый следует за ползунком времени по своим датам. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. Адрес сервиса тайлов: растровые тайлы с {z}/{x}/{y} (или {-y}, {q}, {bbox-epsg-3857} для WMS), векторные тайлы (.pbf) или TileJSON, стиль MapLibre, архив PMTiles (веб-адрес или файл на этом компьютере) или файл MBTiles на этом компьютере. A token is set. Токен задан. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Значение, поле или два из них, соединённые знаком + − × ÷. Применяется к выбранным строкам или ко всем строкам, если ничего не выбрано. a vector a pixel вектор на пиксель a vector an image patch вектор на фрагмент изображения A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Хранилище Zarr (формат 2 или 3) по веб-адресу, с консолидированными метаданными, или папка на этом компьютере. Карта переменной читается для выбранных шагов её других измерений, например времени. +A Zarr variable's values through time at points clicked on the map. Значения переменной Zarr во времени в точках, указанных щелчком на карте. +A2 landscape sheet Лист A2, альбомная ориентация +A2 portrait sheet Лист A2, книжная ориентация +A3 landscape sheet Лист A3, альбомная ориентация +A3 portrait sheet Лист A3, книжная ориентация +A4 landscape sheet Лист A4, альбомная ориентация +A4 portrait sheet Лист A4, книжная ориентация +A5 Grid Сетка A5 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. Равновеликие пятиугольные ячейки A5 заданного разрешения в экстенте карты, слоя или всего мира, в виде полигонов с их идентификаторами. About О программе About plugins О модулях About RustGIS О RustGIS @@ -65,6 +83,7 @@ Add Добавить Add\nData Добавить\nданные Add ({n}) Добавить ({n}) Add a bar chart of a layer's numeric field. Добавить столбчатую диаграмму числового поля слоя. +Add a circle. Добавить круг. Add a colorbar of a raster layer's colour scale in the map frame. Добавить во фрейм карты цветовую шкалу растрового слоя. Add a description. Добавьте описание. Add a field. Добавить поле. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Добавить маке Add a page to the presentation. Добавить страницу в презентацию. Add a PNG or JPEG picture to the page. Добавить на страницу изображение PNG или JPEG. Add a presentation file. Добавить файл презентации. +Add a rectangle, to frame or highlight part of the page. Добавить прямоугольник, чтобы обрамить или выделить часть страницы. Add a report file, or a report template for the selected layer. Добавить файл отчёта или шаблон отчёта для выбранного слоя. Add a scale bar for the map frame. Добавить масштабную линейку для фрейма карты. add a second map first (Data › New Map) сначала добавьте вторую карту (Данные › Новая карта) Add a second map frame first Сначала добавьте второй фрейм карты +Add a straight line. Добавить прямую линию. Add a text element. Добавить текстовый элемент. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Добавить веб-базовую карту поверх остальных, не заменяя их, чтобы смешать её с ними. Щёлкните по показанной, чтобы убрать её. +Add a Zarr variable first (Add Data › Zarr…). Сначала добавьте переменную Zarr (Добавить данные › Zarr…). Add Administrative Boundaries Добавить административные границы +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Добавить стрелку. Поверните её круглым маркером над ней или задайте угол в её свойствах. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. Добавьте токен Earthdata Login (Earthdata Login, выше), чтобы помещать GeoTIFF NASA на карту. Add an elevation raster first. Сначала добавьте растр высот. Add an elevation raster to the map first Сначала добавьте на карту растр высот +Add an ellipse. Добавить эллипс. Add an empty layer with a ready-made schema, to draw into. Добавить пустой слой с готовой схемой, чтобы рисовать в нём. Add an input for each layer the model reads. Добавьте вход для каждого слоя, который читает модель. Add an output to keep a result under a name. Добавьте выход, чтобы сохранить результат под именем. Add and Open Добавить и открыть Add and Open Notebook Добавить и открыть блокнот Add Archive Добавить архив +Add as Time Series Добавить как временной ряд add at least two keyframes добавьте не менее двух ключевых кадров Add Cell Добавить ячейку Add Color Ramp File… Добавить файл цветовых шкал… @@ -132,6 +157,7 @@ Add Graphics Layer Добавить графический слой Add Input Добавить вход Add Job Добавить задание Add Keyframe Добавить ключевой кадр +Add Label Class Добавить класс подписей Add Layer File Добавить файл слоя Add Open Data Добавить открытые данные Add Output Добавить выход @@ -147,9 +173,11 @@ Add Rule Добавить правило Add sample countries Добавить пример стран Add Sample Data Добавить пример данных Add Satellite Embeddings Добавить спутниковые эмбеддинги +Add Selected ({n}) Добавить выбранные ({n}) Add Service Layer Добавить слой сервиса Add skill… Добавить навык… Add STAC Asset Добавить ресурс STAC +Add STAC Data Cube Добавить куб данных STAC Add STAC Footprints Добавить контуры STAC Add Step Добавить шаг Add Stroke Добавить обводку @@ -171,6 +199,7 @@ Add to Favourites Добавить в избранное Add to Map Добавить на карту Add vector data. Добавить векторные данные. Add view Добавить вид +Add With Options… Добавить с параметрами… Add Zarr Variable Добавить переменную Zarr Add Zarr Variable… Добавить переменную Zarr… Add ▾ Добавить ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Агенты имеют полный доступ: они могут запускать скрипты, задачи, модели и модули. Щёлкните, чтобы остановить MCP-релей Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Подключившиеся агенты на этом компьютере (Claude Code, Codex, Cursor и другие) работают с открытой здесь картой через инструменты RustGIS: они могут открывать и сохранять файлы и пользоваться вебом. Скрипты и управление плагинами остаются выключенными. Agents then run commands, change files and use the web on this computer without asking you. Тогда агенты будут без вопросов выполнять команды, изменять файлы и пользоваться вебом на этом компьютере. +Aggregate Points to A5 Агрегировать точки в A5 Aggregation Агрегация Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Границы сельскохозяйственных полей, найденные моделью Fields of the World на снимках Sentinel-2 по всему миру, по одному году (2nd Edition, CC BY 4.0). Это предсказания модели, а не обмеренные участки. AI ИИ @@ -221,15 +251,18 @@ All steps done. Все шаги выполнены. All tables with geometry Все таблицы с геометрией Allow Разрешить Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Разрешите использование тарифа ChatGPT при входе. Если ваше рабочее пространство этого не допускает, обратитесь к его администратору или выберите провайдера с ключом API. +Allow Feature Selection Разрешить выбор объектов Allow only certain combinations of values across fields. Разрешить только определённые сочетания значений в полях. Allow such actions for this conversation Разрешать такие действия в этом разговоре Allow this action? Разрешить это действие? Allow this agent's RustGIS action? Разрешить это действие агента в RustGIS? Along Selected Line Вдоль выбранной линии +Alt+ Alt+ Alternating bar Чередующаяся полоса Altitude Высота Always allow Всегда разрешать -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3 или S3-совместимый сервис (MinIO, Cloudflare R2, Wasabi…) по его конечной точке. Ключи хранятся до закрытия RustGIS; запомненное подключение сохраняет имя, конечную точку и регион, но никогда не сохраняет секретный ключ. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3 или S3-совместимый сервис (MinIO, Cloudflare R2, Wasabi…) по его конечной точке. Ключи хранятся до закрытия RustGIS; сохранённое подключение сохраняет имя, конечную точку и регион, но никогда не сохраняет секретный ключ. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Агент на этом компьютере просит: An AWS profile Профиль AWS @@ -248,6 +281,7 @@ API key Ключ API Apply Применить Apply a function to a raster Применить функцию к растру Apply Control Points Применить контрольные точки +Apply elevation Применить высоту Apply Style Применить стиль Apply to layers Применить к слоям Apply to the selected layer Применить к выбранному слою @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Область применени Area {area} · Perimeter {perimeter} Площадь {area} · Периметр {perimeter} Areas (name, land use, area) Территории (название, землепользование, площадь) Arrange Упорядочить +Arrow Стрелка +Arrowheads Наконечники стрелок Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Задайте вопрос о своих данных или опишите, что сделать с картой. Помощник выполняет команды RustGIS за вас; любое изменение можно отменить. Ask about the map, or tell it what to do… Спросите о карте или скажите, что сделать… Ask about your data, or tell the assistant what to do with the map. Спросите о своих данных или скажите помощнику, что сделать с картой. @@ -275,12 +311,13 @@ Asking NASA's CMR… Запрос к CMR NASA… Asking the catalog… Запрос к каталогу… Asking the NLDI… Запрос к NLDI… Assistant settings Настройки помощника +At the end В конце +At the start В начале Atlas Атлас Attachments Вложения Attribute\nTable Таблица\nатрибутов Attribute Rules Правила атрибутов Attribute Table Таблица атрибутов -Attribute table Таблица атрибутов Attributes Атрибуты Attribution Атрибуция Aurora Аврора @@ -295,11 +332,9 @@ Automatic (system language) Автоматически (язык системы) Automatic (system time zone and region) Автоматически (часовой пояс и регион системы) Automatic ({kind}) Автоматически ({kind}) Avoid Избегать -AWS AWS AWS, anonymous AWS, анонимно Azimuth Азимут Back Назад -Back to Explore; cancel a sketch Назад к обзору; отменить эскиз Back to the Map Назад к карте Back to the map Назад к карте Back to tools Назад к инструментам @@ -314,8 +349,6 @@ Basemap Базовая карта Basemaps Базовые карты Basin from hydrolocation Бассейн по гидролокации Bearing Азимут -Bearing {b}° · Pitch {p}° Азимут {b}° · Наклон {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Азимут: направление верха карты, по часовой стрелке от севера. Наклон: отклонение от взгляда строго вниз. Щёлкните, чтобы сбросить оба. Before the event До события Below Снизу Below 1:5,000,000 show everything; beyond it only what matches: Мельче 1:5 000 000 показывать всё; крупнее только подходящее: @@ -328,10 +361,12 @@ Bilinear Билинейная Bind To Time Slider Привязать к ползунку времени Bind to Time Slider Привязать к ползунку времени Blank Пустая +Blank for Amazon S3 Пусто для Amazon S3 Blank Letter page Пустая страница Letter Blank Map Пустая карта Blank Page Пустая страница Blend: {mode} Смешивание: {mode} +Block Feature Selection Запретить выбор объектов Blue Синий Bold Жирный Bold label text. Жирный текст подписей. @@ -339,6 +374,7 @@ Bookmark Description Описание закладки Bookmarks Закладки Bookmarks Pane Панель закладок Boolean Логическое +Both ends Оба конца Bottom Снизу Boundaries… Границы… Bounding\nGeometry Ограничивающая\nгеометрия @@ -346,6 +382,7 @@ Brightness Яркость Brightness, contrast, saturation and sepia for the whole map. Яркость, контраст, насыщенность и сепия для всей карты. Bring this bookmark's map into view to update it Откройте карту этой закладки, чтобы обновить её Browse Обзор +Browse Connection Просмотреть подключение Browse it in the Browser Просмотреть в браузере Browse Plugins Обзор модулей Browse the geoprocessing toolbox. Просмотр набора инструментов геообработки. @@ -357,6 +394,7 @@ Browser Браузер Browser\nPane Панель\nбраузера Browser View Вид браузера Bucket Бакет +Buckets Бакеты Buffer Буфер Buffer A Point Буфер точки Buffer the cities by 200 km Построить буфер 200 км вокруг городов @@ -381,7 +419,9 @@ Calculate Field Вычислить поле Calculate field Вычислить поле Calculate field values. Вычислить значения поля. Calculate fields and enforce conditions on every edit. Вычислять поля и проверять условия при каждой правке. +Calculate Geometry Вычислить геометрию Calculate Geometry Attributes Вычислить геометрические атрибуты +Calculated {rows} features Вычислено объектов: {rows} Calculation Вычисление Camera Камера Camera Effect Эффект камеры @@ -396,12 +436,14 @@ Case Field Поле группировки Cast Shadow Отбрасывать тени Catalog Каталог catalog is a static STAC catalog's catalog.json or collection.json catalog: catalog.json или collection.json статического каталога STAC +Catalogue (CSW) Каталог (CSW) Catalogue search (CSW) Поиск по каталогу (CSW) Celestial Bodies Небесные тела Celestial Body Небесное тело Celestial body: {body} Небесное тело: {body} Cell size Размер ячейки cell size ° размер ячейки ° +Cells Ячейки Center Центр Center Map Here Центрировать карту здесь Centered По центру @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. Использо ChatGPT sign-in failed. Check the connection and local settings, then try again. Не удалось войти в ChatGPT. Проверьте соединение и локальные настройки, затем повторите попытку. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. Использование ChatGPT временно недоступно. Сохраните вход и повторите попытку позже. CHE CHE +Check Проверить Check Again Проверить снова Check for\nUpdates Проверить\nобновления Check for Updates Проверить обновления @@ -428,6 +471,7 @@ Check for Updates… Проверить обновления… Check the address and the service type. Проверьте адрес и тип сервиса. Check the address and your connection. Проверьте адрес и подключение. Check the address. Проверьте адрес. +Check the clause, and count the features it takes. Проверить условие и подсчитать отбираемые им объекты. Check the integration's client, permissions, workspace policy and route configuration. Проверьте клиент, разрешения, политику рабочего пространства и настройки маршрутов интеграции. Checking for updates… Проверка обновлений… China Китай @@ -455,6 +499,7 @@ Choose which layers a click identifies. Выбрать, какие слои ид Choose which tabs the ribbon shows. Выбрать, какие вкладки показывает лента. Circle Круг Circle Radius (km) Радиус круга (км) +Class Класс Class color Цвет класса Class Style Стиль классов Classes Классы @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Щёлкайте, чт Click to edit the description Щёлкните, чтобы изменить описание Click to place a point graphic. Щёлкните, чтобы разместить точечную графику. Click to select the node Щёлкните, чтобы выбрать узел +Click to select, double-click to add, right-click for more Щелчок — выбрать, двойной щелчок — добавить, правый щелчок — другие действия Click to switch between decimal degrees and DMS Щёлкните для переключения между десятичными градусами и ГМС Clip Обрезать Clip a raster to the view, or resample it. Обрезать растр по виду или передискретизировать его. @@ -532,7 +578,7 @@ Close thread Закрыть поток Close {name} Закрыть {name} Closest Ближайшее Coded values, one per line: code=description Кодированные значения, по одному в строке: код=описание -Collapse or expand the ribbon Свернуть или развернуть ленту +Collapse or Expand the Ribbon Свернуть или развернуть ленту Collapse the ribbon (Ctrl+F1) Свернуть ленту (Ctrl+F1) Collection Коллекция Collection {id}: choose dates and Find Items Коллекция {id}: выберите даты и нажмите «Найти элементы» @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Сочетания из поля выше, по одному в строке COMID {comid} COMID {comid} Comma Запятая +Comma-separated (optional) Через запятую (необязательно) Command\nSearch Поиск\nкоманд command is required требуется command Command Search Поиск команд @@ -572,10 +619,13 @@ Connect Подключить Connect a folder to the Browser. Подключить папку к браузеру. Connect to S3 Подключиться к S3 Connect, or pick a layer Подключите или выберите слой +Connected to {kind} {title} Подключено к {kind} {title} Connecting to {url}… Подключение к {url}… Connection Подключение +connection.new returned no id connection.new не вернул идентификатор Connections Подключения -Connect… Подключить… +Connections you add are listed here Здесь перечислены добавленные вами подключения +Connections… Подключения… Constant Константа Constant height Постоянная высота Constant… Константа… @@ -583,11 +633,14 @@ Contingent Values Условные значения Continue Продолжить Continue with ChatGPT Продолжить с ChatGPT Contrast Контраст +Control Point Fit Подгонка по контрольным точкам Conversion Преобразование Convert Преобразовать Convert a layer to another format Преобразовать слой в другой формат Convert the selected layer's labels into an annotation layer. Преобразовать подписи выбранного слоя в слой аннотаций. +Convert this layer's labels into an annotation layer. Преобразовать подписи этого слоя в слой аннотаций. Convert To Annotation Преобразовать в аннотации +Convert to Annotation Преобразовать в аннотации Converted data Преобразованные данные Convert… Преобразовать… Coordinate Координата @@ -599,11 +652,15 @@ Copied the map to the clipboard Карта скопирована в буфер Copied {path} Скопировано: {path} Copies the (selected) features of a layer to a new layer. Копирует (выбранные) объекты слоя в новый слой. Copy Копировать +Copy / Cut / Paste features Копировать / вырезать / вставить объекты Copy a model.run command that runs this model, for the Script pane or Python Копировать команду model.run, запускающую эту модель, для панели скриптов или Python Copy Address Копировать адрес Copy address Копировать адрес Copy an image of the map to the clipboard. Копировать изображение карты в буфер обмена. +Copy as CLI Command Копировать как команду CLI +Copy as Command Копировать как команду Copy As GeoJSON Копировать как GeoJSON +Copy as Python Копировать как Python Copy Command Копировать команду Copy coordinates to the clipboard Копировать координаты в буфер обмена Copy details Копировать подробности @@ -611,11 +668,13 @@ Copy Group Копировать группу Copy Layer Копировать слой Copy Layers Копировать слои Copy Path Копировать путь +Copy Run As Code Копировать запуск как код Copy sign-in link Скопировать ссылку для входа Copy the selected features (also as GeoJSON text). Копировать выбранные объекты (также как текст GeoJSON). Copy the selected features and delete them. Копировать выбранные объекты и удалить их. Copy the selected layer's data source path. Копировать путь к источнику данных выбранного слоя. Copy this element (Ctrl+D, ⌘D on a Mac) Копировать этот элемент (Ctrl+D, ⌘D на Mac) +Copy this run as code, or open it in its tool Скопировать этот запуск как код или открыть его в инструменте Corrections Коррекция Cost Стоимость could not draw the map не удалось отрисовать карту @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Не удалось загрузит Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Количество, сумма, среднее, минимум, максимум и стандартное отклонение поля, при необходимости по полю группировки. Countries, cities, rivers and lakes Страны, города, реки и озёра Country Страна +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Подсчитывает точки слоя по содержащим их ячейкам A5 (ячейки равной площади, поэтому количество отражает плотность), с суммой и средним значением поля. Counts the points that fall inside each polygon. Подсчитывает точки, попадающие в каждый полигон. Counts, totals and top categories of what's in view. Количества, итоги и основные категории в пределах вида. CPU CPU @@ -647,12 +707,8 @@ Cross Section Поперечный разрез Cross Section (2 clicks) Поперечный разрез (2 щелчка) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Накопительно Current date Текущая дата Current Project Текущий проект @@ -680,13 +736,16 @@ Data type Тип данных Data you add is listed here Здесь перечисляются добавленные данные data.gov and CKAN catalogues Каталоги data.gov и CKAN Database База данных -Databases Базы данных +Database connections are used by the desktop app Подключения к базам данных используются настольным приложением +databases are read through DuckDB, a program the browser can't run базы данных читаются через DuckDB, программу, которую браузер не может запустить Database… База данных… Dataset Набор данных Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Наборы данных на Hugging Face по имени или владелец/имя; просматривайте файлы набора и добавляйте GeoTIFF, GeoParquet, PMTiles, LiDAR и другие файлы. Data… Данные… Date Дата Dates Даты +Dates a cell needs Дат нужно ячейке +Dates a cell needs is a whole number, or empty for half «Дат нужно ячейке» — целое число или пусто для половины Day День Day and night shading for a time. Затенение дня и ночи на заданное время. Decimal Десятичные @@ -715,7 +774,6 @@ Delete Notebook Удалить блокнот Delete Page Удалить страницу Delete Presentation Удалить презентацию Delete Report Удалить отчёт -Delete selected features Удалить выбранные объекты Delete selected rows Удалить выбранные строки Delete Style Удалить стиль Delete Task Удалить задачу @@ -736,11 +794,15 @@ Desktop and command line only Только настольное приложен Details Сведения Details of the selected layer. Сведения о выбранном слое. Deviations Отклонения +DGGS DGGS Diagnostic\nMonitor Монитор\nдиагностики Diagnostic Monitor Монитор диагностики Diagnostics Диагностика +Diagonal ↗ Диагональ ↗ +Diagonal ↘ Диагональ ↘ Diamond Ромб Difference, ratio or percent change between two rasters. Разность, отношение или процентное изменение между двумя растрами. +Digit Digit Directions Маршрут Directions From Here Маршрут отсюда Directions To Here Маршрут сюда @@ -758,6 +820,7 @@ Dissolve Field Поле объединения Distance Расстояние Distance (km) Расстояние (км) Distance Coefficient P Коэффициент расстояния P +Distance units Единицы расстояния Distances along the surface, heights, areas and volumes. Расстояния по поверхности, высоты, площади и объёмы. DISTINCT DISTINCT Distinct Уникальные @@ -804,18 +867,23 @@ Drag to draw Тяните, чтобы рисовать Drag to draw a freehand line. Тяните, чтобы нарисовать линию от руки. Drag to draw a freehand polygon. Тяните, чтобы нарисовать полигон от руки. Drag to draw a rectangle. Тяните, чтобы нарисовать прямоугольник. +Drag to resize the message box Перетащите, чтобы изменить размер поля сообщения Drag tools from the palette onto the canvas. Перетаскивайте инструменты из палитры на холст. Drag vertices on the map; click an edge to add one Перетаскивайте вершины на карте; щёлкните ребро, чтобы добавить вершину Draw a halo around label text. Рисовать ореол вокруг текста подписей. Draw a labelled latitude/longitude grid over the map frame. Нарисовать подписанную сетку широт и долгот поверх фрейма карты. Draw a model: chain tools from input layers to the results to keep Нарисуйте модель: соедините инструменты в цепочку от входных слоёв к сохраняемым результатам Draw a model: chain tools from input layers to the results to keep. Нарисуйте модель: соедините инструменты в цепочку от входных слоёв к сохраняемым результатам. +Draw at its place in the stack, the bottom stroke first Отрисовывать на своём месте в стеке, начиная с нижней обводки +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Отрисовывать обводки всех объектов одного уровня перед следующим, чтобы окантовки соединялись под основными линиями в местах пересечения Draw labels at every scale and distance. Отображать подписи при любом масштабе и расстоянии. Draw new features in the selected layer. Double-click to finish lines and polygons. Рисуйте новые объекты в выбранном слое. Двойной щелчок завершает линии и полигоны. Draw new single-band rasters with the inverted grey stretch again. Снова отображать новые одноканальные растры с инвертированной серой растяжкой. +Draw only polygon outlines, keeping their colors and widths. Отрисовывать только контуры полигонов, сохраняя их цвета и толщину. Draw points as bins coloured by how many fall in each. Отображать точки ячейками, окрашенными по их количеству в каждой. Draw polygons as outlines only. Отображать полигоны только контурами. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Отображать добавляемые с этого момента одноканальные растры с этой цветовой схемой, растяжкой и передискретизацией. +Draw strokes in levels Отрисовывать обводки по уровням Draw the layer at every scale. Отображать слой в любом масштабе. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Отрисовывать карту на GPU (бета; глобус, эффекты вида и имитация цветового зрения остаются на CPU-рендерере). Draw the selected layer above others. Отображать выбранный слой над остальными. @@ -828,6 +896,7 @@ drawn by the server отрисовано сервером Drive Авто Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. Снимки с дронов и аэросъёмка, опубликованные на OpenAerialMap, сначала новые; добавляются как облачно-оптимизированные GeoTIFF. Duplicate Дублировать +Duplicate Connection Дублировать подключение Duplicate Element Дублировать элемент Duplicate Layout Дублировать макет Duplicate Map Дублировать карту @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name напр. pop_est / 1000000 e.g. 0, 255, -9999 напр. 0, 255, -9999 e.g. continent = 'Europe' напр. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' напр. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 например, pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Каждый запуск геообработки становится шагом, связанным через свои результаты Earth Index has no points in the view В Earth Index нет точек в этом виде Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Изменить\nвершины Edit and run the notebook. Редактировать и запускать блокнот. Edit Attribute Изменить атрибут Edit Cell Изменить ячейку +Edit Connection Изменить подключение Edit Description Изменить описание Edit Graphic Изменить графику Edit Status Состояние правок @@ -882,6 +953,7 @@ Editor\nTools Инструменты\nредактора Editor Tools Инструменты редактора Edits since the project was saved. Правки после сохранения проекта. Edits the project Изменяет проект +Edit… Изменить… Effects Эффекты Element Элемент Element Properties Свойства элемента @@ -898,21 +970,25 @@ Enable Lighting Включить освещение Enable Plugin Включить модуль Enabled Включён Encloses each feature, or all features, in its convex hull. Заключает каждый объект или все объекты в выпуклую оболочку. +Encoded polyline Закодированная полилиния End Конец End date Дата окончания Endpoint Конечная точка Enhancement Улучшение Enter Enter Enter an API key. Введите ключ API. +Enter an EPSG code as a number, such as 32650 Введите код EPSG числом, например 32650 Enter the provider's address. Введите адрес поставщика. Enter to send, Shift+Enter for a new line Enter — отправить, Shift+Enter — новая строка EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Среды EPSG EPSG +EPSG code Код EPSG EPSG or ESRI code Код EPSG или ESRI EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Равные интервалы Equirectangular (Plate Carrée) Равнопромежуточная (Plate Carrée) @@ -922,14 +998,18 @@ Error Ошибка Error Inspector Инспектор ошибок Error Inspector (validate) Инспектор ошибок (проверка) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Европа, равновеликая (ETRS89 LAEA) Every band's value at a pixel. Значения всех каналов в пикселе. Every editing tool. Все инструменты редактирования. +Every label setting of this layer, in the Labeling pane. Все настройки подписей этого слоя на панели подписей. Every layer, as FEMA draws them Все слои в отрисовке FEMA +Every run, in order, as a script that opens the project Все запуски по порядку в виде скрипта, открывающего проект every Socrata site, or one such as data.cityofnewyork.us любой сайт Socrata или конкретный, например data.cityofnewyork.us Everywhere Везде +Existing field Существующее поле Existing Template Существующий шаблон exit rotary съезд с кольца exit roundabout съезд с кольца @@ -948,6 +1028,7 @@ Export\nRaster Экспорт\nрастра Export Animation Экспорт анимации Export Bookmarks Экспорт закладок Export Bookmarks… Экспорт закладок… +Export CSV… Экспорт CSV… Export Features Экспорт объектов Export Features (tool) Экспорт объектов (инструмент) Export Features needs a vector layer (Data › Export Raster writes a raster) Для экспорта объектов нужен векторный слой (Данные › Экспорт растра записывает растр) @@ -996,6 +1077,7 @@ Face Culling Отсечение граней Faces Грани Factor Коэффициент Failed Ошибка +FALSE FALSE Farthest Самое дальнее Faster drawing: no antialiasing. Быстрая отрисовка: без сглаживания. Favourite Data Source Избранный источник данных @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Границы полей Field density Плотность полей Field name Имя поля +Field Prefix Префикс поля +Field to Sum and Average Поле для суммы и среднего Fields Поля Fields are drawn from zoom 9: zoom in to see them. Поля отображаются с масштаба 9: приблизьте, чтобы увидеть их. Fields colored by score, drawn from zoom 9 Поля, окрашенные по оценке, отображаются с масштаба 9 @@ -1079,6 +1163,8 @@ Folder Connections Подключения к папкам folder connections need the desktop app для подключения к папкам нужно настольное приложение Folders Папки Folder… Папка… +Follow the time slider Следовать за ползунком времени +Font Шрифт for a store that doesn't say для хранилища, где это не указано Forest Лес Forget Earthdata Login Token Забыть токен Earthdata Login @@ -1090,9 +1176,11 @@ France Франция Free the prepared geometry. Освободить подготовленную геометрию. Freehand От руки FROM FROM +From С From Selection Из выборки From Service… Из сервиса… From symbol Из символа +From the address Из адреса from the address из адреса from the service из сервиса From URL… По URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Скрывать объекты, да Hide Group Скрыть группу Hide labels when the viewer is closer than this. Скрывать подписи, когда наблюдатель ближе этого расстояния. Hide labels when the viewer is farther than this. Скрывать подписи, когда наблюдатель дальше этого расстояния. +Hide Layers Скрыть слои Hide other values Скрыть прочие значения Hide Steps Скрыть шаги Hide the description Скрыть описание @@ -1213,6 +1302,8 @@ Hold Приостановить HOME HOME Home Главная Home › Add Главная › Добавить +Home › Add Data Главная › Добавить данные +Home › Inspect › Measure Главная › Изучение › Измерить Home › Navigate Главная › Навигация Home › Navigate › Bookmarks Главная › Навигация › Закладки Home › Web Data Главная › Веб-данные @@ -1220,7 +1311,7 @@ Home › Web Data › United States Главная › Веб-данные › Homepage Домашняя страница Horizontal Горизонтально host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=… или файл .duckdb +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Размещённые изображения Hour Час How the layer's colours combine with the tile layers and basemap beneath. Как цвета слоя сочетаются с тайловыми слоями и базовой картой под ним. @@ -1248,6 +1339,7 @@ Imagery Sharing Публикация изображений Imagery Tools Инструменты изображений ImageServer ImageServer Image… Изображение… +Imperial Имперские Import Импорт Import\nProject Импорт\nпроекта Import All Импортировать всё @@ -1267,6 +1359,7 @@ Import Task Импорт задачи Import Task… Импорт задачи… Import Thumbnail Импорт эскиза Import… Импорт… +IN IN In Beyond Вкл. за пределами In memory В памяти In Progress Выполняется @@ -1281,6 +1374,8 @@ Information Информация INPUT ВХОД Input Вход Input Features Входные объекты +Input Layer Входной слой +Input Points Входные точки Input Polygons Входные полигоны Input Rows Входные строки Input Summary Features Входные объекты сводки @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Установленные модули Installed with Homebrew, winget, the AUR or COPR? Update it there. Установлено через Homebrew, winget, AUR или COPR? Обновите там. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. Установлен модуль {name} {version}. Он находится на вкладке «Модули»; его инструменты также есть на панели «Инструменты». Installing it downloads Python {version} and the packages it needs from the internet. При установке из интернета загружаются Python {version} и нужные ему пакеты. +Integer field: values are rounded. Целочисленное поле: значения округляются. Interface font: {font} Шрифт интерфейса: {font} Interpolate a surface from points (IDW) Интерполировать поверхность по точкам (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Интерполирует двухканальную растровую поверхность (прогноз и дисперсия) по точечным объектам методом обычного кригинга. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. Интерполирует растровую поверхность по значениям объектов методом обратно взвешенных расстояний, как в QGIS. Interpolation Интерполяция Interpolation Attribute Атрибут интерполяции @@ -1316,10 +1413,13 @@ Invalid: {error} Недопустимо: {error} Invert Инвертировать Invert color scheme Инвертировать цветовую схему Invert the selection. Инвертировать выборку. +IS IS IS NULL IS NULL it connects to an address on the internet подключается к адресу в интернете It has no code: nothing in it runs. В нём нет кода: ничего не запускается. +It is locked, or one of its surrounds is: unlock it first Он закреплён или закреплён один из его элементов окружения: сначала открепите его it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) доступно только вам (модули, скрипты, задачи, модели и средство запуска GeoLibre) +it needs input: open it from its pane or button, or run it from a script or the assistant нужны входные данные: откройте его с его панели или кнопки либо запустите из скрипта или помощника it reads or writes files on this computer читает или записывает файлы на этом компьютере it replaces the open project заменяет открытый проект it runs a plugin's command запускает команду модуля @@ -1332,20 +1432,30 @@ Italic Курсив Item Pop-ups Всплывающие окна элементов Item {id} Элемент {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. Его брандмауэр ({firewall}) заблокировал RustGIS. Сервис может разрешать только некоторые приложения: свяжитесь с его администратором и отправьте ему подробности ниже. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Его инструмент сейчас недоступен (инструмент модуля, который не загружен, или набора, который не установлен) Jobs Задания JOIN JOIN Join Соединение +Join Features Объекты для присоединения +Join Operation Операция соединения +Joins attributes from a join layer to a target layer based on their spatial relationship. Присоединяет атрибуты из присоединяемого слоя к целевому слою на основе их пространственного отношения. +Journal figure, 1 column (90 mm) Рисунок для журнала, 1 колонка (90 мм) +Journal figure, 1.5 columns (140 mm) Рисунок для журнала, 1,5 колонки (140 мм) +Journal figure, 2 columns (190 mm) Рисунок для журнала, 2 колонки (190 мм) Keep a history of every edit to the layer, to look back or restore. Хранить историю всех правок слоя, чтобы просматривать или восстанавливать их. Keep an archive of edits Хранить архив правок keep current оставить текущее +Keep it from being moved, resized, deleted or edited by mistake Защитить от случайного перемещения, изменения размера, удаления или правки Keep one value of each point's vector as its value field Сохранить одно значение вектора каждой точки как её поле значения Keep Result Сохранить результат Keep the labelled features fixed as the map moves. Сохранять подписанные объекты неизменными при перемещении карты. Keep the last drawing tool active after each graphic. Оставлять последний инструмент рисования активным после каждой графики. Keep the last tool active Оставлять последний инструмент активным +Keep this endpoint under Data Sources › Connections, without its password or keys Сохранить эту конечную точку в разделе «Источники данных › Подключения» без пароля и ключей Keep this result Сохранить этот результат Keep Tool Удерживать инструмент Keep value Сохранить значение +Key Key Keyboard Shortcuts Сочетания клавиш Keyboard shortcuts. Сочетания клавиш. Keys from Ключи из @@ -1356,13 +1466,20 @@ KMZ KMZ Label Подпись Label Class Класс подписей Label Distance Range Диапазон расстояний подписей +Label features in this layer Подписывать объекты этого слоя Label Field Поле подписей +Label only the features that match. Подписывать только подходящие объекты. Label Placement Размещение подписей Label Range Диапазон подписей Label the cities with their names Подписать города их названиями Label Visibility Range Диапазон видимости подписей Labeling Подписи +Labeling Pane Панель подписей +Labeling Properties… Свойства подписей… Labels Подписи +labels are drawn for feature layers, not rasters подписи отрисовываются для векторных слоёв, а не для растров +Labels are drawn for feature layers, not rasters. Подписи отрисовываются для векторных слоёв, а не для растров. +Labels are off for this layer: the clause applies once they are on. Подписи этого слоя выключены: условие применится после их включения. Lagoon Лагуна Lambert Conformal Conic Равноугольная коническая Ламберта Lambert Cylindrical Equal Area Равновеликая цилиндрическая Ламберта @@ -1383,12 +1500,14 @@ Layer Blend Смешивание слоя Layer Extrusion Выдавливание слоя Layer File Файл слоя Layer From\nSelection Слой из\nвыборки +Layer not found Слой не найден Layer Template Шаблон слоя Layer Templates Шаблоны слоёв layer {id} слой {id} Layers Слои Layers ({n}) Слои ({n}) Layers and Table Views Слои и табличные представления +Layers, a date each Слои, по дате на каждый Layout Макет Layout\nFile Файл\nмакета Layout after: {name} Макет после: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Учебные\nматериалы Left Слева Legend Легенда Legend label. Clear it to show the value again. Подпись в легенде. Очистите её, чтобы снова показать значение. -Length {length} · gain {gain} m · loss {loss} m Длина {length} · подъём {gain} м · спуск {loss} м +Length {length} · gain {gain} · loss {loss} Длина {length} · подъём {gain} · спуск {loss} Let the assistant hand tasks to agents on this computer Разрешить помощнику передавать задачи агентам на этом компьютере Let your Agents Control RustGIS Разрешить вашим агентам управлять RustGIS Letter spacing Межбуквенный интервал @@ -1439,6 +1558,7 @@ List by drawing order Список по порядку отображения List by selection Список по выборке List Columns in File Список столбцов в файле List Commands Список команд +List Connections Список подключений List Data Sources Список источников данных List Database Columns Список столбцов базы данных List Database Tables Список таблиц базы данных @@ -1475,6 +1595,11 @@ Locate… Указать… Location Расположение Locator Локатор Lock Закрепить +Locked Закреплён +Locked: unlock it in the Format pane to move or change it Закреплён: открепите его на панели «Формат», чтобы переместить или изменить +Log In to Connection Войти в подключение +Log In to {name}… Войти в {name}… +Log In… Войти… Long Длинное целое Longitude Долгота Longitude (X) Долгота (X) @@ -1493,6 +1618,7 @@ Manage Управление Manage Bookmarks Управление закладками Manage Edits Управление правками Manage Templates Управление шаблонами +Mann–Kendall Манн–Кендалл Map Карта Map\nFrame Фрейм\nкарты Map (the current view) Карта (текущий вид) @@ -1504,6 +1630,7 @@ Map File Файл карты Map Frame Фрейм карты Map Frames Фреймы карты Map frames per second (live while the map moves) Кадров в секунду на карте (в реальном времени при движении карты) +Map Labeling Подписи карты Map Link Ссылка на карту Map name Имя карты Map Page Страница карты @@ -1515,6 +1642,7 @@ Map style (JSON) Стиль карты (JSON) Map Surrounds Элементы оформления карты Map Thumbnail Эскиз карты Map tiles of the whole world Тайлы карты всего мира +Map Tool: {tool} Инструмент карты: {tool} Map View Вид карты map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Карта: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Маркер Mask Values Маскировать значения Masking Маскирование +Match Option Условие сопоставления Matching places Подходящие места Max Макс. Max % Макс. % +Max Neighbors Макс. соседей Maximum zoom Максимальный масштаб MCP Relay MCP-релей MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCP-релей уже слушает 127.0.0.1:{port}; сначала остановите его, чтобы использовать порт {asked} @@ -1542,6 +1672,8 @@ Measure Distance Измерить расстояние Measure geodesic distances and areas on the map. Измерять геодезические расстояния и площади на карте. Measure on this body, with one of its basemaps. Измерять на этом теле, с одной из его базовых карт. Measure with an elevation raster Измерение по растру высот +Measurement units Единицы измерения +Measurement units must be metric or imperial Единицы измерения должны быть метрическими или имперскими Measurements so far. Измерения на данный момент. Mensuration Мензурация Mensuration Tools Инструменты мензурации @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Объед Messages appear here. Здесь появляются сообщения. Meters Метры Method Метод +Metric Метрические Midnight Полночь Miles Мили Miller Cylindrical Цилиндрическая Миллера @@ -1560,21 +1693,43 @@ Min must be less than Max Мин. должен быть меньше Макс. Min must not be more than Max Мин. не должен быть больше Макс. Minimum Bounding Geometry Минимальная ограничивающая геометрия Minimum Maximum Минимум-максимум +Minus Minus Minutes Минуты minutes ago минут назад Miter Острое Mobile Map Мобильная карта Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Модель Model\nBuilder Конструктор\nмоделей model id идентификатор модели Model output Выход модели ModelBuilder ModelBuilder Models Модели +Modified Mann–Kendall (Hamed & Rao) Модифицированный Манн–Кендалл (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Модифицированный Манн–Кендалл (Yue & Wang) Modify Изменение Modify Selected Features Изменить выбранные объекты Mollweide (equal-area world) Мольвейде (равновеликая карта мира) Month Месяц +Months Месяцы +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Месяцы задаются числами от 1 до 12 (12, 1, 2 для декабря–февраля) More Ещё More basemaps: Ещё базовые карты: Mosaic images into an orthomosaic Сшить изображения в ортомозаику @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Сшить видимые рас Most cloud cover (%) Макс. облачность (%) Mouse wheel Колесо мыши Move Переместить -Move (m) Переместить (м) Move Bookmark Переместить закладку Move Down Переместить вниз Move down Переместить вниз Move Group Переместить группу +Move Label Class Переместить класс подписей Move Task Step Переместить шаг задачи +Move To Bottom Переместить вниз Move To Group Переместить в группу +Move To Top Переместить наверх Move To {group} Переместить в {group} Move Up Переместить вверх Move up Переместить вверх @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Мои бакеты My OGC Layer Мой слой OGC +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Имя name the plugin whose panel to open: {"id": plugin id} укажите модуль, панель которого нужно открыть: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Новый\nблокнот New\nPresentation Новая\nпрезентация New\nReport Новый\nотчёт New\nWorkspace Новое рабочее\nпространство -New / Open / Save project Создать / открыть / сохранить проект New Bookmark Новая закладка New Bookmark… Новая закладка… +New Connection Новое подключение +New Connection ▾ Новое подключение ▾ New conversation Новый разговор New Custom Template Новый пользовательский шаблон New Custom Template… Новый пользовательский шаблон… New features use this template's values Новые объекты получают значения этого шаблона +New field Новое поле New Group Новая группа New job name Имя нового задания New Layout Новый макет @@ -1649,6 +1809,7 @@ New Notebook Новый блокнот New Presentation Новая презентация New Project Новый проект New project Новый проект +New Publication Layout Новый макет для публикации New Relationship… Новое отношение… New Report Новый отчёт New Selection Новая выборка @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Закладо No catalogs below this one: Find Items lists its items Ниже этого каталогов нет: «Найти элементы» выводит его элементы No COMID was returned for this point. Для этой точки не возвращён COMID. no command нет команды -No connections this session В этом сеансе подключений нет +No data at the points picked. В выбранных точках нет данных. No datasets match. Нет подходящих наборов данных. No datasets with data RustGIS can add Нет наборов данных, которые RustGIS может добавить +no date in the name в имени нет даты No edits since the project was saved Нет правок после сохранения проекта No field of this layer holds dates. Ни одно поле этого слоя не содержит дат. +No Fill Без заливки +No geometry to calculate Нет геометрии для вычисления No granules in this view and date range. Нет гранул в этом виде и диапазоне дат. no home folder нет домашней папки no layer нет слоя @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) Нет отношений (Ст No relationships yet Отношений пока нет No reports yet. Publish › New Report lists the selected layer's records. Отчётов пока нет. Публикация › Новый отчёт выводит записи выбранного слоя. no row нет строки +No serif font is installed, so this text is drawn in the sans-serif Шрифт с засечками не установлен, поэтому этот текст отрисован шрифтом без засечек no service or file links нет ссылок на сервисы или файлы No services match your search. Нет сервисов, соответствующих поиску. No standalone tables Нет отдельных таблиц @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Нет None (public buckets) Нет (публичные бакеты) none, a .duckdb file, or host=… dbname=… user=… нет, файл .duckdb или host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD нет, или из PGPASSWORD или MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD нет или из PGPASSWORD, MYSQL_PWD или SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. Нет. Добавьте слой по адресу или из плагина базовых карт. North Север North\nArrow Стрелка\nсевера North Polar Stereographic Северная полярная стереографическая +NOT NOT not a picture не изображение not a RustGIS layout не макет RustGIS not a RustGIS project не проект RustGIS +Not applied yet. Ещё не применено. Not for texts: a text is edited in the Format pane. Не для текстов: текст редактируется на панели «Формат». not needed не требуется Not sandboxed Без песочницы @@ -1739,9 +1906,12 @@ Nothing found where the map is В области карты ничего не н Nothing here Здесь ничего нет Nothing named {name} to rename Нечего переименовывать: нет объекта с именем {name} Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Пока ничего не опубликовано. Публикация › Поделиться как создаёт веб-карты, слои, стили, сцены и другое. +Nothing to add here Здесь нечего добавить +Nothing to test Нечего проверять Nothing was found at this address. По этому адресу ничего не найдено. Nothing was recorded, so no task was made. Ничего не записано, поэтому задача не создана. Now Сейчас +Nugget Самородок NULL NULL Nulls Пустые значения Observations (observer, date, species, count) Наблюдения (наблюдатель, дата, вид, количество) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. На компьютере к On-screen compass, zoom slider and pan pad. Экранный компас, ползунок масштаба и панель перемещения. One command per line: id {json}. Lines starting with # are comments. По одной команде в строке: id {json}. Строки, начинающиеся с #, являются комментариями. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Один запрос DuckDB SELECT: функции ST_ из DuckDB Spatial, файлы и веб-адреса (read_parquet('…')) и таблицы базы данных ниже. Ctrl+Enter запускает его. +One layer: a Zarr variable or a raster's bands Один слой: переменная Zarr или каналы растра One page per feature of an index layer. По одной странице на каждый объект индексного слоя. One per line: code=name По одному в строке: код=имя One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Одна обводка из символа. Добавьте обводки для окантовки, параллельных линий и смещений. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Открыть папку Open In Geoprocessing… Открыть в геообработке… Open in ModelBuilder Открыть в ModelBuilder +Open in Tool Открыть в инструменте Open Layout Открыть макет Open ModelBuilder Открыть ModelBuilder Open Notebook Открыть блокнот @@ -1797,6 +1969,7 @@ Open Presentation Открыть презентацию Open Project Открыть проект Open project Открыть проект Open Project (from text) Открыть проект (из текста) +Open Project… Открыть проект… Open Report Открыть отчёт Open the attribute table of the selected layer. Открыть таблицу атрибутов выбранного слоя. Open the Basemaps tab Открыть вкладку «Базовые карты» @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Или выполните в Or run in PowerShell, for Claude Code or Codex: Или выполните в PowerShell для Claude Code или Codex: Or run, for Claude Code or Codex: Или выполните для Claude Code или Codex: ORDER BY ORDER BY +Ordinary Kriging Обычный кригинг Orientation Ориентация Ortho Mapping Ортотрансформирование Other Другое Other Data Другие данные +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Другие классы подписей этого слоя (созданные командами): {n}. Каждый подписывает объекты, подходящие под его условие, поэтому объект может получить подписи от нескольких классов. Out Beyond Выкл. за пределами Outline Контур Outline another frame's extent in this map frame. Обвести экстент другого фрейма в этом фрейме карты. @@ -1851,6 +2026,7 @@ Output Вывод Output Name Имя результата Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Более 1000 инструментов для растров, векторов, LiDAR, гидрологии и рельефа, работающих в браузере, после однократной загрузки около 25 МБ. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Более 1000 инструментов для растров, векторов, LiDAR, гидрологии и рельефа в необязательной загрузке размером около 20 МБ. +Overlapping Пересекающиеся Overlay Наложение overlay наложение Overlay (a transparent layer kept on top, such as labels or roads) Наложение (прозрачный слой поверх остальных, например подписи или дороги) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is У Overture нет данных «{ty Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Открытые данные Overture Maps: здания, места, транспорт, административные деления, базовые слои и адреса. В области карты объекты читаются на месте из файлов GeoParquet Overture (приблизьтесь до города или меньше); весь мир поставляется как тайлы карты. Overture Maps… Overture Maps… +Overwrite existing values Перезаписать существующие значения Package Пакет Package a trained deep learning model. Упаковать обученную модель глубокого обучения. Package Map Упаковать карту @@ -1907,8 +2084,12 @@ Performance Производительность Phase Фаза Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Выберите динамическое значение, например масштаб или дату, затем щёлкните по карте, чтобы разместить его. +Pick a Point Выбрать точку +Pick a point on the map to see the variable's values through time there. Выберите точку на карте, чтобы увидеть значения переменной в ней во времени. Pick a route in the list, or click a grey route on the map. Выберите маршрут в списке или щёлкните серый маршрут на карте. +Pick a single layer. Выберите один слой. Pick it on the map Указать на карте +Pick one… Выберите… Picture Изображение Pictures Изображения Pictures, attachments and chart Изображения, вложения и диаграмма @@ -1966,8 +2147,11 @@ Portal Портал Portolan Portolan Portolan… Portolan… Portrait Книжная +Position Положение +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Постобработка power 2 степень 2 +Precision 6 (OSRM, Valhalla) Точность 6 (OSRM, Valhalla) Prepare Подготовить Prepare every layer's geometry for this projection. Подготовить геометрию всех слоёв для этой проекции. Prepare Plugin Environment Подготовить окружение модуля @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Шаблоны проек project's path is required требуется путь к проекту Projection Проекция Projections Проекции +Properties Свойства +Property Свойство Proportional Symbols Пропорциональные символы Provider Поставщик +Provinces: Провинции: Proximity Близость Public GIS portals of US counties and cities, by state. Публичные ГИС-порталы округов и городов США по штатам. Public GIS portals of US federal agencies, by department. Публичные ГИС-порталы федеральных агентств США по ведомствам. Public GIS portals of US states and the District of Columbia. Публичные ГИС-порталы штатов США и округа Колумбия. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. Публичные слои в ArcGIS Online или другом портале ArcGIS: сервисы объектов, карт, изображений и векторных тайлов, веб-карты и файлы GeoJSON. +Publication Layout Макет для публикации Publish Публикация Publish › Output Публикация › Вывод Put the layer back as it was then Вернуть слой в тогдашнее состояние Python environment failed: {error} Ошибка окружения Python: {error} Python environment not prepared Окружение Python не подготовлено Python environment ready Окружение Python готово +Python script Скрипт Python Qualitative Качественные Quantile Квантили Query files, web addresses and databases with DuckDB Spatial; add results to the map. Запросы к файлам, веб-адресам и базам данных через DuckDB Spatial; добавление результатов на карту. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Поднять полигоны на вы Raise Priority Повысить приоритет Random points in polygons Случайные точки в полигонах Range Диапазон +Range (Degrees) Радиус (градусы) +range: 0, 0.9 диапазон: 0, 0.9 Raster Растр Raster\nAnalysis Анализ\nрастров Raster\nInformation Сведения\nо растре @@ -2075,6 +2266,7 @@ Redo {action} Повторить: {action} Red–Blue Красный–синий Refine the search to see {n} more Уточните поиск, чтобы увидеть ещё {n} Refresh Обновить +Refresh every connection's lists Обновить списки всех подключений Refresh Map Series Обновить серию карт Refresh the map series from its index layer. Обновить серию карт по её индексному слою. Region Регион @@ -2091,11 +2283,10 @@ Relationships Отношения Release Выпуск Reload Обновить Reload Web Layers Обновить веб-слои -Remember (without the secret) Запомнить (без секретного ключа) Remember the key on this computer Запомнить ключ на этом компьютере -Remembered: Запомненные: Remove Удалить Remove All Удалить все +Remove All Label Classes Удалить все классы подписей Remove an offline area. Удалить автономную область. Remove Bookmark Удалить закладку Remove Connection Удалить соединение @@ -2108,11 +2299,14 @@ Remove from Favourites Удалить из избранного Remove From Group Удалить из группы Remove Group Удалить группу Remove Keyframe Удалить ключевой кадр +Remove Label Class Удалить класс подписей Remove Section Удалить раздел Remove Table Удалить таблицу +Remove the clause. Удалить условие. Remove the last keyframe. Удалить последний ключевой кадр. Remove the layer from the map. Удалить слой с карты. Remove the strokes and draw the symbol's line alone Удалить обводки и рисовать только линию символа +Remove this class's own strokes and draw it with the layer's Удалить собственные обводки этого класса и отрисовывать его обводками слоя Remove this stop Удалить эту остановку Remove Tile Layer Удалить слой тайлов Remove token Удалить токен @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Уда Rename Переименовать Rename Bookmark Переименовать закладку Rename Group Переименовать группу +Rename it to save it: a connection has this name Переименуйте, чтобы сохранить: подключение с таким именем уже есть +Rename Label Class Переименовать класс подписей Rename Layer Переименовать слой Rename Layout Переименовать макет Rename Map Переименовать карту @@ -2149,6 +2345,7 @@ Reset Default Сбросить по умолчанию Reset Effects Сбросить эффекты Reset North Север вверх Reset north Север вверх +Resolution (0–30) Разрешение (0–30) Response Ответ Restore Восстановить Restore From Archive Восстановить из архива @@ -2162,6 +2359,7 @@ RGB RGB Ribbon Лента Right Справа Right-click a source to add it here Щёлкните источник правой кнопкой, чтобы добавить его сюда +Right-click for more Правый щелчок — другие действия Robinson Робинсон Rotate Повернуть Rotate (°) Повернуть (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Пример данных (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Пример данных © Natural Earth (общественное достояние). RustGIS распространяется по лицензии Apache-2.0. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Пример данных: Natural Earth (общественное достояние). Лицензия Apache-2.0. +Sans-serif Без засечек Satellite Embedding Datasets Наборы спутниковых эмбеддингов Satellite Embeddings Спутниковые эмбеддинги Satellite Embeddings… Спутниковые эмбеддинги… Saturation Насыщенность Save Сохранить Save As Сохранить как +Save as Connection Сохранить как подключение Save as Default Сохранить по умолчанию Save as Default Raster Style Сохранить как стиль растра по умолчанию Save as Layer Сохранить как слой Save As Presentation File… Сохранить как файл презентации… +Save as Python Script… Сохранить как скрипт Python… Save As Report File… Сохранить как файл отчёта… Save As Report Template… Сохранить как шаблон отчёта… Save as {name} Сохранить как {name} Save As… Сохранить как… Save Color Ramps Сохранить цветовые шкалы Save Data Source Сохранить источник данных +Save History As Python Script Сохранить историю как скрипт Python Save Layer File Сохранить файл слоя Save Layout File Сохранить файл макета Save Map File Сохранить файл карты @@ -2254,6 +2456,7 @@ save or load a pane set by name сохранить или загрузить н Save Project Сохранить проект Save project Сохранить проект Save Project As Сохранить проект как +Save Project As… Сохранить проект как… Save Project Template Сохранить шаблон проекта Save Style Items Сохранить элементы стилей Save Task Item Сохранить элемент задачи @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Сохранить модель как Save the model in the project Сохранить модель в проекте Save the open layout as a layout file. Сохранить открытый макет как файл макета. Save the panes you have open, and switch back to them. Сохранить открытые панели и возвращаться к ним. +Save the project too: the script opens its saved file, without changes made since. Сохраните и проект: скрипт открывает его сохранённый файл без изменений, внесённых после сохранения. Save the project under a new name. Сохранить проект под новым именем. Save the project with your edits. Сохранить проект с вашими правками. Save the project's custom color ramps to a color ramp file. Сохранить пользовательские цветовые шкалы проекта в файл цветовых шкал. @@ -2275,13 +2479,16 @@ Save Web Link Chart Сохранить веб-диаграмму связей Save Web Map Сохранить веб-карту Save Web Scene Сохранить веб-сцену Saved Сохранено +Saved as the connection {name} Сохранено как подключение {name} Saved as the default style for new single-band rasters. Сохранено как стиль по умолчанию для новых одноканальных растров. +Saved connection Сохранённое подключение Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Сохранённые ключи хранятся в настройках приложения в незашифрованном виде (в веб-версии — в хранилище браузера). Saved models Сохранённые модели Saved symbology: apply it to the selected layer, or save the layer's. Сохранённые символы: примените их к выбранному слою или сохраните символы слоя. Saved View Сохранённый вид Saved {name} Сохранено: {name} Saved {name}. Сохранено: {name}. +Saved {path} Сохранено: {path} saving failed unexpectedly сохранение неожиданно завершилось ошибкой Scale Масштаб Scale\nBar Масштабная\nлинейка @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Поиск (этому порталу нуж Search Catalogue Поиск по каталогу Search commands — e.g. buffer, basemap, labels Поиск команд — напр. буфер, базовая карта, подписи Search datasets, e.g. GEDI biomass Поиск наборов данных, напр. GEDI biomass -Search Distance (km) Расстояние поиска (км) +Search Distance Расстояние поиска Search every command and its parameters Поиск по всем командам и их параметрам Search layers (e.g. temperature) Поиск слоёв (напр. temperature) Search layers, places, lat/lon… Поиск слоёв, мест, широты/долготы… @@ -2329,6 +2536,8 @@ Searching the folders… Поиск по папкам… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. Поиск здесь работает, но серверы данных NASA не позволяют веб-страницам читать свои файлы (нет CORS), поэтому для GeoTIFF и загрузки защищённых данных нужно настольное приложение. «Загрузить» открывает файл в новой вкладке, где Earthdata Login попросит вас войти. Searching… Поиск… Searching… try again in a moment. Идёт поиск… попробуйте снова через мгновение. +Seasonal Mann–Kendall Сезонный Манн–Кендалл +Seasons a year Сезонов в году Second standard parallel Вторая стандартная параллель Seconds Секунды Secret access key Секретный ключ доступа @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Разделитель Sepia Сепия Sequential Последовательные +Serif С засечками Server Сервер Service Group Группа сервиса Service group data exceeds 64 MB. Объём данных группы сервиса превышает 64 МБ. Service Import Requests Запросы импорта сервиса Service Layers Слои сервиса -Services Сервисы +Session token Токен сеанса Set Задать Set Cell Size Задать размер ячейки Set Domain Задать домен @@ -2411,6 +2621,7 @@ Shaded Затенено Shaded Relief Теневой рельеф Shape Форма Shapefile Shapefile +Shapes Фигуры Share Поделиться Share As Поделиться как Share every layer and table as OGC API Features collections. Опубликовать все слои и таблицы как коллекции OGC API Features. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Показывать св Show different features at different scales. Показывать разные объекты при разных масштабах. Show grid Показать сетку Show Group Показать группу +Show labels Показать подписи Show latitude/longitude lines on the basemap. Показывать линии широты и долготы на базовой карте. +Show Layers Показать слои Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Показывать слои во времени: только объекты, дата которых попадает в окно ползунка, и датированные снимки на его дату. Show Map Показать карту Show Me Показать @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Показать или скрыть панель Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Показать линейки. Потяните от линейки, чтобы добавить направляющую; перетащите направляющую обратно на линейку, чтобы удалить её. Show the bands picked, read in place as the map needs them Показать выбранные каналы, читаемые на месте по мере необходимости Show the description Показать описание +Show the Labeling pane for the selected layer. Показать панель подписей для выбранного слоя. Show the labels that don't fit, in red. Показывать неразмещённые подписи красным. Show the layer only inside another layer's polygons. Показывать слой только внутри полигонов другого слоя. Show the layer only left of a divider, to compare it with what's beneath. Показывать слой только слева от разделителя, чтобы сравнить его с нижележащим. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Вход отменён. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. Время ожидания входа истекло. Начните заново и разрешите браузеру доступ к адресу обратной петли этого компьютера. signed in вход выполнен Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Выход на этом компьютере выполнен, но OpenAI его не подтвердил. Вы можете отключить RustGIS в настройках ChatGPT. +Significance level Уровень значимости Signing in with ChatGPT works in the desktop app. Вход через ChatGPT работает в настольном приложении. Signing the asset (Planetary Computer)… Подписание ресурса (Planetary Computer)… +Sill Порог Simplify Упростить Simulate the drive, or save the route as a layer. Смоделируйте поездку или сохраните маршрут как слой. Simulation Моделирование @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} Навыки для ИИ-помощника Slideshow, PDF or PNG Слайд-шоу, PDF или PNG slight left плавно налево slight right плавно направо +Slope per Наклон за Slope, aspect, hillshade, NDVI, band math, thresholds. Уклон, экспозиция, отмывка рельефа, NDVI, растровая алгебра, пороги. Slope, hillshade, NDVI and more. Уклон, отмывка рельефа, NDVI и другое. Small scales Мелкие масштабы @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Юг South Polar Stereographic Южная полярная стереографическая +Spatial Join Пространственное соединение Speaker notes Заметки докладчика Spectral Analysis Спектральный анализ Spectral indices Спектральные индексы @@ -2530,6 +2748,7 @@ Split (click the line) Разделить (щёлкните по линии) SQL SQL SQL Pane Панель SQL SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL-запросы выполняются через DuckDB, программу, которую вызывает настольное приложение: откройте этот проект в настольном приложении, чтобы выполнять запросы. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL-условие, которое должно быть истинным, напр. pop > 0 Square Квадрат Squares Квадраты @@ -2588,6 +2807,7 @@ Start over at the end while playing. Начинать сначала по око Start With Начинать с Start-up Запуск starting with a project file needs the desktop app для запуска с файлом проекта нужно настольное приложение +Statistic Статистика Statistics Статистика Statistics Field Поле статистики Status Состояние @@ -2613,8 +2833,10 @@ Stretch\nType Тип\nрастяжки Stretch it over the map view, or give control points (one per line: column row lon lat). Растяните его по виду карты или задайте контрольные точки (по одной в строке: column row lon lat). Stretch Type Тип растяжки Stretch type Тип растяжки +Stroke Levels Уровни обводок Stroke {n} Обводка {n} Strokes Обводки +Strokes for Обводки для Style Стиль Style Items Элементы стилей Style › Drawing Стиль › Отображение @@ -2624,7 +2846,10 @@ Subreport: {name} Подотчёт: {name} Subtypes Подтипы Subtypes, one per line: code=name Подтипы, по одному в строке: код=имя Suitability Modeler Моделирование пригодности +Summarise Обобщить +Summarise over time Обобщить по времени Summarise the conversation so far, so a long chat stays within the model's context Кратко изложить разговор до этого момента, чтобы длинный чат помещался в контекст модели +Summarise Zarr Over Time Обобщить Zarr по времени Summarising the earlier conversation to keep it within the model's context… Краткое изложение предыдущей части разговора, чтобы он помещался в контекст модели… Summarize Сводка Summarize\nWithin Сводка\nвнутри @@ -2658,6 +2883,8 @@ Table or layer Таблица или слой Tables Таблицы Tags Теги Take the data in view offline, to edit and sync back later. Перевести данные в пределах вида в автономный режим, чтобы редактировать и затем синхронизировать. +Target Features Целевые объекты +Target Records Целевые записи Task Задача Task Item Элемент задачи Task name Название задачи @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Публичные ГИС-данные Теннесси (Tennessee GeoData, geodata.tn.gov): границы участков, транспорт, здравоохранение, гидрология и другое. Terrain Рельеф Test Test +Test Connection Проверить подключение +Testing… Проверка… Text Текст Text Symbol Текстовый символ That connection would create a loop. Это соединение создало бы цикл. The address fields are empty in every row. Поля адреса пусты во всех строках. +The answer isn't UTF-8 text Ответ не является текстом UTF-8 the answer isn't UTF-8 text ответ не является текстом UTF-8 The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. Помощник следует инструкциям навыка, когда использует его. Что бы в них ни говорилось, команды, требующие вашего одобрения, по-прежнему спрашивают. The assistant is busy; stop it or wait for it to finish. Помощник занят; остановите его или дождитесь завершения. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. Вкладка модуля «Ба the capture failed unexpectedly снимок неожиданно завершился ошибкой The connection failed; trying again in {seconds} s. Не удалось подключиться; повтор через {seconds} с. The country list couldn't be read ({e}): type a country's code Не удалось прочитать список стран ({e}): введите код страны +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. Текущая высота действует, пока вы изменяете поля ввода. Нажмите «Применить высоту», чтобы применить значение и единицы. The dataset has nothing RustGIS can add В наборе данных нет ничего, что RustGIS может добавить The environment (AWS_ACCESS_KEY_ID…) Окружение (AWS_ACCESS_KEY_ID…) The features where the map is Объекты в области карты @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. Flow The installed GeoLibre runner lists no tools. Установленный модуль запуска GeoLibre не содержит инструментов. The job covers the map view Задание охватывает вид карты The key is kept for this session only. Ключ хранится только в течение этого сеанса. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. Слой показывает шаг, на котором стоит ползунок времени, и включает ползунок по датам переменной. The layer's file couldn't be read: {error} Не удалось прочитать файл слоя: {error} +The layer's strokes Обводки слоя the layout export failed unexpectedly экспорт макета неожиданно завершился ошибкой The link doesn't say its STAC API: Find Items searches {api} Ссылка не указывает свой STAC API: «Найти элементы» ищет в {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. Список модулей для просмотра. Оставьте пустым, чтобы использовать каталог модулей RustGIS. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. Эскиз проекта из ви The projection the map opens in. Проекция, в которой открывается карта. The provider's content filter stopped the reply. Фильтр содержимого провайдера остановил ответ. The public server is free and shared by many: it answers light use, and may ask you to wait. Публичный сервер бесплатный и используется многими: он рассчитан на небольшую нагрузку и может попросить вас подождать. +The range is two numbers, low and high (0, 0.9) Диапазон задаётся двумя числами, нижним и верхним (0, 0.9) The raster's data couldn't be read. Не удалось прочитать данные растра. The raster's data couldn't be read: {path} Не удалось прочитать данные растра: {path} the release isn't known выпуск неизвестен @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Файлы Shapefile скачиваю The share of each cell covered by fields, for the zoomed-out view Доля каждой ячейки, покрытая полями, для отдалённого вида The sign-in expired or was revoked. Sign in again. Срок действия входа истёк или доступ отозван. Войдите снова. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. Данные входа хранятся в папке настроек приложения, только для вашей учётной записи, и никогда в файле проекта. +The significance level is a number, such as 0.05 Уровень значимости — число, например 0.05 The start-up project {path} didn't open: {error} Не удалось открыть стартовый проект {path}: {error} The start-up project {path} isn't a RustGIS project file. Стартовый проект {path} не является файлом проекта RustGIS. The step's layer {name} isn't in the map. Слоя {name} из этого шага нет на карте. The store has no variables with two or more dimensions. В хранилище нет переменных с двумя и более измерениями. the store is gone хранилище больше недоступно +The summary couldn't start: {error} Не удалось запустить обобщение: {error} +The summary failed unexpectedly Обобщение неожиданно завершилось ошибкой +The summary finished after another project was opened, so it wasn't added Обобщение завершилось после открытия другого проекта, поэтому не было добавлено The task's steps changed, so the step being edited was closed: edit it again. Шаги задачи изменились, поэтому редактируемый шаг закрыт: измените его снова. the time slider is off ползунок времени выключен The token expires on {date}. Срок действия токена истекает {date}. +The trend couldn't start: {error} Не удалось запустить анализ тренда: {error} +The trend failed unexpectedly Анализ тренда неожиданно завершился ошибкой +The trend finished after another project was opened, so it wasn't added Анализ тренда завершился после открытия другого проекта, поэтому не был добавлен +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. Значения, отображаемые от чёрного к белому, одинаковые для всех дат и тайлов (в единицах канала). Пусто: собственная растяжка каждого файла. the view has no extent у вида нет экстента The view is larger than one read: its centre was read (zoom in for the rest) Вид больше одного чтения: прочитан его центр (приблизьте для остального) The web app always runs the latest release: reload the page to get it. Веб-приложение всегда работает на последнем выпуске: перезагрузите страницу, чтобы получить его. the window's screenshot didn't come back in time, so the map was drawn offscreen снимок окна не вернулся вовремя, поэтому карта отрисована вне экрана the window's screenshot didn't hold the map, so it was drawn offscreen снимок окна не содержал карту, поэтому она отрисована вне экрана Theme Тема +There is no expression defined. Выражение не задано. Thermal Тепловая +Thesis figure, full page (160 × 225 mm) Рисунок для диссертации, полная страница (160 × 225 мм) +Thesis figure, half page (160 × 110 mm) Рисунок для диссертации, половина страницы (160 × 110 мм) Thinking… Думает… +Third-party licences Лицензии третьих сторон +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins эта сборка не может загружать модули this build can't reach AI providers эта сборка не может подключаться к поставщикам ИИ this build saves projects with Save As эта сборка сохраняет проекты через «Сохранить как» This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Этот аккаунт ChatGPT уже был сохранён. В следующий раз выберите его в списке, а не добавляйте снова. +This class is drawn with the layer's strokes. Changing them here gives it its own. Этот класс отрисовывается обводками слоя. Изменение их здесь даст ему собственные. +This class is hidden, so it labels nothing. Этот класс скрыт, поэтому ничего не подписывает. +This clause no longer reads, so no feature is labelled: {error} Это условие больше не читается, поэтому объекты не подписаны: {error} This map has no layers yet. На этой карте пока нет слоёв. This map's thumbnail, from an image. Эскиз этой карты из изображения. This map's thumbnail, from the view. Эскиз этой карты из вида. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Для этого запроса нет варианта разового разрешения. This request uses an unsupported capability. Check the parameter and change the request. Запрос использует неподдерживаемую функцию. Проверьте параметр и измените запрос. This result is kept Этот результат сохраняется +This run has no tool form to open У этого запуска нет формы инструмента для открытия +This session only (optional) Только этот сеанс (необязательно) This step Этот шаг This step names no tool to show. В этом шаге не указан инструмент для показа. Thumbnail Эскиз @@ -2789,15 +3041,21 @@ Tilt Map Наклонить карту Tilt-shift Тилт-шифт Tilt-shift blur and film grain. Размытие тилт-шифт и зернистость плёнки. Time Время +Time\nSeries Временной\nряд Time\nSlider Ползунок\nвремени Time Fields Поля времени +Time Series Временной ряд +Time series Временной ряд +Time Series at a Point Временной ряд в точке Time Slider Ползунок времени Time Slider Settings Настройки ползунка времени Tint by elevation Окраска по высоте Tissot's Indicatrix Индикатриса Тиссо Title Заголовок +To По Toggle Dark Mode Переключить тёмную тему Toggle GPU Rendering Переключить отрисовку на GPU +Toggle Imperial Measurements Переключить имперские единицы Tolerance (km) Допуск (км) Tolls Платные дороги Tool Gallery Галерея инструментов @@ -2828,7 +3086,10 @@ Train Deep Learning Model Обучить модель глубокого обу Trained models Обученные модели Transparency Прозрачность Traverse Ход +Trend Analysis Анализ тренда +Trend over a series of rasters Тренд по серии растров Triangle Треугольник +TRUE TRUE Try again in a moment. Повторите попытку через мгновение. Try again later. Повторите попытку позже. Turn editing on or off for the map. Включить или выключить редактирование карты. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map введите долготу и Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Введите масштаб, например 1:12,345, 12345 или 25k, и нажмите Enter Type an address or coordinates and press Enter, or click the map. Введите адрес или координаты и нажмите Enter или щёлкните по карте. Type numbers separated by commas Введите числа через запятую +Type the keys to log in for this session, then Save. Введите ключи для входа в этом сеансе, затем нажмите «Сохранить». +Type the password or keys to log in for this session, then Save. Введите пароль или ключи для входа в этом сеансе, затем нажмите «Сохранить». Typed below Ввод ниже UM UM Unavailable: {reason} Недоступно: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Отвязать от ползунка времени Unbind from Time Slider Отвязать от ползунка времени Undo Отменить Undo (Ctrl+Z) Отменить (Ctrl+Z) -Undo / Redo Отменить / Повторить Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Отмена не удаляет записанный шаг: удалите его в «Изменить шаги» после остановки записи. Undo the last change. Отменить последнее изменение. Undo {action} Отменить: {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools Удалить инструменты GeoLibre Uninstall Plugin Удалить модуль Uninstalled {id}. Модуль {id} удалён. Unique Values Уникальные значения +Unit Единица United States США Units Единицы Units per step Единиц на шаг @@ -2877,6 +3140,7 @@ Up a level На уровень вверх Update Обновить Update Bookmark Обновить закладку Update Job Обновить задание +Update Label Class Обновить класс подписей Update to {version} Обновить до {version} Upload File Загрузить файл Upload File… Загрузить файл… @@ -2895,6 +3159,7 @@ Use another account Использовать другой аккаунт Use Current View Использовать текущий вид Use each feature's line color Использовать цвет линии каждого объекта Use each feature's line width Использовать толщину линии каждого объекта +Use the Layer's Strokes Использовать обводки слоя USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan Используется тариф ChatGPT Utilities Коммунальные сети Valid combinations, one per line: a, b Допустимые сочетания, по одному в строке: a, b Valid expression · {n} matching rows Выражение допустимо · Подходящих строк: {n} +Valid: {n} of {of} features match Допустимо: подходят {n} из {of} объектов Validate Проверить Validate Model Проверить модель value (empty: no-data) значение (пусто: нет данных) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Открытые снимки стихийных бедствий от Vantor: выберите событие и фазу (до или после него), затем «Найти элементы». Vantor… Vantor… Variable Переменная +Variogram Model Модель вариограммы Vector and raster data Векторные и растровые данные Vector Layer Векторный слой +vector tiles векторные тайлы Vector tiles (MVT) Векторные тайлы (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Векторы, созданные геопространственной базовой моделью для каждого пикселя или фрагмента изображения, найденные в области карты и показанные цветами. Version {version} Версия {version} @@ -2933,6 +3201,9 @@ Video file or URL Видеофайл или URL Video Page Страница видео Videos Видео Video… Видео… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Национальная система координат Вьетнама (VN-2000) в поперечной проекции Меркатора с 3-градусными зонами и 7-параметрическим преобразованием Гельмерта в WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Национальная система координат Вьетнама (VN-2000), зона UTM 48N (102° в. д.–108° в. д., осевой меридиан 105° в. д.). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Национальная система координат Вьетнама (VN-2000), зона UTM 49N (108° в. д.–114° в. д., осевой меридиан 111° в. д.). View Вид View Clipping Обрезка вида View Effects Эффекты вида @@ -2952,6 +3223,9 @@ Visibility Range Диапазон видимости Visible Видимый Visible Layers Видимые слои Visual Effect Визуальный эффект +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Вьетнам) +VN-2000 / UTM zone 48N VN-2000 / UTM zone 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zone 49N Volume Объём Waiting for the browser… Ожидание браузера… Waiting for your answer Ждёт вашего ответа @@ -2970,6 +3244,7 @@ Web Link Chart Веб-диаграмма связей Web maps Веб-карты Web Mercator Web Mercator Web Scene Веб-сцена +Web service (detected) Веб-сервис (определён) Web Style Веб-стиль Web Tile Layers Слои веб-тайлов Web Tool Веб-инструмент @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Положение подписей Where new features get their elevation (the z field). Откуда новые объекты получают высоту (поле z). Where the map opens: the full extent or a view you saved. Где открывается карта: в полном охвате или в сохранённом виде. Where tools work Где работают инструменты +Whether the Select tool picks these layers' features on the map Выбирает ли инструмент «Выбрать» объекты этих слоёв на карте Which 5 countries have the largest GDP? У каких 5 стран самый большой ВВП? Whitebox and GeoLibre tools Инструменты Whitebox и GeoLibre Whole map Вся карта @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Мир, равновелика World, equal-area: true sizes, shapes bend towards the edges. Мир, равновеликая: истинные площади, формы изгибаются к краям. Worldview Границы Write a plugin Написать модуль +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Записывает наклон Сена, Z и p-значение теста, а также наклон там, где тренд значим. X (mm) X (мм) X column Столбец X X Field Поле X @@ -3050,7 +3327,6 @@ Yours Ваши YYYY-MM-DD ГГГГ-ММ-ДД YYYY-MM-DD, for the layers that change with time ГГГГ-ММ-ДД, для слоёв, меняющихся во времени YYYY-MM-DD, for time layers ГГГГ-ММ-ДД, для временных слоёв -z (m) z (м) Z factor Z-фактор Zarr Zarr Zarr Variables Переменные Zarr @@ -3061,7 +3337,6 @@ Zoom Box Приблизить рамкой Zoom Frame Масштабировать фрейм Zoom In Приблизить Zoom in Приблизить -Zoom in / out Приблизить / отдалить Zoom In Here Приблизить здесь Zoom in on the centre of the map. Приблизить к центру карты. Zoom in · finish a sketch Приблизить · завершить эскиз @@ -3073,6 +3348,7 @@ Zoom To\nLayer Приблизить\nк слою Zoom to a box (Explore) Приблизить к рамке (обзор) Zoom To Extent Приблизить к экстенту Zoom To Layer Приблизить к слою +Zoom To Layers Приблизить к слоям Zoom To Map View Приблизить к виду карты Zoom To Selection Приблизить к выборке Zoom to selection Приблизить к выборке @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Приблизить к экстенту слоя. Zoom to the selected features. Приблизить к выбранным объектам. Zoom to the whole world. Показать весь мир. Zoom to this bookmark Приблизить к этой закладке +Zoom To Tile Layer Приблизить к слою тайлов Zoom to Tokyo Приблизить к Токио Zoom To World Приблизить к миру +Zoom {z} · Bearing {b}° · Pitch {p}° Масштаб {z} · Азимут {b}° · Наклон {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Масштаб: уровень масштаба карты, как его считают веб-карты. Азимут: направление верха карты, по часовой стрелке от севера. Наклон: отклонение от взгляда строго вниз. Щёлкните, чтобы сбросить азимут и наклон. {agent} was stopped by you. Вы остановили {agent}. {id} changed since it was installed; install it again from the Plugin Manager. Модуль {id} изменился после установки; установите его заново через Менеджер модулей. {names}: needs Node.js (npx) for its ACP adapter. {names}: для ACP-адаптера нужен Node.js (npx). diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ff1c8cde4..525e87efa 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6,6 +6,7 @@ (filtered) (filtrelenmiş) + Add Stop + Durak Ekle + Input + Girdi ++ New expression + Yeni ifade + Output + Çıktı /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh 3B ağ A 3D globe seen from space. Uzaydan görülen 3B küre. A blank map with a basemap Altlık haritalı boş bir harita +A call for the rustgis Python package rustgis Python paketi için bir çağrı A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. Bir kataloğun (CSW 2.0.2) adresi: kayıtlarında sözcüklerle ve isterseniz haritanın alanıyla arama yapın, ardından bağlantı verdikleri servisleri ve dosyaları ekleyin. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. ChatGPT planı veya uygulama kullanım sınırına ulaşıldı. Yeniden denemeden önce haftalık kullanımı inceleyin. A ChatGPT sign-in can only be used with the OpenAI API. ChatGPT oturumu yalnızca OpenAI API ile kullanılabilir. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). Bir Geo A globe scene. Bir küre sahnesi. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Bir enlem ve boylam (40.7, -74.0 veya 40°42'N 74°0'W), bir katman, bir yer imi veya bir katman tablosundaki bir değer. Enter vurgulanan satırı seçer; Yer ara metni bir yer olarak arar A layer the model reads Modelin okuduğu bir katman +A line for the Script pane Betik bölmesi için bir satır A magnifying lens that follows the pointer. İşaretçiyi izleyen bir büyüteç. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). Bir harita, detay veya kapsama servisinin adresi: WMS, WMTS, WFS, WCS, OGC API Features veya Tiles ya da bir ArcGIS FeatureServer, MapServer, ImageServer veya VectorTileServer (ya da göz atmak için bir servis dizini). a name, or owner/name such as giswqs/geospatial bir ad veya giswqs/geospatial gibi sahip/ad +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Yeniden kullanabileceğiniz adlandırılmış bir uç nokta. Parolalar ve anahtarlar RustGIS kapanana kadar tutulur, asla kaydedilmez. A node cannot connect to itself. Bir düğüm kendisine bağlanamaz. A notebook of text and command cells. Metin ve komut hücrelerinden oluşan bir not defteri. +A password or token in the address was left out: connections never keep secrets. Adresteki parola veya belirteç dışarıda bırakıldı: bağlantılar asla gizli bilgi saklamaz. A place or an address Bir yer veya adres A project keeps at least one map Bir proje en az bir harita tutar A projected coordinate system. Projeksiyonlu bir koordinat sistemi. A report listing the selected layer's records, page after page. Seçili katmanın kayıtlarını sayfa sayfa listeleyen bir rapor. A result that only feeds other tools is removed once the model has run; add an output to keep it. Yalnızca başka araçları besleyen bir sonuç, model çalıştıktan sonra kaldırılır; saklamak için bir çıktı ekleyin. A result the model keeps, under a name Modelin bir adla sakladığı bir sonuç +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) POSIX kabuğu için bir rustgis-cli komutu (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Bir servis içe aktarma veya yenileme işlemi zaten sürüyor. +A service's layers stay in their service group. Bir servisin katmanları kendi servis grubunda kalır. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Bir görev en fazla 200 adım içerir: görevi oluşturmak için kaydı durdurun, ardından kalanını başka bir görev olarak kaydedin. A template with the first selected feature's values İlk seçili detayın değerleriyle bir şablon +A tile layer per grid tile, each following the time slider through its dates. Her ızgara karosu için bir karo katmanı; her biri tarihleri boyunca zaman kaydırıcısını izler. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. Bir karo servisinin adresi: {z}/{x}/{y} içeren raster karolar (veya WMS için {-y}, {q}, {bbox-epsg-3857}), vektör karolar (.pbf) veya TileJSON, bir MapLibre stili, bir PMTiles arşivi (bir web adresi veya bu bilgisayardaki bir dosya) ya da bu bilgisayardaki bir MBTiles dosyası. A token is set. Bir belirteç ayarlı. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Bir değer, bir alan veya + − × ÷ ile birleştirilmiş iki tanesi. Seçili satırlara, hiçbiri seçili değilse tüm satırlara uygulanır. a vector a pixel piksel başına bir vektör a vector an image patch görüntü parçası başına bir vektör A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Web adresiyle, birleştirilmiş meta verili bir Zarr deposu (biçim 2 veya 3) ya da bu bilgisayardaki bir klasör. Bir değişkenin haritası, zaman gibi diğer boyutlarının seçilen adımları için okunur. +A Zarr variable's values through time at points clicked on the map. Haritada tıklanan noktalarda bir Zarr değişkeninin zaman içindeki değerleri. +A2 landscape sheet A2 yatay sayfa +A2 portrait sheet A2 dikey sayfa +A3 landscape sheet A3 yatay sayfa +A3 portrait sheet A3 dikey sayfa +A4 landscape sheet A4 yatay sayfa +A4 portrait sheet A4 dikey sayfa +A5 Grid A5 Izgarası +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. Bir çözünürlükte, harita kapsamı, bir katman veya tüm dünya üzerinde A5'in eşit alanlı beşgen hücreleri, kimlikleriyle birlikte poligonlar olarak. About Hakkında About plugins Eklentiler hakkında About RustGIS RustGIS Hakkında @@ -65,6 +83,7 @@ Add Ekle Add\nData Veri\nEkle Add ({n}) Ekle ({n}) Add a bar chart of a layer's numeric field. Bir katmanın sayısal alanından çubuk grafik ekle. +Add a circle. Bir daire ekle. Add a colorbar of a raster layer's colour scale in the map frame. Harita çerçevesine bir raster katmanının renk ölçeği için renk çubuğu ekle. Add a description. Bir açıklama ekleyin. Add a field. Bir alan ekle. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Haritayı yazdırmak ve d Add a page to the presentation. Sunuma bir sayfa ekle. Add a PNG or JPEG picture to the page. Sayfaya PNG veya JPEG resim ekle. Add a presentation file. Bir sunum dosyası ekle. +Add a rectangle, to frame or highlight part of the page. Sayfanın bir bölümünü çerçevelemek veya vurgulamak için bir dikdörtgen ekle. Add a report file, or a report template for the selected layer. Bir rapor dosyası veya seçili katman için bir rapor şablonu ekle. Add a scale bar for the map frame. Harita çerçevesi için ölçek çubuğu ekle. add a second map first (Data › New Map) önce ikinci bir harita ekleyin (Veri › Yeni Harita) Add a second map frame first Önce ikinci bir harita çerçevesi ekleyin +Add a straight line. Düz bir çizgi ekle. Add a text element. Bir metin öğesi ekle. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Bir web altlık haritasını diğerlerinin yerine koymak yerine üstlerine ekleyerek onlarla karıştır. Kaldırmak için görünen birine tıklayın. +Add a Zarr variable first (Add Data › Zarr…). Önce bir Zarr değişkeni ekleyin (Veri Ekle › Zarr…). Add Administrative Boundaries İdari Sınırları Ekle +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Bir ok ekle. Üstündeki yuvarlak tutamaçla döndürün veya açısını özelliklerinden ayarlayın. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. NASA GeoTIFF'lerini haritaya eklemek için bir Earthdata Login belirteci ekleyin (yukarıdaki Earthdata Login). Add an elevation raster first. Önce bir yükseklik rasteri ekleyin. Add an elevation raster to the map first Önce haritaya bir yükseklik rasteri ekleyin +Add an ellipse. Bir elips ekle. Add an empty layer with a ready-made schema, to draw into. Çizim yapmak için hazır şemalı boş bir katman ekle. Add an input for each layer the model reads. Modelin okuduğu her katman için bir girdi ekleyin. Add an output to keep a result under a name. Bir sonucu bir adla saklamak için bir çıktı ekleyin. Add and Open Ekle ve Aç Add and Open Notebook Not Defteri Ekle ve Aç Add Archive Arşiv Ekle +Add as Time Series Zaman Serisi Olarak Ekle add at least two keyframes en az iki anahtar kare ekleyin Add Cell Hücre Ekle Add Color Ramp File… Renk Rampası Dosyası Ekle… @@ -132,6 +157,7 @@ Add Graphics Layer Grafik Katmanı Ekle Add Input Girdi Ekle Add Job İş Ekle Add Keyframe Anahtar Kare Ekle +Add Label Class Etiket Sınıfı Ekle Add Layer File Katman Dosyası Ekle Add Open Data Açık Veri Ekle Add Output Çıktı Ekle @@ -147,9 +173,11 @@ Add Rule Kural Ekle Add sample countries Örnek ülkeleri ekle Add Sample Data Örnek Veri Ekle Add Satellite Embeddings Uydu Gömmeleri Ekle +Add Selected ({n}) Seçilenleri Ekle ({n}) Add Service Layer Servis Katmanı Ekle Add skill… Beceri ekle… Add STAC Asset STAC Varlığı Ekle +Add STAC Data Cube STAC Veri Küpü Ekle Add STAC Footprints STAC Kapsama Alanlarını Ekle Add Step Adım Ekle Add Stroke Kontur Ekle @@ -171,6 +199,7 @@ Add to Favourites Sık Kullanılanlara Ekle Add to Map Haritaya Ekle Add vector data. Vektör veri ekle. Add view Görünümü ekle +Add With Options… Seçeneklerle Ekle… Add Zarr Variable Zarr Değişkeni Ekle Add Zarr Variable… Zarr Değişkeni Ekle… Add ▾ Ekle ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Ajanların tam erişimi var: betik, görev, model ve eklenti çalıştırabilirler. MCP rölesini durdurmak için tıklayın Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Bağlanan bu bilgisayardaki ajanlar (Claude Code, Codex, Cursor ve diğerleri) RustGIS'in araçlarıyla burada açık olan harita üzerinde çalışır: dosya açıp kaydedebilir ve web'i kullanabilirler. Betikler ve eklenti yönetimi kapalı kalır. Agents then run commands, change files and use the web on this computer without asking you. Ajanlar bu durumda size sormadan bu bilgisayarda komut çalıştırır, dosyaları değiştirir ve web'i kullanır. +Aggregate Points to A5 Noktaları A5'te Topla Aggregation Toplama Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Fields of the World modelinin Sentinel-2 görüntülerinde bulduğu tarım tarlası sınırları, dünya genelinde, yıl yıl (2. Baskı, CC BY 4.0). Bunlar ölçülmüş parseller değil, model tahminleridir. AI YZ @@ -221,15 +251,18 @@ All steps done. Tüm adımlar tamamlandı. All tables with geometry Geometrisi olan tüm tablolar Allow İzin ver Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Oturum açarken ChatGPT planının kullanılmasına izin verin. Çalışma alanınız buna izin vermiyorsa yöneticisine başvurun veya API anahtarlı bir sağlayıcı seçin. +Allow Feature Selection Detay Seçimine İzin Ver Allow only certain combinations of values across fields. Alanlar arasında yalnızca belirli değer kombinasyonlarına izin ver. Allow such actions for this conversation Bu sohbette bu tür işlemlere izin ver Allow this action? Bu işleme izin verilsin mi? Allow this agent's RustGIS action? Bu ajanın RustGIS işlemine izin verilsin mi? Along Selected Line Seçili Çizgi Boyunca +Alt+ Alt+ Alternating bar Dönüşümlü çubuk Altitude Yükseklik Always allow Her zaman izin ver -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3 veya uç noktasıyla S3 uyumlu bir servis (MinIO, Cloudflare R2, Wasabi…). Anahtarlar RustGIS kapanana kadar tutulur; hatırlanan bir bağlantı adını, uç noktasını ve bölgesini saklar, gizli anahtarını asla saklamaz. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3 veya uç noktasıyla S3 uyumlu bir servis (MinIO, Cloudflare R2, Wasabi…). Anahtarlar RustGIS kapanana kadar tutulur; kaydedilen bir bağlantı adını, uç noktasını ve bölgesini saklar, gizli anahtarını asla saklamaz. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Bu bilgisayardaki bir ajan şunu yapmak istiyor: An AWS profile Bir AWS profili @@ -248,6 +281,7 @@ API key API anahtarı Apply Uygula Apply a function to a raster Bir rastere fonksiyon uygula Apply Control Points Kontrol Noktalarını Uygula +Apply elevation Yüksekliği uygula Apply Style Stili Uygula Apply to layers Katmanlara uygula Apply to the selected layer Seçili katmana uygula @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Kullanım alanı: {lon}°, {lat}° Area {area} · Perimeter {perimeter} Alan {area} · Çevre {perimeter} Areas (name, land use, area) Alanlar (ad, arazi kullanımı, alan) Arrange Düzenle +Arrow Ok +Arrowheads Ok Uçları Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Verileriniz hakkında bir soru sorun ya da haritayla ne yapılacağını anlatın. Asistan RustGIS komutlarını sizin için çalıştırır; her değişiklik geri alınabilir. Ask about the map, or tell it what to do… Harita hakkında sorun ya da ne yapacağını söyleyin… Ask about your data, or tell the assistant what to do with the map. Verileriniz hakkında sorun ya da asistana haritayla ne yapacağını söyleyin. @@ -275,12 +311,13 @@ Asking NASA's CMR… NASA'nın CMR'sine soruluyor… Asking the catalog… Kataloğa soruluyor… Asking the NLDI… NLDI'ye soruluyor… Assistant settings Asistan ayarları +At the end Sonda +At the start Başta Atlas Atlas Attachments Ekler Attribute\nTable Öznitelik\nTablosu Attribute Rules Öznitelik Kuralları Attribute Table Öznitelik Tablosu -Attribute table Öznitelik tablosu Attributes Öznitelikler Attribution Kaynak gösterimi Aurora Aurora @@ -295,11 +332,9 @@ Automatic (system language) Otomatik (sistem dili) Automatic (system time zone and region) Otomatik (sistem saat dilimi ve bölgesi) Automatic ({kind}) Otomatik ({kind}) Avoid Kaçın -AWS AWS AWS, anonymous AWS, anonim Azimuth Azimut Back Geri -Back to Explore; cancel a sketch Keşfet'e dön; çizimi iptal et Back to the Map Haritaya Dön Back to the map Haritaya dön Back to tools Araçlara dön @@ -314,8 +349,6 @@ Basemap Altlık Harita Basemaps Altlık Haritalar Basin from hydrolocation Hidro-konumdan havza Bearing Yön -Bearing {b}° · Pitch {p}° Yön {b}° · Eğim {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Yön: haritanın üst kenarının baktığı yön, kuzeyden saat yönünde. Eğim: tam aşağı bakıştan sapma. İkisini de sıfırlamak için tıklayın. Before the event Olaydan önce Below Alt Below 1:5,000,000 show everything; beyond it only what matches: 1:5.000.000 altında her şeyi göster; ötesinde yalnızca eşleşenleri: @@ -328,10 +361,12 @@ Bilinear Çift Doğrusal Bind To Time Slider Zaman Kaydırıcısına Bağla Bind to Time Slider Zaman kaydırıcısına bağla Blank Boş +Blank for Amazon S3 Amazon S3 için boş bırakın Blank Letter page Boş Letter sayfası Blank Map Boş harita Blank Page Boş Sayfa Blend: {mode} Karışım: {mode} +Block Feature Selection Detay Seçimini Engelle Blue Mavi Bold Kalın Bold label text. Kalın etiket metni. @@ -339,6 +374,7 @@ Bookmark Description Yer İmi Açıklaması Bookmarks Yer İmleri Bookmarks Pane Yer İmleri Paneli Boolean Mantıksal +Both ends Her iki uç Bottom Alt Boundaries… Sınırlar… Bounding\nGeometry Sınırlayıcı\nGeometri @@ -346,6 +382,7 @@ Brightness Parlaklık Brightness, contrast, saturation and sepia for the whole map. Tüm harita için parlaklık, kontrast, doygunluk ve sepya. Bring this bookmark's map into view to update it Güncellemek için bu yer iminin haritasını görünüme getirin Browse Göz at +Browse Connection Bağlantıya Göz At Browse it in the Browser Tarayıcı'da göz at Browse Plugins Eklentilere Göz At Browse the geoprocessing toolbox. Coğrafi işleme araç kutusuna göz at. @@ -357,6 +394,7 @@ Browser Tarayıcı Browser\nPane Tarayıcı\nPaneli Browser View Tarayıcı Görünümü Bucket Bucket +Buckets Bucket'lar Buffer Tampon Buffer A Point Noktaya Tampon Buffer the cities by 200 km Şehirlerin etrafında 200 km'lik tampon oluştur @@ -381,7 +419,9 @@ Calculate Field Alan Hesapla Calculate field Alan hesapla Calculate field values. Alan değerlerini hesapla. Calculate fields and enforce conditions on every edit. Her düzenlemede alanları hesapla ve koşulları uygula. +Calculate Geometry Geometri Hesapla Calculate Geometry Attributes Geometri Özniteliklerini Hesapla +Calculated {rows} features {rows} detay hesaplandı Calculation Hesaplama Camera Kamera Camera Effect Kamera Efekti @@ -396,12 +436,14 @@ Case Field Gruplama Alanı Cast Shadow Gölge Düşür Catalog Katalog catalog is a static STAC catalog's catalog.json or collection.json catalog, statik bir STAC kataloğunun catalog.json veya collection.json dosyasıdır +Catalogue (CSW) Katalog (CSW) Catalogue search (CSW) Katalog araması (CSW) Celestial Bodies Gök Cisimleri Celestial Body Gök Cismi Celestial body: {body} Gök cismi: {body} Cell size Hücre boyutu cell size ° hücre boyutu ° +Cells Hücreler Center Merkez Center Map Here Haritayı Buraya Ortala Centered Ortalanmış @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. Bu hesap veya ça ChatGPT sign-in failed. Check the connection and local settings, then try again. ChatGPT oturumu açılamadı. Bağlantıyı ve yerel ayarları kontrol edip yeniden deneyin. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. ChatGPT kullanımı geçici olarak sağlanamıyor. Oturumunuzu koruyun ve daha sonra yeniden deneyin. CHE CHE +Check Denetle Check Again Yeniden Denetle Check for\nUpdates Güncellemeleri\nDenetle Check for Updates Güncellemeleri Denetle @@ -428,6 +471,7 @@ Check for Updates… Güncellemeleri Denetle… Check the address and the service type. Adresi ve servis türünü kontrol edin. Check the address and your connection. Adresi ve bağlantınızı kontrol edin. Check the address. Adresi kontrol edin. +Check the clause, and count the features it takes. Yan tümceyi denetle ve kapsadığı detayları say. Check the integration's client, permissions, workspace policy and route configuration. Entegrasyonun istemcisini, izinlerini, çalışma alanı politikasını ve yönlendirme yapılandırmasını kontrol edin. Checking for updates… Güncellemeler denetleniyor… China Çin @@ -455,6 +499,7 @@ Choose which layers a click identifies. Bir tıklamanın hangi katmanları tanı Choose which tabs the ribbon shows. Şeridin hangi sekmeleri göstereceğini seç. Circle Daire Circle Radius (km) Daire Yarıçapı (km) +Class Sınıf Class color Sınıf rengi Class Style Sınıf Stili Classes Sınıflar @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Köşe eklemek için t Click to edit the description Açıklamayı düzenlemek için tıklayın Click to place a point graphic. Nokta grafiği yerleştirmek için tıklayın. Click to select the node Düğümü seçmek için tıklayın +Click to select, double-click to add, right-click for more Seçmek için tıklayın, eklemek için çift tıklayın, daha fazlası için sağ tıklayın Click to switch between decimal degrees and DMS Ondalık derece ile DMS arasında geçiş için tıklayın Clip Kırp Clip a raster to the view, or resample it. Bir rasteri görünüme kırp veya yeniden örnekle. @@ -532,7 +578,7 @@ Close thread İş parçacığını kapat Close {name} Kapat: {name} Closest En Yakın Coded values, one per line: code=description Kodlu değerler, her satıra bir tane: kod=açıklama -Collapse or expand the ribbon Şeridi daralt veya genişlet +Collapse or Expand the Ribbon Şeridi Daralt veya Genişlet Collapse the ribbon (Ctrl+F1) Şeridi daralt (Ctrl+F1) Collection Koleksiyon Collection {id}: choose dates and Find Items Koleksiyon {id}: tarihleri seçin ve Öğeleri Bul'a tıklayın @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Yukarıdaki kutudan kombinasyonlar, her satıra bir tane COMID {comid} COMID {comid} Comma Virgül +Comma-separated (optional) Virgülle ayrılmış (isteğe bağlı) Command\nSearch Komut\nAra command is required command gerekli Command Search Komut Ara @@ -572,10 +619,13 @@ Connect Bağlan Connect a folder to the Browser. Tarayıcıya bir klasör bağla. Connect to S3 S3'e Bağlan Connect, or pick a layer Bağlayın veya bir katman seçin +Connected to {kind} {title} {kind} {title} ile bağlantı kuruldu Connecting to {url}… {url} adresine bağlanılıyor… Connection Bağlantı +connection.new returned no id connection.new bir kimlik döndürmedi Connections Bağlantılar -Connect… Bağlan… +Connections you add are listed here Eklediğiniz bağlantılar burada listelenir +Connections… Bağlantılar… Constant Sabit Constant height Sabit yükseklik Constant… Sabit… @@ -583,11 +633,14 @@ Contingent Values Koşullu Değerler Continue Devam Continue with ChatGPT ChatGPT ile devam et Contrast Kontrast +Control Point Fit Kontrol Noktası Uyumu Conversion Dönüştürme Convert Dönüştür Convert a layer to another format Bir katmanı başka bir biçime dönüştür Convert the selected layer's labels into an annotation layer. Seçili katmanın etiketlerini bir not katmanına dönüştür. +Convert this layer's labels into an annotation layer. Bu katmanın etiketlerini bir not katmanına dönüştür. Convert To Annotation Nota Dönüştür +Convert to Annotation Nota Dönüştür Converted data Dönüştürülen veri Convert… Dönüştür… Coordinate Koordinat @@ -599,11 +652,15 @@ Copied the map to the clipboard Harita panoya kopyalandı Copied {path} {path} kopyalandı Copies the (selected) features of a layer to a new layer. Bir katmanın (seçili) detaylarını yeni bir katmana kopyalar. Copy Kopyala +Copy / Cut / Paste features Detayları Kopyala / Kes / Yapıştır Copy a model.run command that runs this model, for the Script pane or Python Bu modeli çalıştıran bir model.run komutunu Betik bölmesi veya Python için kopyala Copy Address Adresi Kopyala Copy address Adresi kopyala Copy an image of the map to the clipboard. Haritanın görüntüsünü panoya kopyala. +Copy as CLI Command CLI Komutu Olarak Kopyala +Copy as Command Komut Olarak Kopyala Copy As GeoJSON GeoJSON Olarak Kopyala +Copy as Python Python Olarak Kopyala Copy Command Komutu Kopyala Copy coordinates to the clipboard Koordinatları panoya kopyala Copy details Ayrıntıları kopyala @@ -611,11 +668,13 @@ Copy Group Grubu Kopyala Copy Layer Katmanı Kopyala Copy Layers Katmanları Kopyala Copy Path Yolu Kopyala +Copy Run As Code Çalıştırmayı Kod Olarak Kopyala Copy sign-in link Oturum açma bağlantısını kopyala Copy the selected features (also as GeoJSON text). Seçili detayları kopyala (GeoJSON metni olarak da). Copy the selected features and delete them. Seçili detayları kopyala ve sil. Copy the selected layer's data source path. Seçili katmanın veri kaynağı yolunu kopyala. Copy this element (Ctrl+D, ⌘D on a Mac) Bu öğeyi kopyala (Ctrl+D, Mac'te ⌘D) +Copy this run as code, or open it in its tool Bu çalıştırmayı kod olarak kopyala veya kendi aracında aç Corrections Düzeltmeler Cost Maliyet could not draw the map harita çizilemedi @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Eklenti kayıt defteri yüklenemedi: Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Bir alanın sayısı, toplamı, ortalaması, minimumu, maksimumu ve standart sapması; isteğe bağlı olarak gruplama alanına göre. Countries, cities, rivers and lakes Ülkeler, şehirler, nehirler ve göller Country Ülke +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Bir katmanın noktalarını içinde bulundukları A5 hücresine göre sayar (hücreler eşit alanlı olduğundan sayı bir yoğunluktur); bir alanın toplamı ve ortalamasıyla birlikte. Counts the points that fall inside each polygon. Her poligonun içine düşen noktaları sayar. Counts, totals and top categories of what's in view. Görünümdekilerin sayıları, toplamları ve en sık kategorileri. CPU CPU @@ -647,12 +707,8 @@ Cross Section Enine Kesit Cross Section (2 clicks) Enine Kesit (2 tıklama) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Kümülatif Current date Geçerli tarih Current Project Geçerli Proje @@ -680,13 +736,16 @@ Data type Veri türü Data you add is listed here Eklediğiniz veriler burada listelenir data.gov and CKAN catalogues data.gov ve CKAN katalogları Database Veritabanı -Databases Veritabanları +Database connections are used by the desktop app Veritabanı bağlantıları masaüstü uygulaması tarafından kullanılır +databases are read through DuckDB, a program the browser can't run veritabanları, tarayıcının çalıştıramadığı bir program olan DuckDB üzerinden okunur Database… Veritabanı… Dataset Veri kümesi Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Hugging Face'teki veri kümeleri, ada veya sahip/ada göre; bir veri kümesinin dosyalarına göz atın ve GeoTIFF, GeoParquet, PMTiles, LiDAR ve diğer dosyaları ekleyin. Data… Veri… Date Tarih Dates Tarihler +Dates a cell needs Bir hücrenin gerektirdiği tarih sayısı +Dates a cell needs is a whole number, or empty for half Bir hücrenin gerektirdiği tarih sayısı bir tam sayıdır veya yarısı için boştur Day Gün Day and night shading for a time. Bir zaman için gece ve gündüz gölgelendirmesi. Decimal Ondalık @@ -715,7 +774,6 @@ Delete Notebook Not Defterini Sil Delete Page Sayfayı Sil Delete Presentation Sunumu Sil Delete Report Raporu Sil -Delete selected features Seçili detayları sil Delete selected rows Seçili satırları sil Delete Style Stili Sil Delete Task Görevi Sil @@ -736,11 +794,15 @@ Desktop and command line only Yalnızca masaüstü uygulaması ve komut satırı Details Ayrıntılar Details of the selected layer. Seçili katmanın ayrıntıları. Deviations Sapma +DGGS DGGS Diagnostic\nMonitor Tanılama\nİzleyicisi Diagnostic Monitor Tanılama İzleyicisi Diagnostics Tanılama +Diagonal ↗ Çapraz ↗ +Diagonal ↘ Çapraz ↘ Diamond Elmas Difference, ratio or percent change between two rasters. İki raster arasındaki fark, oran veya yüzde değişim. +Digit Digit Directions Yol Tarifi Directions From Here Buradan Yol Tarifi Directions To Here Buraya Yol Tarifi @@ -758,6 +820,7 @@ Dissolve Field Çözme Alanı Distance Mesafe Distance (km) Mesafe (km) Distance Coefficient P Uzaklık Katsayısı P +Distance units Mesafe birimleri Distances along the surface, heights, areas and volumes. Yüzey boyunca mesafeler, yükseklikler, alanlar ve hacimler. DISTINCT DISTINCT Distinct Benzersiz @@ -804,18 +867,23 @@ Drag to draw Çizmek için sürükleyin Drag to draw a freehand line. Serbest çizgi çizmek için sürükleyin. Drag to draw a freehand polygon. Serbest poligon çizmek için sürükleyin. Drag to draw a rectangle. Dikdörtgen çizmek için sürükleyin. +Drag to resize the message box İleti kutusunu yeniden boyutlandırmak için sürükleyin Drag tools from the palette onto the canvas. Araçları paletten tuvale sürükleyin. Drag vertices on the map; click an edge to add one Haritada köşeleri sürükleyin; köşe eklemek için bir kenara tıklayın Draw a halo around label text. Etiket metninin çevresine hale çiz. Draw a labelled latitude/longitude grid over the map frame. Harita çerçevesi üzerine etiketli bir enlem/boylam ızgarası çiz. Draw a model: chain tools from input layers to the results to keep Bir model çizin: araçları girdi katmanlarından saklanacak sonuçlara zincirleyin Draw a model: chain tools from input layers to the results to keep. Bir model çizin: araçları girdi katmanlarından saklanacak sonuçlara zincirleyin. +Draw at its place in the stack, the bottom stroke first Yığındaki yerinde çiz, önce en alttaki kontur +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Her detayın bir düzeydeki konturlarını bir sonrakinden önce çiz; böylece çizgiler buluştuğunda kaplamalar çekirdeklerin altında birleşir Draw labels at every scale and distance. Etiketleri her ölçekte ve mesafede çiz. Draw new features in the selected layer. Double-click to finish lines and polygons. Seçili katmanda yeni detaylar çizin. Çizgileri ve poligonları bitirmek için çift tıklayın. Draw new single-band rasters with the inverted grey stretch again. Yeni tek bantlı rasterları yeniden ters gri germe ile çiz. +Draw only polygon outlines, keeping their colors and widths. Renklerini ve kalınlıklarını koruyarak yalnızca poligon dış çizgilerini çiz. Draw points as bins coloured by how many fall in each. Noktaları, her birine düşen sayıya göre renklendirilen kutucuklar olarak çiz. Draw polygons as outlines only. Poligonları yalnızca dış çizgi olarak çiz. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Bundan sonra eklenen tek bantlı rasterları bu renk şeması, germe ve yeniden örnekleme ile çiz. +Draw strokes in levels Konturları düzeyler halinde çiz Draw the layer at every scale. Katmanı her ölçekte çiz. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Haritayı GPU'da çiz (beta; küre, görünüm efektleri ve renk görme simülasyonu CPU çizicisinde kalır). Draw the selected layer above others. Seçili katmanı diğerlerinin üstünde çiz. @@ -828,6 +896,7 @@ drawn by the server sunucu tarafından çizilir Drive Araç Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. OpenAerialMap'te paylaşılan drone ve hava görüntüleri, en yeniler önce, bulut için optimize edilmiş GeoTIFF olarak eklenir. Duplicate Çoğalt +Duplicate Connection Bağlantıyı Çoğalt Duplicate Element Öğeyi Çoğalt Duplicate Layout Sayfa Düzenini Çoğalt Duplicate Map Haritayı Çoğalt @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name örn. pop_est / 1000000 v e.g. 0, 255, -9999 ör. 0, 255, -9999 e.g. continent = 'Europe' örn. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' örn. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 örn. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Her coğrafi işleme çalıştırması, çıktılarıyla zincirlenen bir adım olur Earth Index has no points in the view Earth Index'in görünümde noktası yok Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Köşeleri\nDüzenle Edit and run the notebook. Not defterini düzenle ve çalıştır. Edit Attribute Özniteliği Düzenle Edit Cell Hücreyi Düzenle +Edit Connection Bağlantıyı Düzenle Edit Description Açıklamayı Düzenle Edit Graphic Grafiği Düzenle Edit Status Düzenleme Durumu @@ -882,6 +953,7 @@ Editor\nTools Düzenleyici\nAraçları Editor Tools Düzenleyici Araçları Edits since the project was saved. Proje kaydedildiğinden beri yapılan düzenlemeler. Edits the project Projeyi düzenler +Edit… Düzenle… Effects Efektler Element Öğe Element Properties Öğe Özellikleri @@ -898,21 +970,25 @@ Enable Lighting Aydınlatmayı Etkinleştir Enable Plugin Eklentiyi Etkinleştir Enabled Etkin Encloses each feature, or all features, in its convex hull. Her detayı veya tüm detayları dışbükey zarfı içine alır. +Encoded polyline Kodlanmış çoklu çizgi End Bitiş End date Bitiş tarihi Endpoint Uç nokta Enhancement İyileştirme Enter Enter Enter an API key. Bir API anahtarı girin. +Enter an EPSG code as a number, such as 32650 Bir EPSG kodunu 32650 gibi bir sayı olarak girin Enter the provider's address. Sağlayıcının adresini girin. Enter to send, Shift+Enter for a new line Göndermek için Enter, yeni satır için Shift+Enter EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Ortamlar EPSG EPSG +EPSG code EPSG kodu EPSG or ESRI code EPSG veya ESRI kodu EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Eşit Aralık Equirectangular (Plate Carrée) Eşdikdörtgen (Plate Carrée) @@ -922,14 +998,18 @@ Error Hata Error Inspector Hata Denetçisi Error Inspector (validate) Hata Denetçisi (doğrula) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Avrupa Eşalan (ETRS89 LAEA) Every band's value at a pixel. Bir pikseldeki her bandın değeri. Every editing tool. Tüm düzenleme araçları. +Every label setting of this layer, in the Labeling pane. Bu katmanın tüm etiket ayarları, Etiketleme bölmesinde. Every layer, as FEMA draws them FEMA'nın çizdiği şekliyle her katman +Every run, in order, as a script that opens the project Projeyi açan bir betik olarak, sırayla her çalıştırma every Socrata site, or one such as data.cityofnewyork.us tüm Socrata siteleri veya data.cityofnewyork.us gibi tek bir site Everywhere Her yerde +Existing field Mevcut alan Existing Template Mevcut Şablon exit rotary döner kavşaktan çıkın exit roundabout göbekten çıkın @@ -948,6 +1028,7 @@ Export\nRaster Rasteri\nDışa Aktar Export Animation Animasyonu Dışa Aktar Export Bookmarks Yer İmlerini Dışa Aktar Export Bookmarks… Yer İmlerini Dışa Aktar… +Export CSV… CSV Dışa Aktar… Export Features Detayları Dışa Aktar Export Features (tool) Detayları Dışa Aktar (araç) Export Features needs a vector layer (Data › Export Raster writes a raster) Detayları Dışa Aktar bir vektör katmanı gerektirir (Veri › Rasteri Dışa Aktar raster yazar) @@ -996,6 +1077,7 @@ Face Culling Yüz Ayıklama Faces Yüzler Factor Çarpan Failed Başarısız +FALSE FALSE Farthest En Uzak Faster drawing: no antialiasing. Daha hızlı çizim: kenar yumuşatma yok. Favourite Data Source Sık Kullanılan Veri Kaynağı @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Tarla sınırları Field density Tarla yoğunluğu Field name Alan adı +Field Prefix Alan Öneki +Field to Sum and Average Toplanacak ve Ortalaması Alınacak Alan Fields Alanlar Fields are drawn from zoom 9: zoom in to see them. Tarlalar yakınlaştırma 9'dan itibaren çizilir: görmek için yakınlaştırın. Fields colored by score, drawn from zoom 9 Puana göre renklendirilmiş tarlalar, yakınlaştırma 9'dan itibaren çizilir @@ -1079,6 +1163,8 @@ Folder Connections Klasör Bağlantıları folder connections need the desktop app klasör bağlantıları masaüstü uygulamasını gerektirir Folders Klasörler Folder… Klasör… +Follow the time slider Zaman kaydırıcısını izle +Font Yazı Tipi for a store that doesn't say bunu belirtmeyen bir depo için Forest Orman Forget Earthdata Login Token Earthdata Login Belirtecini Unut @@ -1090,9 +1176,11 @@ France Fransa Free the prepared geometry. Hazırlanan geometriyi serbest bırak. Freehand Serbest Çizim FROM FROM +From Başlangıç From Selection Seçimden From Service… Servisten… From symbol Sembolden +From the address Adresten from the address adresten from the service servisten From URL… URL'den… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Görünümün merkezinden uzaktaki det Hide Group Grubu Gizle Hide labels when the viewer is closer than this. Görüntüleyen bundan daha yakındayken etiketleri gizle. Hide labels when the viewer is farther than this. Görüntüleyen bundan daha uzaktayken etiketleri gizle. +Hide Layers Katmanları Gizle Hide other values Diğer değerleri gizle Hide Steps Adımları Gizle Hide the description Açıklamayı gizle @@ -1213,6 +1302,8 @@ Hold Beklet HOME HOME Home Giriş Home › Add Giriş › Ekle +Home › Add Data Giriş › Veri Ekle +Home › Inspect › Measure Giriş › İncele › Ölç Home › Navigate Giriş › Gezin Home › Navigate › Bookmarks Giriş › Gezin › Yer İmleri Home › Web Data Giriş › Web Verileri @@ -1220,7 +1311,7 @@ Home › Web Data › United States Giriş › Web Verileri › Amerika Birleşi Homepage Ana sayfa Horizontal Yatay host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=… veya bir .duckdb dosyası +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Barındırılan Görüntüler Hour Saat How the layer's colours combine with the tile layers and basemap beneath. Katmanın renklerinin alttaki karo katmanları ve altlık haritayla nasıl birleştiği. @@ -1248,6 +1339,7 @@ Imagery Sharing Görüntü Paylaşımı Imagery Tools Görüntü Araçları ImageServer ImageServer Image… Resim… +Imperial İngiliz Import İçe Aktar Import\nProject Proje\nİçe Aktar Import All Tümünü içe aktar @@ -1267,6 +1359,7 @@ Import Task Görevi İçe Aktar Import Task… Görevi İçe Aktar… Import Thumbnail Küçük Resmi İçe Aktar Import… İçe Aktar… +IN IN In Beyond Ötesinde Açık In memory Bellekte In Progress Devam Ediyor @@ -1281,6 +1374,8 @@ Information Bilgi INPUT GİRDİ Input Girdi Input Features Girdi Detayları +Input Layer Girdi Katmanı +Input Points Girdi Noktaları Input Polygons Girdi Poligonları Input Rows Girdi Satırları Input Summary Features Girdi Özet Detayları @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Yüklü eklentiler şurada tutulur: {fold Installed with Homebrew, winget, the AUR or COPR? Update it there. Homebrew, winget, AUR veya COPR ile mi kurdunuz? Oradan güncelleyin. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} yüklendi. Eklentiler sekmesinde bulabilirsiniz; araçları Araçlar panelinde de yer alır. Installing it downloads Python {version} and the packages it needs from the internet. Yüklemek, internetten Python {version} ve gereken paketleri indirir. +Integer field: values are rounded. Tam sayı alanı: değerler yuvarlanır. Interface font: {font} Arayüz yazı tipi: {font} Interpolate a surface from points (IDW) Noktalardan yüzey enterpole et (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Sıradan Kriging kullanarak nokta detaylarından 2 bantlı bir raster yüzey (Tahmin ve Varyans) enterpole eder. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. QGIS'te olduğu gibi, detay değerlerinden ters mesafe ağırlıklandırmasıyla bir raster yüzey enterpole eder. Interpolation Enterpolasyon Interpolation Attribute Enterpolasyon Özniteliği @@ -1316,10 +1413,13 @@ Invalid: {error} Geçersiz: {error} Invert Ters Çevir Invert color scheme Renk şemasını ters çevir Invert the selection. Seçimi ters çevir. +IS IS IS NULL IS NULL it connects to an address on the internet internetteki bir adrese bağlanır It has no code: nothing in it runs. Kod içermez: içindeki hiçbir şey çalışmaz. +It is locked, or one of its surrounds is: unlock it first Kendisi veya öğelerinden biri kilitli: önce kilidini açın it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) yalnızca size ayrılmıştır (eklentiler, betikler, görevler, modeller ve GeoLibre çalıştırıcısı) +it needs input: open it from its pane or button, or run it from a script or the assistant girdi gerektiriyor: bölmesinden veya düğmesinden açın ya da bir betikten veya asistandan çalıştırın it reads or writes files on this computer bu bilgisayardaki dosyaları okur veya yazar it replaces the open project açık projeyi değiştirir it runs a plugin's command bir eklentinin komutunu çalıştırır @@ -1332,20 +1432,30 @@ Italic İtalik Item Pop-ups Öğe Açılır Pencereleri Item {id} Öğe {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. Güvenlik duvarı ({firewall}) RustGIS'i engelledi. Servis yalnızca bazı uygulamalara izin veriyor olabilir: yöneticisiyle iletişime geçin ve aşağıdaki ayrıntıları gönderin. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Aracı şu anda kullanılamıyor (yüklenmemiş bir eklentinin veya kurulmamış bir paketin aracı) Jobs İşler JOIN JOIN Join Birleşim +Join Features Detayları Birleştir +Join Operation Birleştirme İşlemi +Joins attributes from a join layer to a target layer based on their spatial relationship. Mekânsal ilişkilerine göre bir birleştirme katmanındaki öznitelikleri bir hedef katmana birleştirir. +Journal figure, 1 column (90 mm) Dergi şekli, 1 sütun (90 mm) +Journal figure, 1.5 columns (140 mm) Dergi şekli, 1,5 sütun (140 mm) +Journal figure, 2 columns (190 mm) Dergi şekli, 2 sütun (190 mm) Keep a history of every edit to the layer, to look back or restore. Geriye bakmak veya geri yüklemek için katmandaki her düzenlemenin geçmişini tut. Keep an archive of edits Düzenlemelerin arşivini tut keep current geçerli olanı koru +Keep it from being moved, resized, deleted or edited by mistake Yanlışlıkla taşınmasını, yeniden boyutlandırılmasını, silinmesini veya düzenlenmesini önle Keep one value of each point's vector as its value field Her noktanın vektöründen bir değeri değer alanı olarak sakla Keep Result Sonucu Sakla Keep the labelled features fixed as the map moves. Harita hareket ederken etiketlenen detayları sabit tut. Keep the last drawing tool active after each graphic. Her grafikten sonra son çizim aracını etkin tut. Keep the last tool active Son aracı etkin tut +Keep this endpoint under Data Sources › Connections, without its password or keys Bu uç noktayı parolası veya anahtarları olmadan Veri Kaynakları › Bağlantılar altında tut Keep this result Bu sonucu sakla Keep Tool Aracı Tut Keep value Değeri sakla +Key Key Keyboard Shortcuts Klavye Kısayolları Keyboard shortcuts. Klavye kısayolları. Keys from Anahtar kaynağı @@ -1356,13 +1466,20 @@ KMZ KMZ Label Etiket Label Class Etiket Sınıfı Label Distance Range Etiket Mesafe Aralığı +Label features in this layer Bu katmandaki detayları etiketle Label Field Etiket Alanı +Label only the features that match. Yalnızca eşleşen detayları etiketle. Label Placement Etiket Yerleşimi Label Range Etiket Aralığı Label the cities with their names Şehirleri adlarıyla etiketle Label Visibility Range Etiket Görünürlük Aralığı Labeling Etiketleme +Labeling Pane Etiketleme Paneli +Labeling Properties… Etiketleme Özellikleri… Labels Etiketler +labels are drawn for feature layers, not rasters etiketler rasterlar için değil, detay katmanları için çizilir +Labels are drawn for feature layers, not rasters. Etiketler rasterlar için değil, detay katmanları için çizilir. +Labels are off for this layer: the clause applies once they are on. Bu katmanda etiketler kapalı: yan tümce, etiketler açıldığında uygulanır. Lagoon Lagün Lambert Conformal Conic Lambert Konform Konik Lambert Cylindrical Equal Area Lambert Silindirik Eşalan @@ -1383,12 +1500,14 @@ Layer Blend Katman Karışımı Layer Extrusion Katman Yükseltme Layer File Katman Dosyası Layer From\nSelection Seçimden\nKatman +Layer not found Katman bulunamadı Layer Template Katman Şablonu Layer Templates Katman Şablonları layer {id} katman {id} Layers Katmanlar Layers ({n}) Katmanlar ({n}) Layers and Table Views Katmanlar ve Tablo Görünümleri +Layers, a date each Katmanlar, her biri bir tarih Layout Sayfa Düzeni Layout\nFile Sayfa Düzeni\nDosyası Layout after: {name} Sonraki sayfa düzeni: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Öğrenme\nKaynakları Left Sol Legend Lejant Legend label. Clear it to show the value again. Lejant etiketi. Değeri yeniden göstermek için temizleyin. -Length {length} · gain {gain} m · loss {loss} m Uzunluk {length} · çıkış {gain} m · iniş {loss} m +Length {length} · gain {gain} · loss {loss} Uzunluk {length} · artış {gain} · kayıp {loss} Let the assistant hand tasks to agents on this computer Asistanın görevleri bu bilgisayardaki ajanlara devretmesine izin ver Let your Agents Control RustGIS Ajanlarınızın RustGIS'i yönetmesine izin verin Letter spacing Harf aralığı @@ -1439,6 +1558,7 @@ List by drawing order Çizim sırasına göre listele List by selection Seçime göre listele List Columns in File Dosyadaki Sütunları Listele List Commands Komutları Listele +List Connections Bağlantıları Listele List Data Sources Veri Kaynaklarını Listele List Database Columns Veritabanı Sütunlarını Listele List Database Tables Veritabanı Tablolarını Listele @@ -1475,6 +1595,11 @@ Locate… Bul… Location Konum Locator Konum Bulucu Lock Kilitle +Locked Kilitli +Locked: unlock it in the Format pane to move or change it Kilitli: taşımak veya değiştirmek için Biçim bölmesinde kilidini açın +Log In to Connection Bağlantıda Oturum Aç +Log In to {name}… {name} Oturumu Aç… +Log In… Oturum Aç… Long Tamsayı (Long) Longitude Boylam Longitude (X) Boylam (X) @@ -1493,6 +1618,7 @@ Manage Yönet Manage Bookmarks Yer İmlerini Yönet Manage Edits Düzenlemeleri Yönet Manage Templates Şablonları Yönet +Mann–Kendall Mann–Kendall Map Harita Map\nFrame Harita\nÇerçevesi Map (the current view) Harita (geçerli görünüm) @@ -1504,6 +1630,7 @@ Map File Harita Dosyası Map Frame Harita Çerçevesi Map Frames Harita Çerçeveleri Map frames per second (live while the map moves) Saniyedeki harita karesi (harita hareket ederken canlı) +Map Labeling Harita Etiketleme Map Link Harita Bağlantısı Map name Harita adı Map Page Harita Sayfası @@ -1515,6 +1642,7 @@ Map style (JSON) Harita stili (JSON) Map Surrounds Harita Öğeleri Map Thumbnail Harita Küçük Resmi Map tiles of the whole world Tüm dünyanın harita karoları +Map Tool: {tool} Harita Aracı: {tool} Map View Harita Görünümü map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Harita: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker İşaretçi Mask Values Değerleri Maskele Masking Maskeleme +Match Option Eşleşme Seçeneği Matching places Eşleşen yerler Max Maks Max % Maks % +Max Neighbors En Fazla Komşu Maximum zoom En büyük yakınlaştırma MCP Relay MCP rölesi MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCP rölesi zaten 127.0.0.1:{port} adresinde dinliyor; {asked} portunu kullanmak için önce durdurun @@ -1542,6 +1672,8 @@ Measure Distance Mesafe Ölç Measure geodesic distances and areas on the map. Haritada jeodezik mesafe ve alanları ölç. Measure on this body, with one of its basemaps. Bu gök cismi üzerinde, altlık haritalarından biriyle ölç. Measure with an elevation raster Bir yükseklik rasteriyle ölç +Measurement units Ölçü birimleri +Measurement units must be metric or imperial Ölçü birimleri metrik veya İngiliz olmalıdır Measurements so far. Şimdiye kadarki ölçümler. Mensuration Ölçümleme Mensuration Tools Ölçümleme Araçları @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Aynı alan Messages appear here. Mesajlar burada görünür. Meters Metre Method Yöntem +Metric Metrik Midnight Gece Yarısı Miles Mil Miller Cylindrical Miller Silindirik @@ -1560,21 +1693,43 @@ Min must be less than Max Min, Maks'tan küçük olmalı Min must not be more than Max Min, Maks'tan büyük olmamalı Minimum Bounding Geometry En Küçük Sınırlayıcı Geometri Minimum Maximum Minimum Maksimum +Minus Minus Minutes Dakika minutes ago dakika önce Miter Gönye Mobile Map Mobil Harita Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Model Model\nBuilder Model\nOluşturucu model id model kimliği Model output Model çıktısı ModelBuilder Model Oluşturucu Models Modeller +Modified Mann–Kendall (Hamed & Rao) Değiştirilmiş Mann–Kendall (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Değiştirilmiş Mann–Kendall (Yue & Wang) Modify Değiştir Modify Selected Features Seçili Detayları Değiştir Mollweide (equal-area world) Mollweide (eşalan dünya) Month Ay +Months Aylar +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Aylar 1 ile 12 arasında sayılardır (Aralık–Şubat için 12, 1, 2) More Diğer More basemaps: Daha fazla altlık harita: Mosaic images into an orthomosaic Görüntüleri bir ortomozaikte birleştir @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Görünür rasterları bir ortom Most cloud cover (%) En fazla bulut örtüsü (%) Mouse wheel Fare tekerleği Move Taşı -Move (m) Taşı (m) Move Bookmark Yer İmini Taşı Move Down Aşağı Taşı Move down Aşağı taşı Move Group Grubu Taşı +Move Label Class Etiket Sınıfını Taşı Move Task Step Görev Adımını Taşı +Move To Bottom En Alta Taşı Move To Group Gruba Taşı +Move To Top En Üste Taşı Move To {group} Taşı: {group} Move Up Yukarı Taşı Move up Yukarı taşı @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Bucket'larım My OGC Layer OGC Katmanım +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Ad name the plugin whose panel to open: {"id": plugin id} paneli açılacak eklentiyi belirtin: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Yeni\nNot Defteri New\nPresentation Yeni\nSunum New\nReport Yeni\nRapor New\nWorkspace Yeni\nÇalışma Alanı -New / Open / Save project Yeni / Aç / Kaydet proje New Bookmark Yeni Yer İmi New Bookmark… Yeni Yer İmi… +New Connection Yeni Bağlantı +New Connection ▾ Yeni Bağlantı ▾ New conversation Yeni sohbet New Custom Template Yeni Özel Şablon New Custom Template… Yeni Özel Şablon… New features use this template's values Yeni detaylar bu şablonun değerlerini kullanır +New field Yeni alan New Group Yeni Grup New job name Yeni iş adı New Layout Yeni Sayfa Düzeni @@ -1649,6 +1809,7 @@ New Notebook Yeni Not Defteri New Presentation Yeni Sunum New Project Yeni Proje New project Yeni proje +New Publication Layout Yeni Yayın Sayfa Düzeni New Relationship… Yeni İlişki… New Report Yeni Rapor New Selection Yeni Seçim @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Henüz yer imi No catalogs below this one: Find Items lists its items Bunun altında katalog yok: Öğeleri Bul öğelerini listeler No COMID was returned for this point. Bu nokta için COMID döndürülmedi. no command komut yok -No connections this session Bu oturumda bağlantı yok +No data at the points picked. Seçilen noktalarda veri yok. No datasets match. Eşleşen veri kümesi yok. No datasets with data RustGIS can add RustGIS'in ekleyebileceği veriye sahip veri kümesi yok +no date in the name adda tarih yok No edits since the project was saved Proje kaydedildiğinden beri düzenleme yok No field of this layer holds dates. Bu katmanın hiçbir alanı tarih içermiyor. +No Fill Dolgu Yok +No geometry to calculate Hesaplanacak geometri yok No granules in this view and date range. Bu görünümde ve tarih aralığında granül yok. no home folder ana klasör yok no layer katman yok @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) İlişki yok (Veri Tasarımı No relationships yet Henüz ilişki yok No reports yet. Publish › New Report lists the selected layer's records. Henüz rapor yok. Yayımla › Yeni Rapor seçili katmanın kayıtlarını listeler. no row satır yok +No serif font is installed, so this text is drawn in the sans-serif Tırnaklı yazı tipi yüklü olmadığından bu metin tırnaksız yazı tipiyle çizilir no service or file links servis veya dosya bağlantısı yok No services match your search. Aramanızla eşleşen servis yok. No standalone tables Bağımsız tablo yok @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Yok None (public buckets) Yok (herkese açık bucket'lar) none, a .duckdb file, or host=… dbname=… user=… yok, bir .duckdb dosyası veya host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD yok veya PGPASSWORD ya da MYSQL_PWD'den +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD yok veya PGPASSWORD, MYSQL_PWD ya da SQLCMDPASSWORD'den None. Add one by address, or from a basemap plugin. Yok. Adresle veya bir altlık harita eklentisinden ekleyin. North Kuzey North\nArrow Kuzey\nOku North Polar Stereographic Kuzey Kutup Stereografik +NOT NOT not a picture resim değil not a RustGIS layout bir RustGIS sayfa düzeni değil not a RustGIS project RustGIS projesi değil +Not applied yet. Henüz uygulanmadı. Not for texts: a text is edited in the Format pane. Metinler için değil: metin Biçim bölmesinde düzenlenir. not needed gerekmiyor Not sandboxed Korumalı alan dışında @@ -1739,9 +1906,12 @@ Nothing found where the map is Haritanın olduğu yerde hiçbir şey bulunamadı Nothing here Burada hiçbir şey yok Nothing named {name} to rename Yeniden adlandırılacak {name} adında bir öğe yok Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Henüz paylaşılan bir şey yok. Yayımla › Farklı Paylaş web haritaları, katmanlar, stiller, sahneler ve daha fazlasını oluşturur. +Nothing to add here Burada eklenecek bir şey yok +Nothing to test Test edilecek bir şey yok Nothing was found at this address. Bu adreste hiçbir şey bulunamadı. Nothing was recorded, so no task was made. Hiçbir şey kaydedilmedi, bu yüzden görev oluşturulmadı. Now Şimdi +Nugget Külçe NULL NULL Nulls Boş değerler Observations (observer, date, species, count) Gözlemler (gözlemci, tarih, tür, sayı) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. Masaüstünde anahtar {vars} On-screen compass, zoom slider and pan pad. Ekran pusulası, yakınlaştırma kaydırıcısı ve kaydırma pedi. One command per line: id {json}. Lines starting with # are comments. Her satıra bir komut: id {json}. # ile başlayan satırlar yorumdur. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Tek bir DuckDB SELECT: DuckDB Spatial'ın ST_ işlevleri, dosyalar ve web adresleri (read_parquet('…')) ve aşağıdaki veritabanının tabloları. Ctrl+Enter çalıştırır. +One layer: a Zarr variable or a raster's bands Tek katman: bir Zarr değişkeni veya bir rasterin bantları One page per feature of an index layer. Bir indeks katmanının her detayı için bir sayfa. One per line: code=name Her satıra bir tane: kod=ad One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Sembolden tek kontur. Kaplamalar, paralel çizgiler ve ötelemeler için kontur ekleyin. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Klasörü aç Open In Geoprocessing… Coğrafi İşlemede Aç… Open in ModelBuilder Model Oluşturucuda Aç +Open in Tool Araçta Aç Open Layout Sayfa Düzeni Aç Open ModelBuilder Model Oluşturucuyu Aç Open Notebook Not Defteri Aç @@ -1797,6 +1969,7 @@ Open Presentation Sunum Aç Open Project Proje Aç Open project Proje aç Open Project (from text) Proje Aç (metinden) +Open Project… Proje Aç… Open Report Rapor Aç Open the attribute table of the selected layer. Seçili katmanın öznitelik tablosunu aç. Open the Basemaps tab Altlık Haritalar sekmesini aç @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Ya da Claude Code veya Codex Or run in PowerShell, for Claude Code or Codex: Ya da Claude Code veya Codex için PowerShell'de çalıştırın: Or run, for Claude Code or Codex: Veya Claude Code ya da Codex için şunu çalıştırın: ORDER BY ORDER BY +Ordinary Kriging Sıradan Kriging Orientation Yönlendirme Ortho Mapping Orto Haritalama Other Diğer Other Data Diğer Veriler +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Bu katmandaki diğer etiket sınıfları (komutlarla oluşturulmuş): {n}. Her biri kendi yan tümcesinin eşleştiği detayları etiketler, bu nedenle bir detay birden fazla sınıftan etiket alabilir. Out Beyond Ötesinde Kapalı Outline Dış Çizgi Outline another frame's extent in this map frame. Bu harita çerçevesinde başka bir çerçevenin kapsamını çiz. @@ -1851,6 +2026,7 @@ Output Çıktı Output Name Çıktı Adı Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Yaklaşık 25 MB'lık tek seferlik bir indirmeyle tarayıcıda çalışan 1.000'den fazla raster, vektör, LiDAR, hidroloji ve arazi aracı. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Yaklaşık 20 MB'lık isteğe bağlı bir indirmeyle 1.000'den fazla raster, vektör, LiDAR, hidroloji ve arazi aracı. +Overlapping Çakışan Overlay Bindirme overlay bindirme Overlay (a transparent layer kept on top, such as labels or roads) Bindirme (etiketler veya yollar gibi üstte tutulan saydam bir katman) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture'ın haritanın olduğu yerde Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Overture Maps'in açık verileri: binalar, yerler, ulaşım, idari bölümler, temel katmanlar ve adresler. Haritanın olduğu yerde detayları Overture'ın GeoParquet dosyalarından yerinde okunur (bir şehre veya daha küçüğüne yakınlaştırın); tüm dünya harita karoları olarak gelir. Overture Maps… Overture Maps… +Overwrite existing values Mevcut değerlerin üzerine yaz Package Paket Package a trained deep learning model. Eğitilmiş bir derin öğrenme modelini paketle. Package Map Haritayı Paketle @@ -1907,8 +2084,12 @@ Performance Performans Phase Evre Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Ölçek veya tarih gibi canlı bir değer seçin, ardından yerleştirmek için haritaya tıklayın. +Pick a Point Bir Nokta Seç +Pick a point on the map to see the variable's values through time there. Değişkenin oradaki zaman içindeki değerlerini görmek için haritada bir nokta seçin. Pick a route in the list, or click a grey route on the map. Listeden bir rota seçin veya haritadaki gri bir rotaya tıklayın. +Pick a single layer. Tek bir katman seçin. Pick it on the map Haritada seç +Pick one… Birini seçin… Picture Resim Pictures Resimler Pictures, attachments and chart Resimler, ekler ve grafik @@ -1966,8 +2147,11 @@ Portal Portal Portolan Portolan Portolan… Portolan… Portrait Dikey +Position Konum +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Son İşleme power 2 üs 2 +Precision 6 (OSRM, Valhalla) Hassasiyet 6 (OSRM, Valhalla) Prepare Hazırla Prepare every layer's geometry for this projection. Her katmanın geometrisini bu projeksiyon için hazırla. Prepare Plugin Environment Eklenti Ortamını Hazırla @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. Proje şablonları: projel project's path is required projenin yolu gerekli Projection Projeksiyon Projections Projeksiyonlar +Properties Özellikler +Property Özellik Proportional Symbols Orantılı Semboller Provider Sağlayıcı +Provinces: İller: Proximity Yakınlık Public GIS portals of US counties and cities, by state. ABD ilçe ve şehirlerinin herkese açık CBS portalları, eyalete göre. Public GIS portals of US federal agencies, by department. ABD federal kurumlarının herkese açık CBS portalları, bakanlığa göre. Public GIS portals of US states and the District of Columbia. ABD eyaletlerinin ve Columbia Bölgesi'nin herkese açık CBS portalları. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. ArcGIS Online veya başka bir ArcGIS portalındaki herkese açık katmanlar: detay, harita, görüntü ve vektör karo servisleri, web haritaları ve GeoJSON dosyaları. +Publication Layout Yayın Sayfa Düzeni Publish Yayımla Publish › Output Yayımla › Çıktı Put the layer back as it was then Katmanı o zamanki haline geri getir Python environment failed: {error} Python ortamı başarısız oldu: {error} Python environment not prepared Python ortamı hazırlanmadı Python environment ready Python ortamı hazır +Python script Python betiği Qualitative Nitel Quantile Kantil Query files, web addresses and databases with DuckDB Spatial; add results to the map. DuckDB Spatial ile dosyaları, web adreslerini ve veritabanlarını sorgula; sonuçları haritaya ekle. @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. Poligonları bir yükseklik veya alana g Raise Priority Önceliği Yükselt Random points in polygons Poligonlarda rastgele noktalar Range Aralık +Range (Degrees) Erim (Derece) +range: 0, 0.9 aralık: 0, 0.9 Raster Raster Raster\nAnalysis Raster\nAnalizi Raster\nInformation Raster\nBilgisi @@ -2075,6 +2266,7 @@ Redo {action} Yinele: {action} Red–Blue Kırmızı–Mavi Refine the search to see {n} more {n} tane daha görmek için aramayı daraltın Refresh Yenile +Refresh every connection's lists Her bağlantının listelerini yenile Refresh Map Series Harita Serisini Yenile Refresh the map series from its index layer. Harita serisini indeks katmanından yenile. Region Bölge @@ -2091,11 +2283,10 @@ Relationships İlişkiler Release Sürüm Reload Yeniden Yükle Reload Web Layers Web Katmanlarını Yeniden Yükle -Remember (without the secret) Hatırla (gizli anahtar olmadan) Remember the key on this computer Anahtarı bu bilgisayarda hatırla -Remembered: Hatırlananlar: Remove Kaldır Remove All Tümünü Kaldır +Remove All Label Classes Tüm Etiket Sınıflarını Kaldır Remove an offline area. Bir çevrimdışı alanı kaldır. Remove Bookmark Yer İmini Kaldır Remove Connection Bağlantıyı Kaldır @@ -2108,11 +2299,14 @@ Remove from Favourites Sık Kullanılanlardan Kaldır Remove From Group Gruptan Kaldır Remove Group Grubu Kaldır Remove Keyframe Anahtar Kareyi Kaldır +Remove Label Class Etiket Sınıfını Kaldır Remove Section Bölümü Kaldır Remove Table Tabloyu Kaldır +Remove the clause. Yan tümceyi kaldır. Remove the last keyframe. Son anahtar kareyi kaldır. Remove the layer from the map. Katmanı haritadan kaldır. Remove the strokes and draw the symbol's line alone Konturları kaldır ve yalnızca sembolün çizgisini çiz +Remove this class's own strokes and draw it with the layer's Bu sınıfın kendi konturlarını kaldır ve katmanınkilerle çiz Remove this stop Bu durağı kaldır Remove Tile Layer Karo Katmanını Kaldır Remove token Belirteci kaldır @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Bir tol Rename Yeniden Adlandır Rename Bookmark Yer İmini Yeniden Adlandır Rename Group Grubu Yeniden Adlandır +Rename it to save it: a connection has this name Kaydetmek için yeniden adlandırın: bu adda bir bağlantı var +Rename Label Class Etiket Sınıfını Yeniden Adlandır Rename Layer Katmanı Yeniden Adlandır Rename Layout Sayfa Düzenini Yeniden Adlandır Rename Map Haritayı Yeniden Adlandır @@ -2149,6 +2345,7 @@ Reset Default Varsayılanı Sıfırla Reset Effects Efektleri Sıfırla Reset North Kuzeye Sıfırla Reset north Kuzeye sıfırla +Resolution (0–30) Çözünürlük (0–30) Response Yanıt Restore Geri Yükle Restore From Archive Arşivden Geri Yükle @@ -2162,6 +2359,7 @@ RGB RGB Ribbon Şerit Right Sağ Right-click a source to add it here Buraya eklemek için bir kaynağa sağ tıklayın +Right-click for more Daha fazlası için sağ tıklayın Robinson Robinson Rotate Döndür Rotate (°) Döndür (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Örnek veri (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Örnek veri © Natural Earth (kamu malı). RustGIS, Apache-2.0 ile lisanslanmıştır. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Örnek veri: Natural Earth (kamu malı). Apache-2.0 ile lisanslanmıştır. +Sans-serif Tırnaksız Satellite Embedding Datasets Uydu Gömme Veri Kümeleri Satellite Embeddings Uydu Gömmeleri Satellite Embeddings… Uydu Gömmeleri… Saturation Doygunluk Save Kaydet Save As Farklı Kaydet +Save as Connection Bağlantı Olarak Kaydet Save as Default Varsayılan Olarak Kaydet Save as Default Raster Style Varsayılan Raster Stili Olarak Kaydet Save as Layer Katman Olarak Kaydet Save As Presentation File… Sunum Dosyası Olarak Kaydet… +Save as Python Script… Python Betiği Olarak Kaydet… Save As Report File… Rapor Dosyası Olarak Kaydet… Save As Report Template… Rapor Şablonu Olarak Kaydet… Save as {name} {name} olarak kaydet Save As… Farklı Kaydet… Save Color Ramps Renk Rampalarını Kaydet Save Data Source Veri Kaynağını Kaydet +Save History As Python Script Geçmişi Python Betiği Olarak Kaydet Save Layer File Katman Dosyasını Kaydet Save Layout File Sayfa Düzeni Dosyasını Kaydet Save Map File Harita Dosyasını Kaydet @@ -2254,6 +2456,7 @@ save or load a pane set by name bir panel setini adıyla kaydedin veya yükleyin Save Project Projeyi Kaydet Save project Projeyi kaydet Save Project As Projeyi Farklı Kaydet +Save Project As… Projeyi Farklı Kaydet… Save Project Template Proje Şablonunu Kaydet Save Style Items Stil Öğelerini Kaydet Save Task Item Görev Öğesini Kaydet @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) Modeli dosya olarak kaydet (*.model.json Save the model in the project Modeli projeye kaydet Save the open layout as a layout file. Açık sayfa düzenini sayfa düzeni dosyası olarak kaydet. Save the panes you have open, and switch back to them. Açık panelleri kaydet ve onlara geri dön. +Save the project too: the script opens its saved file, without changes made since. Projeyi de kaydedin: betik, projenin kaydedilmiş dosyasını, sonradan yapılan değişiklikler olmadan açar. Save the project under a new name. Projeyi yeni bir adla kaydet. Save the project with your edits. Projeyi düzenlemelerinizle birlikte kaydet. Save the project's custom color ramps to a color ramp file. Projenin özel renk rampalarını bir renk rampası dosyasına kaydet. @@ -2275,13 +2479,16 @@ Save Web Link Chart Web Bağlantı Grafiğini Kaydet Save Web Map Web Haritasını Kaydet Save Web Scene Web Sahnesini Kaydet Saved Kaydedildi +Saved as the connection {name} {name} bağlantısı olarak kaydedildi Saved as the default style for new single-band rasters. Yeni tek bantlı rasterlar için varsayılan stil olarak kaydedildi. +Saved connection Kayıtlı bağlantı Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Kaydedilen anahtarlar uygulamanın tercihlerinde şifrelenmeden saklanır (web'de tarayıcının deposunda). Saved models Kayıtlı modeller Saved symbology: apply it to the selected layer, or save the layer's. Kayıtlı semboloji: seçili katmana uygula veya katmanınkini kaydet. Saved View Kayıtlı görünüm Saved {name} {name} kaydedildi Saved {name}. {name} kaydedildi. +Saved {path} Kaydedildi: {path} saving failed unexpectedly kaydetme beklenmedik şekilde başarısız oldu Scale Ölçek Scale\nBar Ölçek\nÇubuğu @@ -2304,7 +2511,7 @@ Search (this portal needs words)… Ara (bu portal sözcük gerektirir)… Search Catalogue Katalogda Ara Search commands — e.g. buffer, basemap, labels Komut ara — örn. tampon, altlık harita, etiketler Search datasets, e.g. GEDI biomass Veri kümesi ara, ör. GEDI biomass -Search Distance (km) Arama Mesafesi (km) +Search Distance Arama Mesafesi Search every command and its parameters Tüm komutlarda ve parametrelerinde ara Search layers (e.g. temperature) Katman ara (ör. temperature) Search layers, places, lat/lon… Katman, yer, enlem/boylam ara… @@ -2329,6 +2536,8 @@ Searching the folders… Klasörlerde aranıyor… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. Arama burada çalışır, ancak NASA'nın veri sunucuları web sayfalarının dosyalarını okumasına izin vermez (CORS yok); bu nedenle korumalı verilerin GeoTIFF'leri ve indirmeleri için masaüstü uygulaması gerekir. İndir, dosyayı yeni bir sekmede açar; orada Earthdata Login oturum açmanızı ister. Searching… Aranıyor… Searching… try again in a moment. Aranıyor… birazdan yeniden deneyin. +Seasonal Mann–Kendall Mevsimsel Mann–Kendall +Seasons a year Yıllık mevsim sayısı Second standard parallel İkinci standart paralel Seconds Saniye Secret access key Gizli erişim anahtarı @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Ayırıcı Sepia Sepya Sequential Sıralı +Serif Tırnaklı Server Sunucu Service Group Servis grubu Service group data exceeds 64 MB. Servis grubu verileri 64 MB sınırını aşıyor. Service Import Requests Servis içe aktarma istekleri Service Layers Servis Katmanları -Services Servisler +Session token Oturum belirteci Set Ayarla Set Cell Size Hücre Boyutunu Ayarla Set Domain Değer Kümesi Ayarla @@ -2411,6 +2621,7 @@ Shaded Gölgeli Shaded Relief Gölgeli Rölyef Shape Şekil Shapefile Shapefile +Shapes Şekiller Share Paylaş Share As Farklı Paylaş Share every layer and table as OGC API Features collections. Her katmanı ve tabloyu OGC API Features koleksiyonu olarak paylaş. @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. Tarayıcı görünümünde Show different features at different scales. Farklı ölçeklerde farklı detaylar göster. Show grid Izgarayı göster Show Group Grubu Göster +Show labels Etiketleri göster Show latitude/longitude lines on the basemap. Altlık haritada enlem/boylam çizgilerini göster. +Show Layers Katmanları Göster Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Katmanları zaman içinde göster: yalnızca tarihi kaydırıcının penceresine düşen detaylar ve tarihli görüntüler için kaydırıcının tarihi. Show Map Haritayı Göster Show Me Bana Göster @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. Araçlar panelini göster veya gizle. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Cetvelleri göster. Kılavuz eklemek için bir cetvelden sürükleyin; kaldırmak için kılavuzu cetvele geri sürükleyin. Show the bands picked, read in place as the map needs them Seçilen bantları, harita ihtiyaç duydukça yerinde okuyarak göster Show the description Açıklamayı göster +Show the Labeling pane for the selected layer. Seçili katman için Etiketleme bölmesini göster. Show the labels that don't fit, in red. Sığmayan etiketleri kırmızıyla göster. Show the layer only inside another layer's polygons. Katmanı yalnızca başka bir katmanın poligonları içinde göster. Show the layer only left of a divider, to compare it with what's beneath. Katmanı altındakiyle karşılaştırmak için yalnızca bir ayırıcının solunda göster. @@ -2491,8 +2705,10 @@ Sign-in cancelled. Oturum açma iptal edildi. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. Oturum açma zaman aşımına uğradı. Yeniden başlayın ve tarayıcının bu bilgisayarın geri döngü adresine erişmesine izin verin. signed in oturum açık Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Bu bilgisayarda oturum kapatıldı, ancak OpenAI bunu onaylamadı. RustGIS'in bağlantısını ChatGPT ayarlarından kesebilirsiniz. +Significance level Anlamlılık düzeyi Signing in with ChatGPT works in the desktop app. ChatGPT ile oturum açma masaüstü uygulamasında çalışır. Signing the asset (Planetary Computer)… Varlık imzalanıyor (Planetary Computer)… +Sill Eşik Simplify Basitleştir Simulate the drive, or save the route as a layer. Sürüşü canlandırın veya rotayı katman olarak kaydedin. Simulation Simülasyon @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} YZ Asistanı için beceriler: {names} Slideshow, PDF or PNG Slayt gösterisi, PDF veya PNG slight left hafif sola dönün slight right hafif sağa dönün +Slope per Eğim birimi Slope, aspect, hillshade, NDVI, band math, thresholds. Eğim, bakı, tepe gölgeleme, NDVI, bant matematiği, eşikler. Slope, hillshade, NDVI and more. Eğim, tepe gölgeleme, NDVI ve daha fazlası. Small scales Küçük ölçekler @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Güney South Polar Stereographic Güney Kutup Stereografik +Spatial Join Mekânsal Birleştirme Speaker notes Konuşmacı notları Spectral Analysis Spektral Analiz Spectral indices Spektral indeksler @@ -2530,6 +2748,7 @@ Split (click the line) Böl (çizgiye tıklayın) SQL SQL SQL Pane SQL Paneli SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL sorguları, masaüstü uygulamasının çağırdığı bir program olan DuckDB üzerinden çalışır: sorgulamak için bu projeyi masaüstü uygulamasında açın. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 Doğru olması gereken SQL, örn. pop > 0 Square Kare Squares Kareler @@ -2588,6 +2807,7 @@ Start over at the end while playing. Oynatırken sona gelince baştan başla. Start With Şununla başla Start-up Başlangıç starting with a project file needs the desktop app bir proje dosyasıyla başlatmak masaüstü uygulamasını gerektirir +Statistic İstatistik Statistics İstatistikler Statistics Field İstatistik Alanı Status Durum @@ -2613,8 +2833,10 @@ Stretch\nType Germe\nTürü Stretch it over the map view, or give control points (one per line: column row lon lat). Harita görünümüne yay veya kontrol noktaları ver (her satıra bir tane: sütun satır boylam enlem). Stretch Type Germe Türü Stretch type Germe türü +Stroke Levels Kontur Düzeyleri Stroke {n} Kontur {n} Strokes Konturlar +Strokes for Konturların sahibi Style Stil Style Items Stil Öğeleri Style › Drawing Stil › Çizim @@ -2624,7 +2846,10 @@ Subreport: {name} Alt rapor: {name} Subtypes Alt Türler Subtypes, one per line: code=name Alt türler, her satıra bir tane: kod=ad Suitability Modeler Uygunluk Modelleyici +Summarise Özetle +Summarise over time Zaman içinde özetle Summarise the conversation so far, so a long chat stays within the model's context Uzun bir sohbet modelin bağlamına sığsın diye şimdiye kadarki konuşmayı özetle +Summarise Zarr Over Time Zarr'ı Zaman İçinde Özetle Summarising the earlier conversation to keep it within the model's context… Önceki konuşma, modelin bağlamına sığması için özetleniyor… Summarize Özetle Summarize\nWithin İçinde\nÖzetle @@ -2658,6 +2883,8 @@ Table or layer Tablo veya katman Tables Tablolar Tags Etiketler Take the data in view offline, to edit and sync back later. Görünümdeki verileri düzenlemek ve sonra eşitlemek için çevrimdışına al. +Target Features Hedef Detaylar +Target Records Hedef Kayıtlar Task Görev Task Item Görev Öğesi Task name Görev adı @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Tennessee'nin herkese açık CBS verileri (Tennessee GeoData, geodata.tn.gov): mülk sınırları, ulaşım, sağlık, hidroloji ve daha fazlası. Terrain Arazi Test Test +Test Connection Bağlantıyı Test Et +Testing… Test ediliyor… Text Metin Text Symbol Metin Sembolü That connection would create a loop. Bu bağlantı bir döngü oluşturur. The address fields are empty in every row. Adres alanları her satırda boş. +The answer isn't UTF-8 text Yanıt UTF-8 metni değil the answer isn't UTF-8 text yanıt UTF-8 metin değil The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. Asistan bir beceriyi kullanırken onun talimatlarına uyar. Talimatlar ne derse desin, onayınızı gerektiren komutlar yine de sorar. The assistant is busy; stop it or wait for it to finish. Asistan meşgul; durdurun veya bitirmesini bekleyin. @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. Altlık Haritalar eklentisinin her the capture failed unexpectedly yakalama beklenmedik şekilde başarısız oldu The connection failed; trying again in {seconds} s. Bağlantı başarısız oldu; {seconds} sn sonra yeniden denenecek. The country list couldn't be read ({e}): type a country's code Ülke listesi okunamadı ({e}): bir ülke kodu yazın +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. Girdileri düzenlerken geçerli yükseklik etkin kalır. Değeri ve birimleri uygulamak için Yüksekliği uygula'ya tıklayın. The dataset has nothing RustGIS can add Veri kümesinde RustGIS'in ekleyebileceği bir şey yok The environment (AWS_ACCESS_KEY_ID…) Ortam (AWS_ACCESS_KEY_ID…) The features where the map is Haritanın olduğu yerdeki detaylar @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. Akı The installed GeoLibre runner lists no tools. Yüklü GeoLibre çalıştırıcısı hiçbir araç listelemiyor. The job covers the map view İş harita görünümünü kapsar The key is kept for this session only. Anahtar yalnızca bu oturum için saklanır. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. Katman, zaman kaydırıcısının bulunduğu adımı gösterir ve kaydırıcıyı değişkenin tarihleri üzerinde açar. The layer's file couldn't be read: {error} Katmanın dosyası okunamadı: {error} +The layer's strokes Katmanın konturları the layout export failed unexpectedly sayfa düzeni dışa aktarımı beklenmedik şekilde başarısız oldu The link doesn't say its STAC API: Find Items searches {api} Bağlantı STAC API'sini belirtmiyor: Öğeleri Bul {api} üzerinde arar The list of plugins to browse. Leave it empty for the RustGIS plugin registry. Göz atılacak eklenti listesi. RustGIS eklenti kayıt defteri için boş bırakın. @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. Harita görünümünden projenin kü The projection the map opens in. Haritanın açıldığı projeksiyon. The provider's content filter stopped the reply. Sağlayıcının içerik filtresi yanıtı durdurdu. The public server is free and shared by many: it answers light use, and may ask you to wait. Herkese açık sunucu ücretsizdir ve birçok kişi tarafından paylaşılır: hafif kullanıma yanıt verir ve beklemenizi isteyebilir. +The range is two numbers, low and high (0, 0.9) Aralık, alt ve üst olmak üzere iki sayıdır (0, 0.9) The raster's data couldn't be read. Rasterin verisi okunamadı. The raster's data couldn't be read: {path} Rasterin verisi okunamadı: {path} the release isn't known sürüm bilinmiyor @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Shapefile dosyaları tek bir zip olar The share of each cell covered by fields, for the zoomed-out view Uzaklaştırılmış görünüm için her hücrenin tarlalarla kaplı oranı The sign-in expired or was revoked. Sign in again. Oturumun süresi doldu veya erişim iptal edildi. Yeniden oturum açın. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. Oturum, uygulamanın ayarlar klasöründe yalnızca kullanıcı hesabınız için saklanır, asla bir proje dosyasında saklanmaz. +The significance level is a number, such as 0.05 Anlamlılık düzeyi 0.05 gibi bir sayıdır The start-up project {path} didn't open: {error} Başlangıç projesi {path} açılamadı: {error} The start-up project {path} isn't a RustGIS project file. Başlangıç projesi {path} bir RustGIS proje dosyası değil. The step's layer {name} isn't in the map. Adımın katmanı {name} haritada değil. The store has no variables with two or more dimensions. Depoda iki veya daha fazla boyutlu değişken yok. the store is gone depo artık yok +The summary couldn't start: {error} Özet başlatılamadı: {error} +The summary failed unexpectedly Özet beklenmedik şekilde başarısız oldu +The summary finished after another project was opened, so it wasn't added Özet, başka bir proje açıldıktan sonra tamamlandığı için eklenmedi The task's steps changed, so the step being edited was closed: edit it again. Görevin adımları değişti, bu yüzden düzenlenen adım kapatıldı: yeniden düzenleyin. the time slider is off zaman kaydırıcısı kapalı The token expires on {date}. Belirtecin süresi {date} tarihinde doluyor. +The trend couldn't start: {error} Eğilim analizi başlatılamadı: {error} +The trend failed unexpectedly Eğilim analizi beklenmedik şekilde başarısız oldu +The trend finished after another project was opened, so it wasn't added Eğilim analizi, başka bir proje açıldıktan sonra tamamlandığı için eklenmedi +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. Siyahtan beyaza gösterilen değerler; her tarih ve karo için aynıdır (bandın kendi birimlerinde). Boş: her dosyanın kendi germesi. the view has no extent görünümün kapsamı yok The view is larger than one read: its centre was read (zoom in for the rest) Görünüm tek okumadan büyük: merkezi okundu (geri kalanı için yakınlaştırın) The web app always runs the latest release: reload the page to get it. Web uygulaması her zaman en son sürümü çalıştırır: almak için sayfayı yeniden yükleyin. the window's screenshot didn't come back in time, so the map was drawn offscreen pencerenin ekran görüntüsü zamanında gelmedi, bu yüzden harita ekran dışında çizildi the window's screenshot didn't hold the map, so it was drawn offscreen pencerenin ekran görüntüsü haritayı içermiyordu, bu yüzden harita ekran dışında çizildi Theme Tema +There is no expression defined. Tanımlı bir ifade yok. Thermal Termal +Thesis figure, full page (160 × 225 mm) Tez şekli, tam sayfa (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Tez şekli, yarım sayfa (160 × 110 mm) Thinking… Düşünüyor… +Third-party licences Üçüncü taraf lisansları +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins bu sürüm eklenti indiremez this build can't reach AI providers bu sürüm YZ sağlayıcılarına erişemez this build saves projects with Save As bu sürüm projeleri Farklı Kaydet ile kaydeder This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Bu ChatGPT hesabı zaten kayıtlıydı. Bir dahaki sefere yeniden eklemek yerine listeden seçin. +This class is drawn with the layer's strokes. Changing them here gives it its own. Bu sınıf katmanın konturlarıyla çizilir. Onları burada değiştirmek sınıfa kendi konturlarını verir. +This class is hidden, so it labels nothing. Bu sınıf gizli, bu nedenle hiçbir şeyi etiketlemez. +This clause no longer reads, so no feature is labelled: {error} Bu yan tümce artık okunamıyor, bu nedenle hiçbir detay etiketlenmiyor: {error} This map has no layers yet. Bu haritada henüz katman yok. This map's thumbnail, from an image. Bir görüntüden bu haritanın küçük resmi. This map's thumbnail, from the view. Görünümden bu haritanın küçük resmi. @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Bu istek için tek seferlik izin seçeneği yok. This request uses an unsupported capability. Check the parameter and change the request. Bu istek desteklenmeyen bir özellik kullanıyor. Parametreyi kontrol edip isteği değiştirin. This result is kept Bu sonuç saklanır +This run has no tool form to open Bu çalıştırmanın açılacak bir araç formu yok +This session only (optional) Yalnızca bu oturum (isteğe bağlı) This step Bu adım This step names no tool to show. Bu adım gösterilecek bir araç belirtmiyor. Thumbnail Küçük Resim @@ -2789,15 +3041,21 @@ Tilt Map Haritayı Eğ Tilt-shift Tilt-shift Tilt-shift blur and film grain. Tilt-shift bulanıklığı ve film greni. Time Zaman +Time\nSeries Zaman\nSerisi Time\nSlider Zaman\nKaydırıcısı Time Fields Zaman Alanları +Time Series Zaman Serisi +Time series Zaman serisi +Time Series at a Point Bir Noktada Zaman Serisi Time Slider Zaman Kaydırıcısı Time Slider Settings Zaman Kaydırıcısı Ayarları Tint by elevation Yüksekliğe göre renklendir Tissot's Indicatrix Tissot İndikatrisi Title Başlık +To Bitiş Toggle Dark Mode Koyu Modu Aç/Kapat Toggle GPU Rendering GPU Çizimini Aç/Kapat +Toggle Imperial Measurements İngiliz Ölçü Birimlerini Aç/Kapat Tolerance (km) Tolerans (km) Tolls Ücretli yollar Tool Gallery Araç Galerisi @@ -2828,7 +3086,10 @@ Train Deep Learning Model Derin Öğrenme Modeli Eğit Trained models Eğitilmiş modeller Transparency Saydamlık Traverse Poligon Geçki +Trend Analysis Eğilim Analizi +Trend over a series of rasters Bir raster serisi üzerinde eğilim Triangle Üçgen +TRUE TRUE Try again in a moment. Birazdan yeniden deneyin. Try again later. Daha sonra yeniden deneyin. Turn editing on or off for the map. Harita için düzenlemeyi aç veya kapat. @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map bir boylam ve enlem yazın veya Type a scale, such as 1:12,345, 12345 or 25k, and press Enter 1:12,345, 12345 veya 25k gibi bir ölçek yazın ve Enter'a basın Type an address or coordinates and press Enter, or click the map. Bir adres veya koordinat yazıp Enter'a basın ya da haritaya tıklayın. Type numbers separated by commas Virgülle ayrılmış sayılar yazın +Type the keys to log in for this session, then Save. Bu oturumda oturum açmak için anahtarları yazın, ardından Kaydet'e tıklayın. +Type the password or keys to log in for this session, then Save. Bu oturumda oturum açmak için parolayı veya anahtarları yazın, ardından Kaydet'e tıklayın. Typed below Aşağıya yazılan UM UM Unavailable: {reason} Kullanılamıyor: {reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider Zaman Kaydırıcısından Ayır Unbind from Time Slider Zaman kaydırıcısından ayır Undo Geri Al Undo (Ctrl+Z) Geri Al (Ctrl+Z) -Undo / Redo Geri Al / Yinele Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Geri Al kaydedilen adımı kaldırmaz: kaydı durdurduktan sonra Adımları Düzenle'de silin. Undo the last change. Son değişikliği geri al. Undo {action} Geri al: {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools GeoLibre araçlarını kaldır Uninstall Plugin Eklenti Kaldır Uninstalled {id}. {id} kaldırıldı. Unique Values Benzersiz Değerler +Unit Birim United States Amerika Birleşik Devletleri Units Birimler Units per step Adım başına birim @@ -2877,6 +3140,7 @@ Up a level Bir düzey yukarı Update Güncelle Update Bookmark Yer İmini Güncelle Update Job İşi Güncelle +Update Label Class Etiket Sınıfını Güncelle Update to {version} {version} sürümüne güncelle Upload File Dosya Yükle Upload File… Dosya Yükle… @@ -2895,6 +3159,7 @@ Use another account Başka bir hesap kullan Use Current View Geçerli görünümü kullan Use each feature's line color Her detayın çizgi rengini kullan Use each feature's line width Her detayın çizgi kalınlığını kullan +Use the Layer's Strokes Katmanın Konturlarını Kullan USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan ChatGPT planı kullanılıyor Utilities Yardımcı Araçlar Valid combinations, one per line: a, b Geçerli kombinasyonlar, her satıra bir tane: a, b Valid expression · {n} matching rows Geçerli ifade · {n} eşleşen satır +Valid: {n} of {of} features match Geçerli: {of} detaydan {n} tanesi eşleşiyor Validate Doğrula Validate Model Modeli Doğrula value (empty: no-data) değer (boş: veri yok) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Vantor'un açık afet görüntüleri: bir olay ve bir evre (öncesi veya sonrası) seçin, ardından Öğeleri Bul'a tıklayın. Vantor… Vantor… Variable Değişken +Variogram Model Variogram Modeli Vector and raster data Vektör ve raster verisi Vector Layer Vektör Katmanı +vector tiles vektör karolar Vector tiles (MVT) Vektör karolar (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Bir jeo-uzamsal temel modelin her piksel veya görüntü parçası için ürettiği vektörler; haritanın olduğu yerde bulunur ve renk olarak gösterilir. Version {version} Sürüm {version} @@ -2933,6 +3201,9 @@ Video file or URL Video dosyası veya URL Video Page Video Sayfası Videos Videolar Video… Video… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. WGS 84'e 7 parametreli Helmert dönüşümüyle 3 derecelik Transverse Mercator kullanan Vietnam ulusal koordinat sistemi (VN-2000). +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Vietnam ulusal koordinat sistemi (VN-2000) UTM dilimi 48N (102°D–108°D, orta meridyen 105°D). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Vietnam ulusal koordinat sistemi (VN-2000) UTM dilimi 49N (108°D–114°D, orta meridyen 111°D). View Görünüm View Clipping Görünüm Kırpma View Effects Görünüm Efektleri @@ -2952,6 +3223,9 @@ Visibility Range Görünürlük Aralığı Visible Görünür Visible Layers Görünür Katmanlar Visual Effect Görsel Efekt +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vietnam) +VN-2000 / UTM zone 48N VN-2000 / UTM dilimi 48N +VN-2000 / UTM zone 49N VN-2000 / UTM dilimi 49N Volume Hacim Waiting for the browser… Tarayıcı bekleniyor… Waiting for your answer Yanıtınızı bekliyor @@ -2970,6 +3244,7 @@ Web Link Chart Web Bağlantı Grafiği Web maps Web haritaları Web Mercator Web Mercator Web Scene Web Sahnesi +Web service (detected) Web servisi (algılandı) Web Style Web Stili Web Tile Layers Web Karo Katmanları Web Tool Web Aracı @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. Etiketlerin detaylarına göre nere Where new features get their elevation (the z field). Yeni detayların yüksekliğini nereden aldığı (z alanı). Where the map opens: the full extent or a view you saved. Haritanın nerede açılacağı: tam kapsam ya da kaydettiğiniz bir görünüm. Where tools work Araçların çalıştığı yer +Whether the Select tool picks these layers' features on the map Seç aracının bu katmanların detaylarını haritada seçip seçmeyeceği Which 5 countries have the largest GDP? GSYİH'si en büyük 5 ülke hangileri? Whitebox and GeoLibre tools Whitebox ve GeoLibre araçları Whole map Tüm harita @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Dünya, eşalan: gerçek boyut World, equal-area: true sizes, shapes bend towards the edges. Dünya, eşalan: gerçek boyutlar, şekiller kenarlara doğru bükülür. Worldview Sınırlar Write a plugin Eklenti yaz +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Sen eğimini, testin Z ve p değerini ve eğilimin anlamlı olduğu yerlerdeki eğimi yazar. X (mm) X (mm) X column X sütunu X Field X Alanı @@ -3050,7 +3327,6 @@ Yours Sizin YYYY-MM-DD YYYY-MM-DD YYYY-MM-DD, for the layers that change with time YYYY-MM-DD, zamanla değişen katmanlar için YYYY-MM-DD, for time layers YYYY-MM-DD, zaman katmanları için -z (m) z (m) Z factor Z faktörü Zarr Zarr Zarr Variables Zarr Değişkenleri @@ -3061,7 +3337,6 @@ Zoom Box Kutuyla Yakınlaştır Zoom Frame Çerçeveyi Yakınlaştır Zoom In Yakınlaştır Zoom in Yakınlaştır -Zoom in / out Yakınlaştır / uzaklaştır Zoom In Here Buraya Yakınlaştır Zoom in on the centre of the map. Haritanın merkezine yakınlaştır. Zoom in · finish a sketch Yakınlaştır · çizimi bitir @@ -3073,6 +3348,7 @@ Zoom To\nLayer Katmana\nYakınlaştır Zoom to a box (Explore) Kutuya yakınlaştır (Keşfet) Zoom To Extent Kapsama Yakınlaştır Zoom To Layer Katmana Yakınlaştır +Zoom To Layers Katmanlara Yakınlaştır Zoom To Map View Harita Görünümüne Yakınlaştır Zoom To Selection Seçime Yakınlaştır Zoom to selection Seçime yakınlaştır @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. Katmanın kapsamına yakınlaştır. Zoom to the selected features. Seçili detaylara yakınlaştır. Zoom to the whole world. Tüm dünyaya yakınlaştır. Zoom to this bookmark Bu yer imine yakınlaştır +Zoom To Tile Layer Karo Katmanına Yakınlaştır Zoom to Tokyo Tokyo'ya yakınlaştır Zoom To World Dünyaya Yakınlaştır +Zoom {z} · Bearing {b}° · Pitch {p}° Yakınlaştırma {z} · Yön {b}° · Eğim {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Yakınlaştırma: web haritalarının saydığı şekliyle haritanın yakınlaştırma düzeyi. Yön: haritanın üst kenarının baktığı yön, kuzeyden saat yönünde. Eğim: tam aşağı bakıştan sapma. Yönü ve eğimi sıfırlamak için tıklayın. {agent} was stopped by you. {agent} sizin tarafınızdan durduruldu. {id} changed since it was installed; install it again from the Plugin Manager. {id}, yüklendiğinden beri değişti; Eklenti Yöneticisi'nden yeniden yükleyin. {names}: needs Node.js (npx) for its ACP adapter. {names}: ACP bağdaştırıcısı için Node.js (npx) gerekir. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 225286fea..4d426558d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6,6 +6,7 @@ (filtered) (đã lọc) + Add Stop + Thêm điểm dừng + Input + Đầu vào ++ New expression + Biểu thức mới + Output + Đầu ra /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh Lưới 3D A 3D globe seen from space. Quả địa cầu 3D nhìn từ không gian. A blank map with a basemap Bản đồ trống có bản đồ nền +A call for the rustgis Python package Lệnh gọi cho gói Python rustgis A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. Địa chỉ của một danh mục (CSW 2.0.2): tìm các bản ghi của nó theo từ khóa và, nếu muốn, theo vùng bản đồ, rồi thêm các dịch vụ và tệp mà chúng liên kết tới. A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. Đã đạt giới hạn sử dụng của gói ChatGPT hoặc ứng dụng. Xem mức sử dụng hằng tuần trước khi thử lại. A ChatGPT sign-in can only be used with the OpenAI API. Đăng nhập ChatGPT chỉ có thể dùng với API của OpenAI. @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). Các b A globe scene. Cảnh quả địa cầu. A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place Vĩ độ và kinh độ (40.7, -74.0 hoặc 40°42'N 74°0'W), một lớp, một dấu trang hoặc một giá trị trong bảng của lớp. Enter chọn hàng được tô sáng; Tìm địa điểm tra cứu văn bản như một địa điểm A layer the model reads Một lớp mà mô hình đọc +A line for the Script pane Một dòng cho khung Script A magnifying lens that follows the pointer. Kính lúp di chuyển theo con trỏ. A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). Địa chỉ của một dịch vụ bản đồ, đối tượng hoặc coverage: WMS, WMTS, WFS, WCS, OGC API Features hoặc Tiles, hoặc ArcGIS FeatureServer, MapServer, ImageServer hoặc VectorTileServer (hoặc một thư mục dịch vụ để duyệt). a name, or owner/name such as giswqs/geospatial một tên, hoặc chủ sở hữu/tên như giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. Một điểm cuối có tên để dùng lại. Mật khẩu và khóa được giữ cho đến khi RustGIS đóng, không bao giờ được lưu. A node cannot connect to itself. Một nút không thể kết nối với chính nó. A notebook of text and command cells. Sổ tay gồm các ô văn bản và ô lệnh. +A password or token in the address was left out: connections never keep secrets. Mật khẩu hoặc token trong địa chỉ đã bị bỏ qua: kết nối không bao giờ giữ thông tin bí mật. A place or an address Một địa điểm hoặc địa chỉ A project keeps at least one map Dự án luôn giữ ít nhất một bản đồ A projected coordinate system. Một hệ tọa độ phép chiếu. A report listing the selected layer's records, page after page. Báo cáo liệt kê các bản ghi của lớp được chọn, hết trang này đến trang khác. A result that only feeds other tools is removed once the model has run; add an output to keep it. Kết quả chỉ làm đầu vào cho công cụ khác sẽ bị xóa sau khi mô hình chạy xong; hãy thêm một đầu ra để giữ lại. A result the model keeps, under a name Một kết quả mà mô hình giữ lại, dưới một tên +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) Một lệnh rustgis-cli, cho shell POSIX (macOS, Linux, WSL, Git Bash) A service import or refresh is already running. Đang nhập hoặc làm mới một dịch vụ. +A service's layers stay in their service group. Các lớp của một dịch vụ được giữ trong nhóm dịch vụ của chúng. A task holds at most 200 steps: stop recording to make it, then record the rest as another task. Một nhiệm vụ chứa tối đa 200 bước: hãy dừng ghi để tạo nhiệm vụ, rồi ghi phần còn lại thành một nhiệm vụ khác. A template with the first selected feature's values Mẫu với giá trị của đối tượng được chọn đầu tiên +A tile layer per grid tile, each following the time slider through its dates. Mỗi ô lưới một lớp ô bản đồ, mỗi lớp theo thanh trượt thời gian qua các ngày của nó. A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. Địa chỉ của dịch vụ ô bản đồ: ô raster với {z}/{x}/{y} (hoặc {-y}, {q}, {bbox-epsg-3857} cho WMS), ô vector (.pbf) hoặc TileJSON, một kiểu MapLibre, một kho PMTiles (địa chỉ web hoặc tệp trên máy tính này), hoặc một tệp MBTiles trên máy tính này. A token is set. Đã đặt token. A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. Một giá trị, một trường, hoặc hai trong số đó nối bằng + − × ÷. Áp dụng cho các hàng được chọn, hoặc mọi hàng nếu không có hàng nào được chọn. a vector a pixel một vectơ cho mỗi pixel a vector an image patch một vectơ cho mỗi mảnh ảnh A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. Một kho Zarr (định dạng 2 hoặc 3) theo địa chỉ web, có siêu dữ liệu hợp nhất, hoặc một thư mục trên máy tính này. Bản đồ của một biến được đọc theo các bước đã chọn của các chiều khác, chẳng hạn thời gian. +A Zarr variable's values through time at points clicked on the map. Giá trị theo thời gian của một biến Zarr tại các điểm được nhấp trên bản đồ. +A2 landscape sheet Trang A2 nằm ngang +A2 portrait sheet Trang A2 thẳng đứng +A3 landscape sheet Trang A3 nằm ngang +A3 portrait sheet Trang A3 thẳng đứng +A4 landscape sheet Trang A4 nằm ngang +A4 portrait sheet Trang A4 thẳng đứng +A5 Grid Lưới A5 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. Các ô ngũ giác đồng diện tích của A5 ở một độ phân giải, trên phạm vi bản đồ, một lớp hoặc toàn thế giới, dưới dạng vùng kèm id của chúng. About Giới thiệu About plugins Giới thiệu về plugin About RustGIS Giới thiệu RustGIS @@ -65,6 +83,7 @@ Add Thêm Add\nData Thêm\ndữ liệu Add ({n}) Thêm ({n}) Add a bar chart of a layer's numeric field. Thêm biểu đồ cột cho một trường số của lớp. +Add a circle. Thêm hình tròn. Add a colorbar of a raster layer's colour scale in the map frame. Thêm thanh màu thể hiện thang màu của một lớp raster vào khung bản đồ. Add a description. Thêm mô tả. Add a field. Thêm một trường. @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. Thêm bố cục trang đ Add a page to the presentation. Thêm một trang vào bản trình bày. Add a PNG or JPEG picture to the page. Thêm ảnh PNG hoặc JPEG vào trang. Add a presentation file. Thêm tệp bản trình bày. +Add a rectangle, to frame or highlight part of the page. Thêm hình chữ nhật để đóng khung hoặc làm nổi bật một phần trang. Add a report file, or a report template for the selected layer. Thêm tệp báo cáo, hoặc mẫu báo cáo cho lớp được chọn. Add a scale bar for the map frame. Thêm thước tỷ lệ cho khung bản đồ. add a second map first (Data › New Map) hãy thêm bản đồ thứ hai trước (Dữ liệu › Bản đồ mới) Add a second map frame first Hãy thêm khung bản đồ thứ hai trước +Add a straight line. Thêm đường thẳng. Add a text element. Thêm phần tử văn bản. Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. Thêm một bản đồ nền web chồng lên các bản đồ khác thay vì thay thế chúng, để hòa trộn với chúng. Nhấp vào một bản đồ nền đang hiện để gỡ nó. +Add a Zarr variable first (Add Data › Zarr…). Hãy thêm một biến Zarr trước (Thêm dữ liệu › Zarr…). Add Administrative Boundaries Thêm ranh giới hành chính +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. Thêm mũi tên. Xoay nó bằng tay nắm tròn phía trên, hoặc đặt góc trong thuộc tính của nó. Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. Thêm token Earthdata Login (Earthdata Login, ở trên) để đưa GeoTIFF của NASA lên bản đồ. Add an elevation raster first. Hãy thêm raster độ cao trước. Add an elevation raster to the map first Hãy thêm một raster độ cao vào bản đồ trước +Add an ellipse. Thêm hình elip. Add an empty layer with a ready-made schema, to draw into. Thêm một lớp trống có lược đồ dựng sẵn để vẽ vào. Add an input for each layer the model reads. Thêm một đầu vào cho mỗi lớp mà mô hình đọc. Add an output to keep a result under a name. Thêm một đầu ra để giữ một kết quả dưới một tên. Add and Open Thêm và mở Add and Open Notebook Thêm và mở sổ tay Add Archive Thêm lưu trữ +Add as Time Series Thêm dưới dạng chuỗi thời gian add at least two keyframes hãy thêm ít nhất hai khung hình chính Add Cell Thêm ô Add Color Ramp File… Thêm tệp dải màu… @@ -132,6 +157,7 @@ Add Graphics Layer Thêm lớp đồ họa Add Input Thêm đầu vào Add Job Thêm tác vụ Add Keyframe Thêm khung hình chính +Add Label Class Thêm lớp nhãn Add Layer File Thêm tệp lớp Add Open Data Thêm dữ liệu mở Add Output Thêm đầu ra @@ -147,9 +173,11 @@ Add Rule Thêm quy tắc Add sample countries Thêm các quốc gia mẫu Add Sample Data Thêm dữ liệu mẫu Add Satellite Embeddings Thêm embedding vệ tinh +Add Selected ({n}) Thêm mục đã chọn ({n}) Add Service Layer Thêm lớp dịch vụ Add skill… Thêm kỹ năng… Add STAC Asset Thêm tài nguyên STAC +Add STAC Data Cube Thêm khối dữ liệu STAC Add STAC Footprints Thêm đường bao STAC Add Step Thêm bước Add Stroke Thêm nét @@ -171,6 +199,7 @@ Add to Favourites Thêm vào mục yêu thích Add to Map Thêm vào bản đồ Add vector data. Thêm dữ liệu vector. Add view Thêm khung nhìn +Add With Options… Thêm với tùy chọn… Add Zarr Variable Thêm biến Zarr Add Zarr Variable… Thêm biến Zarr… Add ▾ Thêm ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Các tác tử có toàn quyền truy cập: chúng có thể chạy script, nhiệm vụ, mô hình và plugin. Nhấp để dừng chuyển tiếp MCP Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. Các tác tử trên máy tính này đã kết nối (Claude Code, Codex, Cursor và các tác tử khác) làm việc trên bản đồ đang mở ở đây bằng công cụ của RustGIS: chúng có thể mở, lưu tệp và dùng web. Script và quản lý plugin vẫn tắt. Agents then run commands, change files and use the web on this computer without asking you. Khi đó tác tử sẽ chạy lệnh, thay đổi tệp và dùng web trên máy tính này mà không hỏi bạn. +Aggregate Points to A5 Gộp điểm vào A5 Aggregation Gộp nhóm Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Ranh giới ruộng nông nghiệp mà mô hình Fields of the World tìm thấy trong ảnh Sentinel-2, trên toàn thế giới, theo từng năm (Ấn bản thứ 2, CC BY 4.0). Đây là dự đoán của mô hình, không phải thửa đất đo đạc. AI AI @@ -221,15 +251,18 @@ All steps done. Đã hoàn tất mọi bước. All tables with geometry Tất cả bảng có hình học Allow Cho phép Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. Hãy cho phép dùng gói ChatGPT khi đăng nhập. Nếu không gian làm việc của bạn không cho phép, hãy hỏi quản trị viên hoặc chọn nhà cung cấp dùng khóa API. +Allow Feature Selection Cho phép chọn đối tượng Allow only certain combinations of values across fields. Chỉ cho phép một số tổ hợp giá trị nhất định giữa các trường. Allow such actions for this conversation Cho phép các thao tác như vậy trong cuộc trò chuyện này Allow this action? Cho phép thao tác này? Allow this agent's RustGIS action? Cho phép thao tác RustGIS này của tác tử? Along Selected Line Dọc theo đường được chọn +Alt+ Alt+ Alternating bar Thanh xen kẽ Altitude Độ cao Always allow Luôn cho phép -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3, hoặc một dịch vụ tương thích S3 (MinIO, Cloudflare R2, Wasabi…) theo điểm cuối của nó. Khóa được giữ cho đến khi RustGIS đóng; kết nối được ghi nhớ giữ tên, điểm cuối và khu vực, không bao giờ giữ khóa bí mật. +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3, hoặc một dịch vụ tương thích S3 (MinIO, Cloudflare R2, Wasabi…) theo điểm cuối của nó. Khóa được giữ cho đến khi RustGIS đóng; kết nối đã lưu giữ tên, điểm cuối và khu vực, không bao giờ giữ khóa bí mật. amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: Một tác tử trên máy tính này yêu cầu: An AWS profile Một hồ sơ AWS @@ -248,6 +281,7 @@ API key Khóa API Apply Áp dụng Apply a function to a raster Áp dụng một hàm cho raster Apply Control Points Áp dụng điểm khống chế +Apply elevation Áp dụng độ cao Apply Style Áp dụng kiểu Apply to layers Áp dụng cho các lớp Apply to the selected layer Áp dụng cho lớp được chọn @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° Phạm vi sử dụng: trong vòng Area {area} · Perimeter {perimeter} Diện tích {area} · Chu vi {perimeter} Areas (name, land use, area) Vùng (tên, sử dụng đất, diện tích) Arrange Sắp xếp +Arrow Mũi tên +Arrowheads Đầu mũi tên Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. Đặt câu hỏi về dữ liệu của bạn, hoặc mô tả việc cần làm với bản đồ. Trợ lý sẽ chạy các lệnh RustGIS cho bạn; mọi thay đổi đều có thể hoàn tác. Ask about the map, or tell it what to do… Hỏi về bản đồ, hoặc bảo nó cần làm gì… Ask about your data, or tell the assistant what to do with the map. Hỏi về dữ liệu của bạn, hoặc bảo trợ lý cần làm gì với bản đồ. @@ -275,12 +311,13 @@ Asking NASA's CMR… Đang hỏi CMR của NASA… Asking the catalog… Đang hỏi danh mục… Asking the NLDI… Đang hỏi NLDI… Assistant settings Cài đặt trợ lý +At the end Ở cuối +At the start Ở đầu Atlas Atlas Attachments Tệp đính kèm Attribute\nTable Bảng\nthuộc tính Attribute Rules Quy tắc thuộc tính Attribute Table Bảng thuộc tính -Attribute table Bảng thuộc tính Attributes Thuộc tính Attribution Ghi công Aurora Cực quang @@ -295,11 +332,9 @@ Automatic (system language) Tự động (ngôn ngữ hệ thống) Automatic (system time zone and region) Tự động (múi giờ và khu vực của hệ thống) Automatic ({kind}) Tự động ({kind}) Avoid Tránh -AWS AWS AWS, anonymous AWS, ẩn danh Azimuth Góc phương vị Back Quay lại -Back to Explore; cancel a sketch Quay lại Khám phá; hủy bản phác thảo Back to the Map Quay lại bản đồ Back to the map Quay lại bản đồ Back to tools Quay lại công cụ @@ -314,8 +349,6 @@ Basemap Bản đồ nền Basemaps Bản đồ nền Basin from hydrolocation Lưu vực từ vị trí thủy văn Bearing Hướng -Bearing {b}° · Pitch {p}° Hướng {b}° · Độ nghiêng {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. Hướng: hướng mà cạnh trên của bản đồ quay về, tính theo chiều kim đồng hồ từ hướng bắc. Độ nghiêng: góc nghiêng so với khi nhìn thẳng xuống. Nhấp để đặt lại cả hai. Before the event Trước sự kiện Below Dưới Below 1:5,000,000 show everything; beyond it only what matches: Dưới 1:5.000.000 hiển thị mọi thứ; vượt quá đó chỉ hiển thị những gì khớp: @@ -328,10 +361,12 @@ Bilinear Song tuyến Bind To Time Slider Gắn với thanh trượt thời gian Bind to Time Slider Gắn với thanh trượt thời gian Blank Trống +Blank for Amazon S3 Để trống cho Amazon S3 Blank Letter page Trang Letter trống Blank Map Bản đồ trống Blank Page Trang trống Blend: {mode} Hòa trộn: {mode} +Block Feature Selection Chặn chọn đối tượng Blue Xanh lam Bold Đậm Bold label text. In đậm văn bản nhãn. @@ -339,6 +374,7 @@ Bookmark Description Mô tả dấu trang Bookmarks Dấu trang Bookmarks Pane Ngăn Dấu trang Boolean Boolean +Both ends Cả hai đầu Bottom Dưới Boundaries… Ranh giới… Bounding\nGeometry Hình học\nbao @@ -346,6 +382,7 @@ Brightness Độ sáng Brightness, contrast, saturation and sepia for the whole map. Độ sáng, độ tương phản, độ bão hòa và màu nâu đỏ cho toàn bản đồ. Bring this bookmark's map into view to update it Hãy hiển thị bản đồ của dấu trang này để cập nhật nó Browse Duyệt +Browse Connection Duyệt kết nối Browse it in the Browser Duyệt trong Trình duyệt Browse Plugins Duyệt plugin Browse the geoprocessing toolbox. Duyệt hộp công cụ xử lý không gian. @@ -357,6 +394,7 @@ Browser Trình duyệt Browser\nPane Ngăn\ntrình duyệt Browser View Khung nhìn trình duyệt Bucket Bucket +Buckets Bucket Buffer Vùng đệm Buffer A Point Vùng đệm một điểm Buffer the cities by 200 km Tạo vùng đệm 200 km quanh các thành phố @@ -381,7 +419,9 @@ Calculate Field Tính trường Calculate field Tính trường Calculate field values. Tính giá trị trường. Calculate fields and enforce conditions on every edit. Tính trường và áp đặt điều kiện cho mỗi lần chỉnh sửa. +Calculate Geometry Tính hình học Calculate Geometry Attributes Tính thuộc tính hình học +Calculated {rows} features Đã tính {rows} đối tượng Calculation Phép tính Camera Máy ảnh Camera Effect Hiệu ứng máy ảnh @@ -396,12 +436,14 @@ Case Field Trường phân nhóm Cast Shadow Đổ bóng Catalog Danh mục catalog is a static STAC catalog's catalog.json or collection.json catalog là tệp catalog.json hoặc collection.json của một danh mục STAC tĩnh +Catalogue (CSW) Danh mục (CSW) Catalogue search (CSW) Tìm kiếm danh mục (CSW) Celestial Bodies Thiên thể Celestial Body Thiên thể Celestial body: {body} Thiên thể: {body} Cell size Kích thước ô cell size ° kích thước ô ° +Cells Ô Center Tâm Center Map Here Đặt tâm bản đồ tại đây Centered Căn giữa @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. Không thể sử ChatGPT sign-in failed. Check the connection and local settings, then try again. Đăng nhập ChatGPT thất bại. Kiểm tra kết nối và cài đặt cục bộ rồi thử lại. ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. Tạm thời không thể sử dụng ChatGPT. Giữ phiên đăng nhập và thử lại sau. CHE CHE +Check Kiểm tra Check Again Kiểm tra lại Check for\nUpdates Kiểm tra\ncập nhật Check for Updates Kiểm tra cập nhật @@ -428,6 +471,7 @@ Check for Updates… Kiểm tra cập nhật… Check the address and the service type. Hãy kiểm tra địa chỉ và loại dịch vụ. Check the address and your connection. Hãy kiểm tra địa chỉ và kết nối của bạn. Check the address. Hãy kiểm tra địa chỉ. +Check the clause, and count the features it takes. Kiểm tra mệnh đề và đếm số đối tượng mà nó lấy. Check the integration's client, permissions, workspace policy and route configuration. Kiểm tra ứng dụng khách, quyền, chính sách không gian làm việc và cấu hình định tuyến của tích hợp. Checking for updates… Đang kiểm tra cập nhật… China Trung Quốc @@ -455,6 +499,7 @@ Choose which layers a click identifies. Chọn các lớp mà một cú nhấp s Choose which tabs the ribbon shows. Chọn các thẻ mà ribbon hiển thị. Circle Hình tròn Circle Radius (km) Bán kính vòng tròn (km) +Class Lớp Class color Màu lớp phân loại Class Style Kiểu lớp phân loại Classes Lớp phân loại @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line Nhấp để thêm đ Click to edit the description Nhấp để sửa mô tả Click to place a point graphic. Nhấp để đặt một đồ họa điểm. Click to select the node Nhấp để chọn nút +Click to select, double-click to add, right-click for more Nhấp để chọn, nhấp đúp để thêm, nhấp chuột phải để xem thêm Click to switch between decimal degrees and DMS Nhấp để chuyển giữa độ thập phân và DMS Clip Cắt Clip a raster to the view, or resample it. Cắt raster theo khung nhìn hoặc lấy mẫu lại. @@ -532,7 +578,7 @@ Close thread Đóng luồng Close {name} Đóng {name} Closest Gần nhất Coded values, one per line: code=description Giá trị mã, mỗi dòng một giá trị: mã=mô tả -Collapse or expand the ribbon Thu gọn hoặc mở rộng ribbon +Collapse or Expand the Ribbon Thu gọn hoặc mở rộng ribbon Collapse the ribbon (Ctrl+F1) Thu gọn ribbon (Ctrl+F1) Collection Tập hợp Collection {id}: choose dates and Find Items Tập hợp {id}: chọn ngày rồi Tìm mục @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line Các tổ hợp từ ô phía trên, mỗi dòng một tổ hợp COMID {comid} COMID {comid} Comma Dấu phẩy +Comma-separated (optional) Phân cách bằng dấu phẩy (tùy chọn) Command\nSearch Tìm\nlệnh command is required cần có lệnh Command Search Tìm lệnh @@ -572,10 +619,13 @@ Connect Kết nối Connect a folder to the Browser. Kết nối một thư mục với Trình duyệt. Connect to S3 Kết nối tới S3 Connect, or pick a layer Kết nối, hoặc chọn một lớp +Connected to {kind} {title} Đã kết nối tới {kind} {title} Connecting to {url}… Đang kết nối tới {url}… Connection Kết nối +connection.new returned no id connection.new không trả về id Connections Kết nối -Connect… Kết nối… +Connections you add are listed here Các kết nối bạn thêm được liệt kê ở đây +Connections… Kết nối… Constant Hằng số Constant height Chiều cao không đổi Constant… Hằng số… @@ -583,11 +633,14 @@ Contingent Values Giá trị phụ thuộc Continue Tiếp tục Continue with ChatGPT Tiếp tục với ChatGPT Contrast Độ tương phản +Control Point Fit Khớp điểm khống chế Conversion Chuyển đổi Convert Chuyển đổi Convert a layer to another format Chuyển đổi lớp sang định dạng khác Convert the selected layer's labels into an annotation layer. Chuyển nhãn của lớp được chọn thành lớp chú thích. +Convert this layer's labels into an annotation layer. Chuyển nhãn của lớp này thành một lớp chú thích. Convert To Annotation Chuyển thành chú thích +Convert to Annotation Chuyển thành chú thích Converted data Dữ liệu đã chuyển đổi Convert… Chuyển đổi… Coordinate Tọa độ @@ -599,11 +652,15 @@ Copied the map to the clipboard Đã sao chép bản đồ vào bảng nhớ t Copied {path} Đã sao chép {path} Copies the (selected) features of a layer to a new layer. Sao chép các đối tượng (được chọn) của một lớp sang một lớp mới. Copy Sao chép +Copy / Cut / Paste features Sao chép / Cắt / Dán đối tượng Copy a model.run command that runs this model, for the Script pane or Python Sao chép lệnh model.run chạy mô hình này, cho khung Script hoặc Python Copy Address Sao chép địa chỉ Copy address Sao chép địa chỉ Copy an image of the map to the clipboard. Sao chép ảnh bản đồ vào bảng nhớ tạm. +Copy as CLI Command Sao chép thành lệnh CLI +Copy as Command Sao chép thành lệnh Copy As GeoJSON Sao chép dạng GeoJSON +Copy as Python Sao chép thành Python Copy Command Sao chép lệnh Copy coordinates to the clipboard Sao chép tọa độ vào bảng nhớ tạm Copy details Sao chép chi tiết @@ -611,11 +668,13 @@ Copy Group Sao chép nhóm Copy Layer Sao chép lớp Copy Layers Sao chép các lớp Copy Path Sao chép đường dẫn +Copy Run As Code Sao chép lần chạy thành mã Copy sign-in link Sao chép liên kết đăng nhập Copy the selected features (also as GeoJSON text). Sao chép các đối tượng được chọn (cả dưới dạng văn bản GeoJSON). Copy the selected features and delete them. Sao chép các đối tượng được chọn rồi xóa chúng. Copy the selected layer's data source path. Sao chép đường dẫn nguồn dữ liệu của lớp được chọn. Copy this element (Ctrl+D, ⌘D on a Mac) Sao chép phần tử này (Ctrl+D, ⌘D trên Mac) +Copy this run as code, or open it in its tool Sao chép lần chạy này thành mã, hoặc mở nó trong công cụ của nó Corrections Hiệu chỉnh Cost Chi phí could not draw the map không thể vẽ bản đồ @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} Không thể tải kho plugin: {error Count, sum, mean, min, max and standard deviation of a field, optionally per case field. Số lượng, tổng, trung bình, nhỏ nhất, lớn nhất và độ lệch chuẩn của một trường, có thể theo từng trường phân nhóm. Countries, cities, rivers and lakes Quốc gia, thành phố, sông và hồ Country Quốc gia +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. Đếm các điểm của một lớp theo ô A5 chứa chúng (các ô có diện tích bằng nhau, nên số đếm là mật độ), kèm tổng và trung bình của một trường. Counts the points that fall inside each polygon. Đếm số điểm nằm trong mỗi vùng. Counts, totals and top categories of what's in view. Số lượng, tổng và các danh mục hàng đầu của những gì đang hiển thị. CPU CPU @@ -647,12 +707,8 @@ Cross Section Mặt cắt ngang Cross Section (2 clicks) Mặt cắt ngang (2 lần nhấp) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative Tích lũy Current date Ngày hiện tại Current Project Dự án hiện tại @@ -680,13 +736,16 @@ Data type Kiểu dữ liệu Data you add is listed here Dữ liệu bạn thêm được liệt kê ở đây data.gov and CKAN catalogues Danh mục data.gov và CKAN Database Cơ sở dữ liệu -Databases Cơ sở dữ liệu +Database connections are used by the desktop app Kết nối cơ sở dữ liệu được dùng bởi ứng dụng máy tính +databases are read through DuckDB, a program the browser can't run cơ sở dữ liệu được đọc qua DuckDB, một chương trình trình duyệt không chạy được Database… Cơ sở dữ liệu… Dataset Bộ dữ liệu Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. Các bộ dữ liệu trên Hugging Face theo tên hoặc chủ sở hữu/tên; duyệt các tệp của bộ dữ liệu và thêm GeoTIFF, GeoParquet, PMTiles, LiDAR và các tệp khác. Data… Dữ liệu… Date Ngày Dates Ngày +Dates a cell needs Số ngày một ô cần +Dates a cell needs is a whole number, or empty for half Số ngày một ô cần là số nguyên, hoặc để trống để lấy một nửa Day Ngày Day and night shading for a time. Đổ bóng ngày và đêm theo thời điểm. Decimal Thập phân @@ -715,7 +774,6 @@ Delete Notebook Xóa sổ tay Delete Page Xóa trang Delete Presentation Xóa bản trình bày Delete Report Xóa báo cáo -Delete selected features Xóa các đối tượng được chọn Delete selected rows Xóa các hàng được chọn Delete Style Xóa kiểu Delete Task Xóa nhiệm vụ @@ -736,11 +794,15 @@ Desktop and command line only Chỉ ứng dụng máy tính và dòng lệnh Details Chi tiết Details of the selected layer. Chi tiết của lớp được chọn. Deviations Độ lệch +DGGS DGGS Diagnostic\nMonitor Giám sát\nchẩn đoán Diagnostic Monitor Giám sát chẩn đoán Diagnostics Chẩn đoán +Diagonal ↗ Đường chéo ↗ +Diagonal ↘ Đường chéo ↘ Diamond Hình thoi Difference, ratio or percent change between two rasters. Hiệu, tỷ số hoặc phần trăm thay đổi giữa hai raster. +Digit Digit Directions Chỉ đường Directions From Here Chỉ đường từ đây Directions To Here Chỉ đường đến đây @@ -758,6 +820,7 @@ Dissolve Field Trường hợp nhất Distance Khoảng cách Distance (km) Khoảng cách (km) Distance Coefficient P Hệ số khoảng cách P +Distance units Đơn vị khoảng cách Distances along the surface, heights, areas and volumes. Khoảng cách theo bề mặt, độ cao, diện tích và thể tích. DISTINCT DISTINCT Distinct Phân biệt @@ -804,18 +867,23 @@ Drag to draw Kéo để vẽ Drag to draw a freehand line. Kéo để vẽ đường tự do. Drag to draw a freehand polygon. Kéo để vẽ vùng tự do. Drag to draw a rectangle. Kéo để vẽ hình chữ nhật. +Drag to resize the message box Kéo để đổi kích thước hộp tin nhắn Drag tools from the palette onto the canvas. Kéo các công cụ từ bảng công cụ vào khung vẽ. Drag vertices on the map; click an edge to add one Kéo các đỉnh trên bản đồ; nhấp vào một cạnh để thêm đỉnh Draw a halo around label text. Vẽ quầng sáng quanh văn bản nhãn. Draw a labelled latitude/longitude grid over the map frame. Vẽ lưới kinh vĩ độ có nhãn trên khung bản đồ. Draw a model: chain tools from input layers to the results to keep Vẽ một mô hình: nối chuỗi các công cụ từ lớp đầu vào tới các kết quả cần giữ Draw a model: chain tools from input layers to the results to keep. Vẽ một mô hình: nối chuỗi các công cụ từ lớp đầu vào tới các kết quả cần giữ. +Draw at its place in the stack, the bottom stroke first Vẽ theo vị trí trong ngăn xếp, nét dưới cùng trước +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet Vẽ các nét cùng một cấp của mọi đối tượng trước cấp tiếp theo, để viền ngoài nối liền bên dưới lõi nơi các đường gặp nhau Draw labels at every scale and distance. Vẽ nhãn ở mọi tỷ lệ và khoảng cách. Draw new features in the selected layer. Double-click to finish lines and polygons. Vẽ đối tượng mới trong lớp được chọn. Nhấp đúp để hoàn tất đường và vùng. Draw new single-band rasters with the inverted grey stretch again. Vẽ lại các raster một băng mới bằng kiểu kéo giãn xám đảo ngược. +Draw only polygon outlines, keeping their colors and widths. Chỉ vẽ viền vùng, giữ nguyên màu và độ rộng. Draw points as bins coloured by how many fall in each. Vẽ điểm thành các ô tô màu theo số điểm rơi vào mỗi ô. Draw polygons as outlines only. Chỉ vẽ đường viền của vùng. Draw single-band rasters added from now on with this color scheme, stretch and resampling. Vẽ các raster một băng được thêm từ nay với bảng màu, kiểu kéo giãn và lấy mẫu lại này. +Draw strokes in levels Vẽ nét theo cấp Draw the layer at every scale. Vẽ lớp ở mọi tỷ lệ. Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). Vẽ bản đồ trên GPU (beta; quả địa cầu, hiệu ứng khung nhìn và mô phỏng thị giác màu vẫn dùng trình kết xuất CPU). Draw the selected layer above others. Vẽ lớp được chọn phía trên các lớp khác. @@ -828,6 +896,7 @@ drawn by the server do máy chủ vẽ Drive Ô tô Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. Ảnh drone và ảnh hàng không được chia sẻ trên OpenAerialMap, mới nhất trước, thêm vào dưới dạng GeoTIFF tối ưu cho đám mây. Duplicate Nhân bản +Duplicate Connection Nhân bản kết nối Duplicate Element Nhân bản phần tử Duplicate Layout Nhân bản bố cục Duplicate Map Nhân bản bản đồ @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name vd. pop_est / 1000000 ho e.g. 0, 255, -9999 ví dụ 0, 255, -9999 e.g. continent = 'Europe' vd. continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' vd. pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 vd. pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs Mỗi lần chạy xử lý không gian trở thành một bước, nối với nhau qua đầu ra Earth Index has no points in the view Earth Index không có điểm nào trong khung nhìn Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices Sửa\nđỉnh Edit and run the notebook. Chỉnh sửa và chạy sổ tay. Edit Attribute Sửa thuộc tính Edit Cell Sửa ô +Edit Connection Chỉnh sửa kết nối Edit Description Sửa mô tả Edit Graphic Sửa đồ họa Edit Status Trạng thái chỉnh sửa @@ -882,6 +953,7 @@ Editor\nTools Công cụ\nbiên tập Editor Tools Công cụ biên tập Edits since the project was saved. Các chỉnh sửa từ lần lưu dự án gần nhất. Edits the project Chỉnh sửa dự án +Edit… Chỉnh sửa… Effects Hiệu ứng Element Phần tử Element Properties Thuộc tính phần tử @@ -898,21 +970,25 @@ Enable Lighting Bật chiếu sáng Enable Plugin Bật plugin Enabled Đã bật Encloses each feature, or all features, in its convex hull. Bao mỗi đối tượng, hoặc tất cả đối tượng, trong bao lồi của nó. +Encoded polyline Polyline mã hóa End Kết thúc End date Ngày kết thúc Endpoint Điểm cuối Enhancement Tăng cường Enter Enter Enter an API key. Hãy nhập khóa API. +Enter an EPSG code as a number, such as 32650 Nhập mã EPSG dưới dạng số, ví dụ 32650 Enter the provider's address. Hãy nhập địa chỉ của nhà cung cấp. Enter to send, Shift+Enter for a new line Enter để gửi, Shift+Enter để xuống dòng EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments Môi trường EPSG EPSG +EPSG code Mã EPSG EPSG or ESRI code Mã EPSG hoặc ESRI EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth Equal Interval Khoảng đều Equirectangular (Plate Carrée) Phép chiếu trụ đẳng cự (Plate Carrée) @@ -922,14 +998,18 @@ Error Lỗi Error Inspector Trình kiểm tra lỗi Error Inspector (validate) Trình kiểm tra lỗi (xác thực) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) Châu Âu đồng diện tích (ETRS89 LAEA) Every band's value at a pixel. Giá trị của mọi kênh tại một điểm ảnh. Every editing tool. Mọi công cụ chỉnh sửa. +Every label setting of this layer, in the Labeling pane. Mọi thiết lập nhãn của lớp này, trong ngăn Gán nhãn. Every layer, as FEMA draws them Mọi lớp, như FEMA vẽ chúng +Every run, in order, as a script that opens the project Mọi lần chạy, theo thứ tự, thành một script mở dự án every Socrata site, or one such as data.cityofnewyork.us mọi trang Socrata, hoặc một trang như data.cityofnewyork.us Everywhere Mọi nơi +Existing field Trường hiện có Existing Template Mẫu có sẵn exit rotary ra khỏi vòng xuyến exit roundabout ra khỏi vòng xuyến @@ -948,6 +1028,7 @@ Export\nRaster Xuất\nraster Export Animation Xuất hoạt ảnh Export Bookmarks Xuất dấu trang Export Bookmarks… Xuất dấu trang… +Export CSV… Xuất CSV… Export Features Xuất đối tượng Export Features (tool) Xuất đối tượng (công cụ) Export Features needs a vector layer (Data › Export Raster writes a raster) Xuất đối tượng cần một lớp vector (Dữ liệu › Xuất raster ghi ra raster) @@ -996,6 +1077,7 @@ Face Culling Loại bỏ mặt khuất Faces Mặt Factor Hệ số Failed Thất bại +FALSE FALSE Farthest Xa nhất Faster drawing: no antialiasing. Vẽ nhanh hơn: không khử răng cưa. Favourite Data Source Nguồn dữ liệu yêu thích @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries Ranh giới ruộng Field density Mật độ ruộng Field name Tên trường +Field Prefix Tiền tố trường +Field to Sum and Average Trường để tính tổng và trung bình Fields Trường Fields are drawn from zoom 9: zoom in to see them. Ruộng được vẽ từ mức thu phóng 9: hãy phóng to để thấy. Fields colored by score, drawn from zoom 9 Ruộng tô màu theo điểm số, vẽ từ mức thu phóng 9 @@ -1079,6 +1163,8 @@ Folder Connections Kết nối thư mục folder connections need the desktop app kết nối thư mục cần ứng dụng máy tính Folders Thư mục Folder… Thư mục… +Follow the time slider Theo thanh trượt thời gian +Font Phông chữ for a store that doesn't say cho kho không ghi rõ Forest Rừng Forget Earthdata Login Token Quên token Earthdata Login @@ -1090,9 +1176,11 @@ France Pháp Free the prepared geometry. Giải phóng hình học đã chuẩn bị. Freehand Vẽ tự do FROM FROM +From Từ From Selection Từ vùng chọn From Service… Từ dịch vụ… From symbol Từ ký hiệu +From the address Từ địa chỉ from the address từ địa chỉ from the service từ dịch vụ From URL… Từ URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. Ẩn các đối tượng ở xa tâm Hide Group Ẩn nhóm Hide labels when the viewer is closer than this. Ẩn nhãn khi người xem ở gần hơn giá trị này. Hide labels when the viewer is farther than this. Ẩn nhãn khi người xem ở xa hơn giá trị này. +Hide Layers Ẩn các lớp Hide other values Ẩn các giá trị khác Hide Steps Ẩn các bước Hide the description Ẩn mô tả @@ -1213,6 +1302,8 @@ Hold Giữ HOME HOME Home Trang chủ Home › Add Trang chủ › Thêm +Home › Add Data Trang chủ › Thêm dữ liệu +Home › Inspect › Measure Trang chủ › Kiểm tra › Đo Home › Navigate Trang chủ › Điều hướng Home › Navigate › Bookmarks Trang chủ › Điều hướng › Dấu trang Home › Web Data Trang chủ › Dữ liệu web @@ -1220,7 +1311,7 @@ Home › Web Data › United States Trang chủ › Dữ liệu web › Hoa Kỳ Homepage Trang chủ Horizontal Ngang host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=…, hoặc một tệp .duckdb +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery Ảnh lưu trữ trực tuyến Hour Giờ How the layer's colours combine with the tile layers and basemap beneath. Cách màu của lớp kết hợp với các lớp ô bản đồ và bản đồ nền bên dưới. @@ -1248,6 +1339,7 @@ Imagery Sharing Chia sẻ ảnh Imagery Tools Công cụ ảnh ImageServer ImageServer Image… Ảnh… +Imperial Hệ Anh Import Nhập Import\nProject Nhập\ndự án Import All Nhập tất cả @@ -1267,6 +1359,7 @@ Import Task Nhập nhiệm vụ Import Task… Nhập nhiệm vụ… Import Thumbnail Nhập ảnh thu nhỏ Import… Nhập… +IN IN In Beyond Khi phóng to hơn In memory Trong bộ nhớ In Progress Đang thực hiện @@ -1281,6 +1374,8 @@ Information Thông tin INPUT ĐẦU VÀO Input Đầu vào Input Features Đối tượng đầu vào +Input Layer Lớp đầu vào +Input Points Điểm đầu vào Input Polygons Vùng đầu vào Input Rows Hàng đầu vào Input Summary Features Đối tượng tổng hợp đầu vào @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} Plugin đã cài đặt được lưu tro Installed with Homebrew, winget, the AUR or COPR? Update it there. Đã cài bằng Homebrew, winget, AUR hoặc COPR? Hãy cập nhật ở đó. Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. Đã cài đặt {name} {version}. Bạn có thể tìm thấy plugin trên thẻ Plugin; các công cụ của nó cũng có trong ngăn Công cụ. Installing it downloads Python {version} and the packages it needs from the internet. Khi cài đặt sẽ tải Python {version} và các gói cần thiết từ internet. +Integer field: values are rounded. Trường số nguyên: giá trị được làm tròn. Interface font: {font} Phông giao diện: {font} Interpolate a surface from points (IDW) Nội suy bề mặt từ điểm (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Nội suy bề mặt raster 2 băng (Dự đoán và Phương sai) từ đối tượng điểm bằng phương pháp Kriging thông thường. Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. Nội suy bề mặt raster từ giá trị của đối tượng bằng phương pháp trọng số khoảng cách nghịch đảo, giống như QGIS. Interpolation Nội suy Interpolation Attribute Thuộc tính nội suy @@ -1316,10 +1413,13 @@ Invalid: {error} Không hợp lệ: {error} Invert Đảo ngược Invert color scheme Đảo ngược bảng màu Invert the selection. Đảo ngược vùng chọn. +IS IS IS NULL IS NULL it connects to an address on the internet nó kết nối tới một địa chỉ trên internet It has no code: nothing in it runs. Không có mã: không có gì trong đó được chạy. +It is locked, or one of its surrounds is: unlock it first Nó hoặc một phần tử bao quanh của nó đang bị khóa: hãy mở khóa trước it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) nó chỉ dành cho bạn (plugin, tập lệnh, tác vụ, mô hình và trình chạy GeoLibre) +it needs input: open it from its pane or button, or run it from a script or the assistant nó cần đầu vào: hãy mở nó từ ngăn hoặc nút của nó, hoặc chạy từ script hay trợ lý it reads or writes files on this computer nó đọc hoặc ghi tệp trên máy tính này it replaces the open project nó thay thế dự án đang mở it runs a plugin's command nó chạy một lệnh của plugin @@ -1332,20 +1432,30 @@ Italic Nghiêng Item Pop-ups Cửa sổ bật lên của mục Item {id} Mục {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. Tường lửa của nó ({firewall}) đã chặn RustGIS. Dịch vụ có thể chỉ cho phép một số ứng dụng: hãy liên hệ quản trị viên và gửi cho họ các chi tiết bên dưới. +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) Công cụ của nó hiện không khả dụng (của một plugin chưa tải, hoặc của một bộ công cụ chưa cài đặt) Jobs Tác vụ JOIN JOIN Join Kiểu nối +Join Features Đối tượng nối +Join Operation Thao tác nối +Joins attributes from a join layer to a target layer based on their spatial relationship. Nối thuộc tính từ lớp nối vào lớp đích dựa trên quan hệ không gian của chúng. +Journal figure, 1 column (90 mm) Hình tạp chí, 1 cột (90 mm) +Journal figure, 1.5 columns (140 mm) Hình tạp chí, 1,5 cột (140 mm) +Journal figure, 2 columns (190 mm) Hình tạp chí, 2 cột (190 mm) Keep a history of every edit to the layer, to look back or restore. Lưu lịch sử mọi chỉnh sửa của lớp để xem lại hoặc khôi phục. Keep an archive of edits Lưu trữ các chỉnh sửa keep current giữ nguyên +Keep it from being moved, resized, deleted or edited by mistake Ngăn nó bị di chuyển, đổi kích thước, xóa hoặc chỉnh sửa do nhầm lẫn Keep one value of each point's vector as its value field Giữ một giá trị trong vectơ của mỗi điểm làm trường giá trị của nó Keep Result Giữ kết quả Keep the labelled features fixed as the map moves. Giữ cố định các đối tượng đã gán nhãn khi bản đồ di chuyển. Keep the last drawing tool active after each graphic. Giữ công cụ vẽ cuối cùng hoạt động sau mỗi đồ họa. Keep the last tool active Giữ công cụ cuối cùng hoạt động +Keep this endpoint under Data Sources › Connections, without its password or keys Giữ điểm cuối này trong Nguồn dữ liệu › Kết nối, không kèm mật khẩu hay khóa Keep this result Giữ kết quả này Keep Tool Giữ công cụ Keep value Giữ giá trị +Key Key Keyboard Shortcuts Phím tắt Keyboard shortcuts. Phím tắt. Keys from Khóa lấy từ @@ -1356,13 +1466,20 @@ KMZ KMZ Label Nhãn Label Class Lớp nhãn Label Distance Range Phạm vi khoảng cách nhãn +Label features in this layer Gán nhãn đối tượng trong lớp này Label Field Trường nhãn +Label only the features that match. Chỉ gán nhãn các đối tượng khớp. Label Placement Vị trí đặt nhãn Label Range Phạm vi nhãn Label the cities with their names Gắn nhãn tên cho các thành phố Label Visibility Range Phạm vi hiển thị nhãn Labeling Gán nhãn +Labeling Pane Ngăn Gán nhãn +Labeling Properties… Thuộc tính gán nhãn… Labels Nhãn +labels are drawn for feature layers, not rasters nhãn được vẽ cho lớp đối tượng, không cho raster +Labels are drawn for feature layers, not rasters. Nhãn được vẽ cho lớp đối tượng, không cho raster. +Labels are off for this layer: the clause applies once they are on. Nhãn của lớp này đang tắt: mệnh đề sẽ áp dụng khi nhãn được bật. Lagoon Đầm phá Lambert Conformal Conic Hình nón đồng góc Lambert Lambert Cylindrical Equal Area Phép chiếu trụ đồng diện tích Lambert @@ -1383,12 +1500,14 @@ Layer Blend Hòa trộn lớp Layer Extrusion Đùn khối lớp Layer File Tệp lớp Layer From\nSelection Lớp từ\nvùng chọn +Layer not found Không tìm thấy lớp Layer Template Mẫu lớp Layer Templates Mẫu lớp layer {id} lớp {id} Layers Các lớp Layers ({n}) Các lớp ({n}) Layers and Table Views Lớp và khung nhìn bảng +Layers, a date each Các lớp, mỗi lớp một ngày Layout Bố cục Layout\nFile Tệp\nbố cục Layout after: {name} Bố cục sau: {name} @@ -1402,7 +1521,7 @@ Learning\nResources Tài nguyên\nhọc tập Left Trái Legend Chú giải Legend label. Clear it to show the value again. Nhãn chú giải. Xóa nhãn để hiện lại giá trị. -Length {length} · gain {gain} m · loss {loss} m Chiều dài {length} · tăng {gain} m · giảm {loss} m +Length {length} · gain {gain} · loss {loss} Chiều dài {length} · lên {gain} · xuống {loss} Let the assistant hand tasks to agents on this computer Cho phép trợ lý giao việc cho các tác tử trên máy tính này Let your Agents Control RustGIS Cho phép tác tử của bạn điều khiển RustGIS Letter spacing Khoảng cách chữ @@ -1439,6 +1558,7 @@ List by drawing order Liệt kê theo thứ tự vẽ List by selection Liệt kê theo vùng chọn List Columns in File Liệt kê cột trong tệp List Commands Liệt kê lệnh +List Connections Liệt kê kết nối List Data Sources Liệt kê nguồn dữ liệu List Database Columns Liệt kê cột cơ sở dữ liệu List Database Tables Liệt kê bảng cơ sở dữ liệu @@ -1475,6 +1595,11 @@ Locate… Tìm… Location Vị trí Locator Bộ định vị Lock Khóa +Locked Đã khóa +Locked: unlock it in the Format pane to move or change it Đã khóa: hãy mở khóa trong khung Định dạng để di chuyển hoặc thay đổi +Log In to Connection Đăng nhập kết nối +Log In to {name}… Đăng nhập {name}… +Log In… Đăng nhập… Long Số nguyên dài Longitude Kinh độ Longitude (X) Kinh độ (X) @@ -1493,6 +1618,7 @@ Manage Quản lý Manage Bookmarks Quản lý dấu trang Manage Edits Quản lý chỉnh sửa Manage Templates Quản lý mẫu +Mann–Kendall Mann–Kendall Map Bản đồ Map\nFrame Khung\nbản đồ Map (the current view) Bản đồ (khung nhìn hiện tại) @@ -1504,6 +1630,7 @@ Map File Tệp bản đồ Map Frame Khung bản đồ Map Frames Khung bản đồ Map frames per second (live while the map moves) Số khung hình bản đồ mỗi giây (trực tiếp khi bản đồ di chuyển) +Map Labeling Gán nhãn bản đồ Map Link Liên kết bản đồ Map name Tên bản đồ Map Page Trang bản đồ @@ -1515,6 +1642,7 @@ Map style (JSON) Kiểu bản đồ (JSON) Map Surrounds Thành phần quanh bản đồ Map Thumbnail Ảnh thu nhỏ bản đồ Map tiles of the whole world Ô bản đồ của toàn thế giới +Map Tool: {tool} Công cụ bản đồ: {tool} Map View Khung nhìn bản đồ map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} Bản đồ: {kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker Điểm đánh dấu Mask Values Che giá trị Masking Che phủ +Match Option Tùy chọn khớp Matching places Địa điểm phù hợp Max Lớn nhất Max % % lớn nhất +Max Neighbors Số láng giềng tối đa Maximum zoom Mức thu phóng tối đa MCP Relay Chuyển tiếp MCP MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} Chuyển tiếp MCP đang nghe tại 127.0.0.1:{port}; hãy dừng trước để dùng cổng {asked} @@ -1542,6 +1672,8 @@ Measure Distance Đo khoảng cách Measure geodesic distances and areas on the map. Đo khoảng cách và diện tích trắc địa trên bản đồ. Measure on this body, with one of its basemaps. Đo trên thiên thể này, với một trong các bản đồ nền của nó. Measure with an elevation raster Đo bằng raster độ cao +Measurement units Đơn vị đo +Measurement units must be metric or imperial Đơn vị đo phải là hệ mét hoặc hệ Anh Measurements so far. Các số đo cho đến nay. Mensuration Trắc lượng Mensuration Tools Công cụ trắc lượng @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. Gộp các Messages appear here. Tin nhắn hiển thị ở đây. Meters Mét Method Phương pháp +Metric Hệ mét Midnight Nửa đêm Miles Dặm Miller Cylindrical Phép chiếu trụ Miller @@ -1560,21 +1693,43 @@ Min must be less than Max Nhỏ nhất phải nhỏ hơn Lớn nhất Min must not be more than Max Nhỏ nhất không được lớn hơn Lớn nhất Minimum Bounding Geometry Hình học bao nhỏ nhất Minimum Maximum Nhỏ nhất lớn nhất +Minus Minus Minutes Phút minutes ago phút trước Miter Nhọn Mobile Map Bản đồ di động Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model Mô hình Model\nBuilder Trình dựng\nmô hình model id mã mô hình Model output Đầu ra mô hình ModelBuilder ModelBuilder Models Mô hình +Modified Mann–Kendall (Hamed & Rao) Mann–Kendall hiệu chỉnh (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) Mann–Kendall hiệu chỉnh (Yue & Wang) Modify Sửa đổi Modify Selected Features Sửa đổi đối tượng được chọn Mollweide (equal-area world) Mollweide (thế giới đồng diện tích) Month Tháng +Months Tháng +Months are numbers from 1 to 12 (12, 1, 2 for December to February) Tháng là số từ 1 đến 12 (12, 1, 2 cho tháng Mười Hai đến tháng Hai) More Thêm nữa More basemaps: Thêm bản đồ nền: Mosaic images into an orthomosaic Khảm ảnh thành ảnh trực giao @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. Khảm các raster đang hiển Most cloud cover (%) Độ che phủ mây tối đa (%) Mouse wheel Con lăn chuột Move Di chuyển -Move (m) Di chuyển (m) Move Bookmark Di chuyển dấu trang Move Down Chuyển xuống Move down Chuyển xuống Move Group Di chuyển nhóm +Move Label Class Di chuyển lớp nhãn Move Task Step Di chuyển bước nhiệm vụ +Move To Bottom Chuyển xuống dưới cùng Move To Group Chuyển vào nhóm +Move To Top Chuyển lên trên cùng Move To {group} Chuyển vào {group} Move Up Chuyển lên Move up Chuyển lên @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets Bucket của tôi My OGC Layer Lớp OGC của tôi +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name Tên name the plugin whose panel to open: {"id": plugin id} nêu plugin cần mở bảng điều khiển: {"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook Sổ tay\nmới New\nPresentation Bản trình bày\nmới New\nReport Báo cáo\nmới New\nWorkspace Không gian\nlàm việc mới -New / Open / Save project Mới / Mở / Lưu dự án New Bookmark Dấu trang mới New Bookmark… Dấu trang mới… +New Connection Kết nối mới +New Connection ▾ Kết nối mới ▾ New conversation Cuộc trò chuyện mới New Custom Template Mẫu tùy chỉnh mới New Custom Template… Mẫu tùy chỉnh mới… New features use this template's values Đối tượng mới dùng giá trị của mẫu này +New field Trường mới New Group Nhóm mới New job name Tên tác vụ mới New Layout Bố cục mới @@ -1649,6 +1809,7 @@ New Notebook Sổ tay mới New Presentation Bản trình bày mới New Project Dự án mới New project Dự án mới +New Publication Layout Bố cục xuất bản mới New Relationship… Quan hệ mới… New Report Báo cáo mới New Selection Vùng chọn mới @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. Chưa có dấ No catalogs below this one: Find Items lists its items Không có danh mục nào bên dưới danh mục này: Tìm mục sẽ liệt kê các mục của nó No COMID was returned for this point. Không có COMID nào được trả về cho điểm này. no command không có lệnh -No connections this session Không có kết nối trong phiên này +No data at the points picked. Không có dữ liệu tại các điểm đã chọn. No datasets match. Không có bộ dữ liệu nào khớp. No datasets with data RustGIS can add Không có bộ dữ liệu nào chứa dữ liệu RustGIS có thể thêm +no date in the name không có ngày trong tên No edits since the project was saved Không có chỉnh sửa nào từ lần lưu dự án gần nhất No field of this layer holds dates. Không có trường nào của lớp này chứa ngày. +No Fill Không tô +No geometry to calculate Không có hình học để tính No granules in this view and date range. Không có granule nào trong khung nhìn và khoảng ngày này. no home folder không có thư mục home no layer không có lớp @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) Không có quan hệ (Thiết k No relationships yet Chưa có quan hệ nào No reports yet. Publish › New Report lists the selected layer's records. Chưa có báo cáo nào. Xuất bản › Báo cáo mới liệt kê các bản ghi của lớp đã chọn. no row không có hàng +No serif font is installed, so this text is drawn in the sans-serif Chưa cài phông chữ có chân, nên văn bản này được vẽ bằng phông không chân no service or file links không có liên kết dịch vụ hay tệp No services match your search. Không có dịch vụ nào khớp với tìm kiếm của bạn. No standalone tables Không có bảng độc lập @@ -1718,14 +1883,16 @@ Nominatim Nominatim None Không có None (public buckets) Không (bucket công khai) none, a .duckdb file, or host=… dbname=… user=… không có, một tệp .duckdb, hoặc host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD không có, hoặc lấy từ PGPASSWORD hay MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD không có, hoặc lấy từ PGPASSWORD, MYSQL_PWD hoặc SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. Không có. Thêm bằng địa chỉ, hoặc từ một plugin bản đồ nền. North Bắc North\nArrow Mũi tên\nchỉ bắc North Polar Stereographic Phép chiếu nổi cực Bắc +NOT NOT not a picture không phải ảnh not a RustGIS layout không phải bố cục RustGIS not a RustGIS project không phải dự án RustGIS +Not applied yet. Chưa được áp dụng. Not for texts: a text is edited in the Format pane. Không dành cho văn bản: văn bản được sửa trong khung Định dạng. not needed không cần Not sandboxed Không có hộp cát @@ -1739,9 +1906,12 @@ Nothing found where the map is Không tìm thấy gì tại vị trí bản đ Nothing here Không có gì ở đây Nothing named {name} to rename Không có mục nào tên {name} để đổi tên Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. Chưa chia sẻ gì. Xuất bản › Chia sẻ thành tạo bản đồ web, lớp, kiểu, cảnh và nhiều hơn nữa. +Nothing to add here Không có gì để thêm ở đây +Nothing to test Không có gì để kiểm tra Nothing was found at this address. Không tìm thấy gì tại địa chỉ này. Nothing was recorded, so no task was made. Chưa ghi được gì nên không có nhiệm vụ nào được tạo. Now Bây giờ +Nugget Hiệu ứng nugget NULL NULL Nulls Giá trị rỗng Observations (observer, date, species, count) Quan sát (người quan sát, ngày, loài, số lượng) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. Trên máy tính, khóa cũng On-screen compass, zoom slider and pan pad. La bàn trên màn hình, thanh trượt thu phóng và bảng di chuyển. One command per line: id {json}. Lines starting with # are comments. Mỗi dòng một lệnh: id {json}. Các dòng bắt đầu bằng # là chú thích. One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. Một câu SELECT DuckDB: các hàm ST_ của DuckDB Spatial, tệp và địa chỉ web (read_parquet('…')), cùng các bảng của cơ sở dữ liệu bên dưới. Ctrl+Enter để chạy. +One layer: a Zarr variable or a raster's bands Một lớp: một biến Zarr hoặc các băng của một raster One page per feature of an index layer. Mỗi đối tượng của lớp chỉ mục là một trang. One per line: code=name Mỗi dòng một mục: mã=tên One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. Một nét, từ ký hiệu. Thêm nét để tạo viền bọc, đường song song và độ lệch. @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder Mở thư mục Open In Geoprocessing… Mở trong Xử lý không gian… Open in ModelBuilder Mở trong ModelBuilder +Open in Tool Mở trong công cụ Open Layout Mở bố cục Open ModelBuilder Mở ModelBuilder Open Notebook Mở sổ tay @@ -1797,6 +1969,7 @@ Open Presentation Mở bản trình bày Open Project Mở dự án Open project Mở dự án Open Project (from text) Mở dự án (từ văn bản) +Open Project… Mở dự án… Open Report Mở báo cáo Open the attribute table of the selected layer. Mở bảng thuộc tính của lớp được chọn. Open the Basemaps tab Mở thẻ Bản đồ nền @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: Hoặc chạy trong shell POS Or run in PowerShell, for Claude Code or Codex: Hoặc chạy trong PowerShell, cho Claude Code hoặc Codex: Or run, for Claude Code or Codex: Hoặc chạy, cho Claude Code hoặc Codex: ORDER BY ORDER BY +Ordinary Kriging Kriging thông thường Orientation Hướng Ortho Mapping Lập bản đồ trực giao Other Khác Other Data Dữ liệu khác +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. Các lớp nhãn khác trên lớp này (tạo bằng lệnh): {n}. Mỗi lớp nhãn gán nhãn cho các đối tượng khớp với mệnh đề riêng của nó, nên một đối tượng có thể nhận nhãn từ nhiều lớp nhãn. Out Beyond Khi thu nhỏ hơn Outline Viền Outline another frame's extent in this map frame. Vẽ đường bao phạm vi của một khung khác trong khung bản đồ này. @@ -1851,6 +2026,7 @@ Output Đầu ra Output Name Tên đầu ra Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. Hơn 1.000 công cụ raster, vector, LiDAR, thủy văn và địa hình chạy trong trình duyệt, chỉ cần tải một lần khoảng 25 MB. Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. Hơn 1.000 công cụ raster, vector, LiDAR, thủy văn và địa hình, qua một bản tải tùy chọn khoảng 20 MB. +Overlapping Chồng lấn Overlay Chồng lớp overlay chồng lớp Overlay (a transparent layer kept on top, such as labels or roads) Chồng lớp (một lớp trong suốt luôn ở trên cùng, như nhãn hoặc đường) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture không có dữ liệu {ty} Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Dữ liệu mở của Overture Maps: tòa nhà, địa điểm, giao thông, đơn vị hành chính, lớp nền và địa chỉ. Trong vùng bản đồ, các đối tượng được đọc tại chỗ từ tệp GeoParquet của Overture (phóng to tới cấp thành phố hoặc nhỏ hơn); toàn thế giới được cung cấp dưới dạng ô bản đồ. Overture Maps… Overture Maps… +Overwrite existing values Ghi đè giá trị hiện có Package Gói Package a trained deep learning model. Đóng gói một mô hình học sâu đã huấn luyện. Package Map Đóng gói bản đồ @@ -1907,8 +2084,12 @@ Performance Hiệu năng Phase Giai đoạn Photon Photon Pick a live value, such as the scale or date, then click the map to place it. Chọn một giá trị trực tiếp, như tỷ lệ hoặc ngày, rồi nhấp vào bản đồ để đặt nó. +Pick a Point Chọn một điểm +Pick a point on the map to see the variable's values through time there. Chọn một điểm trên bản đồ để xem giá trị theo thời gian của biến tại đó. Pick a route in the list, or click a grey route on the map. Chọn một tuyến đường trong danh sách, hoặc nhấp vào một tuyến màu xám trên bản đồ. +Pick a single layer. Hãy chọn một lớp duy nhất. Pick it on the map Chọn trên bản đồ +Pick one… Chọn một… Picture Ảnh Pictures Ảnh Pictures, attachments and chart Ảnh, tệp đính kèm và biểu đồ @@ -1966,8 +2147,11 @@ Portal Cổng thông tin Portolan Portolan Portolan… Portolan… Portrait Dọc +Position Vị trí +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing Hậu xử lý power 2 lũy thừa 2 +Precision 6 (OSRM, Valhalla) Độ chính xác 6 (OSRM, Valhalla) Prepare Chuẩn bị Prepare every layer's geometry for this projection. Chuẩn bị hình học của mọi lớp cho phép chiếu này. Prepare Plugin Environment Chuẩn bị môi trường plugin @@ -2009,20 +2193,24 @@ Project templates: start projects from a saved setup. Mẫu dự án: bắt đ project's path is required cần có path của project Projection Phép chiếu Projections Các phép chiếu +Properties Thuộc tính +Property Thuộc tính Proportional Symbols Ký hiệu tỷ lệ -Provinces: Tỉnh thành: Provider Nhà cung cấp +Provinces: Tỉnh thành: Proximity Lân cận Public GIS portals of US counties and cities, by state. Các cổng GIS công khai của các quận và thành phố Hoa Kỳ, theo bang. Public GIS portals of US federal agencies, by department. Các cổng GIS công khai của các cơ quan liên bang Hoa Kỳ, theo bộ. Public GIS portals of US states and the District of Columbia. Các cổng GIS công khai của các bang Hoa Kỳ và Đặc khu Columbia. Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. Các lớp công khai trên ArcGIS Online hoặc cổng ArcGIS khác: dịch vụ đối tượng, bản đồ, ảnh và ô vectơ, bản đồ web và tệp GeoJSON. +Publication Layout Bố cục xuất bản Publish Xuất bản Publish › Output Xuất bản › Đầu ra Put the layer back as it was then Đưa lớp trở lại như lúc đó Python environment failed: {error} Môi trường Python thất bại: {error} Python environment not prepared Môi trường Python chưa được chuẩn bị Python environment ready Môi trường Python đã sẵn sàng +Python script Script Python Qualitative Định tính Quantile Phân vị Query files, web addresses and databases with DuckDB Spatial; add results to the map. Truy vấn tệp, địa chỉ web và cơ sở dữ liệu bằng DuckDB Spatial; thêm kết quả vào bản đồ. @@ -2037,6 +2225,8 @@ Raise polygons by a height or a field. Nâng các vùng theo một chiều cao h Raise Priority Tăng ưu tiên Random points in polygons Điểm ngẫu nhiên trong vùng Range Khoảng +Range (Degrees) Tầm ảnh hưởng (độ) +range: 0, 0.9 khoảng: 0, 0.9 Raster Raster Raster\nAnalysis Phân tích\nraster Raster\nInformation Thông tin\nraster @@ -2076,6 +2266,7 @@ Redo {action} Làm lại {action} Red–Blue Đỏ–Xanh lam Refine the search to see {n} more Thu hẹp tìm kiếm để xem thêm {n} Refresh Làm mới +Refresh every connection's lists Làm mới danh sách của mọi kết nối Refresh Map Series Làm mới chuỗi bản đồ Refresh the map series from its index layer. Làm mới chuỗi bản đồ từ lớp chỉ mục của nó. Region Khu vực @@ -2092,11 +2283,10 @@ Relationships Quan hệ Release Bản phát hành Reload Tải lại Reload Web Layers Tải lại lớp web -Remember (without the secret) Ghi nhớ (không kèm khóa bí mật) Remember the key on this computer Ghi nhớ khóa trên máy tính này -Remembered: Đã ghi nhớ: Remove Gỡ bỏ Remove All Gỡ tất cả +Remove All Label Classes Gỡ tất cả lớp nhãn Remove an offline area. Gỡ một khu vực ngoại tuyến. Remove Bookmark Gỡ dấu trang Remove Connection Xóa kết nối @@ -2109,11 +2299,14 @@ Remove from Favourites Gỡ khỏi mục yêu thích Remove From Group Gỡ khỏi nhóm Remove Group Gỡ nhóm Remove Keyframe Gỡ khung hình chính +Remove Label Class Gỡ lớp nhãn Remove Section Gỡ phần Remove Table Gỡ bảng +Remove the clause. Gỡ mệnh đề. Remove the last keyframe. Gỡ khung hình chính cuối cùng. Remove the layer from the map. Gỡ lớp khỏi bản đồ. Remove the strokes and draw the symbol's line alone Xóa các nét và chỉ vẽ đường của ký hiệu +Remove this class's own strokes and draw it with the layer's Gỡ các nét riêng của lớp này và vẽ nó bằng nét của lớp bản đồ Remove this stop Gỡ điểm dừng này Remove Tile Layer Gỡ lớp ô bản đồ Remove token Xóa token @@ -2123,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). Loại Rename Đổi tên Rename Bookmark Đổi tên dấu trang Rename Group Đổi tên nhóm +Rename it to save it: a connection has this name Hãy đổi tên để lưu: đã có kết nối mang tên này +Rename Label Class Đổi tên lớp nhãn Rename Layer Đổi tên lớp Rename Layout Đổi tên bố cục Rename Map Đổi tên bản đồ @@ -2150,6 +2345,7 @@ Reset Default Đặt lại mặc định Reset Effects Đặt lại hiệu ứng Reset North Đặt lại hướng bắc Reset north Đặt lại hướng bắc +Resolution (0–30) Độ phân giải (0–30) Response Phản hồi Restore Khôi phục Restore From Archive Khôi phục từ lưu trữ @@ -2163,6 +2359,7 @@ RGB RGB Ribbon Ribbon Right Phải Right-click a source to add it here Nhấp chuột phải vào một nguồn để thêm vào đây +Right-click for more Nhấp chuột phải để xem thêm Robinson Robinson Rotate Xoay Rotate (°) Xoay (°) @@ -2230,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) Dữ liệu mẫu (Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. Dữ liệu mẫu © Natural Earth (phạm vi công cộng). RustGIS được cấp phép theo Apache-2.0. Sample data: Natural Earth (public domain). Licensed under Apache-2.0. Dữ liệu mẫu: Natural Earth (phạm vi công cộng). Cấp phép theo Apache-2.0. +Sans-serif Không chân Satellite Embedding Datasets Các bộ dữ liệu embedding vệ tinh Satellite Embeddings Embedding vệ tinh Satellite Embeddings… Embedding vệ tinh… Saturation Độ bão hòa Save Lưu Save As Lưu thành +Save as Connection Lưu thành kết nối Save as Default Lưu làm mặc định Save as Default Raster Style Lưu làm kiểu raster mặc định Save as Layer Lưu thành lớp Save As Presentation File… Lưu thành tệp bản trình bày… +Save as Python Script… Lưu thành script Python… Save As Report File… Lưu thành tệp báo cáo… Save As Report Template… Lưu thành mẫu báo cáo… Save as {name} Lưu thành {name} Save As… Lưu thành… Save Color Ramps Lưu dải màu Save Data Source Lưu nguồn dữ liệu +Save History As Python Script Lưu lịch sử thành script Python Save Layer File Lưu tệp lớp Save Layout File Lưu tệp bố cục Save Map File Lưu tệp bản đồ @@ -2255,6 +2456,7 @@ save or load a pane set by name lưu hoặc tải bộ ngăn theo tên Save Project Lưu dự án Save project Lưu dự án Save Project As Lưu dự án thành +Save Project As… Lưu dự án thành… Save Project Template Lưu mẫu dự án Save Style Items Lưu mục kiểu Save Task Item Lưu mục nhiệm vụ @@ -2265,6 +2467,7 @@ Save the model as a file (*.model.json) Lưu mô hình thành tệp (*.model.jso Save the model in the project Lưu mô hình trong dự án Save the open layout as a layout file. Lưu bố cục đang mở thành tệp bố cục. Save the panes you have open, and switch back to them. Lưu các ngăn đang mở và chuyển lại chúng sau. +Save the project too: the script opens its saved file, without changes made since. Hãy lưu cả dự án: script mở tệp đã lưu của nó, không gồm các thay đổi sau đó. Save the project under a new name. Lưu dự án với tên mới. Save the project with your edits. Lưu dự án cùng các chỉnh sửa của bạn. Save the project's custom color ramps to a color ramp file. Lưu các dải màu tùy chỉnh của dự án vào một tệp dải màu. @@ -2276,13 +2479,16 @@ Save Web Link Chart Lưu biểu đồ liên kết web Save Web Map Lưu bản đồ web Save Web Scene Lưu cảnh web Saved Đã lưu +Saved as the connection {name} Đã lưu thành kết nối {name} Saved as the default style for new single-band rasters. Đã lưu làm kiểu mặc định cho các raster một băng mới. +Saved connection Kết nối đã lưu Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). Khóa đã lưu được giữ không mã hóa trong tùy chọn của ứng dụng (trong bộ nhớ của trình duyệt trên web). Saved models Mô hình đã lưu Saved symbology: apply it to the selected layer, or save the layer's. Ký hiệu đã lưu: áp dụng cho lớp được chọn, hoặc lưu ký hiệu của lớp. Saved View Khung nhìn đã lưu Saved {name} Đã lưu {name} Saved {name}. Đã lưu {name}. +Saved {path} Đã lưu {path} saving failed unexpectedly lưu thất bại ngoài dự kiến Scale Tỷ lệ Scale\nBar Thước\ntỷ lệ @@ -2305,7 +2511,7 @@ Search (this portal needs words)… Tìm (cổng này cần từ khóa)… Search Catalogue Tìm trong danh mục Search commands — e.g. buffer, basemap, labels Tìm lệnh — vd. buffer, basemap, labels Search datasets, e.g. GEDI biomass Tìm bộ dữ liệu, ví dụ GEDI biomass -Search Distance (km) Khoảng cách tìm kiếm (km) +Search Distance Khoảng cách tìm kiếm Search every command and its parameters Tìm mọi lệnh và tham số của chúng Search layers (e.g. temperature) Tìm lớp (ví dụ temperature) Search layers, places, lat/lon… Tìm lớp, địa điểm, vĩ độ/kinh độ… @@ -2330,6 +2536,8 @@ Searching the folders… Đang tìm trong các thư mục… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. Tìm kiếm hoạt động ở đây, nhưng máy chủ dữ liệu của NASA không cho phép trang web đọc tệp của chúng (không có CORS), nên GeoTIFF và việc tải xuống dữ liệu được bảo vệ cần ứng dụng máy tính. Tải xuống sẽ mở tệp trong tab mới, nơi Earthdata Login yêu cầu bạn đăng nhập. Searching… Đang tìm… Searching… try again in a moment. Đang tìm… hãy thử lại sau giây lát. +Seasonal Mann–Kendall Mann–Kendall theo mùa +Seasons a year Số mùa mỗi năm Second standard parallel Vĩ tuyến chuẩn thứ hai Seconds Giây Secret access key Khóa truy cập bí mật @@ -2390,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator Dấu phân cách Sepia Nâu đỏ Sequential Tuần tự +Serif Có chân Server Máy chủ Service Group Nhóm dịch vụ Service group data exceeds 64 MB. Dữ liệu nhóm dịch vụ vượt quá 64 MB. Service Import Requests Yêu cầu nhập dịch vụ Service Layers Lớp dịch vụ -Services Dịch vụ +Session token Token phiên Set Đặt Set Cell Size Đặt kích thước ô Set Domain Đặt miền giá trị @@ -2412,6 +2621,7 @@ Shaded Tối Shaded Relief Địa hình bóng đổ Shape Hình dạng Shapefile Shapefile +Shapes Hình Share Chia sẻ Share As Chia sẻ thành Share every layer and table as OGC API Features collections. Chia sẻ mọi lớp và bảng dưới dạng tập hợp OGC API Features. @@ -2440,7 +2650,9 @@ Show details when hovering items in the Browser view. Hiển thị chi tiết kh Show different features at different scales. Hiển thị các đối tượng khác nhau ở các tỷ lệ khác nhau. Show grid Hiện lưới Show Group Hiện nhóm +Show labels Hiện nhãn Show latitude/longitude lines on the basemap. Hiển thị các đường kinh độ/vĩ độ trên bản đồ nền. +Show Layers Hiện các lớp Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. Hiển thị các lớp theo thời gian: chỉ các đối tượng có ngày nằm trong cửa sổ của thanh trượt, và ảnh có ngày theo ngày của thanh trượt. Show Map Hiển thị bản đồ Show Me Chỉ cho tôi @@ -2456,6 +2668,7 @@ Show or hide the Tools pane. Hiện hoặc ẩn ngăn Công cụ. Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Hiện thước. Kéo từ thước để thêm đường gióng; kéo đường gióng trở lại thước để gỡ nó. Show the bands picked, read in place as the map needs them Hiển thị các kênh đã chọn, đọc tại chỗ khi bản đồ cần Show the description Hiện mô tả +Show the Labeling pane for the selected layer. Hiện ngăn Gán nhãn cho lớp được chọn. Show the labels that don't fit, in red. Hiển thị các nhãn không vừa bằng màu đỏ. Show the layer only inside another layer's polygons. Chỉ hiển thị lớp bên trong các vùng của một lớp khác. Show the layer only left of a divider, to compare it with what's beneath. Chỉ hiển thị lớp bên trái đường phân chia, để so sánh với những gì bên dưới. @@ -2492,8 +2705,10 @@ Sign-in cancelled. Đã hủy đăng nhập. Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. Đã hết thời gian đăng nhập. Hãy bắt đầu lại và cho phép trình duyệt truy cập địa chỉ loopback của máy tính này. signed in đã đăng nhập Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. Đã đăng xuất trên máy tính này, nhưng OpenAI chưa xác nhận. Bạn có thể ngắt kết nối RustGIS trong cài đặt ChatGPT. +Significance level Mức ý nghĩa Signing in with ChatGPT works in the desktop app. Đăng nhập bằng ChatGPT hoạt động trong ứng dụng máy tính. Signing the asset (Planetary Computer)… Đang ký tài nguyên (Planetary Computer)… +Sill Ngưỡng (sill) Simplify Đơn giản hóa Simulate the drive, or save the route as a layer. Mô phỏng chuyến đi, hoặc lưu tuyến đường thành một lớp. Simulation Mô phỏng @@ -2507,6 +2722,7 @@ Skills for the AI Assistant: {names} Kỹ năng cho Trợ lý AI: {names} Slideshow, PDF or PNG Trình chiếu, PDF hoặc PNG slight left chếch sang trái slight right chếch sang phải +Slope per Độ dốc mỗi Slope, aspect, hillshade, NDVI, band math, thresholds. Độ dốc, hướng dốc, bóng địa hình, NDVI, đại số kênh, ngưỡng. Slope, hillshade, NDVI and more. Độ dốc, bóng địa hình, NDVI và hơn thế. Small scales Tỷ lệ nhỏ @@ -2522,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South Nam South Polar Stereographic Phép chiếu nổi cực Nam +Spatial Join Nối không gian Speaker notes Ghi chú người trình bày Spectral Analysis Phân tích phổ Spectral indices Chỉ số phổ @@ -2531,6 +2748,7 @@ Split (click the line) Chia tách (nhấp vào đường) SQL SQL SQL Pane Ngăn SQL SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. Truy vấn SQL chạy qua DuckDB, một chương trình mà ứng dụng máy tính gọi: hãy mở dự án này trong ứng dụng máy tính để truy vấn. +SQL Server SQL Server SQL that must be true, e.g. pop > 0 SQL phải đúng, ví dụ pop > 0 Square Hình vuông Squares Hình vuông @@ -2589,6 +2807,7 @@ Start over at the end while playing. Bắt đầu lại khi tới cuối trong l Start With Khởi động với Start-up Khởi động starting with a project file needs the desktop app khởi động với một tệp dự án cần ứng dụng máy tính +Statistic Thống kê Statistics Thống kê Statistics Field Trường thống kê Status Trạng thái @@ -2614,8 +2833,10 @@ Stretch\nType Kiểu\nkéo giãn Stretch it over the map view, or give control points (one per line: column row lon lat). Kéo giãn nó trên khung nhìn bản đồ, hoặc nhập các điểm khống chế (mỗi dòng một điểm: cột hàng kinh độ vĩ độ). Stretch Type Kiểu kéo giãn Stretch type Kiểu kéo giãn +Stroke Levels Cấp nét Stroke {n} Nét {n} Strokes Các nét +Strokes for Nét cho Style Kiểu Style Items Mục kiểu Style › Drawing Kiểu › Vẽ @@ -2625,7 +2846,10 @@ Subreport: {name} Báo cáo con: {name} Subtypes Kiểu con Subtypes, one per line: code=name Kiểu con, mỗi dòng một kiểu: mã=tên Suitability Modeler Trình mô hình hóa mức độ phù hợp +Summarise Tổng hợp +Summarise over time Tổng hợp theo thời gian Summarise the conversation so far, so a long chat stays within the model's context Tóm tắt cuộc trò chuyện đến giờ để cuộc trò chuyện dài vẫn nằm trong ngữ cảnh của mô hình +Summarise Zarr Over Time Tổng hợp Zarr theo thời gian Summarising the earlier conversation to keep it within the model's context… Đang tóm tắt phần trò chuyện trước để vừa ngữ cảnh của mô hình… Summarize Tổng hợp Summarize\nWithin Tổng hợp\ntrong vùng @@ -2659,6 +2883,8 @@ Table or layer Bảng hoặc lớp Tables Bảng Tags Thẻ Take the data in view offline, to edit and sync back later. Lấy dữ liệu trong khung nhìn để dùng ngoại tuyến, chỉnh sửa và đồng bộ lại sau. +Target Features Đối tượng đích +Target Records Bản ghi đích Task Nhiệm vụ Task Item Mục nhiệm vụ Task name Tên nhiệm vụ @@ -2671,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Dữ liệu GIS công khai của Tennessee (Tennessee GeoData, geodata.tn.gov): ranh giới thửa đất, giao thông, y tế, thủy văn và hơn thế. Terrain Địa hình Test Test +Test Connection Kiểm tra kết nối +Testing… Đang kiểm tra… Text Văn bản Text Symbol Ký hiệu văn bản That connection would create a loop. Kết nối đó sẽ tạo thành một vòng lặp. The address fields are empty in every row. Các trường địa chỉ đều trống ở mọi hàng. +The answer isn't UTF-8 text Phản hồi không phải văn bản UTF-8 the answer isn't UTF-8 text phản hồi không phải văn bản UTF-8 The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. Trợ lý làm theo hướng dẫn của một kỹ năng khi dùng nó. Dù hướng dẫn nói gì, các lệnh cần bạn chấp thuận vẫn hỏi bạn. The assistant is busy; stop it or wait for it to finish. Trợ lý đang bận; hãy dừng nó hoặc đợi nó hoàn tất. @@ -2683,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. Thẻ bản đồ nền công khai the capture failed unexpectedly chụp thất bại ngoài dự kiến The connection failed; trying again in {seconds} s. Kết nối thất bại; thử lại sau {seconds} giây. The country list couldn't be read ({e}): type a country's code Không đọc được danh sách quốc gia ({e}): hãy nhập mã quốc gia +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. Độ cao hiện tại vẫn có hiệu lực trong khi bạn sửa đầu vào. Nhấp Áp dụng độ cao để áp dụng giá trị và đơn vị. The dataset has nothing RustGIS can add Bộ dữ liệu không có gì RustGIS có thể thêm The environment (AWS_ACCESS_KEY_ID…) Môi trường (AWS_ACCESS_KEY_ID…) The features where the map is Các đối tượng trong vùng bản đồ @@ -2694,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. Flow The installed GeoLibre runner lists no tools. Trình chạy GeoLibre đã cài đặt không liệt kê công cụ nào. The job covers the map view Tác vụ bao phủ khung nhìn bản đồ The key is kept for this session only. Khóa chỉ được giữ trong phiên này. +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. Lớp hiển thị bước mà thanh trượt thời gian đang ở, và bật thanh trượt trên các ngày của biến. The layer's file couldn't be read: {error} Không thể đọc tệp của lớp: {error} +The layer's strokes Nét của lớp the layout export failed unexpectedly xuất bố cục thất bại ngoài dự kiến The link doesn't say its STAC API: Find Items searches {api} Liên kết không ghi rõ STAC API của nó: Tìm mục sẽ tìm trong {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. Danh sách plugin để duyệt. Để trống để dùng kho plugin RustGIS. @@ -2727,6 +2959,7 @@ The project's thumbnail, from the map view. Ảnh thu nhỏ của dự án, từ The projection the map opens in. Phép chiếu khi bản đồ mở. The provider's content filter stopped the reply. Bộ lọc nội dung của nhà cung cấp đã dừng câu trả lời. The public server is free and shared by many: it answers light use, and may ask you to wait. Máy chủ công cộng miễn phí và được nhiều người dùng chung: nó phục vụ nhu cầu nhẹ và có thể yêu cầu bạn chờ. +The range is two numbers, low and high (0, 0.9) Khoảng là hai số, thấp và cao (0, 0.9) The raster's data couldn't be read. Không thể đọc dữ liệu của raster. The raster's data couldn't be read: {path} Không thể đọc dữ liệu của raster: {path} the release isn't known không rõ bản phát hành @@ -2748,26 +2981,42 @@ The Shapefile's files download as one zip. Các tệp của Shapefile được t The share of each cell covered by fields, for the zoomed-out view Tỷ lệ diện tích ruộng phủ trên mỗi ô, cho khung nhìn thu nhỏ The sign-in expired or was revoked. Sign in again. Phiên đăng nhập đã hết hạn hoặc bị thu hồi. Hãy đăng nhập lại. The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. Thông tin đăng nhập được lưu trong thư mục cài đặt của ứng dụng, chỉ cho tài khoản người dùng của bạn, và không bao giờ trong tệp dự án. +The significance level is a number, such as 0.05 Mức ý nghĩa là một số, ví dụ 0.05 The start-up project {path} didn't open: {error} Không mở được dự án khởi động {path}: {error} The start-up project {path} isn't a RustGIS project file. Dự án khởi động {path} không phải là tệp dự án RustGIS. The step's layer {name} isn't in the map. Lớp {name} của bước này không có trên bản đồ. The store has no variables with two or more dimensions. Kho không có biến nào có từ hai chiều trở lên. the store is gone kho không còn nữa +The summary couldn't start: {error} Không thể bắt đầu tổng hợp: {error} +The summary failed unexpectedly Tổng hợp thất bại ngoài dự kiến +The summary finished after another project was opened, so it wasn't added Tổng hợp hoàn tất sau khi một dự án khác được mở, nên không được thêm The task's steps changed, so the step being edited was closed: edit it again. Các bước của nhiệm vụ đã thay đổi nên bước đang sửa đã bị đóng: hãy sửa lại. the time slider is off thanh trượt thời gian đang tắt The token expires on {date}. Token hết hạn vào {date}. +The trend couldn't start: {error} Không thể bắt đầu phân tích xu hướng: {error} +The trend failed unexpectedly Phân tích xu hướng thất bại ngoài dự kiến +The trend finished after another project was opened, so it wasn't added Phân tích xu hướng hoàn tất sau khi một dự án khác được mở, nên không được thêm +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. Các giá trị hiển thị từ đen đến trắng, giống nhau cho mọi ngày và mọi ô (theo đơn vị riêng của băng). Để trống: mỗi tệp dùng kiểu kéo giãn riêng. the view has no extent khung nhìn không có phạm vi The view is larger than one read: its centre was read (zoom in for the rest) Khung nhìn lớn hơn một lần đọc: đã đọc phần tâm (phóng to để xem phần còn lại) The web app always runs the latest release: reload the page to get it. Ứng dụng web luôn chạy bản phát hành mới nhất: tải lại trang để nhận bản đó. the window's screenshot didn't come back in time, so the map was drawn offscreen ảnh chụp màn hình của cửa sổ không trả về kịp, nên bản đồ đã được vẽ ngoài màn hình the window's screenshot didn't hold the map, so it was drawn offscreen ảnh chụp màn hình của cửa sổ không chứa bản đồ, nên bản đồ đã được vẽ ngoài màn hình Theme Chủ đề +There is no expression defined. Chưa định nghĩa biểu thức nào. Thermal Nhiệt +Thesis figure, full page (160 × 225 mm) Hình luận văn, cả trang (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) Hình luận văn, nửa trang (160 × 110 mm) Thinking… Đang suy nghĩ… +Third-party licences Giấy phép bên thứ ba +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins bản dựng này không thể tải plugin this build can't reach AI providers bản dựng này không thể kết nối tới nhà cung cấp AI this build saves projects with Save As bản dựng này lưu dự án bằng Lưu thành This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. Tài khoản ChatGPT này đã được lưu. Lần sau, hãy chọn nó trong danh sách thay vì thêm lại. +This class is drawn with the layer's strokes. Changing them here gives it its own. Lớp này được vẽ bằng nét của lớp bản đồ. Thay đổi ở đây sẽ tạo nét riêng cho nó. +This class is hidden, so it labels nothing. Lớp này đang ẩn, nên không gán nhãn gì. +This clause no longer reads, so no feature is labelled: {error} Mệnh đề này không còn đọc được, nên không đối tượng nào được gán nhãn: {error} This map has no layers yet. Bản đồ này chưa có lớp nào. This map's thumbnail, from an image. Ảnh thu nhỏ của bản đồ này, từ một ảnh. This map's thumbnail, from the view. Ảnh thu nhỏ của bản đồ này, từ khung nhìn. @@ -2776,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. Yêu cầu này không có tùy chọn cho phép một lần. This request uses an unsupported capability. Check the parameter and change the request. Yêu cầu này dùng tính năng không được hỗ trợ. Kiểm tra tham số và sửa yêu cầu. This result is kept Kết quả này được giữ lại +This run has no tool form to open Lần chạy này không có biểu mẫu công cụ để mở +This session only (optional) Chỉ trong phiên này (tùy chọn) This step Bước này This step names no tool to show. Bước này không nêu công cụ nào để chỉ ra. Thumbnail Ảnh thu nhỏ @@ -2790,15 +3041,21 @@ Tilt Map Nghiêng bản đồ Tilt-shift Tilt-shift Tilt-shift blur and film grain. Làm mờ tilt-shift và hạt phim. Time Thời gian +Time\nSeries Chuỗi\nthời gian Time\nSlider Thanh trượt\nthời gian Time Fields Trường thời gian +Time Series Chuỗi thời gian +Time series Chuỗi thời gian +Time Series at a Point Chuỗi thời gian tại một điểm Time Slider Thanh trượt thời gian Time Slider Settings Thiết lập thanh trượt thời gian Tint by elevation Tô màu theo độ cao Tissot's Indicatrix Chỉ đồ Tissot Title Tiêu đề +To Đến Toggle Dark Mode Bật/tắt chế độ tối Toggle GPU Rendering Bật/tắt kết xuất GPU +Toggle Imperial Measurements Bật/tắt đơn vị đo hệ Anh Tolerance (km) Dung sai (km) Tolls Đường thu phí Tool Gallery Thư viện công cụ @@ -2829,7 +3086,10 @@ Train Deep Learning Model Huấn luyện mô hình học sâu Trained models Mô hình đã huấn luyện Transparency Độ trong suốt Traverse Đường chuyền +Trend Analysis Phân tích xu hướng +Trend over a series of rasters Xu hướng trên một chuỗi raster Triangle Tam giác +TRUE TRUE Try again in a moment. Hãy thử lại sau giây lát. Try again later. Hãy thử lại sau. Turn editing on or off for the map. Bật hoặc tắt chỉnh sửa cho bản đồ. @@ -2847,6 +3107,8 @@ type a longitude and latitude, or click the map nhập kinh độ và vĩ độ, Type a scale, such as 1:12,345, 12345 or 25k, and press Enter Nhập một tỷ lệ, như 1:12,345, 12345 hoặc 25k, rồi nhấn Enter Type an address or coordinates and press Enter, or click the map. Nhập địa chỉ hoặc tọa độ rồi nhấn Enter, hoặc nhấp vào bản đồ. Type numbers separated by commas Nhập các số cách nhau bằng dấu phẩy +Type the keys to log in for this session, then Save. Nhập khóa để đăng nhập cho phiên này, rồi Lưu. +Type the password or keys to log in for this session, then Save. Nhập mật khẩu hoặc khóa để đăng nhập cho phiên này, rồi Lưu. Typed below Nhập bên dưới UM UM Unavailable: {reason} Không khả dụng: {reason} @@ -2854,7 +3116,6 @@ Unbind From Time Slider Bỏ gắn khỏi thanh trượt thời gian Unbind from Time Slider Bỏ gắn khỏi thanh trượt thời gian Undo Hoàn tác Undo (Ctrl+Z) Hoàn tác (Ctrl+Z) -Undo / Redo Hoàn tác / Làm lại Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Hoàn tác không gỡ bước đã ghi: hãy xóa nó trong Sửa các bước sau khi dừng ghi. Undo the last change. Hoàn tác thay đổi cuối cùng. Undo {action} Hoàn tác {action} @@ -2864,6 +3125,7 @@ Uninstall GeoLibre Tools Gỡ cài đặt công cụ GeoLibre Uninstall Plugin Gỡ cài đặt plugin Uninstalled {id}. Đã gỡ cài đặt {id}. Unique Values Giá trị duy nhất +Unit Đơn vị United States Hoa Kỳ Units Đơn vị Units per step Số đơn vị mỗi bước @@ -2878,6 +3140,7 @@ Up a level Lên một cấp Update Cập nhật Update Bookmark Cập nhật dấu trang Update Job Cập nhật tác vụ +Update Label Class Cập nhật lớp nhãn Update to {version} Cập nhật lên {version} Upload File Tải tệp lên Upload File… Tải tệp lên… @@ -2896,6 +3159,7 @@ Use another account Dùng tài khoản khác Use Current View Dùng khung nhìn hiện tại Use each feature's line color Dùng màu đường của từng đối tượng Use each feature's line width Dùng độ rộng đường của từng đối tượng +Use the Layer's Strokes Dùng nét của lớp USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2913,6 +3177,7 @@ Using ChatGPT plan Đang dùng gói ChatGPT Utilities Tiện ích Valid combinations, one per line: a, b Tổ hợp hợp lệ, mỗi dòng một tổ hợp: a, b Valid expression · {n} matching rows Biểu thức hợp lệ · {n} hàng khớp +Valid: {n} of {of} features match Hợp lệ: {n} trong {of} đối tượng khớp Validate Xác thực Validate Model Xác thực mô hình value (empty: no-data) giá trị (trống: không có dữ liệu) @@ -2921,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Ảnh thảm họa mở của Vantor: chọn một sự kiện và một giai đoạn (trước hoặc sau sự kiện), rồi Tìm mục. Vantor… Vantor… Variable Biến +Variogram Model Mô hình variogram Vector and raster data Dữ liệu vector và raster Vector Layer Lớp vector +vector tiles ô vector Vector tiles (MVT) Ô vector (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. Các vectơ mà một mô hình nền tảng không gian địa lý tạo ra cho mỗi pixel hoặc mảnh ảnh, tìm tại vị trí bản đồ và hiển thị dưới dạng màu. Version {version} Phiên bản {version} @@ -2934,13 +3201,13 @@ Video file or URL Tệp video hoặc URL Video Page Trang video Videos Video Video… Video… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Hệ tọa độ quốc gia Việt Nam (VN-2000) dùng phép chiếu Transverse Mercator 3 độ với phép chuyển đổi 7 tham số Helmert sang WGS 84. +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Hệ tọa độ quốc gia Việt Nam (VN-2000) múi chiếu UTM 48N (102°Đ–108°Đ, kinh tuyến trục 105°Đ). +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Hệ tọa độ quốc gia Việt Nam (VN-2000) múi chiếu UTM 49N (108°Đ–114°Đ, kinh tuyến trục 111°Đ). View Xem View Clipping Cắt khung nhìn View Effects Hiệu ứng khung nhìn View Extent Phạm vi khung nhìn -Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). Hệ tọa độ quốc gia Việt Nam (VN-2000) múi chiếu UTM 48N (102°Đ–108°Đ, kinh tuyến trục 105°Đ). -Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). Hệ tọa độ quốc gia Việt Nam (VN-2000) múi chiếu UTM 49N (108°Đ–114°Đ, kinh tuyến trục 111°Đ). -Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. Hệ tọa độ quốc gia Việt Nam (VN-2000) dùng phép chiếu Transverse Mercator 3 độ với phép chuyển đổi 7 tham số Helmert sang WGS 84. View In Google Earth Xem trong Google Earth View In Google Maps Xem trong Google Maps View Unplaced Xem nhãn chưa đặt @@ -2956,6 +3223,9 @@ Visibility Range Phạm vi hiển thị Visible Hiển thị Visible Layers Các lớp hiển thị Visual Effect Hiệu ứng hình ảnh +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Việt Nam) +VN-2000 / UTM zone 48N VN-2000 / UTM múi 48N +VN-2000 / UTM zone 49N VN-2000 / UTM múi 49N Volume Thể tích Waiting for the browser… Đang chờ trình duyệt… Waiting for your answer Đang chờ câu trả lời của bạn @@ -2974,6 +3244,7 @@ Web Link Chart Biểu đồ liên kết web Web maps Bản đồ web Web Mercator Web Mercator Web Scene Cảnh web +Web service (detected) Dịch vụ web (đã phát hiện) Web Style Kiểu web Web Tile Layers Lớp ô bản đồ web Web Tool Công cụ web @@ -2996,6 +3267,7 @@ Where labels sit relative to their features. Vị trí của nhãn so với đ Where new features get their elevation (the z field). Nơi đối tượng mới lấy độ cao (trường z). Where the map opens: the full extent or a view you saved. Bản đồ mở ở đâu: toàn bộ phạm vi hoặc một khung nhìn đã lưu. Where tools work Nơi công cụ hoạt động +Whether the Select tool picks these layers' features on the map Công cụ Chọn có chọn các đối tượng của những lớp này trên bản đồ hay không Which 5 countries have the largest GDP? 5 quốc gia nào có GDP lớn nhất? Whitebox and GeoLibre tools Công cụ Whitebox và GeoLibre Whole map Toàn bộ bản đồ @@ -3036,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. Thế giới, đồng diện t World, equal-area: true sizes, shapes bend towards the edges. Thế giới, đồng diện tích: đúng kích thước, hình dạng cong dần về các mép. Worldview Biên giới Write a plugin Viết plugin +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Ghi độ dốc Sen, giá trị Z và p của kiểm định, và độ dốc tại nơi xu hướng có ý nghĩa. X (mm) X (mm) X column Cột X X Field Trường X @@ -3054,7 +3327,6 @@ Yours Của bạn YYYY-MM-DD YYYY-MM-DD YYYY-MM-DD, for the layers that change with time YYYY-MM-DD, cho các lớp thay đổi theo thời gian YYYY-MM-DD, for time layers YYYY-MM-DD, cho các lớp thời gian -z (m) z (m) Z factor Hệ số Z Zarr Zarr Zarr Variables Biến Zarr @@ -3065,7 +3337,6 @@ Zoom Box Thu phóng hộp Zoom Frame Khung thu phóng Zoom In Phóng to Zoom in Phóng to -Zoom in / out Phóng to / thu nhỏ Zoom In Here Phóng to tại đây Zoom in on the centre of the map. Phóng to vào tâm bản đồ. Zoom in · finish a sketch Phóng to · hoàn tất bản phác thảo @@ -3077,6 +3348,7 @@ Zoom To\nLayer Thu phóng\ntới lớp Zoom to a box (Explore) Thu phóng tới hộp (Khám phá) Zoom To Extent Thu phóng tới phạm vi Zoom To Layer Thu phóng tới lớp +Zoom To Layers Thu phóng tới các lớp Zoom To Map View Thu phóng tới khung nhìn bản đồ Zoom To Selection Thu phóng tới vùng chọn Zoom to selection Thu phóng tới vùng chọn @@ -3086,8 +3358,11 @@ Zoom to the layer's extent. Thu phóng tới phạm vi của lớp. Zoom to the selected features. Thu phóng tới các đối tượng được chọn. Zoom to the whole world. Thu phóng tới toàn thế giới. Zoom to this bookmark Thu phóng tới dấu trang này +Zoom To Tile Layer Thu phóng tới lớp ô bản đồ Zoom to Tokyo Thu phóng tới Tokyo Zoom To World Thu phóng tới thế giới +Zoom {z} · Bearing {b}° · Pitch {p}° Mức thu phóng {z} · Hướng {b}° · Độ nghiêng {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Thu phóng: mức thu phóng của bản đồ, theo cách bản đồ web tính. Hướng: hướng mà cạnh trên của bản đồ quay về, tính theo chiều kim đồng hồ từ hướng bắc. Độ nghiêng: góc nghiêng so với khi nhìn thẳng xuống. Nhấp để đặt lại hướng và độ nghiêng. {agent} was stopped by you. Bạn đã dừng {agent}. {id} changed since it was installed; install it again from the Plugin Manager. {id} đã thay đổi kể từ khi được cài đặt; hãy cài đặt lại từ Trình quản lý plugin. {names}: needs Node.js (npx) for its ACP adapter. {names}: cần Node.js (npx) cho bộ chuyển đổi ACP. diff --git a/crates/ui-egui/src/i18n/zh.tsv b/crates/ui-egui/src/i18n/zh.tsv index 0959c5a53..8ad21c21c 100644 --- a/crates/ui-egui/src/i18n/zh.tsv +++ b/crates/ui-egui/src/i18n/zh.tsv @@ -6,6 +6,7 @@ (filtered) (已筛选) + Add Stop + 添加途经点 + Input + 输入 ++ New expression + 新建表达式 + Output + 输出 /F /F /PID /PID @@ -14,10 +15,12 @@ 06:00 UTC 06:00 UTC 12:00 UTC 12:00 UTC 18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 3D mesh 三维网格 <无> A 3D globe seen from space. 从太空看到的三维地球。 A blank map with a basemap 带底图的空白地图 +A call for the rustgis Python package 用于 rustgis Python 包的调用 A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. 目录服务(CSW 2.0.2)的地址:按关键词(也可按地图区域)搜索其记录,然后添加记录所链接的服务和文件。 A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. 已达到 ChatGPT 套餐或应用的用量限制。请查看每周用量后再重试。 A ChatGPT sign-in can only be used with the OpenAI API. ChatGPT 登录只能用于 OpenAI API。 @@ -30,26 +33,41 @@ A GeoLens server's datasets, read as features (the first 1,000 of each). GeoLens A globe scene. 地球仪场景。 A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place 纬度和经度(40.7, -74.0 或 40°42'N 74°0'W)、图层、书签或图层表中的值。按 Enter 选择高亮行;“搜索地点”将文本作为地点查找 A layer the model reads 模型读取的图层 +A line for the Script pane 用于脚本窗格的一行命令 A magnifying lens that follows the pointer. 跟随指针的放大镜。 A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). 地图、要素或覆盖服务的地址:WMS、WMTS、WFS、WCS、OGC API Features 或 Tiles,或者 ArcGIS FeatureServer、MapServer、ImageServer 或 VectorTileServer(也可以是服务目录,以便浏览)。 a name, or owner/name such as giswqs/geospatial 名称,或 owner/name 形式,例如 giswqs/geospatial +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. 可重复使用的命名端点。密码和密钥保留到 RustGIS 关闭为止,从不保存。 A node cannot connect to itself. 节点不能连接到自身。 A notebook of text and command cells. 由文本单元格和命令单元格组成的笔记本。 +A password or token in the address was left out: connections never keep secrets. 地址中的密码或令牌已被略去:连接从不保存机密信息。 A place or an address 地点或地址 A project keeps at least one map 项目至少保留一个地图 A projected coordinate system. 投影坐标系。 A report listing the selected layer's records, page after page. 逐页列出所选图层记录的报表。 A result that only feeds other tools is removed once the model has run; add an output to keep it. 仅供其他工具使用的结果会在模型运行后删除;若要保留,请添加输出。 A result the model keeps, under a name 模型以某个名称保留的结果 +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) rustgis-cli 命令,用于 POSIX shell(macOS、Linux、WSL、Git Bash) A service import or refresh is already running. 服务导入或刷新已在进行中。 +A service's layers stay in their service group. 服务的图层保留在其服务分组中。 A task holds at most 200 steps: stop recording to make it, then record the rest as another task. 一个任务最多包含 200 个步骤:请停止录制以创建该任务,然后将其余步骤录制为另一个任务。 A template with the first selected feature's values 使用第一个所选要素值的模板 +A tile layer per grid tile, each following the time slider through its dates. 每个格网瓦片一个瓦片图层,各自随时间滑块遍历其日期。 A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. 瓦片服务的地址:带 {z}/{x}/{y} 的栅格瓦片(WMS 可用 {-y}, {q}, {bbox-epsg-3857})、矢量瓦片(.pbf)或 TileJSON、MapLibre 样式、PMTiles 归档(网址或本机文件),或本机上的 MBTiles 文件。 A token is set. 已设置令牌。 A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. 一个值、一个字段,或用 + − × ÷ 连接的两者。应用于所选行;若未选择任何行,则应用于所有行。 a vector a pixel 每个像素一个向量 a vector an image patch 每个影像块一个向量 A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. 通过网址指定的 Zarr 存储(格式 2 或 3,需含合并元数据),或本机上的文件夹。变量的地图按其他维度(如时间)中所选的步长读取。 +A Zarr variable's values through time at points clicked on the map. 在地图上单击的点处 Zarr 变量随时间变化的值。 +A2 landscape sheet A2 横向图纸 +A2 portrait sheet A2 纵向图纸 +A3 landscape sheet A3 横向图纸 +A3 portrait sheet A3 纵向图纸 +A4 landscape sheet A4 横向图纸 +A4 portrait sheet A4 纵向图纸 +A5 Grid A5 格网 +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. 按指定分辨率在地图范围、某个图层或全球范围内生成 A5 等积五边形单元,以带 id 的多边形输出。 About 关于 About plugins 关于插件 About RustGIS 关于 RustGIS @@ -65,6 +83,7 @@ Add 添加 Add\nData 添加\n数据 Add ({n}) 添加 ({n}) Add a bar chart of a layer's numeric field. 添加图层数值字段的条形图。 +Add a circle. 添加圆形。 Add a colorbar of a raster layer's colour scale in the map frame. 为地图框中某个栅格图层的色阶添加色带图例。 Add a description. 添加描述。 Add a field. 添加字段。 @@ -78,22 +97,28 @@ Add a page layout for printing and exporting the map. 添加用于打印和导 Add a page to the presentation. 向演示文稿添加页面。 Add a PNG or JPEG picture to the page. 向页面添加 PNG 或 JPEG 图片。 Add a presentation file. 添加演示文稿文件。 +Add a rectangle, to frame or highlight part of the page. 添加矩形,用于框出或突出显示页面的一部分。 Add a report file, or a report template for the selected layer. 添加报表文件,或所选图层的报表模板。 Add a scale bar for the map frame. 为地图框添加比例尺。 add a second map first (Data › New Map) 请先添加第二个地图(“数据 › 新建地图”) Add a second map frame first 请先添加第二个地图框 +Add a straight line. 添加直线。 Add a text element. 添加文本元素。 Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. 将 Web 底图叠加在其他底图之上而不是替换它们,以便与之混合。单击已显示的底图可将其移除。 +Add a Zarr variable first (Add Data › Zarr…). 请先添加 Zarr 变量(添加数据 › Zarr…)。 Add Administrative Boundaries 添加行政边界 +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. 添加箭头。使用其上方的圆形控制柄旋转,或在其属性中设置角度。 Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. 添加 Earthdata Login 令牌(见上方 Earthdata Login),即可将 NASA GeoTIFF 添加到地图。 Add an elevation raster first. 请先添加高程栅格。 Add an elevation raster to the map first 请先向地图添加高程栅格 +Add an ellipse. 添加椭圆。 Add an empty layer with a ready-made schema, to draw into. 添加具有现成方案的空图层,用于绘制。 Add an input for each layer the model reads. 为模型读取的每个图层添加一个输入。 Add an output to keep a result under a name. 添加输出以按名称保留结果。 Add and Open 添加并打开 Add and Open Notebook 添加并打开笔记本 Add Archive 添加存档 +Add as Time Series 添加为时间序列 add at least two keyframes 请至少添加两个关键帧 Add Cell 添加单元格 Add Color Ramp File… 添加色带文件… @@ -132,6 +157,7 @@ Add Graphics Layer 添加图形图层 Add Input 添加输入 Add Job 添加作业 Add Keyframe 添加关键帧 +Add Label Class 添加标注类 Add Layer File 添加图层文件 Add Open Data 添加开放数据 Add Output 添加输出 @@ -147,9 +173,11 @@ Add Rule 添加规则 Add sample countries 添加示例国家数据 Add Sample Data 添加示例数据 Add Satellite Embeddings 添加卫星嵌入向量 +Add Selected ({n}) 添加所选项({n}) Add Service Layer 添加服务图层 Add skill… 添加技能… Add STAC Asset 添加 STAC 资产 +Add STAC Data Cube 添加 STAC 数据立方体 Add STAC Footprints 添加 STAC 覆盖范围 Add Step 添加步骤 Add Stroke 添加描边 @@ -171,6 +199,7 @@ Add to Favourites 添加到收藏夹 Add to Map 添加到地图 Add vector data. 添加矢量数据。 Add view 添加视图 +Add With Options… 带选项添加… Add Zarr Variable 添加 Zarr 变量 Add Zarr Variable… 添加 Zarr 变量… Add ▾ 添加 ▾ @@ -203,6 +232,7 @@ Agent work Agent work Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay 智能体拥有完全访问权限:它们可以运行脚本、任务、模型和插件。单击以停止 MCP 中继 Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. 已连接的本机智能体(Claude Code、Codex、Cursor 等)使用 RustGIS 的工具处理此处打开的地图:它们可以打开和保存文件并使用网络。脚本和插件管理保持关闭。 Agents then run commands, change files and use the web on this computer without asking you. 智能体将不经询问就在这台电脑上运行命令、更改文件并使用网络。 +Aggregate Points to A5 将点聚合到 A5 Aggregation 聚合 Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. Fields of the World 模型从 Sentinel-2 影像中识别出的全球农田田块边界,按年份提供(第 2 版,CC BY 4.0)。这些是模型预测结果,并非测量得到的地块。 AI AI @@ -221,15 +251,18 @@ All steps done. 所有步骤已完成。 All tables with geometry 所有含几何的表 Allow 允许 Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. 登录时请允许使用 ChatGPT 套餐。如果你的工作区不允许,请联系其管理员,或选择使用 API 密钥的提供方。 +Allow Feature Selection 允许要素选择 Allow only certain combinations of values across fields. 仅允许字段间的特定值组合。 Allow such actions for this conversation 在本次对话中允许此类操作 Allow this action? 允许此操作? Allow this agent's RustGIS action? 允许此智能体的 RustGIS 操作? Along Selected Line 沿所选线 +Alt+ Alt+ Alternating bar 交替条 Altitude 高度角 Always allow 始终允许 -Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a remembered connection keeps its name, endpoint and region, never its secret. Amazon S3,或通过端点指定的 S3 兼容服务(MinIO、Cloudflare R2、Wasabi…)。密钥保留到 RustGIS 关闭为止;记住的连接只保存其名称、端点和区域,从不保存私密密钥。 +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. Amazon S3,或通过端点指定的 S3 兼容服务(MinIO、Cloudflare R2、Wasabi…)。密钥保留到 RustGIS 关闭为止;保存的连接只保存其名称、端点和区域,从不保存私密密钥。 amenity=cafe, shop amenity=cafe, shop An agent on this computer asks to: 这台电脑上的一个智能体请求: An AWS profile AWS 配置文件 @@ -248,6 +281,7 @@ API key API 密钥 Apply 应用 Apply a function to a raster 对栅格应用函数 Apply Control Points 应用控制点 +Apply elevation 应用高程 Apply Style 应用样式 Apply to layers 应用到图层 Apply to the selected layer 应用于所选图层 @@ -268,6 +302,8 @@ Area of use: within {r}° of {lon}°, {lat}° 使用范围: 距 {lon}°, {lat}° Area {area} · Perimeter {perimeter} 面积 {area} · 周长 {perimeter} Areas (name, land use, area) 区域(名称、土地利用、面积) Arrange 排列 +Arrow 箭头 +Arrowheads 箭头样式 Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. 提出有关数据的问题,或描述要对地图进行的操作。助手会为您运行 RustGIS 命令;每项更改都可以撤销。 Ask about the map, or tell it what to do… 询问有关地图的问题,或告诉它要做什么… Ask about your data, or tell the assistant what to do with the map. 询问有关数据的问题,或告诉助手要对地图做什么。 @@ -275,12 +311,13 @@ Asking NASA's CMR… 正在查询 NASA 的 CMR… Asking the catalog… 正在查询目录… Asking the NLDI… 正在查询 NLDI… Assistant settings 助手设置 +At the end 在终点 +At the start 在起点 Atlas 地图集 Attachments 附件 Attribute\nTable 属性\n表 Attribute Rules 属性规则 Attribute Table 属性表 -Attribute table 属性表 Attributes 属性 Attribution 署名 Aurora 极光 @@ -295,11 +332,9 @@ Automatic (system language) 自动(系统语言) Automatic (system time zone and region) 自动(系统时区和区域) Automatic ({kind}) 自动({kind}) Avoid 避开 -AWS AWS AWS, anonymous AWS,匿名 Azimuth 方位角 Back 返回 -Back to Explore; cancel a sketch 返回浏览;取消草图 Back to the Map 返回地图 Back to the map 返回地图 Back to tools 返回工具 @@ -314,8 +349,6 @@ Basemap 底图 Basemaps 底图 Basin from hydrolocation 由水文位置获取流域 Bearing 方位角 -Bearing {b}° · Pitch {p}° 方位角 {b}° · 俯仰角 {p}° -Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset both. 方位角:地图顶部朝向的方向,从正北顺时针计算。俯仰角:相对于垂直俯视的倾斜角度。单击可将两者重置。 Before the event 事件之前 Below 下方 Below 1:5,000,000 show everything; beyond it only what matches: 比例尺小于 1:5,000,000 时显示全部;超出后仅显示匹配项: @@ -328,10 +361,12 @@ Bilinear 双线性 Bind To Time Slider 绑定到时间滑块 Bind to Time Slider 绑定到时间滑块 Blank 空白 +Blank for Amazon S3 Amazon S3 留空 Blank Letter page 空白 Letter 页面 Blank Map 空白地图 Blank Page 空白页面 Blend: {mode} 混合:{mode} +Block Feature Selection 禁止要素选择 Blue 蓝 Bold 粗体 Bold label text. 标注文本加粗。 @@ -339,6 +374,7 @@ Bookmark Description 书签描述 Bookmarks 书签 Bookmarks Pane 书签窗格 Boolean 布尔型 +Both ends 两端 Bottom 底部 Boundaries… 边界… Bounding\nGeometry 边界\n几何 @@ -346,6 +382,7 @@ Brightness 亮度 Brightness, contrast, saturation and sepia for the whole map. 整个地图的亮度、对比度、饱和度和棕褐色调。 Bring this bookmark's map into view to update it 请先显示此书签所属的地图再更新它 Browse 浏览 +Browse Connection 浏览连接 Browse it in the Browser 在浏览器中浏览 Browse Plugins 浏览插件 Browse the geoprocessing toolbox. 浏览地理处理工具箱。 @@ -357,6 +394,7 @@ Browser 浏览器 Browser\nPane 浏览器\n窗格 Browser View 浏览器视图 Bucket 存储桶 +Buckets 存储桶 Buffer 缓冲区 Buffer A Point 为点创建缓冲区 Buffer the cities by 200 km 为城市创建 200 公里缓冲区 @@ -381,7 +419,9 @@ Calculate Field 计算字段 Calculate field 计算字段 Calculate field values. 计算字段值。 Calculate fields and enforce conditions on every edit. 在每次编辑时计算字段并强制执行条件。 +Calculate Geometry 计算几何 Calculate Geometry Attributes 计算几何属性 +Calculated {rows} features 已计算 {rows} 个要素 Calculation 计算 Camera 相机 Camera Effect 相机效果 @@ -396,12 +436,14 @@ Case Field 分组字段 Cast Shadow 投射阴影 Catalog 目录 catalog is a static STAC catalog's catalog.json or collection.json catalog 是静态 STAC 目录的 catalog.json 或 collection.json +Catalogue (CSW) 目录(CSW) Catalogue search (CSW) 目录搜索(CSW) Celestial Bodies 天体 Celestial Body 天体 Celestial body: {body} 天体:{body} Cell size 像元大小 cell size ° 像元大小 ° +Cells 单元 Center 中心 Center Map Here 以此处为地图中心 Centered 居中 @@ -421,6 +463,7 @@ ChatGPT plan use is unavailable for this account or workspace. 此账户或工 ChatGPT sign-in failed. Check the connection and local settings, then try again. ChatGPT 登录失败。请检查连接和本地设置,然后重试。 ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. ChatGPT 用量服务暂时不可用。请保留登录状态并稍后重试。 CHE CHE +Check 检查 Check Again 再次检查 Check for\nUpdates 检查\n更新 Check for Updates 检查更新 @@ -428,6 +471,7 @@ Check for Updates… 检查更新… Check the address and the service type. 请检查地址和服务类型。 Check the address and your connection. 请检查地址和网络连接。 Check the address. 请检查地址。 +Check the clause, and count the features it takes. 检查子句,并统计其选中的要素数量。 Check the integration's client, permissions, workspace policy and route configuration. 请检查集成的客户端、权限、工作区策略及路由配置。 Checking for updates… 正在检查更新… China 中国 @@ -455,6 +499,7 @@ Choose which layers a click identifies. 选择单击时识别哪些图层。 Choose which tabs the ribbon shows. 选择功能区显示哪些选项卡。 Circle 圆形 Circle Radius (km) 圆半径(km) +Class 类 Class color 类颜色 Class Style 类样式 Classes 分类 @@ -511,6 +556,7 @@ Click to add vertices · double-click to finish the line 单击添加折点 · Click to edit the description 单击以编辑描述 Click to place a point graphic. 单击放置点图形。 Click to select the node 单击以选择节点 +Click to select, double-click to add, right-click for more 单击选择,双击添加,右键单击查看更多 Click to switch between decimal degrees and DMS 单击在十进制度和度分秒之间切换 Clip 裁剪 Clip a raster to the view, or resample it. 将栅格裁剪到视图,或对其重采样。 @@ -532,7 +578,7 @@ Close thread 关闭线程 Close {name} 关闭 {name} Closest 最近距离 Coded values, one per line: code=description 编码值,每行一个:代码=描述 -Collapse or expand the ribbon 折叠或展开功能区 +Collapse or Expand the Ribbon 折叠或展开功能区 Collapse the ribbon (Ctrl+F1) 折叠功能区 (Ctrl+F1) Collection 集合 Collection {id}: choose dates and Find Items 集合 {id}:选择日期并“查找项目” @@ -552,6 +598,7 @@ Columns named like longitude/latitude, easting/northing or geometry are picked f Combinations from the box above, one per line 上方框中的组合,每行一个 COMID {comid} COMID {comid} Comma 逗号 +Comma-separated (optional) 以逗号分隔(可选) Command\nSearch 命令\n搜索 command is required 需要提供 command Command Search 命令搜索 @@ -572,10 +619,13 @@ Connect 连接 Connect a folder to the Browser. 将文件夹连接到浏览器。 Connect to S3 连接到 S3 Connect, or pick a layer 连接,或选择图层 +Connected to {kind} {title} 已连接到 {kind} {title} Connecting to {url}… 正在连接到 {url}… Connection 连接 +connection.new returned no id connection.new 未返回 id Connections 连接 -Connect… 连接… +Connections you add are listed here 你添加的连接将列在此处 +Connections… 连接… Constant 常量 Constant height 恒定高度 Constant… 常量… @@ -583,11 +633,14 @@ Contingent Values 条件值 Continue 继续 Continue with ChatGPT 使用 ChatGPT 继续 Contrast 对比度 +Control Point Fit 控制点拟合 Conversion 转换 Convert 转换 Convert a layer to another format 将图层转换为其他格式 Convert the selected layer's labels into an annotation layer. 将所选图层的标注转换为注记图层。 +Convert this layer's labels into an annotation layer. 将此图层的标注转换为注记图层。 Convert To Annotation 转换为注记 +Convert to Annotation 转换为注记 Converted data 转换后的数据 Convert… 转换… Coordinate 坐标 @@ -599,11 +652,15 @@ Copied the map to the clipboard 已将地图复制到剪贴板 Copied {path} 已复制 {path} Copies the (selected) features of a layer to a new layer. 将图层的(所选)要素复制到新图层。 Copy 复制 +Copy / Cut / Paste features 复制/剪切/粘贴要素 Copy a model.run command that runs this model, for the Script pane or Python 复制运行此模型的 model.run 命令,用于脚本窗格或 Python Copy Address 复制地址 Copy address 复制地址 Copy an image of the map to the clipboard. 将地图图像复制到剪贴板。 +Copy as CLI Command 复制为 CLI 命令 +Copy as Command 复制为命令 Copy As GeoJSON 复制为 GeoJSON +Copy as Python 复制为 Python Copy Command 复制命令 Copy coordinates to the clipboard 将坐标复制到剪贴板 Copy details 复制详细信息 @@ -611,11 +668,13 @@ Copy Group 复制组 Copy Layer 复制图层 Copy Layers 复制图层 Copy Path 复制路径 +Copy Run As Code 将运行复制为代码 Copy sign-in link 复制登录链接 Copy the selected features (also as GeoJSON text). 复制所选要素(同时作为 GeoJSON 文本)。 Copy the selected features and delete them. 复制所选要素并将其删除。 Copy the selected layer's data source path. 复制所选图层的数据源路径。 Copy this element (Ctrl+D, ⌘D on a Mac) 复制此元素(Ctrl+D,Mac 上为 ⌘D) +Copy this run as code, or open it in its tool 将此次运行复制为代码,或在其工具中打开 Corrections 校正 Cost 成本 could not draw the map 无法绘制地图 @@ -625,6 +684,7 @@ Couldn't load the plugin registry: {error} 无法加载插件库:{error} Count, sum, mean, min, max and standard deviation of a field, optionally per case field. 字段的计数、总和、平均值、最小值、最大值和标准差,可按分组字段分别统计。 Countries, cities, rivers and lakes 国家、城市、河流和湖泊 Country 国家 +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. 按包含点的 A5 单元统计图层的点数(单元面积相等,因此计数即为密度),并计算某字段的总和与平均值。 Counts the points that fall inside each polygon. 统计落在每个多边形内的点数。 Counts, totals and top categories of what's in view. 视图中内容的计数、总计和主要类别。 CPU CPU @@ -647,12 +707,8 @@ Cross Section 横截面 Cross Section (2 clicks) 横截面(2 次单击) CSV CSV Ctrl+ Ctrl+ -Ctrl+= / Ctrl+- Ctrl+= / Ctrl+- -Ctrl+F1 Ctrl+F1 Ctrl+K Ctrl+K -Ctrl+N / O / S Ctrl+N / O / S Ctrl+T Ctrl+T -Ctrl+Z / Ctrl+Y Ctrl+Z / Ctrl+Y Cumulative 累积 Current date 当前日期 Current Project 当前工程 @@ -680,13 +736,16 @@ Data type 数据类型 Data you add is listed here 您添加的数据会列在这里 data.gov and CKAN catalogues data.gov 和 CKAN 目录 Database 数据库 -Databases 数据库 +Database connections are used by the desktop app 数据库连接由桌面应用使用 +databases are read through DuckDB, a program the browser can't run 数据库通过 DuckDB 读取,而浏览器无法运行该程序 Database… 数据库… Dataset 数据集 Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. 按名称或 owner/name 查找 Hugging Face 上的数据集;浏览数据集的文件并添加 GeoTIFF、GeoParquet、PMTiles、LiDAR 等文件。 Data… 数据… Date 日期 Dates 日期 +Dates a cell needs 单元所需日期数 +Dates a cell needs is a whole number, or empty for half 单元所需日期数应为整数,留空则为一半 Day 日 Day and night shading for a time. 某一时刻的昼夜阴影。 Decimal 十进制 @@ -715,7 +774,6 @@ Delete Notebook 删除笔记本 Delete Page 删除页面 Delete Presentation 删除演示文稿 Delete Report 删除报表 -Delete selected features 删除所选要素 Delete selected rows 删除所选行 Delete Style 删除样式 Delete Task 删除任务 @@ -736,11 +794,15 @@ Desktop and command line only 仅限桌面应用和命令行 Details 详细信息 Details of the selected layer. 所选图层的详细信息。 Deviations 标准差倍数 +DGGS DGGS Diagnostic\nMonitor 诊断\n监视器 Diagnostic Monitor 诊断监视器 Diagnostics 诊断信息 +Diagonal ↗ 对角线 ↗ +Diagonal ↘ 对角线 ↘ Diamond 菱形 Difference, ratio or percent change between two rasters. 两个栅格之间的差值、比值或百分比变化。 +Digit Digit Directions 路线 Directions From Here 从这里出发的路线 Directions To Here 到这里的路线 @@ -758,6 +820,7 @@ Dissolve Field 融合字段 Distance 距离 Distance (km) 距离(km) Distance Coefficient P 距离系数 P +Distance units 距离单位 Distances along the surface, heights, areas and volumes. 沿地表的距离、高度、面积和体积。 DISTINCT DISTINCT Distinct 不同值 @@ -804,18 +867,23 @@ Drag to draw 拖动以绘制 Drag to draw a freehand line. 拖动绘制手绘线。 Drag to draw a freehand polygon. 拖动绘制手绘多边形。 Drag to draw a rectangle. 拖动绘制矩形。 +Drag to resize the message box 拖动以调整消息框大小 Drag tools from the palette onto the canvas. 将工具从工具面板拖到画布上。 Drag vertices on the map; click an edge to add one 在地图上拖动折点;单击边可添加折点 Draw a halo around label text. 在标注文本周围绘制晕圈。 Draw a labelled latitude/longitude grid over the map frame. 在地图框上绘制带标注的经纬网格。 Draw a model: chain tools from input layers to the results to keep 绘制模型:将工具从输入图层串联到要保留的结果 Draw a model: chain tools from input layers to the results to keep. 绘制模型:将工具从输入图层串联到要保留的结果。 +Draw at its place in the stack, the bottom stroke first 按其在堆叠中的位置绘制,先绘制底部描边 +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet 先绘制所有要素同一级别的描边,再绘制下一级别,使线条相交处的外框在内芯下方连接 Draw labels at every scale and distance. 在所有比例尺和距离下绘制标注。 Draw new features in the selected layer. Double-click to finish lines and polygons. 在所选图层中绘制新要素。双击完成线和多边形。 Draw new single-band rasters with the inverted grey stretch again. 重新使用反转灰度拉伸绘制新的单波段栅格。 +Draw only polygon outlines, keeping their colors and widths. 仅绘制多边形轮廓,保留其颜色和宽度。 Draw points as bins coloured by how many fall in each. 将点绘制为按落入数量着色的分箱。 Draw polygons as outlines only. 仅以轮廓绘制多边形。 Draw single-band rasters added from now on with this color scheme, stretch and resampling. 此后添加的单波段栅格将使用此配色方案、拉伸和重采样进行绘制。 +Draw strokes in levels 分级绘制描边 Draw the layer at every scale. 在所有比例尺下绘制图层。 Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). 在 GPU 上绘制地图(测试版;地球仪、视图效果和色觉模拟仍使用 CPU 渲染器)。 Draw the selected layer above others. 将所选图层绘制在其他图层之上。 @@ -828,6 +896,7 @@ drawn by the server 由服务器绘制 Drive 驾车 Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. OpenAerialMap 上共享的无人机和航空影像,最新的在前,以云优化 GeoTIFF 添加。 Duplicate 复制 +Duplicate Connection 复制连接 Duplicate Element 复制元素 Duplicate Layout 复制布局 Duplicate Map 复制地图 @@ -843,6 +912,7 @@ e.g. pop_est / 1000000 or 'Africa' or name 例如 pop_est / 1000000 e.g. 0, 255, -9999 例如 0, 255, -9999 e.g. continent = 'Europe' 例如 continent = 'Europe' e.g. pop_est > 100000000 AND continent = 'Asia' 例如 pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 例如 pop_max > 1000000 Each geoprocessing run becomes a step, chained by its outputs 每次地理处理运行成为一个步骤,按其输出串联 Earth Index has no points in the view Earth Index 在视图中没有点 Earth Search Earth Search @@ -865,6 +935,7 @@ Edit\nVertices 编辑\n折点 Edit and run the notebook. 编辑并运行笔记本。 Edit Attribute 编辑属性 Edit Cell 编辑单元格 +Edit Connection 编辑连接 Edit Description 编辑描述 Edit Graphic 编辑图形 Edit Status 编辑状态 @@ -882,6 +953,7 @@ Editor\nTools 编辑器\n工具 Editor Tools 编辑器工具 Edits since the project was saved. 自工程保存以来的编辑。 Edits the project 编辑工程 +Edit… 编辑… Effects 效果 Element 元素 Element Properties 元素属性 @@ -898,21 +970,25 @@ Enable Lighting 启用光照 Enable Plugin 启用插件 Enabled 已启用 Encloses each feature, or all features, in its convex hull. 用凸包包围每个要素或所有要素。 +Encoded polyline 编码折线 End 端点 End date 结束日期 Endpoint 端点 Enhancement 增强 Enter Enter Enter an API key. 请输入 API 密钥。 +Enter an EPSG code as a number, such as 32650 以数字形式输入 EPSG 代码,例如 32650 Enter the provider's address. 请输入提供商的地址。 Enter to send, Shift+Enter for a new line 按 Enter 发送,Shift+Enter 换行 EnviroAtlas EnviroAtlas EnviroAtlas… EnviroAtlas… Environments 环境 EPSG EPSG +EPSG code EPSG 代码 EPSG or ESRI code EPSG 或 ESRI 代码 EPSG: EPSG: EPSG:4326 EPSG:4326 +Equal Equal Equal Earth Equal Earth 等积投影 Equal Interval 相等间隔 Equirectangular (Plate Carrée) 等距圆柱投影(Plate Carrée) @@ -922,14 +998,18 @@ Error 错误 Error Inspector 错误检查器 Error Inspector (validate) 错误检查器(验证) Esc Esc +Escape Escape ESRI ESRI ESRI: ESRI: Europe Equal Area (ETRS89 LAEA) 欧洲等积投影(ETRS89 LAEA) Every band's value at a pixel. 某个像素处每个波段的值。 Every editing tool. 所有编辑工具。 +Every label setting of this layer, in the Labeling pane. 此图层的所有标注设置,位于标注窗格中。 Every layer, as FEMA draws them 所有图层,按 FEMA 的绘制方式 +Every run, in order, as a script that opens the project 按顺序将每次运行生成为打开该工程的脚本 every Socrata site, or one such as data.cityofnewyork.us 所有 Socrata 站点,或某个站点,如 data.cityofnewyork.us Everywhere 所有位置 +Existing field 现有字段 Existing Template 现有模板 exit rotary 驶出转盘 exit roundabout 驶出环岛 @@ -948,6 +1028,7 @@ Export\nRaster 导出\n栅格 Export Animation 导出动画 Export Bookmarks 导出书签 Export Bookmarks… 导出书签… +Export CSV… 导出 CSV… Export Features 导出要素 Export Features (tool) 导出要素(工具) Export Features needs a vector layer (Data › Export Raster writes a raster) 导出要素需要矢量图层(“数据 › 导出栅格”用于写出栅格) @@ -996,6 +1077,7 @@ Face Culling 面剔除 Faces 面 Factor 因子 Failed 失败 +FALSE FALSE Farthest 最远距离 Faster drawing: no antialiasing. 更快绘制:不进行抗锯齿。 Favourite Data Source 收藏数据源 @@ -1023,6 +1105,8 @@ field * 2, 'text', a + b field * 2, 'text', a + b Field boundaries 田块边界 Field density 田块密度 Field name 字段名称 +Field Prefix 字段前缀 +Field to Sum and Average 求和与平均的字段 Fields 字段 Fields are drawn from zoom 9: zoom in to see them. 田块从缩放级别 9 开始绘制:请放大查看。 Fields colored by score, drawn from zoom 9 按得分着色的田块,从缩放级别 9 开始绘制 @@ -1079,6 +1163,8 @@ Folder Connections 文件夹连接 folder connections need the desktop app 文件夹连接需要桌面应用 Folders 文件夹 Folder… 文件夹… +Follow the time slider 跟随时间滑块 +Font 字体 for a store that doesn't say 用于未注明的存储 Forest 森林 Forget Earthdata Login Token 清除 Earthdata Login 令牌 @@ -1090,9 +1176,11 @@ France 法国 Free the prepared geometry. 释放已准备的几何。 Freehand 手绘 FROM FROM +From 从 From Selection 根据所选内容 From Service… 从服务… From symbol 来自符号 +From the address 来自地址 from the address 取自地址 from the service 取自服务 From URL… 从 URL… @@ -1196,6 +1284,7 @@ Hide features far from the view's centre. 隐藏远离视图中心的要素。 Hide Group 隐藏分组 Hide labels when the viewer is closer than this. 当观察者比此距离更近时隐藏标注。 Hide labels when the viewer is farther than this. 当观察者比此距离更远时隐藏标注。 +Hide Layers 隐藏图层 Hide other values 隐藏其他值 Hide Steps 隐藏步骤 Hide the description 隐藏描述 @@ -1213,6 +1302,8 @@ Hold 挂起 HOME HOME Home 主页 Home › Add 主页 › 添加 +Home › Add Data 主页 › 添加数据 +Home › Inspect › Measure 主页 › 检查 › 测量 Home › Navigate 主页 › 导航 Home › Navigate › Bookmarks 主页 › 导航 › 书签 Home › Web Data 主页 › Web 数据 @@ -1220,7 +1311,7 @@ Home › Web Data › United States 主页 › Web 数据 › 美国 Homepage 主页 Horizontal 水平 host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres -host=… dbname=… user=…, or a .duckdb file host=… dbname=… user=…,或 .duckdb 文件 +host=… dbname=… user=… host=… dbname=… user=… Hosted Imagery 托管影像 Hour 小时 How the layer's colours combine with the tile layers and basemap beneath. 图层颜色与下方瓦片图层和底图的混合方式。 @@ -1248,6 +1339,7 @@ Imagery Sharing 影像共享 Imagery Tools 影像工具 ImageServer ImageServer Image… 图像… +Imperial 英制 Import 导入 Import\nProject 导入\n工程 Import All 全部导入 @@ -1267,6 +1359,7 @@ Import Task 导入任务 Import Task… 导入任务… Import Thumbnail 导入缩略图 Import… 导入… +IN IN In Beyond 放大超过 In memory 内存中 In Progress 进行中 @@ -1281,6 +1374,8 @@ Information 信息 INPUT 输入 Input 输入 Input Features 输入要素 +Input Layer 输入图层 +Input Points 输入点 Input Polygons 输入多边形 Input Rows 输入行 Input Summary Features 输入汇总要素 @@ -1305,8 +1400,10 @@ Installed plugins are kept in {folder} 已安装的插件保存在 {folder} Installed with Homebrew, winget, the AUR or COPR? Update it there. 通过 Homebrew、winget、AUR 或 COPR 安装?请在那里更新。 Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. 已安装 {name} {version}。可在“插件”选项卡中找到它;其工具也位于工具窗格中。 Installing it downloads Python {version} and the packages it needs from the internet. 安装时会从互联网下载 Python {version} 及其所需的软件包。 +Integer field: values are rounded. 整数字段:值将被四舍五入。 Interface font: {font} 界面字体:{font} Interpolate a surface from points (IDW) 由点插值生成表面(IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. 使用普通克里金法根据点要素插值生成双波段栅格表面(预测值和方差)。 Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. 与 QGIS 相同,使用反距离加权法根据要素值插值生成栅格表面。 Interpolation 插值 Interpolation Attribute 插值属性 @@ -1316,10 +1413,13 @@ Invalid: {error} 无效:{error} Invert 反转 Invert color scheme 反转配色方案 Invert the selection. 反转选择。 +IS IS IS NULL IS NULL it connects to an address on the internet 它会连接到互联网上的地址 It has no code: nothing in it runs. 它不含代码:其中没有任何内容会运行。 +It is locked, or one of its surrounds is: unlock it first 它或其某个周边元素已锁定:请先解锁 it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) 它仅限您本人执行(插件、脚本、任务、模型和 GeoLibre 运行器) +it needs input: open it from its pane or button, or run it from a script or the assistant 它需要输入:请从其窗格或按钮打开,或通过脚本或助手运行 it reads or writes files on this computer 它会读取或写入此计算机上的文件 it replaces the open project 它会替换当前打开的工程 it runs a plugin's command 它会运行插件的命令 @@ -1332,20 +1432,30 @@ Italic 斜体 Item Pop-ups 项目弹出窗口 Item {id} 项目 {id} Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. 其防火墙({firewall})阻止了 RustGIS。该服务可能只允许部分应用程序访问:请联系其管理员并将以下详细信息发送给他们。 +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) 其工具当前不可用(插件的工具未加载,或工具套件未安装) Jobs 作业 JOIN JOIN Join 连接 +Join Features 连接要素 +Join Operation 连接操作 +Joins attributes from a join layer to a target layer based on their spatial relationship. 根据空间关系将连接图层的属性连接到目标图层。 +Journal figure, 1 column (90 mm) 期刊插图,单栏(90 mm) +Journal figure, 1.5 columns (140 mm) 期刊插图,1.5 栏(140 mm) +Journal figure, 2 columns (190 mm) 期刊插图,双栏(190 mm) Keep a history of every edit to the layer, to look back or restore. 保留图层每次编辑的历史记录,以便回顾或恢复。 Keep an archive of edits 保留编辑存档 keep current 保持当前 +Keep it from being moved, resized, deleted or edited by mistake 防止其被误移动、调整大小、删除或编辑 Keep one value of each point's vector as its value field 保留每个点向量中的一个值作为其值字段 Keep Result 保留结果 Keep the labelled features fixed as the map moves. 地图移动时保持已标注要素不变。 Keep the last drawing tool active after each graphic. 每次绘制图形后保持上一个绘图工具处于活动状态。 Keep the last tool active 保持上一个工具处于活动状态 +Keep this endpoint under Data Sources › Connections, without its password or keys 将此端点保存在数据源 › 连接下,不含其密码或密钥 Keep this result 保留此结果 Keep Tool 保持工具 Keep value 保留值 +Key Key Keyboard Shortcuts 键盘快捷键 Keyboard shortcuts. 键盘快捷键。 Keys from 密钥来源 @@ -1356,13 +1466,20 @@ KMZ KMZ Label 标注 Label Class 标注类 Label Distance Range 标注距离范围 +Label features in this layer 标注此图层中的要素 Label Field 标注字段 +Label only the features that match. 仅标注匹配的要素。 Label Placement 标注放置 Label Range 标注范围 Label the cities with their names 用名称标注城市 Label Visibility Range 标注可见范围 Labeling 标注 +Labeling Pane 标注窗格 +Labeling Properties… 标注属性… Labels 标注 +labels are drawn for feature layers, not rasters 标注只为要素图层绘制,不为栅格绘制 +Labels are drawn for feature layers, not rasters. 标注只为要素图层绘制,不为栅格绘制。 +Labels are off for this layer: the clause applies once they are on. 此图层的标注已关闭:开启后子句才会生效。 Lagoon 泻湖 Lambert Conformal Conic 兰伯特等角圆锥投影 Lambert Cylindrical Equal Area 兰伯特等积圆柱投影 @@ -1383,12 +1500,14 @@ Layer Blend 图层混合 Layer Extrusion 图层拉伸 Layer File 图层文件 Layer From\nSelection 从所选内容\n创建图层 +Layer not found 未找到图层 Layer Template 图层模板 Layer Templates 图层模板 layer {id} 图层 {id} Layers 图层 Layers ({n}) 图层({n}) Layers and Table Views 图层和表视图 +Layers, a date each 图层,每个对应一个日期 Layout 布局 Layout\nFile 布局\n文件 Layout after: {name} 之后的布局:{name} @@ -1402,7 +1521,7 @@ Learning\nResources 学习\n资源 Left 左侧 Legend 图例 Legend label. Clear it to show the value again. 图例标签。清除后将重新显示值。 -Length {length} · gain {gain} m · loss {loss} m 长度 {length} · 上升 {gain} m · 下降 {loss} m +Length {length} · gain {gain} · loss {loss} 长度 {length} · 上升 {gain} · 下降 {loss} Let the assistant hand tasks to agents on this computer 允许助手把任务交给这台电脑上的智能体 Let your Agents Control RustGIS 让你的智能体控制 RustGIS Letter spacing 字间距 @@ -1439,6 +1558,7 @@ List by drawing order 按绘制顺序列出 List by selection 按选择列出 List Columns in File 列出文件中的列 List Commands 列出命令 +List Connections 列出连接 List Data Sources 列出数据源 List Database Columns 列出数据库列 List Database Tables 列出数据库表 @@ -1475,6 +1595,11 @@ Locate… 定位… Location 位置 Locator 定位器 Lock 锁定 +Locked 已锁定 +Locked: unlock it in the Format pane to move or change it 已锁定:请在格式窗格中解锁后再移动或更改 +Log In to Connection 登录连接 +Log In to {name}… 登录 {name}… +Log In… 登录… Long 长整型 Longitude 经度 Longitude (X) 经度(X) @@ -1493,6 +1618,7 @@ Manage 管理 Manage Bookmarks 管理书签 Manage Edits 管理编辑 Manage Templates 管理模板 +Mann–Kendall Mann–Kendall Map 地图 Map\nFrame 地图\n框 Map (the current view) 地图(当前视图) @@ -1504,6 +1630,7 @@ Map File 地图文件 Map Frame 地图框 Map Frames 地图框 Map frames per second (live while the map moves) 地图每秒帧数(地图移动时实时显示) +Map Labeling 地图标注 Map Link 地图链接 Map name 地图名称 Map Page 地图页面 @@ -1515,6 +1642,7 @@ Map style (JSON) 地图样式 (JSON) Map Surrounds 地图整饰要素 Map Thumbnail 地图缩略图 Map tiles of the whole world 全球地图瓦片 +Map Tool: {tool} 地图工具:{tool} Map View 地图视图 map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} Map: {kind} 地图:{kind} @@ -1525,9 +1653,11 @@ MapServer MapServer Marker 标记 Mask Values 掩膜值 Masking 掩膜 +Match Option 匹配选项 Matching places 匹配的地点 Max 最大值 Max % 最大 % +Max Neighbors 最大邻域数 Maximum zoom 最大缩放级别 MCP Relay MCP 中继 MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCP 中继已在 127.0.0.1:{port} 监听;请先停止它,再使用端口 {asked} @@ -1542,6 +1672,8 @@ Measure Distance 测量距离 Measure geodesic distances and areas on the map. 在地图上测量测地距离和面积。 Measure on this body, with one of its basemaps. 在此天体上使用其某个底图进行测量。 Measure with an elevation raster 使用高程栅格测量 +Measurement units 测量单位 +Measurement units must be metric or imperial 测量单位必须为公制或英制 Measurements so far. 迄今为止的测量结果。 Mensuration 量测 Mensuration Tools 量测工具 @@ -1551,6 +1683,7 @@ Merges polygons that share a field value into one feature per value. 将字段 Messages appear here. 消息显示在此处。 Meters 米 Method 方法 +Metric 公制 Midnight 午夜 Miles 英里 Miller Cylindrical 米勒圆柱投影 @@ -1560,21 +1693,43 @@ Min must be less than Max 最小值必须小于最大值 Min must not be more than Max 最小值不能大于最大值 Minimum Bounding Geometry 最小边界几何 Minimum Maximum 最小值-最大值 +Minus Minus Minutes 分钟 minutes ago 分钟前 Miter 斜接 Mobile Map 移动地图 Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z Model 模型 Model\nBuilder 模型\n构建器 model id 模型 ID Model output 模型输出 ModelBuilder 模型构建器 Models 模型 +Modified Mann–Kendall (Hamed & Rao) 修正 Mann–Kendall(Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) 修正 Mann–Kendall(Yue & Wang) Modify 修改 Modify Selected Features 修改所选要素 Mollweide (equal-area world) 摩尔威德投影(等积世界) Month 月 +Months 月份 +Months are numbers from 1 to 12 (12, 1, 2 for December to February) 月份为 1 到 12 的数字(12、1、2 表示十二月至二月) More 更多 More basemaps: 更多底图: Mosaic images into an orthomosaic 将影像镶嵌为正射镶嵌 @@ -1583,13 +1738,15 @@ Mosaic the visible rasters into an orthomosaic. 将可见栅格镶嵌为正射 Most cloud cover (%) 最大云量(%) Mouse wheel 鼠标滚轮 Move 移动 -Move (m) 移动 (m) Move Bookmark 移动书签 Move Down 下移 Move down 下移 Move Group 移动分组 +Move Label Class 移动标注类 Move Task Step 移动任务步骤 +Move To Bottom 移至底部 Move To Group 移至分组 +Move To Top 移至顶部 Move To {group} 移至 {group} Move Up 上移 Move up 上移 @@ -1599,6 +1756,7 @@ MultiLineString MultiLineString MultiPolygon MultiPolygon My Buckets 我的存储桶 My OGC Layer 我的 OGC 图层 +MySQL/MariaDB MySQL/MariaDB N/S/E/W N/S/E/W Name 名称 name the plugin whose panel to open: {"id": plugin id} 请指定要打开其面板的插件:{"id": plugin id} @@ -1633,13 +1791,15 @@ New\nNotebook 新建\n笔记本 New\nPresentation 新建\n演示文稿 New\nReport 新建\n报表 New\nWorkspace 新建\n工作空间 -New / Open / Save project 新建 / 打开 / 保存工程 New Bookmark 新建书签 New Bookmark… 新建书签… +New Connection 新建连接 +New Connection ▾ 新建连接 ▾ New conversation 新对话 New Custom Template 新建自定义模板 New Custom Template… 新建自定义模板… New features use this template's values 新要素使用此模板的值 +New field 新字段 New Group 新建分组 New job name 新作业名称 New Layout 新建布局 @@ -1649,6 +1809,7 @@ New Notebook 新建笔记本 New Presentation 新建演示文稿 New Project 新建工程 New project 新建工程 +New Publication Layout 新建出版布局 New Relationship… 新建关系… New Report 新建报表 New Selection 创建新选择内容 @@ -1674,11 +1835,14 @@ No bookmarks yet. New Bookmark saves the current view of the map. 尚无书签 No catalogs below this one: Find Items lists its items 此目录下没有子目录:“查找项目”会列出其项目 No COMID was returned for this point. 未返回此点的 COMID。 no command 没有命令 -No connections this session 本次会话没有连接 +No data at the points picked. 所选点处无数据。 No datasets match. 没有匹配的数据集。 No datasets with data RustGIS can add 没有包含 RustGIS 可添加数据的数据集 +no date in the name 名称中没有日期 No edits since the project was saved 自工程保存以来没有编辑 No field of this layer holds dates. 此图层没有包含日期的字段。 +No Fill 无填充 +No geometry to calculate 没有可计算的几何 No granules in this view and date range. 此视图和日期范围内没有颗粒。 no home folder 没有主文件夹 no layer 无图层 @@ -1705,6 +1869,7 @@ No relationships (Data Design › Relationships) 无关系(数据设计 › No relationships yet 暂无关系 No reports yet. Publish › New Report lists the selected layer's records. 暂无报表。“发布 › 新建报表”会列出所选图层的记录。 no row 无行 +No serif font is installed, so this text is drawn in the sans-serif 未安装衬线字体,因此此文本以无衬线字体绘制 no service or file links 没有服务或文件链接 No services match your search. 没有与搜索匹配的服务。 No standalone tables 无独立表 @@ -1718,14 +1883,16 @@ Nominatim Nominatim None 无 None (public buckets) 无(公共存储桶) none, a .duckdb file, or host=… dbname=… user=… 无、.duckdb 文件,或 host=… dbname=… user=… -none, or from PGPASSWORD or MYSQL_PWD 无,或取自 PGPASSWORD 或 MYSQL_PWD +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD 无,或来自 PGPASSWORD、MYSQL_PWD 或 SQLCMDPASSWORD None. Add one by address, or from a basemap plugin. 无。可通过地址添加,或从底图插件添加。 North 北 North\nArrow 指北\n针 North Polar Stereographic 北极球面投影 +NOT NOT not a picture 不是图片 not a RustGIS layout 不是 RustGIS 布局 not a RustGIS project 不是 RustGIS 工程 +Not applied yet. 尚未应用。 Not for texts: a text is edited in the Format pane. 不适用于文本:文本在格式窗格中编辑。 not needed 无需填写 Not sandboxed 未沙箱隔离 @@ -1739,9 +1906,12 @@ Nothing found where the map is 地图所在位置未找到任何内容 Nothing here 此处为空 Nothing named {name} to rename 没有名为 {name} 的项目可重命名 Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. 尚未共享任何内容。“发布 › 共享为”可创建 Web 地图、图层、样式、场景等。 +Nothing to add here 此处没有可添加的内容 +Nothing to test 没有可测试的内容 Nothing was found at this address. 此地址未找到任何内容。 Nothing was recorded, so no task was made. 未录制任何内容,因此未创建任务。 Now 现在 +Nugget 块金 NULL NULL Nulls 空值 Observations (observer, date, species, count) 观测(观测者、日期、物种、数量) @@ -1766,6 +1936,7 @@ On the desktop, the key can also come from {vars}. 在桌面版中,密钥也 On-screen compass, zoom slider and pan pad. 屏幕指南针、缩放滑块和平移面板。 One command per line: id {json}. Lines starting with # are comments. 每行一个命令:id {json}。以 # 开头的行是注释。 One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. 一条 DuckDB SELECT 语句:可使用 DuckDB Spatial 的 ST_ 函数、文件和网址(read_parquet('…'))以及下方数据库中的表。按 Ctrl+Enter 运行。 +One layer: a Zarr variable or a raster's bands 一个图层:Zarr 变量或栅格的各波段 One page per feature of an index layer. 索引图层的每个要素一页。 One per line: code=name 每行一个:代码=名称 One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. 来自符号的一条描边。添加描边可绘制轮廓线、平行线和偏移线。 @@ -1789,6 +1960,7 @@ Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimise Open folder 打开文件夹 Open In Geoprocessing… 在地理处理中打开… Open in ModelBuilder 在模型构建器中打开 +Open in Tool 在工具中打开 Open Layout 打开布局 Open ModelBuilder 打开模型构建器 Open Notebook 打开笔记本 @@ -1797,6 +1969,7 @@ Open Presentation 打开演示文稿 Open Project 打开工程 Open project 打开工程 Open Project (from text) 打开工程(从文本) +Open Project… 打开工程… Open Report 打开报表 Open the attribute table of the selected layer. 打开所选图层的属性表。 Open the Basemaps tab 打开底图选项卡 @@ -1836,10 +2009,12 @@ Or run in a POSIX shell, for Claude Code or Codex: 或在 POSIX shell 中运行 Or run in PowerShell, for Claude Code or Codex: 或在 PowerShell 中运行(适用于 Claude Code 或 Codex): Or run, for Claude Code or Codex: 或者,对于 Claude Code 或 Codex,运行: ORDER BY ORDER BY +Ordinary Kriging 普通克里金 Orientation 方向 Ortho Mapping 正射制图 Other 其他 Other Data 其他数据 +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. 此图层上的其他标注类(通过命令创建):{n}。每个标注类标注其自身子句匹配的要素,因此一个要素可能从多个标注类获得标注。 Out Beyond 缩小超过 Outline 轮廓 Outline another frame's extent in this map frame. 在此地图框中勾勒另一个地图框的范围。 @@ -1851,6 +2026,7 @@ Output 输出 Output Name 输出名称 Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. 1000 多个在浏览器中运行的栅格、矢量、LiDAR、水文和地形工具,只需一次约 25 MB 的下载。 Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. 1000 多个栅格、矢量、LiDAR、水文和地形工具,约 20 MB 的可选下载。 +Overlapping 重叠 Overlay 叠加 overlay 叠加 Overlay (a transparent layer kept on top, such as labels or roads) 叠加(保持在最上层的透明图层,如注记或道路) @@ -1858,6 +2034,7 @@ Overture has no {ty} data where the map is Overture 在地图所在范围内没 Overture Maps Overture Maps Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Overture Maps 的开放数据:建筑物、地点、交通、行政区划、基础图层和地址。地图所在范围内的要素从 Overture 的 GeoParquet 文件就地读取(需缩放到城市或更小范围);全球范围则以地图瓦片提供。 Overture Maps… Overture Maps… +Overwrite existing values 覆盖现有值 Package 打包 Package a trained deep learning model. 打包已训练的深度学习模型。 Package Map 打包地图 @@ -1907,8 +2084,12 @@ Performance 性能 Phase 阶段 Photon Photon Pick a live value, such as the scale or date, then click the map to place it. 选择一个动态值(如比例尺或日期),然后单击地图将其放置。 +Pick a Point 选取点 +Pick a point on the map to see the variable's values through time there. 在地图上选取一个点,查看该处变量随时间变化的值。 Pick a route in the list, or click a grey route on the map. 在列表中选择一条路线,或单击地图上的灰色路线。 +Pick a single layer. 请选择单个图层。 Pick it on the map 在地图上选取 +Pick one… 选择一项… Picture 图片 Pictures 图片 Pictures, attachments and chart 图片、附件和图表 @@ -1966,8 +2147,11 @@ Portal 门户 Portolan Portolan Portolan… Portolan… Portrait 纵向 +Position 位置 +PostgreSQL/PostGIS PostgreSQL/PostGIS Postprocessing 后处理 power 2 幂次 2 +Precision 6 (OSRM, Valhalla) 精度 6(OSRM、Valhalla) Prepare 准备 Prepare every layer's geometry for this projection. 为此投影准备每个图层的几何。 Prepare Plugin Environment 准备插件环境 @@ -2009,19 +2193,24 @@ Project templates: start projects from a saved setup. 工程模板:基于已 project's path is required 需要提供工程的路径 Projection 投影 Projections 投影 +Properties 属性 +Property 属性 Proportional Symbols 比例符号 Provider 提供商 +Provinces: 省份: Proximity 邻域 Public GIS portals of US counties and cities, by state. 美国县市的公共 GIS 门户,按州列出。 Public GIS portals of US federal agencies, by department. 美国联邦机构的公共 GIS 门户,按部门列出。 Public GIS portals of US states and the District of Columbia. 美国各州和哥伦比亚特区的公共 GIS 门户。 Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. ArcGIS Online 或其他 ArcGIS 门户上的公共图层:要素、地图、影像和矢量瓦片服务、Web 地图和 GeoJSON 文件。 +Publication Layout 出版布局 Publish 发布 Publish › Output 发布 › 输出 Put the layer back as it was then 将图层恢复到当时的状态 Python environment failed: {error} Python 环境准备失败:{error} Python environment not prepared Python 环境尚未准备 Python environment ready Python 环境已就绪 +Python script Python 脚本 Qualitative 定性 Quantile 分位数 Query files, web addresses and databases with DuckDB Spatial; add results to the map. 使用 DuckDB Spatial 查询文件、网址和数据库;将结果添加到地图。 @@ -2036,6 +2225,8 @@ Raise polygons by a height or a field. 按高度或字段抬升多边形。 Raise Priority 提高优先级 Random points in polygons 多边形内随机点 Range 范围 +Range (Degrees) 变程(度) +range: 0, 0.9 范围:0, 0.9 Raster 栅格 Raster\nAnalysis 栅格\n分析 Raster\nInformation 栅格\n信息 @@ -2075,6 +2266,7 @@ Redo {action} 重做 {action} Red–Blue 红–蓝 Refine the search to see {n} more 细化搜索以查看另外 {n} 个 Refresh 刷新 +Refresh every connection's lists 刷新所有连接的列表 Refresh Map Series 刷新地图系列 Refresh the map series from its index layer. 从索引图层刷新地图系列。 Region 区域 @@ -2091,11 +2283,10 @@ Relationships 关系 Release 发布版本 Reload 重新加载 Reload Web Layers 重新加载 Web 图层 -Remember (without the secret) 记住(不含私密密钥) Remember the key on this computer 在此计算机上记住密钥 -Remembered: 已记住: Remove 移除 Remove All 全部移除 +Remove All Label Classes 移除所有标注类 Remove an offline area. 移除离线区域。 Remove Bookmark 移除书签 Remove Connection 移除连接 @@ -2108,11 +2299,14 @@ Remove from Favourites 从收藏夹移除 Remove From Group 从分组中移除 Remove Group 移除分组 Remove Keyframe 移除关键帧 +Remove Label Class 移除标注类 Remove Section 移除节 Remove Table 移除表 +Remove the clause. 移除子句。 Remove the last keyframe. 移除最后一个关键帧。 Remove the layer from the map. 从地图中移除图层。 Remove the strokes and draw the symbol's line alone 移除描边,仅绘制符号自身的线 +Remove this class's own strokes and draw it with the layer's 移除此类自身的描边,改用图层的描边绘制 Remove this stop 移除此途经点 Remove Tile Layer 移除瓦片图层 Remove token 移除令牌 @@ -2122,6 +2316,8 @@ Removes vertices that deviate less than a tolerance (Douglas–Peucker). 移除 Rename 重命名 Rename Bookmark 重命名书签 Rename Group 重命名分组 +Rename it to save it: a connection has this name 请重命名后再保存:已有连接使用此名称 +Rename Label Class 重命名标注类 Rename Layer 重命名图层 Rename Layout 重命名布局 Rename Map 重命名地图 @@ -2149,6 +2345,7 @@ Reset Default 重置默认值 Reset Effects 重置效果 Reset North 重置为正北 Reset north 重置为正北 +Resolution (0–30) 分辨率(0–30) Response 响应 Restore 恢复 Restore From Archive 从存档恢复 @@ -2162,6 +2359,7 @@ RGB RGB Ribbon 功能区 Right 右侧 Right-click a source to add it here 右键单击数据源即可添加到此处 +Right-click for more 右键单击查看更多 Robinson 罗宾森投影 Rotate 旋转 Rotate (°) 旋转 (°) @@ -2229,22 +2427,26 @@ S3… S3… Sample data (Natural Earth) 示例数据(Natural Earth) Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. 示例数据 © Natural Earth(公有领域)。RustGIS 采用 Apache-2.0 许可证。 Sample data: Natural Earth (public domain). Licensed under Apache-2.0. 示例数据:Natural Earth(公有领域)。采用 Apache-2.0 许可证。 +Sans-serif 无衬线 Satellite Embedding Datasets 卫星嵌入向量数据集 Satellite Embeddings 卫星嵌入向量 Satellite Embeddings… 卫星嵌入向量… Saturation 饱和度 Save 保存 Save As 另存为 +Save as Connection 保存为连接 Save as Default 保存为默认值 Save as Default Raster Style 保存为默认栅格样式 Save as Layer 保存为图层 Save As Presentation File… 另存为演示文稿文件… +Save as Python Script… 另存为 Python 脚本… Save As Report File… 另存为报表文件… Save As Report Template… 另存为报表模板… Save as {name} 另存为 {name} Save As… 另存为… Save Color Ramps 保存色带 Save Data Source 保存数据源 +Save History As Python Script 将历史记录另存为 Python 脚本 Save Layer File 保存图层文件 Save Layout File 保存布局文件 Save Map File 保存地图文件 @@ -2254,6 +2456,7 @@ save or load a pane set by name 按名称保存或加载窗格集 Save Project 保存工程 Save project 保存工程 Save Project As 工程另存为 +Save Project As… 工程另存为… Save Project Template 保存工程模板 Save Style Items 保存样式项 Save Task Item 保存任务项 @@ -2264,6 +2467,7 @@ Save the model as a file (*.model.json) 将模型保存为文件(*.model.json Save the model in the project 将模型保存到工程中 Save the open layout as a layout file. 将打开的布局保存为布局文件。 Save the panes you have open, and switch back to them. 保存当前打开的窗格,以便切换回来。 +Save the project too: the script opens its saved file, without changes made since. 也请保存工程:脚本会打开其已保存的文件,不包含此后所做的更改。 Save the project under a new name. 以新名称保存工程。 Save the project with your edits. 保存包含编辑内容的工程。 Save the project's custom color ramps to a color ramp file. 将工程的自定义色带保存到色带文件。 @@ -2275,13 +2479,16 @@ Save Web Link Chart 保存 Web 链接图表 Save Web Map 保存 Web 地图 Save Web Scene 保存 Web 场景 Saved 已保存 +Saved as the connection {name} 已保存为连接 {name} Saved as the default style for new single-band rasters. 已保存为新单波段栅格的默认样式。 +Saved connection 已保存的连接 Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). 保存的密钥以未加密形式保存在应用的首选项中(网页版保存在浏览器存储中)。 Saved models 已保存的模型 Saved symbology: apply it to the selected layer, or save the layer's. 已保存的符号系统:将其应用于所选图层,或保存图层的符号系统。 Saved View 已保存的视图 Saved {name} 已保存 {name} Saved {name}. 已保存 {name}。 +Saved {path} 已保存 {path} saving failed unexpectedly 保存意外失败 Scale 比例尺 Scale\nBar 比例\n尺 @@ -2304,7 +2511,7 @@ Search (this portal needs words)… 搜索(此门户需要输入关键词) Search Catalogue 搜索目录 Search commands — e.g. buffer, basemap, labels 搜索命令,例如:缓冲区、底图、标注 Search datasets, e.g. GEDI biomass 搜索数据集,例如 GEDI biomass -Search Distance (km) 搜索距离(km) +Search Distance 搜索距离 Search every command and its parameters 搜索所有命令及其参数 Search layers (e.g. temperature) 搜索图层(例如 temperature) Search layers, places, lat/lon… 搜索图层、地点、经纬度… @@ -2329,6 +2536,8 @@ Searching the folders… 正在搜索文件夹… Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. 此处可以搜索,但 NASA 的数据服务器不允许网页读取其文件(无 CORS),因此 GeoTIFF 和受保护数据的下载需要桌面应用。下载会在新标签页中打开文件,Earthdata Login 会在那里要求你登录。 Searching… 正在搜索… Searching… try again in a moment. 正在搜索… 请稍后重试。 +Seasonal Mann–Kendall 季节性 Mann–Kendall +Seasons a year 每年季节数 Second standard parallel 第二标准纬线 Seconds 秒 Secret access key 私密访问密钥 @@ -2389,12 +2598,13 @@ Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: ad Separator 分隔符 Sepia 棕褐色调 Sequential 连续 +Serif 衬线 Server 服务器 Service Group 服务组 Service group data exceeds 64 MB. 服务组数据超过 64 MB。 Service Import Requests 服务导入请求 Service Layers 服务图层 -Services 服务 +Session token 会话令牌 Set 设置 Set Cell Size 设置像元大小 Set Domain 设置属性域 @@ -2411,6 +2621,7 @@ Shaded 阴影 Shaded Relief 晕渲地貌 Shape 形状 Shapefile Shapefile +Shapes 形状 Share 共享 Share As 共享为 Share every layer and table as OGC API Features collections. 将每个图层和表共享为 OGC API Features 集合。 @@ -2439,7 +2650,9 @@ Show details when hovering items in the Browser view. 在浏览器视图中悬 Show different features at different scales. 在不同比例尺下显示不同要素。 Show grid 显示格网 Show Group 显示分组 +Show labels 显示标注 Show latitude/longitude lines on the basemap. 在底图上显示经纬线。 +Show Layers 显示图层 Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. 按时间显示图层:仅显示日期落在滑块时间窗口内的要素,以及该日期的带日期影像。 Show Map 显示地图 Show Me 显示位置 @@ -2455,6 +2668,7 @@ Show or hide the Tools pane. 显示或隐藏工具窗格。 Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. 显示标尺。从标尺拖出可添加参考线;将参考线拖回标尺可将其移除。 Show the bands picked, read in place as the map needs them 显示选定的波段,按地图需要就地读取 Show the description 显示描述 +Show the Labeling pane for the selected layer. 显示所选图层的标注窗格。 Show the labels that don't fit, in red. 以红色显示无法放置的标注。 Show the layer only inside another layer's polygons. 仅在另一图层的多边形内显示该图层。 Show the layer only left of a divider, to compare it with what's beneath. 仅在分隔线左侧显示该图层,以便与下方内容比较。 @@ -2491,8 +2705,10 @@ Sign-in cancelled. 登录已取消。 Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. 登录超时。请重新开始,并允许浏览器访问此计算机的回环地址。 signed in 已登录 Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. 已在此电脑上退出,但 OpenAI 未确认。你可以在 ChatGPT 设置中断开 RustGIS。 +Significance level 显著性水平 Signing in with ChatGPT works in the desktop app. 使用 ChatGPT 登录仅适用于桌面应用。 Signing the asset (Planetary Computer)… 正在为资产签名(Planetary Computer)… +Sill 基台 Simplify 简化 Simulate the drive, or save the route as a layer. 模拟驾驶,或将路线保存为图层。 Simulation 模拟 @@ -2506,6 +2722,7 @@ Skills for the AI Assistant: {names} AI 助手的技能:{names} Slideshow, PDF or PNG 幻灯片、PDF 或 PNG slight left 稍向左转 slight right 稍向右转 +Slope per 斜率单位 Slope, aspect, hillshade, NDVI, band math, thresholds. 坡度、坡向、山体阴影、NDVI、波段运算、阈值。 Slope, hillshade, NDVI and more. 坡度、山体阴影、NDVI 等。 Small scales 小比例尺 @@ -2521,6 +2738,7 @@ Source Cooperative Source Cooperative Source Cooperative… Source Cooperative… South 南 South Polar Stereographic 南极球面投影 +Spatial Join 空间连接 Speaker notes 演讲者备注 Spectral Analysis 光谱分析 Spectral indices 光谱指数 @@ -2530,6 +2748,7 @@ Split (click the line) 分割(单击线) SQL SQL SQL Pane SQL 窗格 SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL 查询通过桌面应用调用的 DuckDB 程序运行:请在桌面应用中打开此工程进行查询。 +SQL Server SQL Server SQL that must be true, e.g. pop > 0 必须为真的 SQL,例如 pop > 0 Square 正方形 Squares 正方形 @@ -2588,6 +2807,7 @@ Start over at the end while playing. 播放时到达末尾后从头开始。 Start With 启动时打开 Start-up 启动 starting with a project file needs the desktop app 使用工程文件启动需要桌面应用 +Statistic 统计量 Statistics 统计 Statistics Field 统计字段 Status 状态 @@ -2613,8 +2833,10 @@ Stretch\nType 拉伸\n类型 Stretch it over the map view, or give control points (one per line: column row lon lat). 将其拉伸到地图视图,或提供控制点(每行一个:列 行 经度 纬度)。 Stretch Type 拉伸类型 Stretch type 拉伸类型 +Stroke Levels 描边级别 Stroke {n} 描边 {n} Strokes 描边 +Strokes for 描边适用于 Style 样式 Style Items 样式项 Style › Drawing 样式 › 绘制 @@ -2624,7 +2846,10 @@ Subreport: {name} 子报表:{name} Subtypes 子类型 Subtypes, one per line: code=name 子类型,每行一个:代码=名称 Suitability Modeler 适宜性建模器 +Summarise 汇总 +Summarise over time 按时间汇总 Summarise the conversation so far, so a long chat stays within the model's context 总结目前的对话,使长对话保持在模型的上下文范围内 +Summarise Zarr Over Time 按时间汇总 Zarr Summarising the earlier conversation to keep it within the model's context… 正在总结之前的对话,使其保持在模型的上下文范围内… Summarize 汇总 Summarize\nWithin 范围内\n汇总 @@ -2658,6 +2883,8 @@ Table or layer 表或图层 Tables 表 Tags 标签 Take the data in view offline, to edit and sync back later. 将视图中的数据离线,以便编辑并在稍后同步回来。 +Target Features 目标要素 +Target Records 目标记录 Task 任务 Task Item 任务项 Task name 任务名称 @@ -2670,10 +2897,13 @@ Tennessee GIS… Tennessee GIS… Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. 田纳西州的公共 GIS 数据(Tennessee GeoData,geodata.tn.gov):地产边界、交通、卫生、水文等。 Terrain 地形 Test Test +Test Connection 测试连接 +Testing… 正在测试… Text 文本 Text Symbol 文本符号 That connection would create a loop. 该连接会形成循环。 The address fields are empty in every row. 所有行的地址字段均为空。 +The answer isn't UTF-8 text 响应不是 UTF-8 文本 the answer isn't UTF-8 text 响应不是 UTF-8 文本 The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. 助手使用某个技能时会遵循其说明。无论说明写了什么,需要你批准的命令仍会询问。 The assistant is busy; stop it or wait for it to finish. 助手正忙;请停止它或等待其完成。 @@ -2682,6 +2912,7 @@ The Basemaps plugin's tab of public basemaps. 底图插件中列出公共底图 the capture failed unexpectedly 捕获意外失败 The connection failed; trying again in {seconds} s. 连接失败;将在 {seconds} 秒后重试。 The country list couldn't be read ({e}): type a country's code 无法读取国家列表({e}):请输入国家代码 +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. 编辑输入时当前高程保持有效。单击“应用高程”以应用该值和单位。 The dataset has nothing RustGIS can add 该数据集中没有 RustGIS 可添加的内容 The environment (AWS_ACCESS_KEY_ID…) 环境变量(AWS_ACCESS_KEY_ID…) The features where the map is 地图所在范围内的要素 @@ -2693,7 +2924,9 @@ The flowtrace didn't answer: the whole flowline nearest the point is shown. 流 The installed GeoLibre runner lists no tools. 已安装的 GeoLibre 运行器未列出任何工具。 The job covers the map view 作业覆盖地图视图 The key is kept for this session only. 密钥仅在本次会话中保留。 +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. 图层显示时间滑块当前所在的步长,并在变量的日期范围内开启滑块。 The layer's file couldn't be read: {error} 无法读取图层的文件:{error} +The layer's strokes 图层的描边 the layout export failed unexpectedly 布局导出意外失败 The link doesn't say its STAC API: Find Items searches {api} 该链接未注明其 STAC API:“查找项目”将搜索 {api} The list of plugins to browse. Leave it empty for the RustGIS plugin registry. 要浏览的插件列表。留空则使用 RustGIS 插件库。 @@ -2726,6 +2959,7 @@ The project's thumbnail, from the map view. 工程的缩略图,来自地图视 The projection the map opens in. 地图打开时使用的投影。 The provider's content filter stopped the reply. 提供方的内容过滤器停止了回复。 The public server is free and shared by many: it answers light use, and may ask you to wait. 公共服务器免费且由许多人共享:它可满足轻度使用,并可能要求你等待。 +The range is two numbers, low and high (0, 0.9) 范围为两个数字,下限和上限(0, 0.9) The raster's data couldn't be read. 无法读取栅格数据。 The raster's data couldn't be read: {path} 无法读取栅格数据:{path} the release isn't known 未知的发布版本 @@ -2747,26 +2981,42 @@ The Shapefile's files download as one zip. Shapefile 的各个文件将打包为 The share of each cell covered by fields, for the zoomed-out view 每个格网单元被田块覆盖的比例,用于缩小后的视图 The sign-in expired or was revoked. Sign in again. 登录已过期或被撤销。请重新登录。 The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. 登录信息保存在应用的设置文件夹中,仅供您的用户账户使用,绝不会写入项目文件。 +The significance level is a number, such as 0.05 显著性水平为一个数字,例如 0.05 The start-up project {path} didn't open: {error} 无法打开启动工程 {path}:{error} The start-up project {path} isn't a RustGIS project file. 启动工程 {path} 不是 RustGIS 工程文件。 The step's layer {name} isn't in the map. 此步骤的图层 {name} 不在地图中。 The store has no variables with two or more dimensions. 该存储中没有二维或更多维的变量。 the store is gone 该存储已不存在 +The summary couldn't start: {error} 无法开始汇总:{error} +The summary failed unexpectedly 汇总意外失败 +The summary finished after another project was opened, so it wasn't added 汇总完成时已打开另一个工程,因此未添加 The task's steps changed, so the step being edited was closed: edit it again. 任务的步骤已更改,因此正在编辑的步骤已关闭:请重新编辑。 the time slider is off 时间滑块已关闭 The token expires on {date}. 令牌将于 {date} 过期。 +The trend couldn't start: {error} 无法开始趋势分析:{error} +The trend failed unexpectedly 趋势分析意外失败 +The trend finished after another project was opened, so it wasn't added 趋势分析完成时已打开另一个工程,因此未添加 +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. 从黑到白显示的值范围,对每个日期和瓦片相同(使用波段自身的单位)。留空:每个文件使用自身的拉伸。 the view has no extent 视图没有范围 The view is larger than one read: its centre was read (zoom in for the rest) 视图大于单次读取范围:已读取其中心部分(放大以查看其余部分) The web app always runs the latest release: reload the page to get it. Web 应用始终运行最新版本:重新加载页面即可获取。 the window's screenshot didn't come back in time, so the map was drawn offscreen 窗口截图未能及时返回,因此已在屏幕外绘制地图 the window's screenshot didn't hold the map, so it was drawn offscreen 窗口截图中不包含地图,因此已在屏幕外绘制 Theme 主题 +There is no expression defined. 未定义表达式。 Thermal 热力 +Thesis figure, full page (160 × 225 mm) 论文插图,整页(160 × 225 mm) +Thesis figure, half page (160 × 110 mm) 论文插图,半页(160 × 110 mm) Thinking… 思考中… +Third-party licences 第三方许可证 +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt this build can't download plugins 此版本无法下载插件 this build can't reach AI providers 此版本无法连接 AI 提供商 this build saves projects with Save As 此版本通过“另存为”保存工程 This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. 此 ChatGPT 账号已保存。下次请在列表中选择它,而不是再次添加。 +This class is drawn with the layer's strokes. Changing them here gives it its own. 此类使用图层的描边绘制。在此处更改将为其设置独立的描边。 +This class is hidden, so it labels nothing. 此类已隐藏,因此不标注任何要素。 +This clause no longer reads, so no feature is labelled: {error} 此子句已无法解析,因此没有要素被标注:{error} This map has no layers yet. 此地图中还没有图层。 This map's thumbnail, from an image. 此地图的缩略图,来自图像。 This map's thumbnail, from the view. 此地图的缩略图,来自视图。 @@ -2775,6 +3025,8 @@ This plugin runs as a program on your computer, outside RustGIS's sandbox. It ca This request has no one-time allow option. 此请求没有“仅允许一次”选项。 This request uses an unsupported capability. Check the parameter and change the request. 此请求使用了不受支持的功能。请检查参数并修改请求。 This result is kept 此结果将被保留 +This run has no tool form to open 此次运行没有可打开的工具表单 +This session only (optional) 仅限本次会话(可选) This step 此步骤 This step names no tool to show. 此步骤未指定要显示的工具。 Thumbnail 缩略图 @@ -2789,15 +3041,21 @@ Tilt Map 倾斜地图 Tilt-shift 移轴 Tilt-shift blur and film grain. 移轴模糊和胶片颗粒。 Time 时间 +Time\nSeries 时间\n序列 Time\nSlider 时间\n滑块 Time Fields 时间字段 +Time Series 时间序列 +Time series 时间序列 +Time Series at a Point 某点处的时间序列 Time Slider 时间滑块 Time Slider Settings 时间滑块设置 Tint by elevation 按高程着色 Tissot's Indicatrix 蒂索指线 Title 标题 +To 至 Toggle Dark Mode 切换深色模式 Toggle GPU Rendering 切换 GPU 渲染 +Toggle Imperial Measurements 切换英制测量单位 Tolerance (km) 容差(km) Tolls 收费路段 Tool Gallery 工具库 @@ -2828,7 +3086,10 @@ Train Deep Learning Model 训练深度学习模型 Trained models 已训练的模型 Transparency 透明度 Traverse 导线 +Trend Analysis 趋势分析 +Trend over a series of rasters 栅格序列的趋势 Triangle 三角形 +TRUE TRUE Try again in a moment. 请稍后再试。 Try again later. 请稍后重试。 Turn editing on or off for the map. 打开或关闭地图的编辑。 @@ -2846,6 +3107,8 @@ type a longitude and latitude, or click the map 输入经度和纬度,或单 Type a scale, such as 1:12,345, 12345 or 25k, and press Enter 输入比例尺,例如 1:12,345、12345 或 25k,然后按 Enter Type an address or coordinates and press Enter, or click the map. 输入地址或坐标并按 Enter,或单击地图。 Type numbers separated by commas 输入以逗号分隔的数字 +Type the keys to log in for this session, then Save. 输入密钥以在本次会话中登录,然后单击“保存”。 +Type the password or keys to log in for this session, then Save. 输入密码或密钥以在本次会话中登录,然后单击“保存”。 Typed below 在下方输入 UM UM Unavailable: {reason} 不可用:{reason} @@ -2853,7 +3116,6 @@ Unbind From Time Slider 从时间滑块解除绑定 Unbind from Time Slider 从时间滑块解除绑定 Undo 撤销 Undo (Ctrl+Z) 撤销(Ctrl+Z) -Undo / Redo 撤销 / 重做 Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. 撤销不会移除已录制的步骤:请在停止录制后在“编辑步骤”中将其删除。 Undo the last change. 撤销上一次更改。 Undo {action} 撤销 {action} @@ -2863,6 +3125,7 @@ Uninstall GeoLibre Tools 卸载 GeoLibre 工具 Uninstall Plugin 卸载插件 Uninstalled {id}. 已卸载 {id}。 Unique Values 唯一值 +Unit 单位 United States 美国 Units 单位 Units per step 每步单位数 @@ -2877,6 +3140,7 @@ Up a level 向上一级 Update 更新 Update Bookmark 更新书签 Update Job 更新作业 +Update Label Class 更新标注类 Update to {version} 更新到 {version} Upload File 上传文件 Upload File… 上传文件… @@ -2895,6 +3159,7 @@ Use another account 使用其他账号 Use Current View 使用当前视图 Use each feature's line color 使用每个要素的线颜色 Use each feature's line width 使用每个要素的线宽 +Use the Layer's Strokes 使用图层的描边 USERPROFILE USERPROFILE USGS 3DEP DEM USGS 3DEP DEM USGS 3DEP DEM… USGS 3DEP DEM… @@ -2912,6 +3177,7 @@ Using ChatGPT plan 正在使用 ChatGPT 套餐 Utilities 公用设施 Valid combinations, one per line: a, b 有效组合,每行一个:a, b Valid expression · {n} matching rows 有效表达式 · {n} 个匹配行 +Valid: {n} of {of} features match 有效:{of} 个要素中有 {n} 个匹配 Validate 验证 Validate Model 验证模型 value (empty: no-data) 值(空:无数据) @@ -2920,8 +3186,10 @@ Vantor Open Data Vantor Open Data Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Vantor 的开放灾害影像:选择一个事件和一个阶段(事件之前或之后),然后查找项目。 Vantor… Vantor… Variable 变量 +Variogram Model 变异函数模型 Vector and raster data 矢量和栅格数据 Vector Layer 矢量图层 +vector tiles 矢量瓦片 Vector tiles (MVT) 矢量瓦片 (MVT) Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. 地理空间基础模型为每个像素或影像块生成的向量,在地图所在位置查找并以颜色显示。 Version {version} 版本 {version} @@ -2933,6 +3201,9 @@ Video file or URL 视频文件或 URL Video Page 视频页面 Videos 视频 Video… 视频… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. 越南国家坐标系(VN-2000),采用 3 度横轴墨卡托投影,并通过 7 参数 Helmert 转换至 WGS 84。 +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). 越南国家坐标系(VN-2000)UTM 48N 带(102°E–108°E,中央经线 105°E)。 +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). 越南国家坐标系(VN-2000)UTM 49N 带(108°E–114°E,中央经线 111°E)。 View 视图 View Clipping 视图裁剪 View Effects 视图效果 @@ -2952,6 +3223,9 @@ Visibility Range 可见范围 Visible 可见 Visible Layers 可见图层 Visual Effect 视觉效果 +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3(越南) +VN-2000 / UTM zone 48N VN-2000 / UTM 48N 带 +VN-2000 / UTM zone 49N VN-2000 / UTM 49N 带 Volume 体积 Waiting for the browser… 正在等待浏览器… Waiting for your answer 正在等待你的回复 @@ -2970,6 +3244,7 @@ Web Link Chart Web 链接图表 Web maps Web 地图 Web Mercator Web 墨卡托 Web Scene Web 场景 +Web service (detected) Web 服务(已检测) Web Style Web 样式 Web Tile Layers Web 瓦片图层 Web Tool Web 工具 @@ -2992,6 +3267,7 @@ Where labels sit relative to their features. 标注相对于其要素的位置 Where new features get their elevation (the z field). 新要素获取高程(z 字段)的来源。 Where the map opens: the full extent or a view you saved. 地图打开的位置:全图范围或已保存的视图。 Where tools work 工具作用范围 +Whether the Select tool picks these layers' features on the map 选择工具是否在地图上选取这些图层的要素 Which 5 countries have the largest GDP? 哪 5 个国家的 GDP 最高? Whitebox and GeoLibre tools Whitebox 和 GeoLibre 工具 Whole map 整个地图 @@ -3032,6 +3308,7 @@ World, equal-area: true sizes with rounded poles. 世界,等积: 面积真实 World, equal-area: true sizes, shapes bend towards the edges. 世界,等积: 面积真实,形状向边缘弯曲。 Worldview 国界 Write a plugin 编写插件 +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. 输出 Sen 斜率、检验的 Z 值和 p 值,以及趋势显著处的斜率。 X (mm) X(mm) X column X 列 X Field X 字段 @@ -3050,7 +3327,6 @@ Yours 我的 YYYY-MM-DD YYYY-MM-DD YYYY-MM-DD, for the layers that change with time YYYY-MM-DD,用于随时间变化的图层 YYYY-MM-DD, for time layers YYYY-MM-DD,用于时间图层 -z (m) z(m) Z factor Z 因子 Zarr Zarr Zarr Variables Zarr 变量 @@ -3061,7 +3337,6 @@ Zoom Box 拉框缩放 Zoom Frame 缩放图框 Zoom In 放大 Zoom in 放大 -Zoom in / out 放大 / 缩小 Zoom In Here 在此处放大 Zoom in on the centre of the map. 以地图中心为基准放大。 Zoom in · finish a sketch 放大 · 完成草图 @@ -3073,6 +3348,7 @@ Zoom To\nLayer 缩放至\n图层 Zoom to a box (Explore) 缩放到矩形框(浏览) Zoom To Extent 缩放至范围 Zoom To Layer 缩放至图层 +Zoom To Layers 缩放至图层 Zoom To Map View 缩放至地图视图 Zoom To Selection 缩放至所选内容 Zoom to selection 缩放至所选内容 @@ -3082,8 +3358,11 @@ Zoom to the layer's extent. 缩放至图层范围。 Zoom to the selected features. 缩放至所选要素。 Zoom to the whole world. 缩放至全世界。 Zoom to this bookmark 缩放至此书签 +Zoom To Tile Layer 缩放至瓦片图层 Zoom to Tokyo 缩放至东京 Zoom To World 缩放至世界 +Zoom {z} · Bearing {b}° · Pitch {p}° 缩放级别 {z} · 方位角 {b}° · 俯仰角 {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. 缩放:地图的缩放级别,按 Web 地图的计算方式。方位角:地图顶部朝向的方向,从正北顺时针计算。俯仰角:相对于垂直俯视的倾斜角度。单击可重置方位角和俯仰角。 {agent} was stopped by you. 你已停止 {agent}。 {id} changed since it was installed; install it again from the Plugin Manager. {id} 在安装后已被更改;请从插件管理器重新安装。 {names}: needs Node.js (npx) for its ACP adapter. {names}:其 ACP 适配器需要 Node.js(npx)。 From d9b2de09f24d9dd4ea5a897d05e5348b12ebf833 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Fri, 23 Oct 2026 14:26:39 -0300 Subject: [PATCH 073/297] Layouts: an accent must be opaque; a column always holds the legend (#750) A translucent accent (#RRGGBBAA) would leave a banner, a poster's page or the headings faint or unseen, so it is refused, as documented (#RRGGBB). Column::legend was true wherever a column is made, so the field and the branches for false are gone (#742 review). Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/layout.rs | 28 ++++++++++++---------------- crates/engine/src/tests.rs | 1 + 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 3629a7541..e5bc00b8a 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -532,16 +532,14 @@ struct Look { radius: f64, } -/// Where a style sets the locator (and, in a panel, the legend) beside the map rather than over -/// it: the room left for them there, in page millimetres. +/// Where a style sets the locator and the legend (under it) beside the map rather than over it: +/// the room left for them there, in page millimetres. #[derive(Clone, Copy, Debug)] struct Column { x: f64, y: f64, w: f64, h: f64, - /// The legend goes here too, under the locator. - legend: bool, } /// A rectangle with no outline (`width` 0) or no fill (`fill` transparent). @@ -734,6 +732,10 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { } }; let accent = color(p, "accent", style.accent())?; + // A translucent accent would leave a band, a poster's page or the headings faint or unseen. + if accent.a() != 255 { + return Err("accent is an opaque colour as #RRGGBB".into()); + } // No orientation asked: the one the area's map comes out larger on, from its extents east to // west and north to south on the map's projection and the room the style leaves the map. let profile = match &auto { @@ -1072,8 +1074,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let (mw, cx) = (iw - cw - gap, x0 + iw - cw); behind.push(("Map Card".into(), (x0, y, mw, h), pub_card(white, card_line, 0.4, card_r))); let frame = (x0 + card_pad, y + card_pad, mw - 2.0 * card_pad, h - 2.0 * card_pad); - let column = - Column { x: cx + card_pad, y: y + card_pad, w: cw - 2.0 * card_pad, h: h - 2.0 * card_pad, legend: true }; + let column = Column { x: cx + card_pad, y: y + card_pad, w: cw - 2.0 * card_pad, h: h - 2.0 * card_pad }; (page_w, page_h, frame, Some(column)) } Style::Panel => { @@ -1147,7 +1148,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { .into(), ); } - (page_w, page_h, (x0, top, mw, room(top, panel_bottom)?), Some(Column { x: ix, y, w: tw, h: yb - y, legend: true })) + (page_w, page_h, (x0, top, mw, room(top, panel_bottom)?), Some(Column { x: ix, y, w: tw, h: yb - y })) } Style::Editorial => { // A magazine's page: the title large in serif at the top left in the accent colour, @@ -1222,12 +1223,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { .push("the column's note leaves the locator and the legend little room under it: shorten the note".into()); } let ft = y + 4.0 * k; - ( - page_w, - page_h, - (x0, ft, mw, room(ft, fb)?), - Some(Column { x: cx, y: cy, w: cw, h: (fb - cy).max(0.0), legend: true }), - ) + (page_w, page_h, (x0, ft, mw, room(ft, fb)?), Some(Column { x: cx, y: cy, w: cw, h: (fb - cy).max(0.0) })) } } } else { @@ -1380,7 +1376,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let side = match column { // In a column, its width up to an A4 portrait column's (a landscape sheet's wider // column doesn't make it larger), leaving the legend most of the height under it. - Some(c) => c.w.min(52.0 * k).min(if c.legend { 0.45 * c.h } else { c.h }).max(0.0), + Some(c) => c.w.min(52.0 * k).min(0.45 * c.h).max(0.0), None if sheet => (0.24 * fw.min(fh)).clamp(25.0 * k, 70.0 * k), None => (0.3 * fw.min(fh)).clamp(18.0, 45.0), }; @@ -1546,7 +1542,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { let (bar_w, bar_h) = (if sheet { 50.0 * k } else { 40.0 }, COLORBAR_MM); let bar_part = if bar.is_some() { bar_h + if ch > 0.0 { 2.0 } else { 0.0 } } else { 0.0 }; let (content_w, content_h) = (if bar.is_some() { cw.max(bar_w) } else { cw }, ch + bar_part); - let legend_in_column = column.is_some_and(|c| c.legend); + let legend_in_column = column.is_some(); if content_h > 0.0 && !legend_in_column { surrounds.push((Surround::Legend, (content_w + 2.0 * pad).min(fw * 0.45), (content_h + 2.0 * pad).min(fh * 0.6), 2)); } @@ -1669,7 +1665,7 @@ fn publication(s: &mut Session, p: &Value) -> CmdResult { fit.map(|(lw, lh)| (corner, lw, lh)) }); // In a panel, the legend under the locator, as tall as it needs within what is left. - let legend_rect = match column.filter(|c| c.legend) { + let legend_rect = match column { Some(c) if content_h > 0.0 => { let ly = c.y + if locator_map.is_some() { side + 3.0 * k + if card { 2.0 * card_pad } else { 0.0 } } else { 0.0 }; let room_h = c.y + c.h - ly; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d8e9f95cb..0a44d4a44 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2904,6 +2904,7 @@ fn publication_layouts_take_a_style() { json!({"extent": piaui, "style": "banner", "accent": "blue"}), json!({"extent": piaui, "style": 3}), json!({"extent": piaui, "accent": 7}), + json!({"extent": piaui, "style": "poster", "accent": "#1F2D4D80"}), ] { assert!(s.run("layout.publication", &bad).is_err(), "{bad}"); } From 8e9267d53ae009b573a8d86fa397e4bb459c7185 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Fri, 23 Oct 2026 22:12:29 +0200 Subject: [PATCH 074/297] feat(map): a layer's reference scale (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(map): a layer's reference scale A layer can be given the scale its symbol and label sizes are set for (`Layer::reference_scale`, QGIS's reference scale). Drawn at another scale, its lines, markers and labels grow and shrink with the map, as on a printed map: at half the reference scale twice as large, at twice it half as large, kept within 1/100 and 32 times. - map: `reference_scale` (skipped when unset; a project's out of 1..1e9 is none) and `Layer::size_factor`. - render: the layer is drawn with its points that much wider in pixels (one `Ctx` per scaled layer, so the CPU and GPU paths both follow), and its labels that much larger. - engine: `layer.referenceScale={"scale": N|"current"|null}`. - ui: Reference scale in the Style pane's Layer section: none, the usual scales, or the map's current one. - docs: cartography.md › Reference scale, with the 0.28 mm pixel RustGIS takes for its scale against 96 dpi. * fix(render): scale a layer once per draw, and its upright markers, for its reference scale The scaled drawing context is built once per layer instead of per batch of a streamed layer (keeping its marker sprites), and also covers what is drawn after the batches (aggregation bins, extrusions). Point markers standing up in a tilted view take the factor too. "current" refuses a map without a scale, and the picker marks Current scale when it is the layer's. * fix(render): scale a label's gap with its reference scale, and a tilted view's labels at its own scale A label is placed as at its own size and then moved that much farther out, so the fixed gap to its feature grows with it. The labels a tilted view's bands hand back are sized at the view's scale, as its upright markers are, not each band's. Tests: the GPU meshes' stroke widths, and a point label's size and distance at twice the reference scale. * refactor(render): the scaled Ctx copies the view's fields by struct update --- crates/engine/src/cmd/layer.rs | 23 ++++++ crates/engine/src/tests.rs | 41 +++++++++++ crates/map/src/lib.rs | 56 +++++++++++++++ crates/render/src/lib.rs | 125 ++++++++++++++++++++++++++++++--- crates/ui-egui/src/panes.rs | 33 +++++++++ docs/user-guide/cartography.md | 17 +++++ 6 files changed, 286 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 7d98fe36a..0ec7d5631 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -220,6 +220,29 @@ pub fn commands() -> Vec { }) .when(need_layer) .params(r#"{"layer"?, "min"?: scale (out beyond), "max"?: scale (in beyond)}"#), + C::new("layer.referenceScale", "Reference Scale", "Style › Drawing", |s, p| { + let (lo, hi) = rustgis_map::REFERENCE_SCALES; + let scale = match p.get("scale") { + None | Some(Value::Null) => None, + Some(Value::String(t)) if t.eq_ignore_ascii_case("current") => { + let now = s.map().scale().round(); + if !now.is_finite() { + return Err("the map has no scale here".into()); + } + Some(now.clamp(lo, hi)) + } + Some(_) => Some(f(p, "scale").filter(|v| (lo..=hi).contains(v)).ok_or("scale is a number from 1 to 1000000000, \"current\" or null")?), + }; + let id = s.layer_id(p)?; + let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; + if l.is_raster() || l.graphics { + return Err("this works on feature layers".into()); + } + l.reference_scale = scale; + Ok(json!({"scale": scale})) + }) + .when(need_feature_layer) + .params(r#"{"layer"?, "scale": 1:N the symbols' and labels' sizes are set for (drawn at another scale they grow and shrink with the map)|"current" (the map's scale)|null (sizes stay as set on screen)}"#), C::new("layer.selectable", "Selectable", "Layers", |s, p| { let ids = layer_ids(s, p)?; let on = b(p, "on").unwrap_or_else(|| { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0a44d4a44..48de17146 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -528,6 +528,47 @@ fn strokes_draw_in_levels() { check(&s); } +#[test] +fn a_reference_scale_is_set_kept_and_undone() { + let mut s = world(); + let depth = s.undo_depth(); + s.run("layer.referenceScale", &json!({"layer": "Rivers", "scale": 25000})).unwrap(); + // "current" takes the map's scale, rounded. + let now = s.map().scale().round(); + s.run("layer.referenceScale", &json!({"layer": "Cities", "scale": "current"})).unwrap(); + assert_eq!(s.undo_depth(), depth + 2); + let check = |s: &Session| { + let at = |n: &str| s.map().layer_by_name(n).unwrap().reference_scale; + assert_eq!((at("Rivers"), at("Cities"), at("Lakes")), (Some(25000.0), Some(now), None)); + }; + check(&s); + let dir = std::env::temp_dir().join(format!("rustgis-reference-scale-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("reference.rgis"); + s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); + check(&t); + t.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + let depth = s.undo_depth(); + for bad in [ + json!({"layer": "Rivers", "scale": 0}), + json!({"layer": "Rivers", "scale": -25000}), + json!({"layer": "Rivers", "scale": 1e12}), + json!({"layer": "Rivers", "scale": "big"}), + json!({"layer": "Rivers", "scale": [25000]}), + json!({"layer": "Nowhere", "scale": 25000}), + ] { + assert!(s.run("layer.referenceScale", &bad).is_err(), "{bad}"); + } + assert_eq!(s.undo_depth(), depth); + s.run("layer.referenceScale", &json!({"layer": "Rivers", "scale": null})).unwrap(); + assert_eq!(s.map().layer_by_name("Rivers").unwrap().reference_scale, None); + s.run("edit.undo", &Value::Null).unwrap(); + check(&s); +} + #[test] fn project_save_and_open_roundtrip() { let mut s = world(); diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index cb1d1fbc0..9bb76f2e6 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -149,6 +149,11 @@ pub struct Layer { /// instead of cutting across them where lines meet. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub stroke_levels: bool, + /// The scale (1:N) its symbol and label sizes are set for: drawn at another scale they grow + /// and shrink with the map, as on paper (QGIS's reference scale). `None`: they keep their + /// size on screen. + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "de_reference_scale")] + pub reference_scale: Option, /// Layers with a higher priority draw above lower ones, whatever their order (the scene's /// depth priority). #[serde(default)] @@ -226,6 +231,7 @@ impl Layer { extrusion: None, line_strokes: Vec::new(), stroke_levels: false, + reference_scale: None, priority: 0, design: design::Design::default(), raster: None, @@ -435,6 +441,16 @@ impl Layer { (symbol, strokes.unwrap_or(&self.line_strokes)) } + /// How much its symbol and label sizes are multiplied by at the map scale 1:`scale`: its + /// reference scale over that ([`Layer::reference_scale`]), kept within [`SIZE_FACTORS`]; 1 + /// without one. + pub fn size_factor(&self, scale: f64) -> f32 { + match self.reference_scale { + Some(r) if scale.is_finite() && scale > 0.0 => (r / scale).clamp(SIZE_FACTORS.0, SIZE_FACTORS.1) as f32, + _ => 1.0, + } + } + /// Whether some feature may be drawn with stacked strokes ([`Layer::strokes`]). pub fn has_strokes(&self) -> bool { !self.graphics && (!self.line_strokes.is_empty() || self.renderer.has_class_strokes()) @@ -1589,6 +1605,20 @@ impl Map { } } +/// The reference scales a layer takes (1:N), as [`Layer::reference_scale`]. +pub const REFERENCE_SCALES: (f64, f64) = (1.0, 1e9); + +/// The most a reference scale shrinks or grows a layer's sizes ([`Layer::size_factor`]): zoomed +/// far in, its lines would otherwise be drawn thousands of pixels wide. +pub const SIZE_FACTORS: (f64, f64) = (0.01, 32.0); + +/// Reads a layer's reference scale as `layer.referenceScale` would leave it: one out of +/// [`REFERENCE_SCALES`] (or not a number) is none. A project file is hostile input. +fn de_reference_scale<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v: Option = Option::deserialize(d)?; + Ok(v.filter(|r| (REFERENCE_SCALES.0..=REFERENCE_SCALES.1).contains(r))) +} + #[cfg(test)] mod tests { use super::*; @@ -1615,6 +1645,32 @@ mod tests { ); } + #[test] + fn a_reference_scale_scales_sizes_with_the_map_and_hostile_ones_are_dropped() { + let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![])); + assert_eq!(l.size_factor(10_000.0), 1.0, "none: sizes stay as set"); + l.reference_scale = Some(25_000.0); + assert_eq!(l.size_factor(25_000.0), 1.0); + assert_eq!(l.size_factor(12_500.0), 2.0, "zoomed in twice: twice the size"); + assert_eq!(l.size_factor(100_000.0), 0.25); + assert_eq!(l.size_factor(1.0), SIZE_FACTORS.1 as f32, "zoomed far in: kept in bounds"); + assert_eq!(l.size_factor(1e12), SIZE_FACTORS.0 as f32); + for bad in [0.0, -5.0, f64::NAN, f64::INFINITY] { + assert_eq!(l.size_factor(bad), 1.0, "scale {bad}"); + } + // Saved and read back; a crafted project's out of range ones are none. + let text = serde_json::to_string(&l).unwrap(); + assert_eq!(serde_json::from_str::(&text).unwrap().reference_scale, Some(25_000.0)); + let mut v: serde_json::Value = serde_json::from_str(&text).unwrap(); + for bad in [serde_json::json!(0.0), serde_json::json!(-1.0), serde_json::json!(1e300), serde_json::Value::Null] { + v["reference_scale"] = bad.clone(); + assert_eq!(serde_json::from_value::(v.clone()).unwrap().reference_scale, None, "{bad}"); + } + // Without a reference scale nothing is written. + l.reference_scale = None; + assert!(!serde_json::to_string(&l).unwrap().contains("reference_scale")); + } + #[test] fn hostile_class_strokes_in_a_project_are_brought_into_range() { // The same for a class's own strokes, Unique Values' (and ') and diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9917ad950..f448e075b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2107,6 +2107,8 @@ pub fn upright_markers(map: &mut Map, view: &View, margin: f64) -> Vec Vec map.ground_res_at(&to.camera) / rustgis_geom::PIXEL_M, + _ => scale, + }; let data_pending = match labels_to { Labels::Tilted(..) | Labels::Shared => false, _ => map.sync_sources(&view_lonlat(map, view), scale, full_res_rasters), @@ -2668,6 +2679,17 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } else { None }; + // A layer with a reference scale draws its symbols (and labels) grown or shrunk + // with the map (built once for all of a streamed layer's batches): its points are wider or narrower in pixels. + let size_factor = layer.size_factor(scale); + let scaled_ctx; + let ctx = if size_factor == 1.0 { + &ctx + } else { + // Its own sprite cache: the sprites are drawn at the scaled size. + scaled_ctx = Ctx { ppp: ctx.ppp * size_factor, sprites: Default::default(), ..ctx }; + &scaled_ctx + }; let mut draw_chunk = |layer: &mut rustgis_map::Layer| -> rustgis_data::Result<()> { // The definition query and the display filters that apply at this scale (a feature draws // only if it matches all of them). @@ -2770,7 +2792,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let aggregate = layer.aggregation.filter(|_| layer.geometry_type() == GeometryType::Point); let extrude = layer.extrusion.as_ref().filter(|_| layer.geometry_type() == GeometryType::Polygon && globe.is_none()); if let Some(a) = aggregate { - bins.add((w, h), &ctx, &geoms, &rows, a); + bins.add((w, h), ctx, &geoms, &rows, a); aggregate_symbol.get_or_insert_with(|| layer.symbol(0)); } else if extrude.is_some() && matches!(labels_to, Labels::Tilted(..)) { // Stood up in perspective over the tilted view (`render_tilted`); its labels still @@ -2783,9 +2805,9 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } else if let Some(x) = extrude.filter(|_| !matches!(labels_to, Labels::Shared)) { let px_per_m = view.pixels_per_point / (map_ground_res(view, &flat) * body); if let Some(spool) = &mut extrusions { - spool.add(&ctx, ds_ptr, &geoms, &rows, x, px_per_m, |r| layer.symbol(r))?; + spool.add(ctx, ds_ptr, &geoms, &rows, x, px_per_m, |r| layer.symbol(r))?; } else { - effects::extrude(dest, &ctx, ds_ptr, &geoms, &rows, x, px_per_m, |r| layer.symbol(r)); + effects::extrude(dest, ctx, ds_ptr, &geoms, &rows, x, px_per_m, |r| layer.symbol(r)); } } else { let mut stroke_paths = stroke_paths::Paths::default(); @@ -2825,7 +2847,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } match g { Geometry::Line(parts) if !strokes.is_empty() && levels => stacked.push((parts, sym, strokes)), - Geometry::Line(parts) if !strokes.is_empty() => stroke_paths.draw(&ctx, dest, parts, &sym, strokes), + Geometry::Line(parts) if !strokes.is_empty() => stroke_paths.draw(ctx, dest, parts, &sym, strokes), _ => ctx.feature(dest, g, &sym), } } @@ -2843,14 +2865,14 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let Some((parts, sym, strokes)) = stacked.get(f) else { continue }; one.clear(); one.extend(strokes.iter().enumerate().filter(|(i, s)| s.level_at(*i) == level).map(|(_, s)| *s)); - stroke_paths.draw(&ctx, dest, parts, sym, &one); + stroke_paths.draw(ctx, dest, parts, sym, &one); } } cpu_dirty |= drawn_here; } if !complete_source { if let (Some(a), Some(base)) = (layer.aggregation, aggregate_symbol) { - bins.draw(if direct { &mut pm } else { &mut target }, &ctx, a, &base); + bins.draw(if direct { &mut pm } else { &mut target }, ctx, a, &base); } if !direct { composite(&mut pm, &target); @@ -2922,6 +2944,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac Vec::new() }; if label_fields.iter().any(Option::is_some) { + let label_factor = layer.size_factor(label_scale); let classes = rustgis_map::LabelMatcher::new(labels, ds_ptr); let lid = layer.id; for (row, g) in geoms.iter().enumerate() { @@ -2958,12 +2981,16 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac continue; } let text = v.to_string(); + // Placed as at its own size, then that much farther out: the gap between + // the label and its feature grows with it too. + let size = class.size * label_factor; let (dx, dy) = class.placement.offset( text::block_width(&text, class.size, class.bold), text::block_height(&text, class.size), marker, is_point, ); + let (dx, dy) = (dx * label_factor, dy * label_factor); // 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. @@ -2973,7 +3000,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac text, x: x as f32 + dx, y: y as f32 + dy, - size: class.size, + size, color: class.color, halo: class.halo.then_some(halo_for(class.color)), bold: class.bold, @@ -3029,7 +3056,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac return None; } if let (Some(spool), Some(x)) = (&mut extrusions, &layer.extrusion) - && let Err(e) = spool.draw(if direct { &mut pm } else { &mut target }, &ctx, x) + && let Err(e) = spool.draw(if direct { &mut pm } else { &mut target }, ctx, x) { if let Some(source) = &mut layer.source { source.error = Some(e.to_string()); @@ -3038,7 +3065,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac } if complete_source { if let (Some(a), Some(base)) = (layer.aggregation, aggregate_symbol) { - bins.draw(if direct { &mut pm } else { &mut target }, &ctx, a, &base); + bins.draw(if direct { &mut pm } else { &mut target }, ctx, a, &base); } if !direct { composite(&mut pm, &target); @@ -5102,6 +5129,13 @@ mod tests { let up = upright_markers(&mut m, &view, 0.3); assert_eq!(up.len(), 2); assert!(up.first().is_some_and(|u| u.selected) && up.get(1).is_some_and(|u| !u.selected)); + // With a reference scale twice the view's, they stand twice the size. + let size = up.first().map(|u| u.symbol.size).unwrap_or_default(); + let scale = m.ground_res_at(&view.camera) / rustgis_geom::PIXEL_M; + m.layer_mut(id).unwrap().reference_scale = Some(scale * 2.0); + let up = upright_markers(&mut m, &view, 0.3); + assert!(up.iter().all(|u| (u.symbol.size - size * 2.0).abs() < 1e-3), "{size}"); + m.layer_mut(id).unwrap().reference_scale = None; // A point layer seen flat draws as before, and an aggregation stays on the ground. if let Some(l) = m.layer_mut(id) { l.aggregation = Some(rustgis_map::effects::Aggregation { hexagons: true, size: 20.0 }); @@ -5441,6 +5475,79 @@ mod tests { assert_eq!(colour(&r, 60), (0, 0, 0)); } + #[test] + fn a_reference_scale_scales_lines_with_the_map() { + // A 4-point black line: as wide as set without a reference scale or at it, twice as wide + // at half the reference scale (zoomed in), half as wide at twice it. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("road", GeometryType::Polyline, vec![]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, 0.0), Coord::new(1.0, 0.0)]])), vec![]); + let id = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, 0.0)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + m.camera = camera; + let view = View { width: 200.0, height: 200.0, pixels_per_point: 1.0, camera, sky: false }; + let base = Symbol::default_for(GeometryType::Polyline, 0); + m.layer_mut(id).unwrap().renderer = rustgis_map::Renderer::Single(Symbol { stroke: Rgba::rgb(0, 0, 0), stroke_width: 4.0, ..base }); + let width = |m: &mut Map| { + let r = render(m, &view).unwrap(); + (0..200).filter(|&y| r.pixmap.pixel(100, y).unwrap().red() < 128).count() + }; + let plain = width(&mut m); + assert!((3..=5).contains(&plain), "{plain}"); + let scale = m.scale(); + for (reference, want) in [(scale, plain), (scale * 2.0, plain * 2), (scale / 2.0, plain / 2)] { + m.layer_mut(id).unwrap().reference_scale = Some(reference); + let got = width(&mut m); + assert!(got.abs_diff(want) <= 1, "reference 1:{reference:.0} at 1:{scale:.0}: {got} px, not about {want}"); + } + // The GPU path's meshes carry the same widths. + let gpu_width = |m: &mut Map| { + let Some(RenderOutput::Gpu(scene)) = render_backend(m, &view, Backend::Gpu) else { panic!("gpu scene") }; + let widths = scene.cmds.iter().flat_map(|c| match c { + gpu_scene::DrawCmd::Mesh { vertices, .. } => vertices.iter().map(|v| v.stroke_px).collect(), + _ => Vec::new(), + }); + widths.fold(0.0f32, f32::max) + }; + m.layer_mut(id).unwrap().reference_scale = None; + let plain = gpu_width(&mut m); + assert!(plain > 0.0, "the line is meshed"); + for (reference, k) in [(scale, 1.0), (scale * 2.0, 2.0), (scale / 2.0, 0.5)] { + m.layer_mut(id).unwrap().reference_scale = Some(reference); + assert!((gpu_width(&mut m) - plain * k).abs() < 1e-3, "gpu at reference 1:{reference:.0}"); + } + } + + #[test] + fn a_reference_scale_scales_labels_and_their_gap() { + // A point labelled to its right: at twice the size, the label is twice as large and twice + // as far from the point, the gap between them included. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("towns", GeometryType::Point, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![Value::Text("Winterthur".into())]); + let id = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, 0.0)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + m.camera = camera; + let view = View { width: 400.0, height: 200.0, pixels_per_point: 1.0, camera, sky: false }; + let l = m.layer_mut(id).unwrap(); + l.labels.enabled = true; + l.labels.default.field = "name".into(); + l.labels.default.placement = rustgis_map::LabelPlacement::Right; + let label = |m: &mut Map| { + let r = render(m, &view).unwrap(); + let l = r.labels.first().expect("placed"); + (l.x - 200.0, l.size) + }; + let (dx, size) = label(&mut m); + assert!(dx > 0.0, "{dx}"); + let scale = m.scale(); + m.layer_mut(id).unwrap().reference_scale = Some(scale * 2.0); + let (dx2, size2) = label(&mut m); + assert!((size2 - size * 2.0).abs() < 1e-3 && (dx2 - dx * 2.0).abs() < 0.5, "{dx} {size} → {dx2} {size2}"); + } + #[test] fn features_under_a_pixel_still_show() { // A dense grid of tiny squares seen whole: each is splatted rather than filled as a path, diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 59f7d0401..614b852aa 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3420,6 +3420,39 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix(" %").integer()); transparency_drag(app, ui, &r, &l, tr); }); + if !l.graphics { + reference_scale_row(app, ui, &l); + } +} + +/// The scales the Reference scale picker offers, besides none and the map's own. +const REFERENCE_SCALE_CHOICES: [f64; 8] = [5_000.0, 10_000.0, 25_000.0, 50_000.0, 100_000.0, 200_000.0, 500_000.0, 1_000_000.0]; + +/// The Symbology pane's Reference scale: the scale a layer's symbol and label sizes are set +/// for, so they grow and shrink with the map (`layer.referenceScale`). +fn reference_scale_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { + let current = app.session.map().scale().round(); + ui.horizontal(|ui| { + ui.label(tl!("Reference scale")).on_hover_text(tl!( + "The scale the layer's symbol and label sizes are set for. At other scales they grow and shrink with the map, as on paper." + )); + egui::ComboBox::from_id_salt("reference_scale").selected_text(crate::ribbon::scale_label(l.reference_scale)).width(130.0).show_ui( + ui, + |ui| { + let mut pick = |ui: &mut Ui, label: String, scale: Value, on: bool| { + if ui.selectable_label(on, label).clicked() { + let _ = app.run("layer.referenceScale", json!({"scale": scale})); + } + }; + pick(ui, crate::ribbon::scale_label(None), Value::Null, l.reference_scale.is_none()); + for c in REFERENCE_SCALE_CHOICES { + pick(ui, crate::ribbon::scale_label(Some(c)), json!(c), l.reference_scale == Some(c)); + } + let label = crate::i18n::fmt("Current scale ({scale})", &[("scale", &crate::ribbon::scale_label(Some(current)))]); + pick(ui, label, json!("current"), l.reference_scale == Some(current) && !REFERENCE_SCALE_CHOICES.contains(¤t)); + }, + ); + }); } /// The Symbology pane's Transparency slider: the map follows it while it's dragged, and letting diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 12222f6e8..153b938ce 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -128,6 +128,23 @@ time. By command (`"level"` on a stroke, `null` for its place in the stack): layer.strokeLevels={"layer":"Roads","on":true} ``` +### Reference scale + +Symbol and label sizes are set in points, and normally keep that size on screen at every scale. +A layer with a **Reference scale** (Symbology pane, under **Layer**) has its sizes set for that scale +instead, as on a printed map: zoomed in to half of it, its lines, markers and labels are drawn +twice as large; zoomed out to twice it, half as large (within 1/100 and 32 times). Pick a scale, +**Current scale** to keep the sizes as they are now, or **None**. A topographic map designed for +1:25,000 then keeps its proportions as you zoom. By command (a scale, `"current"` or `null`): + +```text +layer.referenceScale={"layer":"Roads","scale":25000} +``` + +Scales in RustGIS take a screen pixel to be 0.28 mm (the OGC standard pixel). Programs that take +96 pixels to the inch (0.2646 mm) call the same view a larger scale; to match their sizes, +multiply their reference scale by 0.2646 / 0.28 (about 0.945). + ### Custom color ramps Besides the built-in color schemes, a project can hold your own color ramps: an organization's From 593cf0b1251d5baeccc87c72d9022c7787ad4892 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Fri, 23 Oct 2026 17:13:55 -0300 Subject: [PATCH 075/297] Analysis: a STAC trend's keys read from cube.trend's description, so none is said to be ignored (#579) (#766) * cube.trend: a STAC collection's keys at the top of its description, so none is said to be ignored The url, asset, bbox, maxCloud, cellSize and resampling keys sat inside the "collection" entry's note, where param_keys doesn't read, so every STAC trend run from the CLI or a script was told they were ignored, though they're used. Co-Authored-By: Claude Opus 5.5 * cube.trend: the keys test refused before any search, off the network a_stac_trends_keys_are_read reached an API on a closed local port. It now gives a cell size of 0, refused before the search, and checks that error, so nothing depends on how a sandbox treats the connection (#766 review). Co-Authored-By: Claude Opus 5.5 * cube.trend: the collection's note names its keys "with the keys below" left the reader to find them; the note now names url, asset, bbox, maxCloud, cellSize and resampling, unquoted inside its parentheses, where they aren't read as keys (#766 review). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/cube.rs | 2 +- crates/engine/src/tests.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/cube.rs b/crates/engine/src/cmd/cube.rs index 03eeb036e..bc2e8cd94 100644 --- a/crates/engine/src/cmd/cube.rs +++ b/crates/engine/src/cmd/cube.rs @@ -22,7 +22,7 @@ use crate::{CmdResult, CommandSpec as C, Session}; pub fn commands() -> Vec { vec![C::new("cube.trend", "Trend Analysis", "Analyze › Image Analysis", trend_cmd).params( - r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer (or "path" | "url", "variable", "index", "crs" as for zarr.reduce) or a raster of several bands, a date each (default: the active layer) | "collection": a STAC collection (desktop: "url"?: its API, "asset": a band, "bbox"?: [w, s, e, n] | "view", "start", "end", "maxCloud"? as for stac.addCube, "cellSize"?: in the files' units, the overview level nearest it is read, "resampling"?: "nearest" (default: that overview's pixels as stored) | "average" (each cell the mean of the full images' pixels in it, which reads them); a large area is read in pieces), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "start"?, "end"?: dates (a whole number from 1000 to 9999 is a year; an end given as a year or a month takes all of it), "months"?: [1–12] (one season across years), "test"?: "hamedRao" (default) | "yueWang" | "seasonal" | "original", "seasons"?: parts of a year for the seasonal test (default 12), "alpha"?: 0.05 (above 0, below 0.5), "minValid"?: dates a cell needs (default: half of them, at least 4; the seasonal test also needs 4 in seasons of two or more), "per"?: "year" (default) | "decade", "name"?: text, "ran"?: (internal: a trend the app has worked out)} → four raster layers: Sen's slope, the test's Z and p-value, and the slope where p < alpha"#, + r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer (or "path" | "url", "variable", "index", "crs" as for zarr.reduce) or a raster of several bands, a date each (default: the active layer) | "collection": a STAC collection (desktop; read in place over an area, in pieces when large; with url, asset, bbox, maxCloud, cellSize and resampling below, and "start", "end"), "url"?: the collection's STAC API (as for stac.addCube), "asset"?: the collection's band, such as NDVI, "bbox"?: [w, s, e, n] | "view" (the collection's area), "maxCloud"?: a collection's cloud cover cap, "cellSize"?: a collection's cell size in the files' units (the overview level nearest it is read), "resampling"?: "nearest" (default: that overview's pixels as stored) | "average" (each cell the mean of the full images' pixels in it, which reads them), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "start"?, "end"?: dates (a whole number from 1000 to 9999 is a year; an end given as a year or a month takes all of it), "months"?: [1–12] (one season across years), "test"?: "hamedRao" (default) | "yueWang" | "seasonal" | "original", "seasons"?: parts of a year for the seasonal test (default 12), "alpha"?: 0.05 (above 0, below 0.5), "minValid"?: dates a cell needs (default: half of them, at least 4; the seasonal test also needs 4 in seasons of two or more), "per"?: "year" (default) | "decade", "name"?: text, "ran"?: (internal: a trend the app has worked out)} → four raster layers: Sen's slope, the test's Z and p-value, and the slope where p < alpha"#, )] } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 48de17146..158f5f66e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10729,6 +10729,25 @@ fn param_keys_reads_descriptions() { } } +/// A STAC trend's keys are cube.trend's own: given, none is said to be ignored (they sat inside +/// the description's "collection" note, where the key reader doesn't look). +#[test] +fn a_stac_trends_keys_are_read() { + let mut s = world(); + let keys = s.registry.get("cube.trend").and_then(|c| c.param_keys()).unwrap(); + for k in ["collection", "url", "asset", "bbox", "maxCloud", "cellSize", "resampling", "start", "end"] { + assert!(keys.contains(&k), "{k}: {keys:?}"); + } + let mark = s.notice_mark(); + // A cell size of 0: refused before any search, so nothing reaches the network, after the keys + // are read. + let p = json!({"collection": "c", "url": "http://127.0.0.1:9/v1", "asset": "NDVI", "bbox": [0, 0, 1, 1], "maxCloud": 20, "cellSize": 0, "resampling": "average", "start": "2020", "end": "2021"}); + let e = s.run("cube.trend", &p).unwrap_err(); + assert!(e.contains("cellSize"), "{e}"); + let said: Vec<&str> = s.notices_since(mark).iter().map(|n| n.text.as_str()).collect(); + assert!(said.iter().all(|t| !t.contains("ignores")), "{said:?}"); +} + #[test] fn unknown_params_are_said() { let mut s = world(); From 23eb906db20ba6da0ee2484cd573d20b8254724f Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:21:49 +0200 Subject: [PATCH 076/297] docs: bring every page up to date with the code (#752) * docs: bring every page up to date with the code * docs: address review comments (wording, LiDAR sample, plugin registry schemas, language count) * docs: correct sslmode=prefer; limit the offline claim to local data --- README.md | 4 +- docs/acknowledgements.md | 25 +++- docs/architecture.md | 53 ++++--- docs/cli.md | 62 +++++--- docs/comparison/geolibre.md | 107 ++++++++------ docs/comparison/index.md | 71 ++++----- docs/comparison/qgis.md | 121 ++++++++------- docs/contributing.md | 58 ++++++-- docs/getting-started.md | 58 +++++--- docs/i18n.md | 40 ++--- docs/index.md | 37 ++--- docs/installation.md | 47 +++--- docs/mcp.md | 100 ++++++++----- docs/plugins.md | 254 ++++++++++++++++++++------------ docs/privacy.md | 69 +++++++-- docs/python.md | 86 +++++++---- docs/sponsor.md | 9 +- docs/user-guide/analysis.md | 144 ++++++++++++------ docs/user-guide/assistant.md | 67 ++++++--- docs/user-guide/cartography.md | 110 ++++++++------ docs/user-guide/data-formats.md | 106 ++++++------- docs/user-guide/data.md | 111 +++++++++----- docs/user-guide/editing.md | 45 ++++-- docs/user-guide/index.md | 69 +++++---- docs/user-guide/layouts.md | 62 ++++---- docs/user-guide/maps.md | 82 +++++++---- docs/user-guide/sharing.md | 75 ++++++---- docs/user-guide/web-data.md | 71 +++++---- docs/user-guide/workspace.md | 65 ++++++-- 29 files changed, 1397 insertions(+), 811 deletions(-) diff --git a/README.md b/README.md index e1405cbb3..885086980 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

Written in Rust License: Apache-2.0 - 16 languages + 17 languages Plugins PyPI Open in Colab @@ -103,7 +103,7 @@ Every screenshot is RustGIS itself, rendered offscreen by `cargo run -p rustgis- | **Sharing** | PNG export at any DPI; PDF; web map pages, OGC API Features collections, MapLibre styles, glTF scenes, locators; project, map and mobile map packages; offline areas with sync | | **AI Assistant** | Ask about your data or tell the map what to do in plain language, with Anthropic, OpenAI (a key, or your ChatGPT plan by signing in), Gemini, OpenRouter, Ollama or any OpenAI-compatible model; also `rustgis-cli ask`, and an [MCP server](docs/mcp.md) (`rustgis-cli mcp`) for Claude Code, Cursor, VS Code and other agents | | **Plugins** | Sandboxed WebAssembly [plugins](docs/plugins.md) add commands, tools and ribbon tabs; install them from [plugins.rustgis.app](https://plugins.rustgis.app) on the desktop, command line and web | -| **Interface** | Layers, Browser and Data Sources panes as tabs, command search (Ctrl+K), keyboard shortcuts, dark mode, a customizable ribbon and Quick Access Toolbar, linked side-by-side maps, 16 languages | +| **Interface** | Layers, Browser and Data Sources panes as tabs, command search (Ctrl+K), keyboard shortcuts, dark mode, a customizable ribbon and Quick Access Toolbar, linked side-by-side maps, 17 languages | The [user guide](https://rustgis.app/user-guide/) covers each area in detail, [Comparison](https://rustgis.app/comparison/) sets RustGIS beside GeoLibre, QGIS and ArcGIS, and diff --git a/docs/acknowledgements.md b/docs/acknowledgements.md index a9a9beee1..3bc5eb3ee 100644 --- a/docs/acknowledgements.md +++ b/docs/acknowledgements.md @@ -15,8 +15,9 @@ work is missing here, please [open an issue](https://github.com/opengeos/RustGIS [QGIS](https://qgis.org) and [ArcGIS Pro](https://www.esri.com/en-us/arcgis/products/arcgis-pro/overview) set the expectations of a desktop GIS that RustGIS is measured against, and [GDAL](https://gdal.org) those of reading geospatial formats. [MapLibre](https://maplibre.org) defines the style -specification RustGIS draws vector tiles with, and [PROJ](https://proj.org) the coordinate -operations it follows. +specification RustGIS implements a subset of for vector tiles, and [PROJ](https://proj.org) +the coordinate operations it follows. [Zarr](https://zarr.dev) defines the chunked-array format +read by RustGIS's raster provider. ## Geoprocessing @@ -25,6 +26,9 @@ built on [whitebox-wasm](https://github.com/opengeos/whitebox-wasm)) brings more vector, LiDAR and hydrology tools, after John Lindsay's [WhiteboxTools](https://www.whiteboxgeo.com). +The suite runs separately from RustGIS: through an optional downloaded `geolibre` program on +the desktop and command line, or a WebAssembly runner loaded on demand in the browser. + ## Data - [Natural Earth](https://www.naturalearthdata.com) (public domain) for the sample data. @@ -45,20 +49,25 @@ with its source and licence. ## Rust crates -RustGIS is pure Rust, built on these crates, among others: +RustGIS is written in Rust, built on these crates, among others: | Crate | For | |---|---| | [egui and eframe](https://github.com/emilk/egui), [wgpu](https://wgpu.rs) | The interface, natively and in the browser, and the GPU map | -| [tiny-skia](https://github.com/linebender/tiny-skia), [lyon](https://github.com/nical/lyon), [ab_glyph](https://crates.io/crates/ab_glyph), [image](https://github.com/image-rs/image) | Drawing, tessellation, text and images | +| [tiny-skia](https://github.com/linebender/tiny-skia), [lyon](https://github.com/nical/lyon), [ab_glyph](https://crates.io/crates/ab_glyph), [image](https://github.com/image-rs/image), [epaint_default_fonts](https://crates.io/crates/epaint_default_fonts) | CPU drawing, tessellation, text, images and fallback interface fonts | | [geo](https://github.com/georust/geo), [geozero](https://github.com/georust/geozero), [shapefile](https://crates.io/crates/shapefile), [a5](https://crates.io/crates/a5) | Geometry, formats and the A5 grid | | [proj4rs](https://crates.io/crates/proj4rs), [crs-definitions](https://crates.io/crates/crs-definitions) | Coordinate systems | | [tiff](https://github.com/image-rs/image-tiff), [laz](https://crates.io/crates/laz) | GeoTIFF and LAZ | -| [Apache Arrow and Parquet](https://github.com/apache/arrow-rs), [flatgeobuf](https://crates.io/crates/flatgeobuf), [rusqlite](https://github.com/rusqlite/rusqlite) ([SQLite](https://sqlite.org)), [calamine](https://crates.io/crates/calamine), [csv](https://crates.io/crates/csv), [roxmltree](https://crates.io/crates/roxmltree) | GeoParquet, FlatGeobuf, GeoPackage, spreadsheets, CSV and XML | -| [postgres](https://crates.io/crates/postgres), [mysql](https://crates.io/crates/mysql), [tiberius](https://crates.io/crates/tiberius), [rustls](https://github.com/rustls/rustls) | Databases | -| [miniz_oxide](https://crates.io/crates/miniz_oxide), [brotli](https://crates.io/crates/brotli), [ruzstd](https://crates.io/crates/ruzstd), [lz4_flex](https://crates.io/crates/lz4_flex), [zip](https://crates.io/crates/zip) | Compression | +| [Apache Parquet and Arrow](https://github.com/apache/arrow-rs), [flatgeobuf](https://crates.io/crates/flatgeobuf), [rusqlite](https://github.com/rusqlite/rusqlite) ([SQLite](https://sqlite.org)), [calamine](https://crates.io/crates/calamine), [csv](https://crates.io/crates/csv), [roxmltree](https://crates.io/crates/roxmltree) | GeoParquet (Arrow writes test fixtures), FlatGeobuf, GeoPackage, spreadsheets, CSV and XML | +| [postgres](https://crates.io/crates/postgres), [mysql](https://crates.io/crates/mysql), [tiberius](https://crates.io/crates/tiberius), [rustls](https://github.com/rustls/rustls) | PostgreSQL/PostGIS, MySQL/MariaDB, SQL Server and TLS | +| [miniz_oxide](https://crates.io/crates/miniz_oxide), [brotli-decompressor](https://crates.io/crates/brotli-decompressor), [ruzstd](https://crates.io/crates/ruzstd), [zstd](https://crates.io/crates/zstd), [lz4_flex](https://crates.io/crates/lz4_flex), [weezl](https://crates.io/crates/weezl), [zip](https://crates.io/crates/zip) | Compression for rasters, Zarr chunks, map tiles, GeoParquet and plugin packages | | [wasmi](https://github.com/wasmi-labs/wasmi) | Sandboxed WebAssembly plugins | | [ureq](https://crates.io/crates/ureq), [serde](https://serde.rs) | HTTP and JSON | | [PyO3](https://pyo3.rs), [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen), [rfd](https://crates.io/crates/rfd) | The Python package, the web app and file dialogs | -The full list of dependencies is in `Cargo.toml` and `Cargo.lock`. +The full dependency list is in [Cargo.toml](https://github.com/opengeos/RustGIS/blob/main/Cargo.toml), +the crate and application manifests, and +[Cargo.lock](https://github.com/opengeos/RustGIS/blob/main/Cargo.lock). Dependency licences and +copyright notices are collected in +[THIRD-PARTY-LICENSES.txt](https://github.com/opengeos/RustGIS/blob/main/THIRD-PARTY-LICENSES.txt), +with a [separate list for Python](https://github.com/opengeos/RustGIS/blob/main/apps/rustgis-python/THIRD-PARTY-LICENSES.txt). diff --git a/docs/architecture.md b/docs/architecture.md index 1833b0a69..822f56219 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,36 +6,55 @@ engine scriptable and the front end swappable. | Layer | Crate | Job | | --- | --- | --- | | L0 | `rustgis-geom` | Coordinates, extents, Web Mercator, geodesic distance/area, scale | -| L1 | `rustgis-data` | Features and typed attributes; GeoJSON, CSV, Shapefile; a read-only SQLite file reader (`sqlite`, for GeoPackages in the browser); data providers reading GeoPackage, GeoParquet and FlatGeobuf in place by their spatial index, and PostGIS, MySQL and MariaDB tables live (desktop) (GDAL formats and DuckDB queries converted by those programs when installed); SQL where clauses; rasters (GeoTIFF, ASCII grid) and their coordinate systems | +| L1 | `rustgis-data` | Features, typed attributes, vector and spreadsheet I/O, SQL where clauses, LiDAR, rasters and EPSG reprojection; indexed file and remote data providers; live PostGIS, MySQL/MariaDB and SQL Server providers on the desktop; optional GDAL and DuckDB programs (see [Data formats](user-guide/data-formats.md)) | | L2 | `rustgis-map`, `rustgis-analysis` | Layers, symbology, labeling, camera, basemaps and web tile layers; geoprocessing tools | | L2 | `rustgis-tiles` | Web map tiles: XYZ/TMS/WMS raster tiles, Mapbox Vector Tiles, PMTiles and MBTiles archives and a subset of MapLibre styles; a cache that fetches in the background (worker threads natively, the browser's `fetch` on the web) | -| L3 | `rustgis-render` | tiny-skia renderer, label placement, PNG export | +| L3 | `rustgis-render` | tiny-skia renderer, label placement, font discovery, map and layout export, and UI-independent GPU scene preparation | | L3 | `rustgis-geolibre` | The [GeoLibre tools](user-guide/analysis.md#geolibre-tools) as geoprocessing tools: run by the optional `geolibre` program natively, by geolibre-rust's WASI runner in the browser | -| L3 | `rustgis-plugins` | [Plugins](plugins.md): sandboxed WebAssembly modules that add commands and geoprocessing tools; `.rgplugin` packages and the plugin registry | -| L4 | `rustgis-engine` | Session, undo, command registry, sample project | -| L5 | `rustgis-assistant` | The [AI Assistant](user-guide/assistant.md): a chat agent that acts through commands; OpenAI-compatible, OpenAI Responses (a ChatGPT plan's sign-in), Anthropic and Gemini wire formats as plain request/response data, so the desktop (a thread), the browser (`fetch`) and the command line share one loop | -| L6 | `rustgis-ui-egui` | The ribbon front end | -| apps | `rustgis`, `rustgis-cli`, `rustgis-web` | Desktop app, command line, browser (WebAssembly) | +| L3 | `rustgis-plugins` | [Plugins](plugins.md): sandboxed WebAssembly modules, desktop process plugins and assistant skills packages; `.rgplugin` packages and the plugin registry | +| L4 | `rustgis-engine` | Session, undo, command registry, projects, sample project | +| L5 | `rustgis-assistant` | The [AI Assistant](user-guide/assistant.md): provider request/response formats, conversation loop, command tools, skills and agent integration; native HTTP transport and local agent/MCP connections, with browser I/O supplied by the web app | +| L6 | `rustgis-ui-egui` | Ribbon, panes, map view, dialogs and the optional wgpu map backend | +| apps | `rustgis`, `rustgis-cli`, `rustgis-web`, `rustgis-python` | Desktop app, command line, browser (WebAssembly), and the PyO3-backed `rustgis` Python package | +| tooling | `xtask` | Workspace checks, third-party licence notices and web service-worker generation | ## Everything is a command -Each user-visible action is a `CommandSpec` with an id, label, ribbon location, shortcut, -parameter description, an `enabled` check and a `run` function. The ribbon, keyboard shortcuts, -command search and CLI all dispatch through `Session::run`, which also snapshots the project -for undo and restores it if a command fails. +Engine actions are `CommandSpec`s with an id, label, ribbon location, shortcut, parameter +description, an `enabled` check and a `run` function. The ribbon, keyboard shortcuts, command +search, CLI and Python dispatch engine commands through `Session::run`. UI-only commands +(`ui.*`) live in `crates/ui-egui/src/lib.rs`; engine commands never open dialogs. -Plugin commands (`plugin:/`) dispatch through `Session::run` too, as one undo -step. While they run, they reach the session only through permission-checked requests (see -[Plugins](plugins.md)). +Mutating commands snapshot the project for undo and restore it on an error or escaped panic. +View-only commands do not take undo snapshots. Undo and rollback cover project state, not +external effects such as files already written. + +Plugin commands (`plugin:/`) dispatch through `Session::run` too; their project +changes form one undo step. WebAssembly plugins reach the session through permission-checked +requests. Process plugins use the same host protocol but are not sandboxed (see [Plugins](plugins.md)). ## Never crash -Production code avoids `unwrap`, `expect` and panics, and treats file contents and command -parameters as hostile. Tests run every command and every geoprocessing tool with junk -parameters. +Production code forbids unsafe Rust. Library crates deny Clippy's unwrap, expect, panic, +unimplemented, todo and unreachable lints. Readers treat file contents and command parameters as hostile. +`Session::run` and `analysis::run` catch escaped panics as a last resort. Hostile-parameter tests +exercise engine commands and built-in geoprocessing tools; some file-writing and network +commands are skipped. ## Quality gates ```sh cargo xtask ci # fmt, clippy -D warnings, tests, layering check ``` + +`cargo xtask layers` checks that workspace dependencies point to lower layers and that crates +below L6 do not directly depend on UI crates such as egui, eframe, rfd, wgpu or winit. +The browser build is a separate CI gate: + +```sh +cargo build --locked --target wasm32-unknown-unknown -p rustgis-web +``` + +Keep platform-specific I/O behind native target gates or the UI's `Services` callbacks. The web +app supplies asynchronous file pickers, downloads, HTTP requests and the GeoLibre WASI runner. +See [Contributing](contributing.md) for the remaining CI gates and developer tasks. diff --git a/docs/cli.md b/docs/cli.md index e690d5225..97e221c7c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,11 +1,12 @@ # Command Line -Every ribbon action is a command with an id. `rustgis-cli` runs them headlessly. +`rustgis-cli` runs RustGIS engine commands headlessly, using the same ids as the app. +Window and pane commands (`ui.*`) belong to the app and are not available in a private CLI session. ```sh rustgis-cli commands # every command id with its parameters rustgis-cli tools # geoprocessing tools -rustgis-cli info data.geojson # layer summary as JSON +rustgis-cli info data.geojson # project and layer summary as JSON rustgis-cli render data.geojson map.png --basemap ocean --width 1600 --height 900 rustgis-cli render data.geojson map.png --tiles 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' rustgis-cli logo icon.png --size 256 # the app icon as a PNG @@ -13,10 +14,25 @@ rustgis-cli plugins # installed plugins (also: browse, get, in rustgis-cli skills # the AI Assistant's skills (also: add, remove, enable, disable) ``` +`commands` lists the engine commands and their JSON parameters; `tools` lists geoprocessing +tools and their parameters. See [Installation](installation.md) for the CLI packages. + +## Rendering + +`render ` opens a data file or saved project and exports its map. +It defaults to 1600 × 900 pixels at 96 DPI; `--width`, `--height` and `--dpi` change those. +A data file is zoomed to its full extent; a project keeps its saved view. +`--tiles` accepts an XYZ template, vector tiles or a MapLibre-style address. + +`--worldview auto|default|china` chooses the disputed borders in the built-in countries and +world outline, for `render`, `run` and the other subcommands. `auto` follows +`RUSTGIS_WORLDVIEW`, else the system time zone and locale. + ## Chaining commands -`run` loads data or a project, runs `--cmd 'id={json params}'` in order, and can save the -result: +`run` starts with an empty project. `--sample` loads the World Atlas sample, `--project ` +opens a project, and repeatable `--data ` adds files, in that order. Repeat `--cmd` to run +commands in order; `--save ` saves the resulting project: ```sh rustgis-cli run --sample \ @@ -27,12 +43,12 @@ rustgis-cli run --sample \ --save atlas.rgis ``` -Each command's result goes to standard output as JSON. What a command says beside it, which the -app shows in its status bar (a file's warnings, or that only the first 32 tables of a GeoPackage -were added), goes to standard error. +Each non-null command result goes to standard output as JSON. `--cmd 'id'` runs a command with +no parameters; otherwise use `--cmd 'id={json params}'`. What a command reports beside its result, +which the app shows in its status bar, goes to standard error. Installed [plugins](plugins.md) load first (`--no-plugins` skips them), and `--plugin ` loads one more for this run only. Their commands and tools have ids like +folder>` (repeatable) loads one more for this run only. Their commands and tools have ids like `plugin:vector-toolkit/hexagon-grid`: ```sh @@ -41,6 +57,10 @@ rustgis-cli run --sample --plugin vector-toolkit-0.1.0.rgplugin \ --cmd 'share.exportMap={"path":"hexagons.png"}' ``` +`plugins install` and `plugins get` also prepare a Python plugin's environment; +`plugins prepare ` prepares it on demand, reusing it if it is already ready. +`--registry ` points `plugins browse` and `plugins get` at another registry. + Rasters work the same way: ```sh @@ -62,7 +82,7 @@ rustgis-cli run --data dem.tif \ then the model answers and acts on the project through commands; `--save` keeps the result: ```sh -export ANTHROPIC_API_KEY=… # or OPENAI_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY +export ANTHROPIC_API_KEY=… # or OPENAI_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, … rustgis-cli ask --sample "Which 5 countries have the largest GDP?" rustgis-cli ask --sample "Buffer the cities by 200 km and label them" --save buffered.rgis rustgis-cli ask --data parcels.geojson --provider ollama --model qwen3:8b "Summarise the fields" @@ -74,13 +94,15 @@ rustgis-cli ask --sample --provider chatgpt "Zoom to Tokyo" The provider is `--provider` (`anthropic`, `openai`, `chatgpt`, `gemini`, `openrouter`, `ollama`, `custom`), else `RUSTGIS_ASSISTANT_PROVIDER`, else the first with a key in the environment, else Ollama (a ChatGPT sign-in is used only when chosen). -`--model` and `--base-url` override the defaults. The answer goes to standard output and each -command the assistant ran to standard error. Commands that read or write files or use the -network are declined unless you pass `--yes`; plugin commands, scripts, tasks, models and -replacing the project are always declined here. `--trace log.txt` appends each request and response -body (never the headers, which carry the key) to a file. `--login` signs in with ChatGPT in -the browser, so `--provider chatgpt` uses your ChatGPT plan (`--another-account` registers RustGIS -for a different account), and `--logout` signs out; see [Signing in with ChatGPT](user-guide/assistant.md#signing-in-with-chatgpt). +`--model` (or `RUSTGIS_ASSISTANT_MODEL`) and `--base-url` override the defaults. The answer goes to +standard output and each command the assistant ran to standard error. Commands that read or write +files or use the network are declined unless you pass `--yes`; plugin commands, scripts, tasks, +models and replacing the project are always declined here. `--trace log.txt` appends each request +and response body (never the headers, which carry the key) to a file; the bodies can contain your +data and prompts. `--login` prints a browser sign-in URL so `--provider chatgpt` uses your ChatGPT +plan. `--another-account` starts a new registration, `--accounts` lists saved registrations, +`--account ` selects one, and `--logout` signs out; see +[Signing in with ChatGPT](user-guide/assistant.md#signing-in-with-chatgpt). ## Serving AI agents @@ -91,8 +113,14 @@ VS Code, which then run commands, look at the map and search the documentation t claude mcp add rustgis -- rustgis-cli mcp --sample ``` +To use the already-open desktop project instead, enable its MCP Relay and register +`rustgis-cli mcp --attach`. Do not combine `--attach` with `--sample`, `--project` or `--data`. +File and network commands require `--yes` unless the app's agent-control setting grants access. + `mcp-register --client claude` registers the open desktop app's MCP over HTTP with Claude Code, -for when agents are turned on in the app (without `--client`, it prints the configuration): +for when agents are turned on in the app. Without `--client`, it prints the configuration; +`--name` (default `rustgis-live`) and `--port` (default `RUSTGIS_MCP_HTTP_PORT`, else 8768) +change the entry: ```sh rustgis-cli mcp-register --client claude diff --git a/docs/comparison/geolibre.md b/docs/comparison/geolibre.md index 58e69df73..6c6830001 100644 --- a/docs/comparison/geolibre.md +++ b/docs/comparison/geolibre.md @@ -25,14 +25,14 @@ collaboration) and the Python and R ecosystems (a Jupyter widget and an R packag **RustGIS is the narrower, native one.** It is a single program written entirely in Rust that draws its own interface on the GPU, with no browser engine or JavaScript runtime inside, and the same program compiled to WebAssembly for the browser. Its interface is a ribbon laid out in the -order of the work, every button is a command you can run from the command line, Python or an AI -agent, its plugins run sandboxed, and it goes further in the classic desktop GIS jobs of data -design, reports and print layouts. +order of the work, its engine commands run from the command line, Python or an AI +agent, and its WebAssembly plugins run sandboxed. It also supports native process plugins +and goes further in the classic desktop GIS jobs of data design, reports and print layouts. -They are complementary rather than competing. Both run the same 1,000+ GeoLibre and Whitebox -geoprocessing tools, both keep your data on your device, and both read and write the same open -formats. Choose GeoLibre for reach, choose RustGIS for a compact, scriptable desktop GIS, or use -both. +They are complementary rather than competing. Both can run the 1,000+ GeoLibre/Whitebox +geoprocessing suite; RustGIS loads it through an optional desktop runner or WebAssembly. +Both read and write common open formats. Choose GeoLibre for reach, choose RustGIS for a +compact, scriptable desktop GIS, or use both. ## Where GeoLibre is stronger @@ -44,16 +44,20 @@ both. DWG, MapInfo TAB, NetCDF-4/HDF5, Kerchunk and Icechunk, ICESat-2 and GEDI. It connects to SQL Server and Apache Iceberg as well as PostGIS and DuckDB, and its plugins browse dozens of catalogs and portals, from NASA Earthdata and Planet to Hugging Face and about 150 US local - agencies. RustGIS reads a focused set natively and the rest through GDAL's programs when they - are installed (see [Data formats](../user-guide/data-formats.md)). + agencies. RustGIS reads a focused set natively, including 2D File Geodatabase feature + classes and tables, GeoRSS and LandXML. It also has NASA Earthdata discovery and live + SQL Server/Azure SQL connections. Other formats and File Geodatabase tables the native + reader cannot read need GDAL (see [Data formats](../user-guide/data-formats.md)). - **SQL in the browser.** GeoLibre runs DuckDB Spatial, PGlite/PostGIS and Apache Sedona inside the browser. RustGIS's SQL pane uses DuckDB on the desktop; its web app has no SQL engine yet. - **Cartography depth.** GeoLibre has rule-based and expression renderers, heat maps, clustering, diagrams, blend modes, layer groups, a data-defined label engine, and style interchange with SLD, QGIS QML and Mapbox GL JSON. RustGIS covers the common renderers and - collision-free labels, without that depth. + SQL-filtered label classes, stacked line symbols, symbol levels, blend modes and layer groups, + but not rule-based renderers or general data-defined styling. - **3D.** GeoLibre has a Cesium globe with terrain, 3D Tiles, I3S, Gaussian splats, CZML and - Google Photorealistic 3D Tiles. RustGIS has a globe, 2.5D extrusion and LiDAR point layers. + Google Photorealistic 3D Tiles. RustGIS has a globe, terrain-draped tilted views, 2.5D + extrusion and LiDAR point layers. - **AI and imagery models.** Besides an AI assistant, GeoLibre has SAM segmentation, in-browser ONNX/YOLO object detection and an object-based image analysis workbench. RustGIS has pixel classification (k-means, minimum distance and a trainable neural network). @@ -69,42 +73,48 @@ both. - **Opening other projects.** GeoLibre imports QGIS (`.qgs`/`.qgz`) and ArcGIS Pro (`.aprx`/`.mapx`) projects. RustGIS opens neither. - **Editing.** GeoLibre edits vertices and writes edits back to their sources (GeoPackage, - PostGIS, ArcGIS feature services). RustGIS cannot yet edit the vertices of existing features, - and edits stay in the project until exported. + PostGIS, ArcGIS feature services). RustGIS moves, inserts and deletes vertices, but + edits stay in the project until exported rather than being written back to those sources. - **Languages.** GeoLibre's interface comes in 21 languages, including right-to-left Arabic, - Hebrew and Persian. RustGIS has [16](../i18n.md), none right-to-left. + Hebrew and Persian. RustGIS has [17](../i18n.md), including Persian text support; + its widget layout is not right-to-left. ## Where RustGIS is stronger - **A native program, not a web view.** RustGIS is memory-safe Rust compiled to one native - program (18–30 MB installers) with no browser engine, JavaScript or Python inside. Large - GeoPackage, GeoParquet, FlatGeobuf and Shapefile files are streamed a view at a time rather - than loaded into a browser tab's memory, and nothing depends on a web view's limits. -- **Everything is a command.** Every ribbon button, shortcut and pane action is a command with an - id and JSON parameters, and the same ids run from [`rustgis-cli`](../cli.md), the + program with no browser engine, JavaScript or Python inside the core app. Large + GeoPackage, GeoParquet, FlatGeobuf and Shapefile files are read a view at a time rather + than loaded whole; the cloud-native readers also work in the browser. +- **A shared command engine.** Ribbon actions dispatch commands with ids and JSON parameters. + Engine commands run from [`rustgis-cli`](../cli.md), the [`rustgis` Python package](../python.md), notebooks, models, the Script pane, the - [MCP server](../mcp.md) and the AI Assistant. A batch of maps can be rendered in CI with one + [MCP server](../mcp.md) and the AI Assistant. UI-only pane/dialog commands stay in the app. + A batch of maps can be rendered in CI with one command line. GeoLibre is driven through its interface, its Python and R packages and its MCP server; it has no general command-line tool. -- **Sandboxed plugins.** RustGIS [plugins](../plugins.md) are WebAssembly modules that can do - only what their declared permissions allow, and the same file runs on the desktop, in - `rustgis-cli` and in the browser. GeoLibre's TypeScript plugins can do much more inside the - app (panels, map tools, custom layers), but its plugin API states that they "run as trusted - code in the app window" and that its policy "is not a security boundary". +- **Sandboxed WebAssembly plugins.** These RustGIS [plugins](../plugins.md) use declared, + approved permissions and run on desktop, in `rustgis-cli` and in the browser. + Plugins can add commands, tools, ribbon tabs, panels and assistant skills. Native process + plugins, including Python environments managed by RustGIS, are also supported on desktop + and CLI, but are not sandboxed. GeoLibre's TypeScript plugin API states that its plugins + "run as trusted code in the app window" and that its policy "is not a security boundary". - **Data design.** RustGIS defines domains, subtypes, attribute rules, contingent values, relationship classes, archiving, topology rules and traverse/COGO in the project itself, the geodatabase design work of ArcGIS Pro. GeoLibre has attribute forms with constraints and honours the domains and subtypes of the ArcGIS feature services it edits, but doesn't define its own. - **Reports and layouts.** RustGIS builds grouped reports with statistics, charts and - subreports, and layouts with map frames, grids, extent indicators and map series. GeoLibre's + subreports, and layouts with publication presets, shaped map frames, legends, colorbars, + grids, tables, charts, extent indicators and map series, exported to PDF or PNG. GeoLibre's print layout and atlas cover the common cases, and it has no report builder. - **A ribbon interface.** Tabs follow the order of the work (Home, Data, Style, Edit, Analyze, View, Publish), with Command Search (++ctrl+k++) to find any command or tool by name. People coming from ArcGIS Pro often find it familiar. GeoLibre uses menus and panels, with a command palette and beginner, intermediate and advanced interface levels. -- **Self-contained.** Everything RustGIS does with local data runs inside the program, with no - network, server or helper process, and offline areas sync edited features back. GeoLibre works +- **Offline local workflows.** RustGIS's local-file readers, built-in tools and rendering work + without a network or server (remote and cloud sources need the network); offline areas sync edits back to the corresponding map in the project, + not to the underlying source data or a remote database. GeoLibre tools, GDAL, DuckDB and process plugins use separate programs. + GeoLibre works offline too, but some features need a remote host (GDAL export, ONNX detection and story map HTML export among them), and on the desktop its raster tools and some vector tools use an optional Python sidecar. @@ -120,27 +130,28 @@ both. | **Interface** | Ribbon with Command Search | Menus and panels with a command palette and interface levels | | **Map engines** | One native renderer (2D map and globe) | MapLibre, Mapbox GL JS, CesiumJS or the ArcGIS Maps SDK | | **Formats** | A focused set read natively; more through GDAL when installed | A wide set in the app, some desktop only (File Geodatabase, MBTiles) | -| **Databases** | PostgreSQL/PostGIS, MySQL and MariaDB, read live, and DuckDB through DuckDB, on the desktop | DuckDB, PostGIS, SQL Server, Iceberg; PGlite and Sedona in the browser | -| **Geoprocessing** | 1,000+ (GeoLibre and Whitebox) plus built-in vector tools | 1,000+ (GeoLibre and Whitebox) plus the GeoLibre Toolbox | -| **Editing** | Create, move, rotate, scale, split, merge, snapping; no vertex editing yet | Vertex editing and write-back to sources | +| **Databases** | PostgreSQL/PostGIS, MySQL/MariaDB and SQL Server/Azure SQL read live (read only); DuckDB through DuckDB; desktop only | DuckDB, PostGIS, SQL Server, Iceberg; PGlite and Sedona in the browser | +| **Geoprocessing** | Built-in tools plus optional 1,000+ GeoLibre/Whitebox suite; list depends on the runner | 1,000+ (GeoLibre and Whitebox) plus the GeoLibre Toolbox | +| **Editing** | Create, move, rotate, scale, split, merge, vertex move/insert/delete, snapping; export edits | Vertex editing and write-back to sources | | **Data design** | Domains, subtypes, attribute rules, relationships, topology | Attribute forms; ArcGIS services' domains and subtypes | | **Output** | Layouts, map series, reports, presentations, web map pages | Print layouts with atlas, story maps, dashboards, offline HTML | -| **Scripting** | Every action a command: CLI, Python package, notebooks, Script pane, MCP | Python and R packages, Jupyter widget, notebooks, MCP | -| **Plugins** | Sandboxed WebAssembly, small registry | TypeScript, trusted, small registry | -| **AI** | Assistant acting through undoable commands; MCP server | Assistant with undoable operations and voice; SAM, ONNX; MCP server | +| **Scripting** | Engine commands: CLI, Python package, notebooks, Script pane, MCP; UI-only commands stay in the app | Python and R packages, Jupyter widget, notebooks, MCP | +| **Plugins** | Sandboxed WebAssembly; unsandboxed desktop/CLI process and Python plugins; registry | TypeScript, trusted, small registry | +| **AI** | Assistant using engine commands and project undo; MCP server; pixel classification | Assistant with undoable operations and voice; SAM, ONNX; MCP server | | **Collaboration** | — | Share links, review comments, real-time (experimental) | -| **Languages** | 16 | 21, including right-to-left | +| **Languages** | 17, including Persian text; no right-to-left widget layout | 21, including right-to-left | | **Project file** | `.rgis` (open JSON) | `.geolibre` (open, documented JSON) | ## Using them together The two read and write the same open formats, so data moves between them without conversion: GeoPackage, GeoParquet, FlatGeobuf, Shapefile, GeoJSON, KML/KMZ, GPX, CSV and cloud-optimised -GeoTIFF. Neither opens the other's project file yet; move the data across and restyle it. +GeoTIFF (GeoPackage export is desktop only in RustGIS). Neither opens the other's project +file yet; move the data across and restyle it. -They also share their geoprocessing. RustGIS's **Analyze › Tools** runs the GeoLibre and -Whitebox suite (natively through the `geolibre` program on the desktop, and on WebAssembly in the -browser), so a tool learned in one works the same way in the other. See +They also share their geoprocessing. RustGIS's **Analyze › Tools** offers the GeoLibre and +Whitebox suite through the optional `geolibre` program on desktop and an on-demand WebAssembly +runner in the browser. The available tools come from that runner's manifests. See [Analysis](../user-guide/analysis.md). A common split is to explore, share and collaborate in GeoLibre, especially from a browser, @@ -153,22 +164,22 @@ or reports and map series printed to PDF. **Why are there two?** They are built on different foundations for different jobs. GeoLibre uses the web platform to reach as many places and data sources as possible. RustGIS explores what a desktop GIS looks like -as one native Rust program in which every action is a command. Each project borrows ideas from -the other. +as one native Rust program with a shared command engine. Each project borrows ideas from the other. **Which one should I start with?** If you want to open a dataset in a browser tab or on a phone right now, or work in Jupyter or R, -start with GeoLibre. If you want a desktop GIS with a ribbon, a command line that can do -everything the interface can, or reports and data design, start with RustGIS. +start with GeoLibre. If you want a desktop GIS with a ribbon, scriptable engine commands, +or reports and data design, start with RustGIS. **Do they run the same geoprocessing tools?** -Yes: the GeoLibre and Whitebox suite of over 1,000 tools, built from -[geolibre-rust](https://github.com/opengeos/geolibre-rust). RustGIS adds its own built-in vector -tools and guided workflows; GeoLibre adds its Toolbox (routing, H3 grids, spatial statistics). +RustGIS can load the GeoLibre/Whitebox suite of over 1,000 tools from +[geolibre-rust](https://github.com/opengeos/geolibre-rust), with the available list depending +on the runner. RustGIS also has built-in vector tools, A5 grids, interpolation and guided +workflows; GeoLibre adds its Toolbox (routing, H3 grids, spatial statistics). **Can a GeoLibre plugin run in RustGIS, or the other way round?** -No. GeoLibre plugins are TypeScript modules for its web interface; RustGIS plugins are sandboxed -WebAssembly modules that add commands and tools. +No. GeoLibre plugins are TypeScript modules for its web interface. RustGIS has its own +WebAssembly and native process-plugin interfaces, including Python process plugins. **Can I open a GeoLibre project in RustGIS?** Not yet. Export the layers (GeoPackage or GeoParquet keep the most) and add them to RustGIS. diff --git a/docs/comparison/index.md b/docs/comparison/index.md index 8ae760418..c2455ffe9 100644 --- a/docs/comparison/index.md +++ b/docs/comparison/index.md @@ -24,8 +24,8 @@ sibling? [RustGIS and GeoLibre](geolibre.md) compares the two in detail. ## RustGIS and GeoLibre RustGIS and GeoLibre are both [opengeos](https://github.com/opengeos) projects, and they share -more than a maintainer: both run the same suite of over 1,000 GeoLibre and Whitebox -geoprocessing tools, natively on the desktop and on WebAssembly in the browser. +more than a maintainer: both can run the suite of over 1,000 GeoLibre and Whitebox +geoprocessing tools, through an optional desktop runner or WebAssembly in the browser. They differ in how they are built and who they suit: @@ -35,12 +35,13 @@ They differ in how they are built and who they suit: real-time collaboration. - **RustGIS** is written entirely in Rust and draws its own interface on the GPU (egui). It is one native program with no browser engine or JavaScript runtime inside, a ribbon laid - out in the order of the work, and an engine where every button is a command with an id, run the - same way by the ribbon, `rustgis-cli`, notebooks, models and the AI Assistant. + out in the order of the work, and an engine whose commands run the + same way from the ribbon, `rustgis-cli`, Python, notebooks, models and the AI Assistant. If you want the widest data access and the most platforms, choose GeoLibre. If you want a -compact native desktop GIS with a classic ribbon workflow and a fully scriptable command -engine, choose RustGIS. Both keep your data on your machine. See +compact native desktop GIS with a classic ribbon workflow and a scriptable command +engine, choose RustGIS. Both work on local files on your machine; remote features, such as web +data and AI providers, send requests over the network. See [RustGIS and GeoLibre](geolibre.md) for a closer look. ## At a glance @@ -51,10 +52,10 @@ engine, choose RustGIS. Both keep your data on your machine. See | **Cost** | Free | Free | Free | Paid subscription | Paid subscription (credit-metered analysis) | | **Built with** | Rust (egui, wgpu, tiny-skia) | TypeScript and Rust (Tauri, React, MapLibre) | C++ and Python (Qt) | .NET and C++ | Web (JavaScript) | | **Desktop** | Windows, macOS, Linux: one native program | Windows, macOS, Linux (Tauri) | Windows, macOS, Linux | Windows only | — | -| **Browser** | Full app compiled to WebAssembly | Full app, nothing to install | No official browser build | No | Yes (the product *is* the browser app) | +| **Browser** | WebAssembly app; external programs and live database connections are desktop only | Full app, nothing to install | No official browser build | No | Yes (the product *is* the browser app) | | **Mobile** | — | Native Android and iOS apps | Via QField / Mergin Maps (separate apps) | — | Via ArcGIS Field Maps (separate app) | | **Interface** | Ribbon, tabs in the order of the work | Panels and toolbars | Toolbars and panels | Ribbon with contextual tab groups | Web map viewer and apps | -| **Works offline** | Yes: the desktop app needs no network; offline areas with sync | Yes: PWA install, offline areas, desktop build | Yes | Yes | Limited (Field Maps offline areas) | +| **Works offline** | Local data and built-in tools work offline; offline areas sync edits back to the project's source map | Yes: PWA install, offline areas, desktop build | Yes | Yes | Limited (Field Maps offline areas) | | **Where your data lives** | Your device | Your device, processed client-side | Your device | Your device / your enterprise geodatabase | Vendor cloud | | **Project file** | `.rgis` (JSON, open) | `.geolibre` / `.geolibre.json` (open, documented) | `.qgs` / `.qgz` (open) | `.aprx` (proprietary) | Web map JSON (hosted) | @@ -62,35 +63,35 @@ engine, choose RustGIS. Both keep your data on your machine. See | | **RustGIS** | **GeoLibre** | **QGIS** | **ArcGIS Pro** | **ArcGIS Online** | |---|---|---|---|---|---| -| **Format breadth** | Focused: GeoJSON, CSV, Shapefile, GeoPackage, GeoParquet, FlatGeobuf, KML/KMZ and GPX in and out (large files read in place); GML, MapInfo MIF, OSM PBF, TopoJSON, DXF, LiDAR and spreadsheets in; GeoTIFF/COG and ASCII grids; WKT out; with GDAL installed, the vector formats it reads; with DuckDB, SQL queries over files and web addresses | Wide: DuckDB-WASM Spatial plus in-house readers | **Widest**: everything GDAL/OGR reads | Very wide, plus native Esri formats | Common upload formats | +| **Format breadth** | GeoJSON, CSV, Shapefile, GeoPackage (desktop export), GeoParquet, FlatGeobuf, KML/KMZ and GPX in and out; GML, MapInfo MIF, OSM PBF, TopoJSON, DXF, GeoRSS, LandXML, LiDAR and spreadsheets in; File Geodatabase feature classes and tables read natively, with limits; GeoTIFF/COG, ASCII grids and georeferenced PNG/JPEG; WKT out; optional GDAL and DuckDB programs extend access on desktop | Wide: DuckDB-WASM Spatial plus in-house readers | **Widest**: everything GDAL/OGR reads | Very wide, plus native Esri formats | Common upload formats | | **Cloud-native data** | GeoParquet, FlatGeobuf, PMTiles and cloud-optimised GeoTIFF read in place, large files and web addresses alike (by address, over HTTP byte ranges: a COG reads only the tiles the view needs, a FlatGeobuf only its index and the features in view, a GeoParquet its footer and the row groups in view, desktop and web), Zarr 2 and 3 stores (the chunks of the step picked, desktop and web), from `s3://` buckets too (private ones signed with a connection's keys, S3-compatible services by endpoint) | GeoParquet, FlatGeobuf, PMTiles, COG, Zarr, streamed over HTTP | GeoParquet, FlatGeobuf, PMTiles, COG, Zarr | GeoParquet, COG, Zarr | Hosted layers | | **Web maps and tiles** | XYZ/TMS/WMS raster tiles, WMTS, OGC API Tiles, Mapbox Vector Tiles, MapLibre styles | XYZ, WMS/WFS/WMTS, OGC API, vector tiles, MapLibre styles | Full OGC support, vector tiles | Full OGC support | Yes | | **Coordinate systems** | Rasters in any EPSG code, reprojected on the fly; the map in presets or projected EPSG and ESRI codes | Reprojects on import, EPSG catalog | Full PROJ support | Full support | Web Mercator focus | | **Geocoding** | Find Place and address tables through Nominatim or Photon (OSM's public servers or your own) | Geocoding search and batch geocoding | Through plugins | ArcGIS World Geocoder (credits) | Yes | | **Open-data portals** | OpenStreetMap by area (Overpass), administrative boundaries (geoBoundaries), and datasets searched by text on data.gov and CKAN sites, Socrata and ArcGIS Hub | ArcGIS Hub, Socrata, CKAN, US portals, geoBoundaries, Overpass, Natural Earth | Through plugins (QuickOSM) | Living Atlas | Living Atlas | -| **STAC, databases, Esri services** | STAC search (Earth Search, Planetary Computer or any STAC API, and `?stac=` links) with footprints and assets by address; Overture Maps (a theme's features in view, read in place, or the whole world as tiles) and Source Cooperative products; PostgreSQL/PostGIS, MySQL and MariaDB databases read live (desktop), and DuckDB databases (desktop, through DuckDB); WMS, WMTS, WFS, WCS, OGC API Features and Tiles, and ArcGIS REST services; CSW catalogue search; Planetary Computer assets signed; a Data Sources pane with favourites, recent sources, grouped named connections for services, databases, STAC and S3, and a library of public services | STAC browser, PostGIS, DuckDB, Iceberg, ArcGIS services, File Geodatabase (desktop) | STAC, PostGIS, SpatiaLite, Oracle, SQL Server, ArcGIS services | Enterprise geodatabases, Esri services | Hosted only | -| **3D** | A 3D globe; 2.5D extrusion with lighting and shadows; glTF scene export; LiDAR (LAS/LAZ/COPC) as point layers; geotagged photos as points with thumbnails | Cesium globe, 3D Tiles, I3S, LiDAR, Gaussian splats | Point clouds, 3D map view | **Deepest 3D**: scenes, I3S, mesh, LAS | Scene Viewer | +| **STAC, databases, Esri services** | STAC search (Earth Search, Planetary Computer or any STAC API, and `?stac=` links), footprints, assets and time-series cubes; Overture Maps, Source Cooperative and NASA Earthdata discovery; PostgreSQL/PostGIS, MySQL/MariaDB and SQL Server/Azure SQL read live (desktop), and DuckDB databases through DuckDB (desktop); WMS, WMTS, WFS, WCS, OGC API Features and Tiles, and ArcGIS REST services; CSW catalogue search; Planetary Computer assets signed; Data Sources with favourites, recent sources, grouped named connections and a public-service library | STAC browser, PostGIS, DuckDB, Iceberg, ArcGIS services, File Geodatabase (desktop) | STAC, PostGIS, SpatiaLite, Oracle, SQL Server, ArcGIS services | Enterprise geodatabases, Esri services | Hosted only | +| **3D** | A 3D globe; tilted views draped over terrain from a DEM layer or elevation tiles; 2.5D extrusion with lighting and shadows; glTF scene export; LiDAR (LAS/LAZ/COPC) as point layers; geotagged photos as points with thumbnails | Cesium globe, 3D Tiles, I3S, LiDAR, Gaussian splats | Point clouds, 3D map view | **Deepest 3D**: scenes, I3S, mesh, LAS | Scene Viewer | ## Analysis and processing | | **RustGIS** | **GeoLibre** | **QGIS** | **ArcGIS Pro** | **ArcGIS Online** | |---|---|---|---|---|---| -| **Geoprocessing tools** | **1,000+** (GeoLibre and Whitebox) plus built-in vector tools | **1,000+** (GeoLibre and Whitebox) | **1,000+** (native, GDAL, GRASS, SAGA) | **Most mature and complete**, plus paid extensions | A useful subset, credit-metered | +| **Geoprocessing tools** | Built-in tools plus the optional **1,000+** GeoLibre/Whitebox suite (the available list comes from the runner) | **1,000+** (GeoLibre and Whitebox) | **1,000+** (native, GDAL, GRASS, SAGA) | **Most mature and complete**, plus paid extensions | A useful subset, credit-metered | | **Where analysis runs** | Your machine; in the browser on WebAssembly | Your browser (WASM); optional Python sidecar on desktop | Your machine | Your machine, or a server | Vendor cloud | -| **Vector analysis** | Buffer, clip, erase, dissolve, summarize within, statistics, selection by location; GeoLibre vector tools | Buffer, overlay, dissolve, joins, topology checks | Comprehensive | Comprehensive | Common tools | -| **Raster analysis** | Raster functions, band math, indices, change detection, classification, viewshed; GeoLibre terrain and hydrology tools | Hillshade, slope, zonal stats, raster calculator, reclassify | Comprehensive (GDAL/GRASS/SAGA) | Comprehensive; Spatial Analyst | Limited | +| **Vector analysis** | Geodesic buffer, clip, erase, dissolve, spatial join, summarize within, statistics, selection by location, A5 grids and aggregation; optional GeoLibre vector tools | Buffer, overlay, dissolve, joins, topology checks | Comprehensive | Comprehensive | Common tools | +| **Raster analysis** | Raster functions, band math, indices, change detection, classification, viewshed, IDW and ordinary kriging; time-series summaries and trends; optional GeoLibre terrain and hydrology tools | Hillshade, slope, zonal stats, raster calculator, reclassify | Comprehensive (GDAL/GRASS/SAGA) | Comprehensive; Spatial Analyst | Limited | | **Query language** | SQL `WHERE` clauses for selection and definition queries; DuckDB Spatial queries over files, web addresses and databases in an SQL pane (desktop) | DuckDB Spatial, PGlite/PostGIS, Sedona, all in the browser | Virtual layers, PostGIS | SQL against geodatabases | — | | **Model / batch chaining** | ModelBuilder (a node canvas; models from history), tasks, scripts | Visual Model Builder, batch runner | Graphical Model Designer | ModelBuilder | — | -| **Guided workflows** | Suitability, visibility, line of sight, IDW, shortest path, neighborhood, simulation | Isochrones, routing, Emerging Hot Spot Analysis | Plugins and processing providers | Extensions (Network, Spatial, Geostatistical Analyst) | Routing services (credits) | +| **Guided workflows** | Suitability, visibility, line of sight, IDW, shortest path on line networks, neighborhood, simulation; driving, cycling and walking directions through Valhalla | Isochrones, routing, Emerging Hot Spot Analysis | Plugins and processing providers | Extensions (Network, Spatial, Geostatistical Analyst) | Routing services (credits) | | **AI / ML** | Pixel classification (k-means, minimum distance, a trainable neural network) | AI Segmentation (SAM), ONNX object detection | Via plugins | Deep-learning toolset | Pretrained models (credits) | ## Cartography, layout and output | | **RustGIS** | **GeoLibre** | **QGIS** | **ArcGIS Pro** | **ArcGIS Online** | |---|---|---|---|---|---| -| **Renderers** | Single, unique values, graduated, proportional; hexagon/square aggregation; raster stretch, RGB, classify, colormap, shaded relief | Single, categorized, graduated, rule-based, heatmap, cluster, proportional | **Richest** | Very rich | Smart-mapping styles | -| **Labeling** | Collision-free placement, scale ranges, annotation | Data-defined engine | Best-in-class placement | Maplex | Basic | -| **Print layout** | Map frames, legends, scale bars, grids, extent indicators, map series; PDF and PNG | Print Layout with atlas, PNG, PDF, SVG | Full composer with atlas | Full layout view with map series | Basic print | +| **Renderers** | Single, unique values, graduated, proportional; stacked line strokes and symbol levels; hexagon/square aggregation; blend modes and layer groups; raster stretch, RGB, classify, colormap, shaded relief | Single, categorized, graduated, rule-based, heatmap, cluster, proportional | **Richest** | Very rich | Smart-mapping styles | +| **Labeling** | Collision avoidance, SQL-filtered label classes, per-class placement and scale/distance ranges, annotation | Data-defined engine | Best-in-class placement | Maplex | Basic | +| **Print layout** | Publication presets, shaped map frames, legends, colorbars, scale bars, grids, extent indicators, tables, charts and map series; PDF and PNG | Print Layout with atlas, PNG, PDF, SVG | Full composer with atlas | Full layout view with map series | Basic print | | **Reports** | Grouped reports with statistics, charts and subreports; PDF | — | Via atlas and plugins | Reports | — | | **Presentations** | HTML slideshow and PDF from map, text, image and video pages | Story maps with presenter view | Via plugins | — | ArcGIS StoryMaps (separate product) | | **Standalone export** | Web map page (one HTML file), OGC API Features collections, MapLibre styles, glTF | Whole project to one offline HTML file | Via qgis2web | — | — | @@ -99,23 +100,23 @@ engine, choose RustGIS. Both keep your data on your machine. See | | **RustGIS** | **GeoLibre** | **QGIS** | **ArcGIS Pro** | **ArcGIS Online** | |---|---|---|---|---|---| -| **Scripting** | Every action is a command: `rustgis-cli` and the `rustgis` Python package run any of them headlessly; a Script pane | Python console, `geolibre` Python package | PyQGIS, `qgis_process` | `arcpy` | ArcGIS API for Python / JavaScript | +| **Scripting** | Engine commands run headlessly through `rustgis-cli` and the `rustgis` Python package; Script pane; UI-only pane/dialog commands stay in the app | Python console, `geolibre` Python package | PyQGIS, `qgis_process` | `arcpy` | ArcGIS API for Python / JavaScript | | **Notebooks** | Built-in notebooks of text and command cells; Jupyter `.ipynb` in and out | The whole app as a Jupyter widget | Bindings, not the UI | Notebooks drive `arcpy` | Hosted notebooks | -| **Plugins** | Sandboxed WebAssembly plugins with declared permissions; a registry; the same file runs on desktop, CLI and web | TypeScript plugin API, a registry | **Largest**: thousands of Python plugins | .NET add-ins, Python toolboxes | App templates | -| **AI assistant** | Acts through undoable commands; Anthropic, OpenAI, Gemini, OpenRouter, Ollama or any OpenAI-compatible model; bring your own key or sign in with ChatGPT; also on the command line | Natural-language assistant with undoable operations; bring your own key | Via plugins | Copilot features | AI assistants | +| **Plugins** | Permission-controlled WebAssembly plugins on desktop, CLI and web; unsandboxed process/Python plugins on desktop and CLI; a registry, custom ribbon tabs and panels, assistant skills | TypeScript plugin API, a registry | **Largest**: thousands of Python plugins | .NET add-ins, Python toolboxes | App templates | +| **AI assistant** | Acts through engine commands, with undo for project edits; Anthropic, OpenAI, Gemini, OpenRouter, Ollama or any OpenAI-compatible model; bring your own key or sign in with ChatGPT; also on the command line; MCP for external agents | Natural-language assistant with undoable operations; bring your own key | Via plugins | Copilot features | AI assistants | | **Collaboration** | — | Real-time (MVP), review comments | No | No | Shared hosted layers | | **Self-hosting** | Serve the static web build anywhere | Docker image or static build | QGIS Server | ArcGIS Enterprise (paid) | — | ## Where each one is the right choice **Choose RustGIS when** you want a compact, native desktop GIS that installs as one program, a -ribbon interface without a licence, every action scriptable from -the command line with the same ids the interface uses, plugins that run sandboxed, or an AI -assistant whose every step is an undoable command. +ribbon interface without a licence, engine commands scriptable from +the command line with the same ids the interface uses, sandboxed WebAssembly plugins, or an AI +assistant whose project edits use the same undo system as the interface. **Choose GeoLibre when** you need the widest data access from a browser tab (cloud-native formats, STAC, databases, OGC and Esri services), the same app on Android, iOS and in Jupyter, -real-time collaboration, or a Python package. +real-time collaboration, or its Jupyter widget. **Choose QGIS when** you need the widest format support through GDAL/OGR, the deepest open-source cartography, a mature plugin for a niche task, or heavy local processing of large datasets. @@ -133,17 +134,19 @@ opens, and writes MapLibre styles and OGC API Features collections for the web. Stated plainly, so the tables above are worth trusting: -- **Format breadth.** Databases are PostgreSQL/PostGIS, MySQL and MariaDB (all read only) and DuckDB files (through - DuckDB), on the desktop, and the web app doesn't write GeoPackages. Other vector formats (File Geodatabase, - DWG…) and rasters (JPEG 2000, NetCDF, HDF…) need GDAL installed, and SQL queries need - DuckDB, which RustGIS calls rather than links. +- **Format breadth.** PostgreSQL/PostGIS, MySQL/MariaDB and SQL Server/Azure SQL are read only, + and these live connections and DuckDB files are desktop only. The web app doesn't write + GeoPackages. The native File Geodatabase reader is 2D and does not read compressed tables, + raster fields or sparse row layouts; GDAL can fill these gaps on desktop. DWG and rasters + such as JPEG 2000, NetCDF and HDF also need GDAL. SQL queries need DuckDB; both are external programs. - **Coordinate systems.** Vector data is stored in longitude/latitude; there is no full PROJ. -- **Editing depth.** No vertex editing of existing features yet, and no versioned or - multi-user editing. -- **Specialized analysis.** Spatial joins, overlay union and intersect, network datasets and - spatial statistics are thin or missing, beyond what the GeoLibre and Whitebox tools cover. +- **Editing depth.** Vertex move, insert and delete are supported, but not versioned or + multi-user database editing. Edits are kept in the project and exported, not written back to databases. +- **Specialized analysis.** Built-in spatial joins and shortest paths on line networks are + available, but full network datasets, overlay union/intersect tools and advanced spatial + statistics depend on the external suite or another GIS. - **Python.** The [`rustgis` package](../python.md) runs commands headlessly, but there is no - Python console inside the app and no Python plugins. + Python console inside the app. Python process plugins are supported on desktop and CLI, without a sandbox. - **Maturity.** RustGIS is young. Many features are first versions; see the [roadmap](https://github.com/opengeos/RustGIS/blob/main/ROADMAP.md). diff --git a/docs/comparison/qgis.md b/docs/comparison/qgis.md index 2dfdc3cba..00774698b 100644 --- a/docs/comparison/qgis.md +++ b/docs/comparison/qgis.md @@ -20,9 +20,9 @@ mobile companions (QField, Mergin Maps) and thousands of Python plugins, with a training material and books behind it. **RustGIS is younger and narrower, built differently.** It is one native program written -entirely in Rust, an 18–30 MB download that also runs as the whole app in a browser tab. Its -interface is a ribbon laid out in the order of the work, every button is a command you can run -from the command line, Python or an AI agent, and its plugins run sandboxed. +entirely in Rust that also runs as a WebAssembly app in a browser tab. Its +interface is a ribbon laid out in the order of the work, its engine commands run +from the command line, Python or an AI agent, and its WebAssembly plugins run sandboxed. RustGIS is not a QGIS replacement, and doesn't aim to be. If your work depends on what QGIS does best, keep using QGIS. RustGIS suits people who want something lighter to install, a browser @@ -45,34 +45,38 @@ The same World Atlas data, styled the same way, in each app. ## Where RustGIS is stronger -- **Installing it.** RustGIS is a single program with no Python, Qt or GDAL inside: the - installers are 18–30 MB depending on the platform (a portable Windows zip, a macOS DMG, an - AppImage, `.deb`/`.rpm`, Homebrew, winget, the AUR, Fedora COPR). QGIS installers bundle a - Python interpreter, Qt, GDAL, GRASS and more, and run to hundreds of megabytes. See +- **Installing it.** RustGIS's core app is a single program with no Python, Qt or GDAL inside. + Packages include a portable Windows zip, a macOS DMG, AppImage, `.deb`/`.rpm`, Homebrew, + winget, the AUR and Fedora COPR. Optional GeoLibre tools, GDAL, DuckDB and process plugins + use separate programs. QGIS installers bundle a Python interpreter, Qt, GDAL, GRASS and more. + See [Installation](../installation.md). -- **In the browser.** The whole app, not a viewer, is compiled to WebAssembly and runs at - [web.rustgis.app](https://web.rustgis.app): add files, style them, run the 1,000+ GeoLibre and - Whitebox tools, build layouts and export, with the data staying on your device. You can serve - the static build yourself. QGIS has no official browser version; QGIS Server and qgis2web - publish maps made on the desktop. -- **Everything is a command.** Every ribbon button, shortcut and pane action is a command with an - id and JSON parameters. The same ids run from [`rustgis-cli`](../cli.md), the +- **In the browser.** The WebAssembly app runs at + [web.rustgis.app](https://web.rustgis.app): add files, style them, load the optional + 1,000+ GeoLibre/Whitebox suite, build layouts and export. Live database connections and + external programs are desktop only, and GeoPackage export is not available on web. + You can serve the static build yourself. QGIS has no official browser version; + QGIS Server and qgis2web publish maps made on the desktop. +- **A shared command engine.** Ribbon actions dispatch commands with ids and JSON parameters. + Engine commands run from [`rustgis-cli`](../cli.md), the [`rustgis` Python package](../python.md), notebooks, models, the Script pane, the - [MCP server](../mcp.md) and the AI Assistant, so what you can click you can script, exactly. + [MCP server](../mcp.md) and the AI Assistant. UI-only commands open panes and dialogs + inside the app rather than running headlessly. QGIS's processing algorithms are scriptable through `qgis_process` and PyQGIS, but much of its interface (styling, layouts, project setup) is reached through the PyQGIS object model rather than the same actions the buttons run. -- **An AI Assistant that acts through undoable commands.** The [AI Assistant](../user-guide/assistant.md) - is built in, works with Anthropic, OpenAI, Gemini, OpenRouter, Ollama or any OpenAI-compatible - model with your own key, and does everything through the same commands, so every step shows - in History and can be undone; file, network and plugin commands wait for your approval. An +- **An AI Assistant that uses the command engine.** The [AI Assistant](../user-guide/assistant.md) + works with Anthropic, OpenAI, Gemini, OpenRouter, Ollama or any OpenAI-compatible + model with your own key, or with a ChatGPT sign-in. Project edits use the same undo + system as the interface; file, network and plugin commands wait for your approval, + and external side effects are not undone. An [MCP server](../mcp.md) lets agents such as Claude Code, Cursor and VS Code drive RustGIS. QGIS has AI through third-party plugins. -- **Sandboxed plugins.** RustGIS [plugins](../plugins.md) are WebAssembly modules that can do - only what their declared permissions allow (editing the project, reading files, the network), - and the same file runs on the desktop, in `rustgis-cli` and in the browser. QGIS plugins are - Python with the full access of the user running QGIS: far more capable, but trusted rather - than contained. +- **Sandboxed WebAssembly plugins.** These RustGIS [plugins](../plugins.md) use declared, + approved permissions and run on desktop, in `rustgis-cli` and in the browser. + Plugins can add commands, tools, ribbon tabs, panels and assistant skills. + RustGIS also supports unsandboxed process plugins, including managed Python environments, + on desktop and CLI. Like QGIS's Python plugins, these are trusted programs, not contained. - **Cloud-native data without extra libraries.** Cloud-optimised GeoTIFF, GeoParquet, FlatGeobuf, PMTiles and Zarr are read in place by RustGIS's own readers, over HTTP byte ranges and from `s3://` buckets, on the desktop and in the browser alike. QGIS reads all of these too, @@ -84,44 +88,49 @@ The same World Atlas data, styled the same way, in each app. - **A permissive licence.** RustGIS is Apache-2.0, so its crates can be embedded in proprietary or differently licensed software. QGIS is GPL-2.0-or-later, which requires derived works to stay under the GPL. For running either program, both are free. -- **Built not to crash.** RustGIS is memory-safe Rust with no `unsafe` code, treats every file - and parameter as hostile, and its test suite runs every command and tool with junk input to - check that none of them panics. +- **Memory-safe core.** RustGIS's workspace forbids `unsafe` Rust, checks file and parameter + inputs, and has hostile-input tests for registered commands and built-in tools. This + does not make external programs or unsandboxed process plugins a security boundary. ## Where QGIS is stronger - **Format and database breadth.** QGIS opens the formats its GDAL/OGR build supports, natively, and writes many of them. RustGIS reads a focused set of formats with its own code (see - [Data formats](../user-guide/data-formats.md)) and reaches the rest, such as File Geodatabase, - DWG, JPEG 2000 or NetCDF, only when GDAL's programs are installed. QGIS also connects to + [Data formats](../user-guide/data-formats.md)), including a native, read-only 2D File + Geodatabase reader; compressed tables, raster fields and sparse row layouts need GDAL. + DWG, JPEG 2000 and NetCDF also need GDAL's programs. QGIS connects to PostGIS, SpatiaLite, Oracle and SQL Server and edits in them directly; RustGIS reads - PostGIS, MySQL and MariaDB tables live (read only) and DuckDB databases through DuckDB, on - the desktop. + PostGIS, MySQL/MariaDB and SQL Server/Azure SQL tables live (read only) and DuckDB databases + through DuckDB, on the desktop. - **Coordinate systems.** QGIS uses full PROJ: every CRS, datum transformations and grids, and layers kept in their own CRS. RustGIS holds vector coordinates as WGS 84 longitude/latitude and reprojects rasters and the map by EPSG code, which covers mapping and most analysis but not survey-grade work in a local datum. -- **Editing.** QGIS has a complete digitizing toolkit: vertex editing, reshape, offset curves, - advanced digitizing with constraints, topological editing, geometry checkers. RustGIS creates, - moves, rotates, scales, splits and merges features with snapping and templates, but cannot yet - edit the vertices of existing features. -- **Processing.** Both have over 1,000 tools, but they differ. QGIS combines its native - algorithms with GDAL, GRASS and SAGA providers, and covers vector analysis RustGIS is thin - on: spatial joins, intersect and union overlays, network analysis on real road graphs, spatial - statistics. RustGIS's breadth comes mostly from the GeoLibre and Whitebox suite, which is - strong on terrain, hydrology, LiDAR and image analysis. +- **Editing.** QGIS has a complete digitizing toolkit: reshape, offset curves, + advanced digitizing with constraints, topological editing and geometry checkers. RustGIS + creates, moves, rotates, scales, splits and merges features with snapping and templates, + and moves, inserts and deletes vertices. Edits stay in the project until exported; + RustGIS does not write them back to live databases. +- **Processing.** RustGIS has built-in spatial joins, geodesic buffers, A5 grids, IDW, + ordinary kriging and shortest paths on line networks, plus the optional + 1,000+ GeoLibre/Whitebox suite. QGIS combines its native algorithms with GDAL, GRASS + and SAGA providers and has more complete built-in overlays, network datasets and + spatial statistics. RustGIS's external suite is strong on terrain, hydrology, LiDAR + and image analysis; its available tools depend on the installed runner. - **Cartography.** QGIS's styling is the richest in open source: rule-based renderers, data-defined overrides on nearly every property through its expression engine, blending modes, geometry generators, a refined labeling engine. RustGIS covers the common renderers (single, unique - values, graduated, proportional, aggregation, raster stretch/RGB/classified/hillshade) and - collision-free labels, without that depth. -- **Print layouts.** QGIS's layout designer and atlas are mature and detailed. RustGIS has map - frames, legends, scale bars, grids, map series, reports and PDF export, as first versions. + values, graduated, proportional, aggregation, raster stretch/RGB/classified/hillshade), + stacked line symbols, symbol levels, blend modes, layer groups and SQL-filtered label + classes, without that depth. +- **Print layouts.** QGIS's layout designer and atlas are mature and detailed. RustGIS has + publication presets, shaped map frames, legends, colorbars, scale bars, grids, tables, + charts, map series, reports and PDF/PNG export. - **Plugins and integrations.** QGIS has thousands of Python plugins for niche tasks, an in-app Python console, QGIS Server for OGC services, and QField and Mergin Maps for field work. RustGIS's plugin registry is new and small, and it has no server or mobile app. - **3D and point clouds.** QGIS's 3D map views, mesh layers and point-cloud rendering go - further than RustGIS's globe, 2.5D extrusion and LiDAR point layers. + further than RustGIS's globe, terrain-draped tilted views, 2.5D extrusion and LiDAR point layers. - **Maturity and community.** QGIS has two decades of releases, a long-term-release cycle, translated documentation, courses, books, user groups and commercial support. RustGIS released its first version in 2026; many features are first versions and some workflows will @@ -134,16 +143,16 @@ The same World Atlas data, styled the same way, in each app. | **Licence** | Apache-2.0 | GPL-2.0-or-later | | **Written in** | Rust (egui, wgpu, tiny-skia) | C++ and Python (Qt) | | **First release** | 2026 | 2002 | -| **Download** | 18–30 MB, one program | Hundreds of MB (bundles Python, Qt, GDAL, GRASS…) | +| **Download** | One core program; optional external tools installed separately | Hundreds of MB (bundles Python, Qt, GDAL, GRASS…) | | **Platforms** | Windows, macOS, Linux, and the browser (WebAssembly) | Windows, macOS, Linux | | **Interface** | Ribbon with Command Search | Toolbars, menus and docked panels | | **Formats** | A focused set read natively; more through GDAL when installed | Whatever its GDAL/OGR build supports (read/write varies by driver) | | **Coordinate systems** | Vectors in WGS 84; rasters and the map by EPSG code | Full PROJ | -| **Geoprocessing** | 1,000+ (GeoLibre and Whitebox) plus built-in vector tools | 1,000+ (native, GDAL, GRASS, SAGA) | -| **Editing** | Create, move, rotate, scale, split, merge, snapping; no vertex editing yet | Complete digitizing and topology tools | -| **Scripting** | Every action a command: CLI, Python package, notebooks, Script pane, MCP | PyQGIS, `qgis_process`, Python console | -| **Plugins** | Sandboxed WebAssembly, small registry | Python, thousands in the official repository | -| **AI** | Built-in assistant (undoable commands) and an MCP server | Through plugins | +| **Geoprocessing** | Built-in tools plus optional 1,000+ GeoLibre/Whitebox suite | 1,000+ (native, GDAL, GRASS, SAGA) | +| **Editing** | Create, move, rotate, scale, split, merge, vertex move/insert/delete, snapping; export edits | Complete digitizing and topology tools | +| **Scripting** | Engine commands: CLI, Python package, notebooks, Script pane, MCP; UI-only commands stay in the app | PyQGIS, `qgis_process`, Python console | +| **Plugins** | Sandboxed WebAssembly; unsandboxed desktop/CLI process and Python plugins; registry | Python, thousands in the official repository | +| **AI** | Built-in assistant using engine commands and project undo; MCP server | Through plugins | | **Server and mobile** | Static web build you can host | QGIS Server; QField and Mergin Maps | | **Project file** | `.rgis` (open JSON) | `.qgs` / `.qgz` (open XML) | @@ -175,7 +184,7 @@ You don't have to choose. RustGIS reads and writes the formats QGIS works in, so between them without conversion: - **Vector data**: GeoPackage, GeoParquet, FlatGeobuf, Shapefile, GeoJSON, KML/KMZ, GPX and - CSV, in both directions. + CSV, in both directions (GeoPackage export is desktop only). - **Rasters**: GeoTIFF and cloud-optimised GeoTIFF. - **Colour ramps**: RustGIS reads QGIS Style Manager exports (`.xml`) and the colour ramp or raster colour map in a layer style (`.qml`); see @@ -189,8 +198,8 @@ across and restyle it. A common split is to do heavy editing, PROJ-dependent work and detailed cartography in QGIS, and use RustGIS where a light install, the browser, scripting by command id or the AI Assistant helps: a quick look at a GeoParquet on a laptop without QGIS, a map shared as a web app, a batch -of maps rendered by `rustgis-cli` in CI, or the 1,000+ GeoLibre and Whitebox tools from a -browser tab. +of maps rendered by `rustgis-cli` in CI, or the optional 1,000+ GeoLibre/Whitebox suite +loaded in a browser tab. ## Common questions @@ -205,8 +214,8 @@ view at a time. We haven't published benchmarks against QGIS, and speed depends the task, so try your own data in both. **Can I use my QGIS plugins in RustGIS?** -No. QGIS plugins are Python written against PyQGIS. RustGIS plugins are sandboxed WebAssembly -modules; see [Plugins](../plugins.md) for writing one. +No. QGIS plugins are written against PyQGIS. RustGIS has its own WebAssembly and process-plugin +interfaces, including Python process plugins; see [Plugins](../plugins.md). **Does RustGIS need GDAL like QGIS does?** No. Its common formats, cloud-native formats and GeoTIFF are read by its own Rust code, which is diff --git a/docs/contributing.md b/docs/contributing.md index 55e1a1303..03de0619d 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -4,12 +4,41 @@ Contributions are welcome. See [AGENTS.md](https://github.com/opengeos/RustGIS/b for the conventions. 1. Fork the repository and create a branch. -2. Make your change with tests. New behaviour is a command in `crates/engine/src/cmd/`. +2. Make your change with tests. Engine behaviour is a command in `crates/engine/src/cmd/`; + UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. 3. Run `cargo xtask ci` and `pre-commit run --all-files`. -4. Open a pull request. CI runs on Linux, and the docs build a preview. A maintainer can label it - `ci:windows`, `ci:macos` or `ci:full` to test those platforms before merging; `main` and the - nightly run test all three. A maintainer's `automerge` label approves it and squash-merges it - once CI passes; pushing again removes the label, and removing it cancels the merge. +4. Open a pull request. Rust CI runs on Linux by default. A maintainer can label it `ci:windows`, + `ci:macos` or `ci:full` to test those platforms before merging; pushes to `main` test all three. + `ci:full` also builds the release apps. The nightly workflow tests and release-builds all three + when there are new commits, and opens an issue on failure. +5. The docs workflow builds a site and web-app preview. A first-time fork contributor's preview + needs a maintainer to add `preview` or re-run the workflow; returning contributors' previews + publish automatically. + +A maintainer's `automerge` label approves and squash-merges a pull request after its required +checks pass. Pushing again removes the label and disables auto-merge; removing the label also +disables it. + +## Developer checks + +`cargo xtask ci` runs `cargo fmt --all --check`, +`cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace -q` and +`cargo xtask layers`. The pre-commit hooks also check TOML, YAML, JSON, whitespace and large files. +CI separately builds `rustgis-web` for `wasm32-unknown-unknown` and runs +`cargo deny --locked check licenses` against `deny.toml`. + +Other workspace tasks: + +| Task | Purpose | +| --- | --- | +| `cargo xtask layers` | Check crate dependency direction and keep UI dependencies out of lower layers | +| `cargo xtask notices` | Regenerate the shipped `THIRD-PARTY-LICENSES.txt` files after changing dependencies; requires `cargo-about` | +| `cargo xtask notices --check` | Check the notices without writing them; stale notices warn in CI but block releases | +| `cargo xtask web-pwa

` | Generate the service worker in a Trunk build output directory, such as `dist/web` | + +Interface strings must be translatable: use `tl!("…")` for literals and +`i18n::fmt("… {name} …", &[("name", value)])` for messages with values. New strings can fall back +to English until the catalogs are updated; see [Languages](i18n.md). ## Test data @@ -32,23 +61,26 @@ zensical serve # http://localhost:8000 Images live in [rustgis-assets](https://github.com/opengeos/rustgis-assets) and are linked by URL; don't commit images to this repository. -The screenshots are rendered by the app itself. `crates/ui-egui/examples/shots/render.sh` -runs the `ui_shot` scripts beside it and writes WebP files to `target/shots/`; copy them to -`images/` in rustgis-assets. The `assistant` shot asks a real model, so set an AI Assistant -provider and key first (see [AI Assistant](user-guide/assistant.md#choosing-a-model)). +The screenshots are rendered by the app itself. From the repository root, run +`crates/ui-egui/examples/shots/render.sh`; it needs ImageMagick (`magick`) and network access for +tiles, plugins and the sample DEM. It runs the `ui_shot` scripts beside it and writes WebP files +to `target/shots/`; copy them to `images/` in rustgis-assets. The `assistant` shot asks a real +model, so set `RUSTGIS_ASSISTANT_PROVIDER` and the provider's key first, or it shows an empty +pane (see [AI Assistant](user-guide/assistant.md#choosing-a-model)). ## Releases 1. Bump `version` in the workspace `Cargo.toml` and merge it. -2. Run the **Release dry run** workflow, which checks the version and builds every package - without publishing anything: +2. Refresh third-party notices if dependencies changed (`cargo xtask notices`). +3. Run the **Release dry run** workflow, which checks the version and builds the desktop/CLI + packages without publishing anything: `gh workflow run release-dry-run.yml -R opengeos/RustGIS --ref main -f version=` -3. When it passes, publish a GitHub release tagged `v` (the tag must match `Cargo.toml`). +4. When it passes, publish a GitHub release tagged `v` (the tag must match `Cargo.toml`). The **Release** workflow then builds and attaches the Windows installers, zip and MSIX, the macOS DMGs, the Linux tarballs, `.deb` and `.rpm` packages and AppImages, and `SHA256SUMS`. To try the packaging locally: ```sh cargo build --release --locked -p rustgis -p rustgis-cli -packaging/package.sh 0.1.0 # writes dist/ +packaging/package.sh # writes dist/ for the host platform ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 43103f2e8..8bd6a658f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -3,12 +3,12 @@ ## Open the app - **In the browser**: open [web.rustgis.app](https://web.rustgis.app). Nothing to install. -- **On the desktop**: [install](installation.md) RustGIS and start it. It opens the World Atlas - sample: countries, cities, rivers and lakes from Natural Earth. +- **On the desktop**: [install](installation.md) RustGIS and start it. On first launch it opens + the World Atlas sample: countries, cities, rivers and lakes from Natural Earth. - **From source**: ```sh -cargo run --release -p rustgis # opens the World Atlas sample +cargo run --release -p rustgis -- --sample # always opens the World Atlas sample cargo run --release -p rustgis -- places.geojson cargo run --release -p rustgis -- atlas.rgis # a saved project cargo run --release -p rustgis -- lesson.rgispkg # a project package, opened unsaved @@ -16,44 +16,60 @@ cargo run --release -p rustgis -- lesson.rgispkg # a project package, opened u ## Five minutes with the World Atlas +If you already changed the start-up settings, use **Project › New › World Atlas** to open the +sample again. **Settings › Start-up › Start With** chooses what opens next time. + 1. **Explore.** Drag to pan, scroll to zoom, and click a country to see its attributes in a pop-up. Press ++home++ for the full extent, or pick a place from **Home › Bookmarks**. -2. **Style a layer.** Click *Countries* in the Layers pane, then open the - **Style** tab and **Style › Symbology**, choose *Graduated Colors* or *Unique Values*, - and pick a colour scheme. -3. **Select.** **Home › Select By Attributes**, enter `continent = 'Asia'`, and press *Apply*. - Open the attribute table with ++ctrl+t++. Rows and map features stay in sync. -4. **Analyse.** **Analyze › Buffer**: choose *Cities*, 300 km, *Dissolve: All*, and *Run*. The - result is added as a new layer, and the run is listed under **Analyze › History**. +2. **Style a layer.** Click *Countries* in the Layers pane, then open **Style › Symbology**. + In the Symbology pane, choose *Graduated Colors*, set *Field* to `pop_est` and pick a colour + scheme. *Unique Values* instead colours categories such as `continent`. +3. **Select.** Open **Home › Select By Attributes**, choose *Countries* under *Input Rows*, + enter `continent = 'Asia'` under *Where*, and press *Apply*. Open the attribute table with + ++ctrl+t++. Rows and map features stay in sync. +4. **Analyze.** Open **Analyze › Buffer**. Set *Input Features* to *Cities*, *Distance* to + `300`, *Units* to *Kilometers* and *Dissolve* to *All*, then press *Run*. The result is added + as a new layer, and the run is listed under **Analyze › History**. 5. **Change the view.** **View › Globe** turns the map into a 3D globe; **View › Projection** offers Equal Earth, Robinson and other projections. **Home › Add › Basemap** switches the basemap or adds a web tile layer, and its **Celestial Bodies** take the map to the Moon or Mars. - Right-drag (or ++ctrl++-drag) turns and tilts the map. + Right-drag (or ++ctrl++-drag) turns and tilts the map; click the north arrow to reset it. 6. **Share.** **Publish › Export Map** saves a PNG with labels and a scale bar. **Home › New - Layout** makes a printable page with a legend and north arrow. **Project › Save As** writes a - `.rgis` project. + Layout** offers page sizes and **Publication Layout** presets for sheets and figures. + Choose a page size for a page with a map frame, legend, north arrow and scale bar. + **Project › Save As** writes a `.rgis` project. !!! tip "Command search" Press ++ctrl+k++ to find any command or geoprocessing tool by name. + On macOS, use ++cmd++ in place of ++ctrl++ for shortcuts. ## Your own data -**Home › Add Data** (or drop files on the window) opens GeoJSON, CSV with longitude and latitude -columns, GeoParquet, FlatGeobuf, GeoTIFFs and ASCII grids, and on the desktop also Shapefiles and -GeoPackages. **Project › New › Map** starts from a blank map. **Home › Web Data** finds public -data on the web: satellite imagery from STAC catalogs, Overture Maps, OpenStreetMap, NASA -Earthdata, open-data portals and more (see [Web Data](user-guide/web-data.md)). -See [Data](user-guide/data.md) for the details of each format. +**Home › Add Data › Data…** (or drop files on the window) opens GeoJSON, GeoPackage, +GeoParquet, FlatGeobuf, KML/KMZ, GPX, GeoTIFF, ASCII grids and more. Shapefiles work in the +browser too: pick or drop the `.shp` and `.dbf` together, with `.shx` and `.prj` if available, +or use a ZIP containing them. Use **Add Data › Delimited Text…** to choose a CSV's coordinate +columns or WKT geometry and coordinate system. + +**Project › New › Map** starts a blank project. **Home › Web Data** finds public satellite +imagery, Overture Maps, OpenStreetMap, NASA Earthdata, open-data portals and more +(see [Web Data](user-guide/web-data.md)). **Home › Add Data › Connections…** opens saved data +connections; desktop database connections include PostGIS, MySQL/MariaDB and SQL Server/Azure SQL. +See [Data](user-guide/data.md) and [Supported Data Formats](user-guide/data-formats.md) for +format and platform limits. ## Ask the AI Assistant **Analyze › AI Assistant** opens a chat pane. Choose a model provider (Anthropic, OpenAI, your ChatGPT account, Gemini, OpenRouter, a local Ollama, or any OpenAI-compatible server) and ask, for example, -"Which 5 countries have the largest GDP?" or "Label the cities and zoom to Tokyo". See -[AI Assistant](user-guide/assistant.md). +"Which 5 countries have the largest GDP?" or "Label the cities and zoom to Tokyo". +By default, the assistant asks before file, network and plugin operations. Undo restores +project changes, not files already written. See [AI Assistant](user-guide/assistant.md). ## Next steps - The [User Guide](user-guide/index.md) walks through every part of the app. - [Command Line](cli.md) shows how to script RustGIS with `rustgis-cli`. - [Plugins](plugins.md) shows how to add tools from the plugin registry, or write your own. +- [Python](python.md) covers sessions, rendering and the interactive notebook map. +- [MCP](mcp.md) connects external AI agents to RustGIS. diff --git a/docs/i18n.md b/docs/i18n.md index dda5e9f42..34a850706 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -16,10 +16,12 @@ RustGIS's interface is available in 17 languages: ## Choosing a language -By default RustGIS follows your system's language (your browser's on the web) and falls back to -English. To choose one yourself, open **Settings › Language**. The choice is remembered (in -`prefs.json` beside the app's settings on the desktop, in the browser on the web). For one run, -set `RUSTGIS_LANGUAGE`: +By default RustGIS follows the first supported language in your system's preferences (your +browser's on the web) and falls back to English. The Chinese catalog is Simplified Chinese; +Traditional Chinese locales are not mapped to it. To choose a language yourself, open +**Settings › Language**. The choice is remembered in `prefs.json` in the desktop app's settings +folder, or in browser storage on the web. When following the system language, you can override +its detection for one desktop run with `RUSTGIS_LANGUAGE`: ```sh RUSTGIS_LANGUAGE=de cargo run --release -p rustgis @@ -42,23 +44,25 @@ installed (Segoe UI, Tahoma, Arial, Vazirmatn or DejaVu Sans); the web app downl ## Translating -Each language is a catalog of `EnglishTranslation` lines in -`crates/ui-egui/src/i18n/.tsv`. A missing entry shows in English, so translations can grow -one line at a time, and corrections from native speakers are very welcome. +Each non-English language is a UTF-8 catalog of `EnglishTranslation` lines in +`crates/ui-egui/src/i18n/.tsv`. English is the source language and has no catalog. Blank lines +and lines beginning with `#` are ignored. Within either field, `\n`, `\t` and `\\` represent a +line break, tab and backslash. A missing entry shows in English, so translations can grow one +line at a time, and corrections from native speakers are welcome. -Every catalog uses the same keys. Pull requests can leave new strings out of the catalogs: the -maintainer translates them before each release. To translate a new string, add a line with the -exact English text, a tab, and the translation. Keep `{placeholders}` such as `{n}` or `{name}` -(they may move), keep the line break (`\n`) in two-line ribbon labels, and leave product, format -and key names (RustGIS, GeoJSON, CSV, Ctrl…) in English. The `i18n` tests check every catalog's format and -placeholders: +Pull requests can leave new strings out of the catalogs: the maintainer translates them before +each release. To translate a new string, add a line with the exact English text, a tab, and the +translation. Keep `{placeholders}` such as `{n}` or `{name}` (they may move), keep the line break +(`\n`) in two-line ribbon labels, and leave product, format and key names (RustGIS, GeoJSON, CSV, +Ctrl…) in English. To add a language, add its catalog and a row in `LANGUAGES` in +`crates/ui-egui/src/i18n/mod.rs`. The `i18n` tests check catalog format and placeholders, and +require translations for some established interface strings: ```sh cargo test -p rustgis-ui-egui i18n ``` -Persian is written right to left, which the UI toolkit doesn't lay out yet: its text is put in -display order before it is drawn (`crates/ui-egui/src/i18n/rtl.rs`), while the panes keep their -left-to-right layout. Other right-to-left languages (Arabic, Hebrew) can use the same layer; -languages needing complex shaping of their own (Hindi, Thai) will follow once the toolkit supports -them. +Persian text uses the toolkit's script shaping, with Latin runs and brackets adjusted by +`crates/ui-egui/src/i18n/rtl.rs`. Long paragraphs are broken into lines before drawing. Panes +and widgets retain their left-to-right layout. Arabic, Hebrew, Hindi and Thai are not included +in the language picker. diff --git a/docs/index.md b/docs/index.md index d6ac6026b..6591e2b4a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,11 +1,11 @@ # RustGIS -**A fast, free and open-source GIS in pure Rust for mapping, analyzing and sharing geospatial +**A fast, free and open-source GIS written in Rust for mapping, analyzing and sharing geospatial data.** -RustGIS runs natively on Windows, macOS and Linux, and in your web browser. Your data stays on -your machine unless you send it to an AI model you choose, and every action is a command you can -script from the command line, a notebook or the AI Assistant. +RustGIS runs natively on Windows, macOS and Linux, and in your web browser. Local files are +processed on your machine; web data sources, AI providers and plugins can use the network. +Map operations are commands you can script from the command line, Python or the AI Assistant.
@@ -28,38 +28,41 @@ script from the command line, a notebook or the AI Assistant. - **Your data, any source** - GeoJSON, Shapefile, GeoPackage, GeoParquet, GeoTIFF and more, live PostGIS and MySQL - databases, and public data from STAC catalogs, Overture Maps and open-data portals. + GeoJSON, Shapefile, GeoPackage, GeoParquet, GeoTIFF, LiDAR and Zarr; live PostGIS, MySQL, + MariaDB and SQL Server databases on the desktop; public data from STAC catalogs, Overture + Maps and open-data portals. Save and browse reusable data connections. [Data](user-guide/data.md) · [Web Data](user-guide/web-data.md) · [Formats](user-guide/data-formats.md) - **Cartography and 3D** - Graduated colours, collision-free labels, page layouts and map series; a 3D globe, any - projection, terrain and extrusions, and maps of the Moon, Mars and other worlds. + Graduated colours, collision-aware labels, stacked line symbols, page layouts and map series; + a 3D globe, projection presets and supported EPSG/ESRI systems, terrain and extrusions, + and maps of the Moon, Mars and other worlds. [Cartography](user-guide/cartography.md) · [Maps](user-guide/maps.md) · [Layouts](user-guide/layouts.md) - **Analysis** - SQL selection, overlay and proximity tools, guided workflows, ModelBuilder, imagery tools and - over 1,000 GeoLibre and Whitebox tools for terrain, hydrology and LiDAR. + SQL selection, overlay and proximity tools, guided workflows, ModelBuilder, imagery tools, + data-cube time series and trends, and over 1,000 tools through the optional GeoLibre/Whitebox suite. [Analysis](user-guide/analysis.md) - **Editing** Create features and edit their vertices with snapping and templates, keep attributes valid - with domains, subtypes and rules, check topology, and undo any change. + with domains, subtypes and rules, check topology, and undo project edits. [Editing](user-guide/editing.md) - **AI Assistant** - Ask about your data or tell the map what to do. The assistant runs RustGIS commands with the - model you choose, asks before going online, and every change can be undone. + Ask about your data or tell the map what to do. Choose a model provider or sign in with + ChatGPT; approve file, network and plugin operations before they run. Project edits are undoable. [AI Assistant](user-guide/assistant.md) - **Scriptable and extensible** - `rustgis-cli` and the `rustgis` Python package run any command and render maps; the MCP - server lets AI agents make maps; sandboxed WebAssembly plugins add tools. + `rustgis-cli` and the `rustgis` Python package run engine commands and render maps; MCP + connects AI agents to headless sessions or the open app. Plugins add tools, commands and + assistant skills, with sandboxed WebAssembly or desktop-only process runtimes. [Command Line](cli.md) · [Python](python.md) · [MCP](mcp.md) · [Plugins](plugins.md)
@@ -95,7 +98,7 @@ script from the command line, a notebook or the AI Assistant.
[![The World Atlas on the 3D globe](https://assets.rustgis.app/images/globe.webp)](https://assets.rustgis.app/images/globe.webp){ target="_blank" rel="noopener" title="Open the full-size image" } -
Globe and projections. A 3D globe, Equal Earth, Robinson or any EPSG code.
+
Globe and projections. A 3D globe, Equal Earth, Robinson and supported EPSG/ESRI systems.
@@ -135,7 +138,7 @@ script from the command line, a notebook or the AI Assistant.
[![The Plugin Manager browsing the plugin registry](https://assets.rustgis.app/images/plugins.webp)](https://assets.rustgis.app/images/plugins.webp){ target="_blank" rel="noopener" title="Open the full-size image" } -
Plugins. Install sandboxed plugins from plugins.rustgis.app.
+
Plugins. Install tools, commands and assistant skills from plugins.rustgis.app.
diff --git a/docs/installation.md b/docs/installation.md index eb409e802..29073ddd9 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,7 +33,7 @@ builds of the desktop app and `rustgis-cli`. The same files, grouped by platform | Linux x64 / arm64 (any distribution, no install) | `RustGIS--linux-x86_64.AppImage` / `RustGIS--linux-aarch64.AppImage` | | Linux x64 / arm64 (other) | `RustGIS--linux-x86_64.tar.gz` / `RustGIS--linux-aarch64.tar.gz` | -On macOS the command line tool is a separate `rustgis-cli--macos-.tar.gz`; +On macOS the command-line tool is a separate `rustgis-cli--macos-.tar.gz`; the other packages include it (the Windows installers and the MSIX add `rustgis-cli` to every new terminal). `SHA256SUMS` lists the checksum of every file. @@ -44,19 +44,18 @@ only the parts that changed, with the `.zsync` file published beside each AppIma On Windows, `setup.exe` installs for your account in `%LOCALAPPDATA%\Programs\RustGIS` and needs no admin rights; the `.msi` installs for every user in `Program Files` and suits managed deployment (`msiexec /i RustGIS--windows-x86_64.msi /qn`). Both add a Start menu -entry, open `.rgis` projects and uninstall from **Settings › Apps**. +entry, open `.rgis` projects, list RustGIS under **Open with** for `.geojson`, and +uninstall from **Settings › Apps**. The `.msix` is the package submitted to the Microsoft Store, which signs it. It is unsigned on the release page, so Windows only installs it from the [Store](https://apps.microsoft.com/detail/9ppjw8h561lq); outside the Store, download an installer or the portable zip instead. -The macOS DMG and command line tool are signed with a Developer ID and notarized by Apple, so -they open without a warning. From the release after 0.1.0, the notarization ticket is also -stapled to RustGIS.app, so it opens even when the Mac cannot reach Apple's servers. If macOS -says Apple could not verify RustGIS 0.1.0, connect to the internet and open it again, or run -`xattr -dr com.apple.quarantine /Applications/RustGIS.app`. The Windows builds are not yet -signed: choose **More info › Run anyway** if SmartScreen warns about an unknown publisher. +The macOS app needs macOS 11 or later. It is signed and notarized by Apple, so it opens without a +warning; the command-line tool is too, but macOS checks it online the first time it runs. The +`setup.exe`, `.msi` and portable zip are not signed: if SmartScreen warns about an unknown +publisher, choose **More info › Run anyway**. ## Package managers @@ -66,7 +65,7 @@ RustGIS is also published to these package managers, which keep it up to date: | --- | --- | | Windows ([winget](https://learn.microsoft.com/windows/package-manager/)) | `winget install OpenGeos.RustGIS` | | macOS ([Homebrew](https://brew.sh)) | `brew install --cask opengeos/rustgis/rustgis` | -| macOS and Linux, command line tool only (Homebrew) | `brew install opengeos/rustgis/rustgis-cli` | +| macOS and Linux, command-line tool only (Homebrew) | `brew install opengeos/rustgis/rustgis-cli` | | Arch Linux ([AUR](https://aur.archlinux.org/packages/rustgis-bin)) | `yay -S rustgis-bin` (or any AUR helper) | | Fedora ([COPR](https://copr.fedorainfracloud.org/coprs/giswqs/rustgis/)) | `sudo dnf copr enable giswqs/rustgis && sudo dnf install rustgis` | @@ -77,18 +76,20 @@ managers shortly after it is published (winget after Microsoft's review). ## From source -RustGIS builds from source with a recent stable Rust toolchain (1.90 or later). +RustGIS builds from source with a recent stable Rust toolchain (1.90 or later), a C compiler (for +the bundled SQLite and the Zstandard decoder) and `curl` (to download the sample data). ```sh git clone https://github.com/opengeos/RustGIS.git cd RustGIS cargo run --release -p rustgis # the desktop app -cargo build --release -p rustgis-cli # the command line tool +cargo build --release -p rustgis-cli # the command-line tool ``` -The build downloads the Natural Earth sample data (a few hundred KB) from -[rustgis-assets](https://github.com/opengeos/rustgis-assets). To build offline, download the -four files from `https://assets.rustgis.app/data/` once and point the build at them: +The build downloads the Natural Earth sample data from +[rustgis-assets](https://github.com/opengeos/rustgis-assets). To build offline, download +`countries.geojson`, `countries_chn.geojson`, `cities.geojson`, `rivers.geojson` and +`lakes.geojson` from `https://assets.rustgis.app/data/` once and point the build at them: ```sh RUSTGIS_DATA_DIR=/path/to/data cargo build --release @@ -106,7 +107,7 @@ RUSTGIS_DATA_DIR=/path/to/data cargo build --release ## Web app RustGIS also runs in the browser, compiled to WebAssembly: open -**[web.rustgis.app](https://web.rustgis.app)**. It is the same app with three differences: +**[web.rustgis.app](https://web.rustgis.app)**. It is the same app, with these differences: - **Open** and **Add Data** use the browser's file picker, and you can drop files on the page. GeoJSON, CSV, GeoTIFF, ASCII grids, `.rgis` projects and the other formats in @@ -116,7 +117,10 @@ RustGIS also runs in the browser, compiled to WebAssembly: open ask for the file name first; the browser decides which folder downloads go to (Firefox, for one, saves to its Downloads folder unless told to ask). A project opened from a file is named after it, and **Save** downloads it under that name again. -- The interface uses the built-in font, because a web page can't read installed fonts. +- The interface uses the built-in font, because a web page can't read installed fonts. Choosing + Chinese, Japanese, Korean or Persian downloads a font for that script. +- Process plugins (including Python plugins), and the formats read through GDAL or DuckDB, + need the desktop app or `rustgis-cli`. The web app opens the way **Settings › Start-up** says (remembered in the browser). The URL can override that for one visit: add `?blank` to start with an empty project, @@ -127,6 +131,9 @@ open on an area, `?lang=de` (or any language code) to pick the interface languag WebGL2. Combine them with `&`, so a self-hosted build can link to `?blank&projection=EPSG:3010&extent=10.5,55,24.5,69.5`. +`?registry=
` opens the Plugin Manager's Browse tab on another plugin registry for the +visit; installing still asks first. + Links can carry data too: `?data=
` adds a file by its web address (any format Add Data reads, downloaded in the background; repeat it for up to 16, and the map zooms to the first that lands with something to show), `?style=
` adds a MapLibre style (opening at the style's @@ -201,8 +208,8 @@ stale cache. ## Fonts -RustGIS uses an installed sans-serif when it finds one (Segoe UI, San Francisco, Inter, Noto -Sans, Open Sans or DejaVu Sans) and falls back to a built-in face. Set +RustGIS uses an installed sans-serif when it finds one (Segoe UI, San Francisco, Arial, Inter, +Noto Sans, Open Sans or DejaVu Sans) and falls back to a built-in face. Set `RUSTGIS_BUILTIN_FONTS=1` to always use the built-in face. ## Settings, data and cache folders @@ -239,12 +246,14 @@ read the log first and remove anything you don't want to share. The desktop app draws its window with the graphics card through Direct3D 12 on Windows, Metal on macOS and Vulkan on Linux, with OpenGL as a fallback. A graphics driver that crashes there closes the app before it can open a window or write `crash.log`. Choose another graphics interface with -`--backend`, from a terminal or in the target of a shortcut to `rustgis.exe`: +`--backend` (`dx12`, `vulkan`, `gl` or `metal`, or several separated by commas), from a terminal +or in the target of a shortcut to `rustgis.exe`: ```sh rustgis --backend gl # OpenGL rustgis --backend dx12 # Direct3D 12 (Windows) rustgis --backend vulkan # Vulkan +rustgis --backend metal # Metal (macOS) ``` The `WGPU_BACKEND` environment variable takes the same names, for every launch. On Windows, diff --git a/docs/mcp.md b/docs/mcp.md index 0b7919590..c6c919675 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,8 +2,9 @@ `rustgis-cli mcp` runs RustGIS as a [Model Context Protocol](https://modelcontextprotocol.io) server, so an AI agent such as Claude Code, Claude Desktop, Cursor, VS Code or Codex can make -maps with it. The agent opens data, runs any RustGIS command or geoprocessing tool, reads -attribute tables, looks at the map as an image and looks things up in this documentation. +maps with it. The agent opens data, runs RustGIS commands and geoprocessing tools within the +access policy below, reads attribute tables, looks at the map as an image and searches this +documentation. RustGIS needs no model API key in this mode: the MCP client supplies the model. ```sh rustgis-cli mcp --sample # the World Atlas sample @@ -15,6 +16,11 @@ rustgis-cli mcp --attach [--yes] # the desktop app that is already open ( The server talks over standard input and output, so you don't start it yourself: you tell your agent how to start it (below), and the agent runs it. Each agent gets its own project, held in memory until the agent saves it. +`--sample`, `--project` and repeated `--data` options initialize a private project. +They cannot be combined with `--attach`, which uses the desktop app's existing project. +Use absolute paths for data, projects and executables in client configurations: clients may +start the server in a different working directory. + ## Adding it to an agent @@ -31,7 +37,7 @@ codex mcp add rustgis -- rustgis-cli mcp --sample ``` Claude Desktop (`claude_desktop_config.json`), Cursor (`~/.cursor/mcp.json`) and other clients -that use an `mcpServers` list: +that use an `mcpServers` object: ```json { @@ -86,8 +92,9 @@ Under it, **Full access** (off unless you tick it, for this session only) lifts agents attached with the relay's own token. They may then also run scripts, tasks and models, and install, remove and run plugins: what you could do yourself in RustGIS. It isn't remembered: it's off again at the next launch, and when agents are turned off. While it's on, the status bar reads -`MCP relay · full access · n attached` in red. Agents the app starts itself keep their own grants' -rules. +`MCP relay · full access · n attached` in red. Full access also lifts the project-replacement +and undo-ownership refusals described below; it is not just file and network access. +Agents the app starts itself keep their own grants' rules. ```sh rustgis-cli mcp --attach # the open app on 127.0.0.1:8767 @@ -118,21 +125,26 @@ it behind: check that its `pid` is still running before trusting it. ``` Attached calls run on the open app's session through the same tools and refusal policy as the -private server. One more tool works only here: `snapshot` with `mode: "screen"` captures the map as -the window shows it. The relay holds the request until the app has taken the picture, and runs -nothing else meanwhile. File and network commands need `--yes`, unless **Let your Agents Control RustGIS** -is ticked. The session is the project you have open, so an attached agent can't replace it: +private server. `snapshot` additionally supports a real window capture with `mode: "screen"`. +If the map is not on screen, it falls back to an offscreen render and explains why in `note`. +The relay holds a screen request until the app has taken the picture, and runs nothing else +meanwhile. File and network commands need `--yes`, unless **Let your Agents Control RustGIS** +is ticked. + +Without **Full access**, an attached agent cannot replace the open project: `project.new`, `project.open`, `project.openText`, `project.sample` and `project.fromTemplate` -are refused, with or without `--yes`. Each agent command is a step in your own undo history, -named for the agent (`agent 1`, `agent 2`…, in the order they first call, with the relay run, so a -relay started again doesn't take an earlier run's agent for its own). An agent's `edit.undo` -and `edit.redo` work only on its own steps: when the step on top is yours (or another agent's), -they're refused, and they run on their own (`run_command`), not inside a `run` script. You can -still undo anything. Each agent also has its own active layer: when an -agent selects a layer (`layer.select`, or a command that makes a layer active), your selection in -the Layers pane stays as it was. Once the agent has chosen a layer (selected one, or run a command -that made one active), its commands that name no layer act on that layer, whatever you select -meanwhile; until then they act on the one you have selected. +are refused, with or without `--yes`. Commands are recorded in your undo history as +`agent 1`, `agent 2`… with the relay run's identity. An agent's `edit.undo` and `edit.redo` +work only on its own steps: when the top step is yours or another agent's, they are refused. +They run on their own through `run_command`, or as a one-line `run` script. You can still undo +anything. Full access lifts both these restrictions for the relay's own token. + +Undo ownership and the active layer are scoped to a token, not a client connection. +Ordinary `--attach` clients share the relay's token, so they share one agent identity and +active layer; local agents with separate grants are kept apart. Selecting a layer through +an agent (`layer.select`, or a command that makes a layer active) does not change your Layers +pane selection. Once that token has chosen a layer, commands that omit `layer` use it while +you select others; until then they use your current selection. Every tool call an attached agent makes is written to an audit log, one JSON line per call in a file per day (`2026-10-18.jsonl`) in the `mcp-audit` folder of the app-data folder @@ -166,9 +178,10 @@ HTTP client. `--name` and `--port` change the registration's name and port. Eith leaves its file: `claude mcp add` takes it as an argument (other users of the computer can see a running program's arguments for the moment it runs), and the printed configuration shows it, so keep it out of shared logs. Whoever holds the token can do, while agents are on, what an attached agent can, files and the -network included. Delete `mcp-http.token` to make a new one if it leaks; each registered -client then needs registering again. On Windows the token file is protected by your user profile's -permissions, not by a mode of its own. +network included. If it leaks, stop the relay, delete `mcp-http.token`, and turn agents on again +to create and load a new token; each registered client then needs registering again. Deleting +the file alone does not revoke the token already loaded by a running listener. On Windows the +token file is protected by your user profile's permissions, not by a mode of its own. An agent the app starts itself gets a run-scoped token instead of the relay's own: `rustgis-cli mcp --attach PORT --token-file FILE`. Each such grant carries its own file and network @@ -189,7 +202,7 @@ couldn't start: agents then stay on, and the status bar says only the file bridg sure a half-written script never runs, write it to another file in the folder and rename it to `bridge-command.txt`.) It runs as one undo step, with the same rules as the `run` tool: a refused or failing line stops it, and nothing it did to the - project is kept. + project is kept. Files written by an earlier line stay written. 3. The outcome is written to `bridge-result.json` in the same folder: `{success, result, lines, layersAdded, notices, error, duration_s, timestamp}`. The status bar says whether it worked. @@ -199,8 +212,8 @@ isn't run. While the bridge is on, `bridge-ready.json` there holds its `pid`, th `started_at`, the app `version` and a `schema` number. The folder is readable by this user only, like the relay's token. Scripts run through the bridge are written to the audit log (`source: "file"`), and **Full access** applies to them as to agents attached with the relay's own token. The bridge is -named `file bridge` in your undo history, so a script's `edit.undo` (alone on its line) takes back -only the bridge's own steps. It also keeps its own active layer, as an agent does. +named `file bridge` in your undo history. Without Full access, a script's `edit.undo` +(alone on its line) takes back only the bridge's own steps. It also keeps its own active layer. ### Relay requirements @@ -223,7 +236,8 @@ only the bridge's own steps. It also keeps its own active layer, as an agent doe **Let your Agents Control RustGIS** is ticked. This includes any process running as the same user that can read that token, not just agents launched by RustGIS. It is checked for every call. Turning the setting off stops the relay and disconnects its clients, including a relay started - manually. A failed launch clears the saved setting and reports the error in the app. + manually. If the relay fails but the file bridge starts, the saved setting stays on and the + app reports the relay error; if both fail, the setting is cleared. - **Grants.** `Relay::grant(access)` writes a run-scoped token to `{port}-{run}-{n}.token` in the same private folder (`run` is random per relay run). A grant asked for after the relay stopped is refused. A connection that attaches with it gets the grant's permission. Dropping the grant @@ -238,7 +252,8 @@ only the bridge's own steps. It also keeps its own active layer, as an agent doe A reply write that takes longer than 10 seconds closes the connection. - **Connection caps.** At most 16 open connections, and at most 4 that have not attached. A connection beyond either cap is dropped without a reply. -- **Queue cap.** At most 64 messages wait for the app's frame. A request beyond that gets error +- **Queue cap.** Up to 64 ordinary messages wait for the app's frame; messages held for + local-agent approval have a separate cap of 64. A request beyond either cap gets error `-32001` (`the app is busy; try again`) if it expects a reply. - **Bounded request waits.** A request waits up to 60 seconds for the app to run it, or as long as the client's attach asks (`timeout_s`, 5 to 600 seconds; `--timeout` on the command line). @@ -310,25 +325,30 @@ These behavioral tests in `crates/assistant/src/relay.rs` check the requirements | Tool | What it does | | --- | --- | | `list_layers` | The whole project in one call: path and unsaved changes, the view (centre, longitude/latitude extent, scale, projection), the active layer, layers in table-of-contents order (group, type, source, extent, feature count, selection, definition query, renderer, labels, fields, visibility), web tile layers, basemap, tables and layouts with their map frames. `name_filter` keeps the layers and tables whose name holds it; `include_fields: false` leaves fields out | -| `view_map` | The map as a PNG image, at the current view | -| `snapshot` | The map as a PNG with JSON metadata: `mode`, `width`, `height`, `extent` (longitude/latitude), `center`, `scale`, `projection` and `render_settled`. `mode: "screen"` is exactly what you see in the RustGIS window (through the relay only), taken once the map has finished drawing or after `wait_s` seconds (default 5, at most 30); `overlays: false` leaves out the map's controls, scale bar, north arrow and sketches. `mode: "render"` draws the same layers, view and styling offscreen at `max_width` (the height follows the view's shape), which works while the window is hidden. `mode: "auto"` (the default) takes the screen when the map is on it. `save_path` also writes the PNG to a file, which needs `--yes` | +| `view_map` | The map as a PNG image at the current view; `width` and `height` default to 1024 × 640, each clamped to 64–2048 pixels | +| `snapshot` | The map as a PNG with JSON metadata: `mode`, `width`, `height`, `extent` (longitude/latitude), `center`, `scale`, `projection`, `render_settled`, `saved` and `note`. `mode: "screen"` captures the RustGIS window through the relay, once the map has finished drawing or after `wait_s` seconds (default 5, clamped to 0–30); `overlays: false` leaves out the map's controls, scale bar, north arrow and sketches. `mode: "render"` draws offscreen at `max_width` (default 1024, clamped to 64–2048 pixels); height follows the view's shape, with each side capped at 2048. It works while the window is hidden. `mode: "auto"` (default) takes the screen when the map is on it. Unavailable screen captures fall back to render. `save_path` also writes the PNG to a file, which needs file access | | `search_commands` | Finds RustGIS commands by words: their ids, ribbon locations and parameters | | `run_command` | Runs a command by id with JSON parameters; each run is one undo step. The result names the layers it added (`layersAdded`) and what the command said beside it (`notices`), including any parameters it ignored (`layer.moveDown ignores 'steps' (it reads layer)`) | | `run` | Runs several commands in one call, as one undo step: a script in the `script.run` format (one `command-id {json}` per line, `#` comments). A failing line stops it, nothing it did to the project is kept, and the error names the line; files an earlier line wrote (an export) stay written. Answers `{success, result, lines, layersAdded, notices, error, duration_s}`, where `result` is the last command's. Each line follows `run_command`'s rules, checked before any line runs | | `search_tools` | Finds geoprocessing tools by words | | `describe_tool` | A geoprocessing tool's parameters | | `run_tool` | Runs a geoprocessing tool; its output is added to the map as a new layer (with `layersAdded` and `notices` as for `run_command`) | -| `read_features` | A layer's attribute rows, optionally only those matching a where clause, sorted | +| `read_features` | A vector layer's attribute rows: `layer` (name or id), optional `where`, `fields`, `order_by`, `descending` and `selected`; `limit` defaults to 20, capped at 200. Returns the matching count and rows with `_row`, `_lon` and `_lat` | | `search_docs` | Searches this documentation and returns the best matching sections | | `read_doc` | Reads a documentation page or section as Markdown; `offset` (1-based line) and `limit` (default 2000 lines) page through long results using `next_offset` | -These are the [AI Assistant](user-guide/assistant.md)'s tools, plus `view_map`. Every page of this documentation is also a resource -(`rustgis://docs/cli.md`, `rustgis://docs/user-guide/maps.md`…) that a client can attach to a -conversation. +MCP exposes these twelve tools: the nine shared [AI Assistant](user-guide/assistant.md) tools, +plus `view_map`, `snapshot` and `run`. The assistant-only `delegate` and `load_skill` tools are +not exposed through MCP. Instead, enabled [skills](user-guide/assistant.md#skills) are offered +as MCP prompts (`prompts/list` and `prompts/get`). Every built-in documentation page is also a +resource (`rustgis://docs/cli.md`, `rustgis://docs/user-guide/maps.md`…) that a client can attach +to a conversation. Successful JSON tool results use the default +[tool-output budget](user-guide/assistant.md#tool-output-limits): an estimated 10,000 tokens +per result. Read large documentation results in sections or continue with `next_offset`. ## What the agent may do -Your agent asks you before each tool call, and the server adds its own limits: +Your MCP client's approval settings determine when it asks you. The server adds its own limits: - Commands that read or write files, connect to an address on the internet or run a plugin's command run only when the server was started with `--yes`, as does opening a project from @@ -338,8 +358,10 @@ Your agent asks you before each tool call, and the server adds its own limits: - Plugin management, scripts, tasks, models and the GeoLibre runner are never run from here, unless you turned on **Full access** for agents in the desktop app. That applies only to agents attached to it with the relay's own token. -- In private-server mode, opening, replacing and undoing in the project are allowed: the project - is the agent's own. With `--attach`, these operations affect the project open in the desktop app. +- In private-server mode, replacing and undoing in the project are allowed: it is the agent's + own. With `--attach`, project replacement and undo ownership follow the desktop restrictions + above, unless Full access is enabled. `ui.*` commands that only open panes and dialogs are + unavailable to the tools. Installed [plugins](plugins.md) load first, as with the other subcommands (`--no-plugins` skips them). Notes such as "MCP server ready" go to standard error; standard output carries only the @@ -352,8 +374,10 @@ Local agents launched by the assistant use `Relay::grant_reviewed(auto)`, not an run grant. With automatic approval off, gated tool calls wait in a RustGIS-side queue scoped to that grant and exact message, including all tool calls in a batch. `--yes` and the general relay checkbox do not bypass it. An approval cannot revive an expired request; revoking the grant or -stopping the relay cancels pending work. Explicit automatic approval bypasses this extra queue -for that run. Ordinary MCP clients retain the access policy described above. +stopping the relay cancels pending work. The approval wait has a separate 15-minute bound; +other clients' reads can still run while it waits. Explicit automatic approval bypasses this +extra queue for that run, but not the reserved-command refusals. Ordinary MCP clients retain +the access policy described above. Stopping the relay through its command or status-bar control also turns off its remembered agent-control choice. Copyable setup commands are labelled for POSIX shells or PowerShell and diff --git a/docs/plugins.md b/docs/plugins.md index 977840650..7ca22b95a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,32 +1,39 @@ # Plugins -Plugins add commands and geoprocessing tools to RustGIS. A plugin is a WebAssembly module with -a `plugin.json` manifest, packaged as a `.rgplugin` file. The same file runs in the desktop app, -on the command line and in the web app. - -Browse the registry at **[plugins.rustgis.app](https://plugins.rustgis.app)**. Plugin sources, -the SDK and the registry live in -[opengeos/rustgis-plugins](https://github.com/opengeos/rustgis-plugins). +Plugins add commands, geoprocessing tools and assistant skills to RustGIS. A plugin has a +`plugin.json` manifest and is packaged as a `.rgplugin` file. WebAssembly plugins run sandboxed +in the desktop app, on the command line and in the web app. [Process plugins](#process-plugins) +run a local program instead and need the desktop app or command line. + +Browse the [plugin catalog](https://plugins.rustgis.app/catalog/). +[opengeos/rustgis-plugins](https://github.com/opengeos/rustgis-plugins) holds the registry, +blocklist and [`rustgis-plugin` SDK](https://github.com/opengeos/rustgis-plugins/tree/main/sdk), +not plugin source. Plugin sources live in their own repositories; start one from the +[Rust](https://github.com/opengeos/rustgis-plugin-template) or +[Python](https://github.com/opengeos/rustgis-python-plugin-template) template. ## Using plugins -Open **Plugins › Plugin Manager**: +Open **Plugins › Plugin Manager**. The **Plugins** tab also has **Install From File** and +**Browse Plugins** buttons. -- **Browse** lists the plugins in the registry. **Install** downloads the plugin, checks it - against the SHA-256 the registry published, and shows what the plugin may do before it loads. +- **Browse** lists the plugins in the registry, with search and a category filter. **Install** + downloads the plugin, checks its SHA-256 and id against the registry entry, and shows what + the plugin may do before it loads. - **Installed** lists your plugins: turn them on or off, update them, or uninstall them. -- **Settings** installs a `.rgplugin` file. You can also drop the file on the window. It also - sets the registry address (empty means the RustGIS registry). On the desktop, it can load a - plugin from its folder while you develop it. +- **Settings** has **Install From File…** for a `.rgplugin` file; you can also drop the file on + the window. It also sets the registry address (empty means the RustGIS registry). On the + desktop, **Add Plugin Folder…** loads an unpacked plugin while you develop it. A plugin's commands and tools appear on the **Plugins** tab, in a group named after the plugin, or on the plugin's own tab if it has one. Its commands are also in command search, and its tools are in the **Tools** pane under **Plugins ›**. -The desktop app keeps installed packages in its data folder (`plugins/` beside the GeoLibre -runner, or `$RUSTGIS_PLUGINS_DIR`) and loads them at startup. The web app remembers the +The desktop app keeps installed packages in `plugins/` in its data folder (overridden by +`$RUSTGIS_PLUGINS_DIR`) and loads them at startup. The web app remembers the plugins you installed from the registry and downloads them again on your next visit. Packages -installed from files are kept too, if they are small enough for the browser's storage. +installed from files are remembered when they are at most 1.5 MiB; larger ones last for the +current session only. On the command line: @@ -43,6 +50,9 @@ rustgis-cli run --sample \ rustgis-cli run --plugin ./dev-plugin/ --sample --cmd 'plugin:my-plugin/hello' # load for one run ``` +Use `--no-plugins` to skip loading installed plugins. `plugins browse` and `plugins get` accept +`--registry https://…/registry.json` for another registry. + ## Safety Plugins run in a sandbox: an interpreter (`wasmi`) with no access to files, the network, the @@ -55,14 +65,17 @@ described below, and RustGIS checks each one: if the command fails. - With the **`files`** permission, it can run commands that read or write files the user names, and its tools can take a file the user picks (a [`file` parameter](#file-parameters)). + SQL, database and Zarr commands require both `files` and `network`, since they can reach + local files or remote servers. - With the **`network`** permission, it can reach the web: read a small document from a web address ([`host.fetch`](#fetching-a-document)) and run the commands that fetch (adding data or services by their address). With `edit` too, it can add web tile layers (`map.addTiles`). The addresses are the plugin's choice and can carry what it read from the project, so this is a permission of its own. A command that would bring in a tile layer some other way (importing a map file) has it taken out again without `network`. -- No plugin may run the user's scripts, tasks or models, install or locate other software, - manage plugins, replace the project, or step through undo history. +- No plugin may run the user's scripts, tasks or models, create or save models, install or + locate other software, manage plugins or connections and logins, replace the project, or + step through undo history. It cannot call another plugin's command directly. A [process plugin](#process-plugins) is the exception: it is a program on your computer, not sandboxed, and the install prompt says so. @@ -72,8 +85,9 @@ loop is stopped. Memory is capped at 512 MB, and requests and replies are size-l that traps, panics or returns garbage produces an error message, never a crash. The registry is curated: plugins are reviewed before they are listed, and packages are -hash-pinned. A `blocklist.json` beside the registry withdraws a plugin (every version, or one -package) from every install. RustGIS checks it when you browse the registry and at startup. +hash-pinned. A `blocklist.json` beside a registry withdraws a plugin (every version, or one +package). RustGIS checks it when you browse that registry. Desktop and command-line startup +apply the cached blocklist; the web app fetches it when restoring plugins. ## Writing a plugin @@ -109,6 +123,7 @@ rustgis_plugin::export!(handle); ``` ```sh +rustup target add wasm32-unknown-unknown cargo build --release --target wasm32-unknown-unknown ``` @@ -143,16 +158,26 @@ cargo build --release --target wasm32-unknown-unknown } ``` -- `id`: lower-case letters, digits and hyphens. Commands and tools get ids - `plugin:/`, which the ribbon, command search, scripts and `rustgis-cli` use. -- `version` and `minRustgisVersion` look like `1.2.3`. RustGIS refuses a plugin that needs a - newer RustGIS. -- `permissions`: `edit`, `files` and `network` (see [Safety](#safety)). Ask only for what you use. +- `id`: 1 to 64 lower-case letters, digits and hyphens, starting with a letter. Command and tool + ids follow the same rules. Their full ids are `plugin:/`; run commands + directly and tools through `gp.run`. +- `runtime`: `wasm` (the default), `process` or `skills`. For WebAssembly plugins, `entry` + defaults to `plugin.wasm` and must name a `.wasm` file relative to the manifest, using + forward slashes with no `.` or `..` path segments. `abi` defaults to `1` and must equal `1`. +- `version` and optional `minRustgisVersion` are numeric versions such as `1.2.3` or `1.2`. + RustGIS compares their numeric components, ignoring prerelease and build suffixes, and + refuses a plugin that needs a newer RustGIS. +- `permissions`: `edit`, `files` and `network` (see [Safety](#safety)); empty by default. + Ask only for what you use. +- `commands[].params`: a string describing the command's parameters for scripts and agents, + not a JSON object or a form definition; it defaults to `"{}"`. - `commands[].icon`: the name of one of RustGIS's own icons (`stats`, `zoom_full`, `bookmark`, `buffer`…), or a puzzle piece when it isn't one. - Tool parameter `kind`s: `layer`, `vectorLayer`, `polygonLayer`, `pointLayer`, `field`, - `numericField`, `number`, `text`, `choice` (with `choices`), `bool` and `file` (see - [File parameters](#file-parameters)). + `numericField`, `number`, `text`, `choice` (with 1 to 64 `choices`), `bool` and `file` (see + [File parameters](#file-parameters)). Each has `name`, `label`, optional `help`, `required` + (default `false`) and an optional JSON `default`. Parameter names start with a lower-case + letter and may also contain upper-case letters and underscores after it. - `panel` (optional) gives the plugin a pane of its own, with widgets bound to its commands. See [A panel](#a-panel). - `i18n` translates the plugin's own text into the interface language, by language code (`de`, @@ -160,8 +185,12 @@ cargo build --release --target wasm32-unknown-unknown - `skills` (optional) lists skills for the AI Assistant that the package carries. See [Skills](#skills). -A package is a zip archive named `-.rgplugin`. It holds `plugin.json` and the module -at its root, or inside one top-level folder. Packages are at most 64 MB, and modules at most 48 MB. +A manifest can declare at most 64 commands, 64 tools and 32 parameters per tool. + +A package is a zip archive, conventionally named `-.rgplugin`. It holds +`plugin.json` at its root or inside one top-level folder; `entry` is relative to that manifest. +Packages are at most 64 MiB with at most 4,096 entries, modules at most 48 MiB, and the manifest +at most 512 KiB. ### File parameters @@ -172,8 +201,8 @@ The plugin needs the `files` permission: { "name": "model", "label": "Model output", "kind": "file", "required": true, "extensions": ["nc", "nc4"] } ``` -- The Tools pane shows a path box with a **Browse** button. `extensions` (without the dot; up - to 16) filters the file picker; leave it out for any file. +- The Tools pane shows a path box with a **Browse** button. `extensions` filters the picker: + at most 16 extensions, each 1 to 16 letters or digits without the dot. Leave it out for any file. - On the desktop and the command line the value is a path, and RustGIS reads the file. In the web app, which has no paths, the user picks the file and its contents go with the run. A script can do the same with `{"name": "run.nc", "data": base64}`. @@ -199,8 +228,9 @@ English strings and their translations, written exactly as they appear in `plugi - It covers the plugin's name and description, command labels and help, tool names, summaries and toolboxes, parameter labels and `choice` values, the tab, group and menu labels, and the panel's title, help and widget labels. A string with no translation shows in English. -- RustGIS's interface languages are `de`, `es`, `fr`, `it`, `nl`, `pt`, `ru`, `tr`, `az`, `id`, - `vi`, `zh`, `ja` and `ko`. A code with a region matches its language: `zh-CN` serves `zh`. +- Alongside English (`en`), RustGIS's interface languages are `de`, `es`, `fr`, `it`, `nl`, + `pt`, `ru`, `tr`, `az`, `id`, `vi`, `fa`, `zh`, `ja`, `ko` and `ka`. A code with a region + matches its language: `zh-CN` serves `zh`. - The registry copies the name and description translations into `registry.json`, so the Plugin Manager lists plugins in the interface language before they are installed. - Messages a plugin builds while it runs can follow the interface language too: every request @@ -234,9 +264,10 @@ can hide it like any other. Declare it in `plugin.json`: Tools pane), `panel` (which opens the plugin's [panel](#a-panel)), or a dropdown `menu` whose `items` are any of those. - A tab has 1 to 8 groups, a group has 1 to 16 items, and a menu has 1 to 32 entries. Every - entry must name one of the plugin's own commands or tools. -- The label can't be the name of one of RustGIS's tabs (Map, Insert, Analysis, Plugins…). The - `i18n` translations apply to the tab, group and menu labels too. + entry must name one of the plugin's own commands or tools, or its panel. +- The label is one line of 1 to 32 characters and can't be the name of one of RustGIS's tabs + (Home, Data, Style, Analyze, Plugins…), including their older names. The `i18n` translations + apply to the tab, group and menu labels too. [Map Explorer](https://github.com/opengeos/rustgis-official-plugins/tree/main/plugins/map-explorer) is an example. @@ -263,25 +294,27 @@ buttons bound to its commands. Declare it in `plugin.json`: } ``` -- `title` is the pane's tab and the label of the button that opens it, which RustGIS puts - first in the plugin's group on the **Plugins** tab. On a tab of its own, put `panel` among a - group's items. Command search lists the panel too. `icon` and `help` are for that button. +- `title` is the pane's tab and the label of the button that opens it: one line of 1 to 48 + characters. RustGIS puts the button first in the plugin's group on the **Plugins** tab. + On a tab of its own, put `panel` among a group's items. Command search lists the panel too. + `icon` and `help` are for that button. - `items` lists the widgets, top to bottom (1 to 32). Buttons that follow each other share a row. Each has an `id` (lower-case letters, digits and hyphens; a button's is its command's unless given), and `label` and `help` (on hover) where they apply: - `text`: a caption (`label`) over text the plugin sets (`text` is what it shows first). - `checkbox`: a box with a `label`, `default` `false` unless given. - - `slider`: whole numbers from `min` to `max` (`default` is `min` unless given), with a - caption, the value's label, and its two ends' labels. `steps: true` adds ‹ and › buttons - that move it by one. + - `slider`: whole numbers from `min` to `max`, both within ±1,000,000,000 (both default + to `0`; `default` is `min` unless given), with a caption, the value's label, and its two + ends' labels. `steps: true` adds ‹ and › buttons that move it by one. - `button`: runs its `command`; its `label` is the command's unless given. - A checkbox runs its `command` when it changes, and a slider when it is let go, or stepped: never while it is dragged, while the pane shows the label of the value it would take. Without a `command`, a widget's value just goes with the plugin's next request. - `state` (optional) is a command RustGIS runs each time the panel is opened, to answer its state. -- Every command and widget must be one of the plugin's own commands. A plugin with a panel - can't have a command called `panel`. +- Every `command` reference, including `state`, must name one of the plugin's own commands. + Widget ids are independent of command ids. A plugin with a panel can't have a command + called `panel`. A plugin keeps nothing between calls, so RustGIS keeps the panel's state. Every request of a plugin with a panel carries the widgets' values, whatever ran the command (the panel, a ribbon @@ -344,14 +377,14 @@ Inside a request, `host::call(op, params)` sends one of these to RustGIS: | Request | Answer | |---|---| -| `host.info` | RustGIS's version, the ABI, the plugin's id and permissions | +| `host.info` | `{rustgis, abi, plugin, permissions}`: version, ABI, plugin id and permissions | | `host.layers` | `[{id, name, raster, visible, geometry, features, selected, active, fields: [{name, type}]}]` | | `host.features` `{layer, selected?, limit?}` | the layer's features as GeoJSON; each feature has its `row` | | `host.view` | `{center: [lon, lat], scale, projection}` | | `host.notify` `{text, error?}` | shows a message in the status bar | | `host.addLayer` `{name, geojson}` | adds a layer (`edit`) | | `host.fetch` `{url}` | `{url, text, json?}`: the document at a web address (`network`; see [Fetching a document](#fetching-a-document)) | -| `map.addTiles` `{url, kind?, name?, attribution?, maxZoom?, overlay?, replace?, blend?}` | adds a web tile layer (`edit` and `network`) | +| `map.addTiles` `{url, kind?, name?, attribution?, maxZoom?, overlay?, replace?, blend?, …}` | adds a tile layer (`edit` and `network`); accepts the built-in command's parameters | | any command id (`layer.zoomTo`, `select.byAttributes`, `layer.symbology`…) | what the command returns, within the plugin's permissions | In the web app a command can't wait for a download, so where `layer.addUrl` has to download a @@ -395,13 +428,13 @@ document parsed when it is a JSON object or array. address taken from the one before, runs once per step, up to 64 times. The browser follows redirects itself, and the server must allow requests from web pages (CORS). -The plugins SDK may offer this as a `host::fetch` helper; until then, use -`host::call("host.fetch", json!({"url": url}))`. +Use `host::call("host.fetch", json!({"url": url}))` in the Rust SDK. Tools work differently. A tool receives its layer parameters' features as GeoJSON (only the -selected features when a layer has a selection), and its `file` parameters' files. It returns new layers, standalone tables or a -selection, plus messages for the Geoprocessing history. RustGIS adds the results as it does for -built-in tools, with undo. Tools can log, but they make no host requests. +selected features when a layer has a selection), and its `file` parameters' files. It returns +new layers and standalone tables, or a selection, plus messages for the Geoprocessing history. +RustGIS adds the results as it does for built-in tools, with undo; returning these results +does not require `edit`. Tools can log, but they make no host requests. In the desktop app a plugin tool runs in the background, so the map and the rest of the window stay usable: the Tools pane shows the line the tool last logged, or a process plugin's last @@ -417,8 +450,10 @@ The module is `wasm32` with no imports except these three, in module `rustgis`: - `call(ptr: i32, len: i32) -> i32` runs the host request at `ptr` (`{"op": "...", "params": {...}}`). It returns the length of the reply, which is - `{"ok": value}` or `{"error": "text"}`, or `-1` when there is none. -- `result(ptr: i32)` copies that reply to `ptr`. Allocate the length `call` returned first. + `{"ok": value}` or `{"error": "text"}`, or `-1` when there is none. A new call replaces any + reply not yet copied. +- `result(ptr: i32)` copies that reply to `ptr` and consumes it. Allocate the length `call` + returned first. - `log(ptr: i32, len: i32)` logs a UTF-8 line. The module exports: @@ -429,17 +464,27 @@ The module exports: - `rustgis_call(ptr: i32, len: i32) -> i64`, which handles the request and returns `reply_ptr << 32 | reply_len`. The reply is `{"ok": value}` or `{"error": "text"}`. -Requests are `{"abi": 1, "kind": "command" | "tool", "id": "...", "params": {...}, "layers": {...}, "lang": "de"}`. -`lang` is the interface language's code (`en` for English). Commands of a plugin with a -[panel](#a-panel) also get `panel`, and may answer `panel` beside the rest of their `ok` value. -Tools also get `layers`, which maps each layer's name to its FeatureCollection, and, when -they have `file` parameters, `files`, which maps each one's name to `{"name", "data"}` with the -file's contents in base64. A tool's `ok` -value is `{"layers": [{"name", "geojson"}], "tables": [...], "selection": {"layer", "rows"}, -"messages": [...]}`. +Command requests are `{"abi": 1, "kind": "command", "id": "...", "params": {...}, "lang": "de"}`. +`id` is the local command id from the manifest, not its `plugin:…/…` id. `lang` is the interface +language's code (`en` for English). Commands of a plugin with a [panel](#a-panel) also get +`panel`, and may answer `panel` beside the rest of their `ok` value. + +Tools get `"kind": "tool"` and their local tool id, plus `layers`, which maps layer names +(not parameter names) to FeatureCollections. When they have file inputs, `files` maps each +parameter name to `{"name": "...", "data": "..."}`, with the contents in standard padded base64. +A tool's `ok` value can contain: + +- `layers`: `[{"name": "...", "geojson": {...}}]`. +- `tables`: the same shape, using FeatureCollections whose features have no geometry. +- `selection`: `{"layer": "", "rows": [0, 2, ...]}`. If present, it takes precedence + over `layers` and `tables`. +- `messages`: an array of strings. + +RustGIS takes at most 64 layers, 64 tables and 200 messages, with at most 5,000,000 features +in each output dataset. Each call gets a fresh instance, so a module keeps no state between calls and need not free -memory. The fuel budget is about 2×10⁹ instructions for a command and 4×10¹⁰ for a tool. +memory. Host requests and module replies are limited to 128 MiB. ## Process plugins @@ -471,22 +516,24 @@ with no **Install** button. - `"runtime": "process"` and `run`: the program and its arguments, started in the plugin's folder. A bare name (`python3`) is looked up on `PATH`; a relative path (`bin/tool`) is in the plugin's folder. `run` can name a program per platform (`windows`, `macos`, `linux`, and - `default` for the rest). There is no `entry`. + `default` when the platform has no entry). Omit `entry`; process plugins use `run` instead. + Each argument list contains the program and at most 63 arguments. - Commands, tools, tabs, translations and permissions work as for WebAssembly plugins. Permissions still decide which host requests RustGIS answers, but they don't limit what the program itself can do. - A `.rgplugin` holds the plugin's files (the manifest, scripts, executables). Installing it - unpacks them to `unpacked//` in the plugins folder, keeping executables executable. A - plugin folder installed for development (Plugin Manager › Settings, or - `rustgis-cli plugins install ./folder`) runs where it is. + unpacks them to `unpacked//` in the plugins folder, keeping Unix execute bits and + rejecting unsafe paths or more than 1 GiB of unpacked files. A plugin folder installed for + development (Plugin Manager › Settings, or `rustgis-cli plugins install ./folder`) runs where + it is. ### The protocol Each command or tool call starts the program once. It speaks the [ABI](#the-abi-version-1)'s requests and replies over its standard input and output, one JSON object per line: -1. RustGIS writes the request: `{"abi", "kind", "id", "params", "layers", "files", "lang"}`, as - a WebAssembly module gets it. +1. RustGIS writes the [request](#the-abi-version-1) as one JSON line, including `panel` for a + command of a plugin with a panel, and `layers` and optional `files` for a tool. 2. The program writes lines: - `{"call": {"op": "...", "params": {...}}}`: a [host request](#what-a-plugin-can-ask-for). RustGIS answers with one line, `{"ok": value}` or `{"error": "text"}`. @@ -546,9 +593,11 @@ any package themselves: "run": ["python", "main.py"] ``` -- `python` in `run` is then that environment's interpreter, on every platform. -- RustGIS makes the environment when the plugin is installed: in the background in the desktop - app (the Plugin Manager shows how it's going, and a **Prepare** button makes it again), and +- `python`, `python3` or `python.exe` as the program in `run` becomes that environment's + interpreter, on every platform. The Python version defaults to `3.12`; omit `requirements` + for an environment with no extra packages. +- RustGIS prepares the environment when the plugin is installed: in the background in the desktop + app (the Plugin Manager shows how it's going, with a **Prepare** button if it is not ready), and before the command returns in `rustgis-cli plugins install` and `get`. `rustgis-cli plugins prepare ` and the `plugin.prepare` command make it on demand. - It uses [uv](https://github.com/astral-sh/uv), which RustGIS downloads once (a pinned version, @@ -580,8 +629,8 @@ to use it, then the instructions in Markdown (the plugin has at most 32 skills. A package of skills alone, with no code, says `"runtime": "skills"`. It has no module, commands, -tools, tab or permissions. Nothing in it runs, and it installs and works like any other plugin, -in the desktop app, on the command line and in the web app: +tools, tab, panel or permissions, and must list at least one skill. Nothing in it runs, and it +installs from a file in the desktop app, on the command line and in the web app: ```json { @@ -595,21 +644,44 @@ in the desktop app, on the command line and in the web app: ## Publishing a plugin -Open a pull request on -[opengeos/rustgis-plugins](https://github.com/opengeos/rustgis-plugins) that adds -`registry/.json`: - -- For a plugin whose source is in that repository: `{"id": "...", "path": "plugins/", "categories": [...]}`. -- For one you publish elsewhere: `{"id": "...", "source": {"url": "https://…/-.rgplugin", "sha256": "…"}, "categories": [...]}`. - -CI builds or downloads the package and checks its manifest and module. A maintainer then -reviews it. Once merged, it is published at `plugins.rustgis.app` with its hash. - -Each plugin's entry in the published `registry.json` comes from its manifest: `id`, `name`, -`version`, `description`, `author`, `homepage`, `license`, `categories`, `permissions`, -`minRustgisVersion` and `i18n` (its name and description translated), with `package` (an HTTPS -address, or a path relative to `registry.json`), `sha256` and `size`. A process plugin's entry -also has `"runtime": "process"` and, when it has a Python environment, `"python": "3.12"` (the -version); a WebAssembly plugin's has neither, and a skills package's has `"runtime": "skills"`. -RustGIS installs a package only if it runs the way its entry says. `rustgis-cli plugins browse` -prints each entry with `runtime` filled in (`wasm` when the registry leaves it out). +1. Write the plugin in your own repository, starting from the + [Rust](https://github.com/opengeos/rustgis-plugin-template) or + [Python](https://github.com/opengeos/rustgis-python-plugin-template) template. See + [Write a plugin](https://plugins.rustgis.app/develop/). +2. Release it: the templates' **Release** workflow attaches the `.rgplugin` and its registry + entry to a GitHub release. +3. Open a pull request on + [opengeos/rustgis-plugins](https://github.com/opengeos/rustgis-plugins) adding that entry as + `registry/.json`: + + ```json + {"id": "...", "source": {"url": "https://…/-.rgplugin", "sha256": "…"}, "categories": ["Analysis"]} + ``` + + Every plugin is listed by `source`; the registry build refuses `path` entries. +4. CI downloads the package, checks its hash, validates its manifest and module (or a process + plugin's files), and runs it in RustGIS. A maintainer reviews it before merging. +5. Once merged, the registry lists the plugin and serves its package at + `https://packages.rustgis.app//-.rgplugin`. Published packages never change, + and every version stays available. To update, release a new version and submit a pull + request updating the entry's `source.url` and `source.sha256`. + +Pull requests get a preview with an **Open in RustGIS** link; process plugins must be tried +in the desktop app instead. The nightly workflow runs every published plugin in the latest +RustGIS. See the registry's [publishing instructions](https://plugins.rustgis.app/registry/) +for details. + +The file you submit is only a pointer to the release. The registry build turns it into the +published `registry.json` that RustGIS reads, whose entries carry `package` (the package's address) and +`sha256` in place of `source`. A registry entry contains `id`, `name`, `version`, `description`, `author`, `homepage`, +`license`, `permissions`, `minRustgisVersion`, `i18n` (the name and description translated), +`categories`, `package`, lower-case hexadecimal `sha256`, and `size` in bytes. +`package` is an HTTPS address or a path relative to `registry.json`; +plain `http://localhost` and `http://127.0.0.1` addresses are accepted for local test +registries. Entries without a valid id, name, version or SHA-256 are skipped. A process +plugin's entry also has `"runtime": "process"` and, when it has a managed Python environment, +`"python": "3.12"` (the version); a WebAssembly plugin can omit both. Registries can list +skills packages with `"runtime": "skills"`. The Plugin Manager checks the package's hash +and id before installing; `rustgis-cli plugins get` also checks its runtime. +`rustgis-cli plugins browse` prints `runtime` for every entry (`wasm` when the registry leaves +it out). diff --git a/docs/privacy.md b/docs/privacy.md index c2bf8c44f..38677034b 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,15 +1,61 @@ # Privacy -The RustGIS desktop app and command line collect no analytics, telemetry or usage data. Your -geospatial data and projects stay on your device unless you choose to share or export them. +The RustGIS desktop app and command line collect no analytics, telemetry or usage data. +Local files and projects are processed on your device. Features that use remote services can +send searches, coordinates, data or assistant context to those services, as described below. ## Online services -Some features ask a public server for an answer, and send it what the answer needs: **Find Place** -and the search box send the place you look up to OpenStreetMap's Nominatim, and **Directions** -sends a route's start, stops and destination to the Valhalla server run by FOSSGIS (it asks you -first). Web basemaps and data added from a web address are fetched from their servers. Nothing -else about you or your data is sent with them. +- **Find Place** and place search send your query to OpenStreetMap's Nominatim by default. + Find Place and **Geocode Addresses** can also use Komoot's Photon or a server you specify; + geocoding sends the address values you choose from your table or layer. +- **Directions** sends the route's start, stops and destination to FOSSGIS's public Valhalla + server by default. The Directions pane asks for consent before sending places and remembers + that choice. The `map.route` command can also use a server you specify. +- Web basemaps and tile layers request tiles for the area you view; vector styles can also + request their tile sources and sprites. These requests reveal the requested map area to the + servers even when your local layers are not uploaded. +- Web data, remote rasters, cloud storage, database connections and online catalogs contact + their configured servers. Searches may send keywords, coordinates, an area of interest, + dates or filters; authenticated connections also send the credentials or tokens required by + that service. See [Web data](user-guide/web-data.md). + +Servers receive ordinary connection information, including your IP address and request headers. +Their own privacy and retention policies apply. Working with local files does not itself +upload their contents to RustGIS. + +## AI Assistant and connected agents + +When you use the [AI Assistant](user-guide/assistant.md), requests go to the provider and +endpoint you configure, which may be a local model server. They include your messages, +conversation history, map context (including the map centre, layer names, counts and field +names), tool results and any skills the assistant reads. Tools can read feature attributes +and geometry, so those results can become part of the conversation sent to the provider. + +Signing in with ChatGPT contacts OpenAI's authentication services and Responses API. Local +agents you connect or let the assistant delegate to use their own model services and tools, +under their own configuration and policies. Approval prompts for file, network and other +restricted actions do not stop ordinary assistant context and tool results from being sent to +the configured model. + +## Plugins and downloads + +Browsing the Plugin Manager fetches a registry and its blocklist; installing or updating a +plugin downloads its package. The default registry is `plugins.rustgis.app`. The web app also +checks the registry's blocklist at startup and may download packages saved by URL in an earlier +visit. Preparing a Python process plugin can download uv from PyPI, Python and the plugin's +dependencies. + +WebAssembly plugins need the `network` permission to make web requests through RustGIS. A +plugin with that permission can send information it can read to addresses it chooses. +Desktop process plugins are not sandboxed and can access files and the network as ordinary +programs. Review the permissions and publisher before installing; see [Plugins](plugins.md). + +On the desktop, **Help › Check for Updates** contacts GitHub's latest-release API only when +you ask. It does not upload your project. The web app checks its own site for service-worker +updates when it loads and returns to view, and fetches app icons from `assets.rustgis.app`. +It downloads language fonts from that asset server when needed. The web GeoLibre tools +download their runner from jsDelivr when needed; the tool list is cached between visits. ## Website analytics @@ -19,10 +65,11 @@ which pages are used. Google receives your IP address, the page you are on, the general device and browser information, and sets its own cookies in your browser. It is governed by [Google's privacy policy](https://policies.google.com/privacy). -Analytics never see the data you work with: layers, files, projects and coordinates are processed -in your browser and are not sent to Google. The web app reports its address trimmed to the site and -path (`https://web.rustgis.app/`), without the query string. The documentation reports the full -page address, which names the page you are reading and, after a search, the words you searched for. +RustGIS does not send layer contents, files or projects to Google Analytics. The web app's +page-view event reports its address trimmed to the site and path (`https://web.rustgis.app/`), +without the query string or map hash; its referring address is trimmed the same way. The +documentation reports the standard page address, which can include a query string, such as the +words of a site search. You can block analytics with a content blocker or your browser's tracking protection; the sites work normally without them. Analytics run only on the sites we host: the measurement ID is added diff --git a/docs/python.md b/docs/python.md index d971b5bae..d99a0911d 100644 --- a/docs/python.md +++ b/docs/python.md @@ -4,9 +4,10 @@ [![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/opengeos/RustGIS/blob/main/apps/rustgis-python/examples/getting-started.ipynb) The `rustgis` package runs the RustGIS engine inside Python, headless. It opens data and -projects, runs any command and geoprocessing tool, styles layers, renders maps and saves projects -that the desktop app opens as they are. There is no app to install and no server to run: the -engine is a native module built for Windows, macOS and Linux, for Python 3.12 and later. +projects, runs engine commands and geoprocessing tools, styles layers, renders maps and saves +projects that the desktop app opens as they are. There is no app to install and no server to run: +the engine is a native module for Python 3.12 and later. Release wheels cover Windows x64, +macOS Intel and Apple Silicon, and Linux x64 and arm64 (glibc 2.28 or later). ```sh pip install rustgis # pip install "rustgis[geopandas]" for GeoDataFrames @@ -21,7 +22,7 @@ through what follows; [open it in Colab](https://colab.research.google.com/githu import rustgis s = rustgis.Session(sample=True) # the World Atlas sample -s.layer_names() # ['Cities', 'Rivers', 'Countries', …] +s.layer_names() # ['Cities', 'Rivers', 'Lakes', 'Countries'] buffers = s.run_tool("buffer", input=s["Cities"], distance=100) s["Countries"].style("graduated", field="pop_est") s.zoom_to(buffers) @@ -49,8 +50,10 @@ cities.visible = False cities.set_transparency(30) cities.filter("pop_max > 1000000") # a definition query; None clears it cities.rename("Towns") +s.undo() # restore the name "Cities" cities.zoom_to() -cities.remove() # s.undo() brings it back +cities.remove() +s.undo() # restore the removed layer ``` Selections work by SQL where clause: @@ -58,7 +61,7 @@ Selections work by SQL where clause: ```python n = cities.select("pop_max > 10000000") # mode="add", "remove" or "subset" cities.to_geojson(selected=True) # only the selected features -cities.clear_selection() +cities.clear_selection() # clears selections in every layer ``` ## The app in a notebook @@ -73,7 +76,7 @@ pip install "rustgis[jupyter]" ``` ```python -m = rustgis.Map(sample=True) # height="800px" by default +m = rustgis.Map(sample=True) # height="800px"; lang="de" sets the language m # the app, in the cell m.add_geojson("sites.geojson", name="Sites") # appears in the app @@ -86,13 +89,16 @@ m.save("sites.rgis") # with what was done in the m.run_in_app("ui.language", code="de") # a command only the app has ``` -`Map` has the `Session` methods (`run`, `run_tool`, `add_data`, `m["Sites"]`, `to_png`, ...), -and what they change, its layers' changes too, shows in the app; `m.session` is the session -itself. Files of up to 2 MB travel with the project, so the app shows them; a larger -file is read from its path, which only Python can see, so it draws in `to_png` but not in the app. -The whole project goes across after each change, so changes made on both sides at once don't -merge: the later one wins. A change made in the app replaces Python's project, so `m.undo()` -undoes only what Python did since. +`Map` shares the command, data and view methods of `Session` (`run`, `run_tool`, `add_data`, +`m["Sites"]`, `to_png`, ...); its layers' changes also appear in the app. `m.session` is the +underlying session; `Map(session=s)` displays an existing one. Make changes through `m`, rather +than directly through `m.session`, to send them to the app. + +Supported files up to 2 MiB added through `Map` travel with the project. Larger files and formats +that cannot be embedded are read from a path only Python can see: they draw in `to_png` but not +in the app. Use a zip for a Shapefile. The whole project goes across after each change, so +simultaneous changes do not merge: the later one wins. A change made in the app replaces Python's +project, so `m.undo()` undoes only what Python did since. The app is the [web app](installation.md), loaded from https://web.rustgis.app, so the notebook needs to be online. `Map(url=...)` or the `RUSTGIS_WEB_URL` environment variable points @@ -106,10 +112,12 @@ address your data stays on your machine. Point `url=` only at a copy of the app ## Commands -Every action in RustGIS is a command with an id and JSON parameters: the same ids the ribbon, -the [command line](cli.md) and the [AI Assistant](user-guide/assistant.md) use. -`Session.run(id, ...)` runs any of them, with the parameters as keyword arguments, a dict, or -both. It returns the command's result and raises `rustgis.RustGISError` when the command fails. +Engine actions are commands with an id and JSON parameters: the same ids the ribbon, the +[command line](cli.md) and the [AI Assistant](user-guide/assistant.md) use. +`Session.run(id, ...)` runs them, with parameters as keyword arguments, a positional dict, or +both (keyword arguments win). It returns the decoded command result and raises +`rustgis.RustGISError` when the command fails. UI-only commands (`ui.*`) need +`Map.run_in_app`, as in the notebook example above. ```python s.commands() # every command, with its parameters @@ -117,12 +125,15 @@ s.tools() # the geoprocessing tools s.describe("layer.symbology") # one command's (or tool's) parameters s.run("labels.field", layer="Cities", field="name") s.run("map.goToXY", {"lon": 114.1, "lat": 22.4, "scale": 2_000_000}) -s.notices() # what the last commands reported +s.notices() # drains (text, is_error) pairs since last call s.undo() +s.redo() ``` -`run_tool` runs a geoprocessing tool and returns the layer it made (a list when it made several); -layers can be given as `Layer` objects or by name: +`run_tool` runs a geoprocessing tool and returns the layer it made (a list when it made several), +or the tool's own result for one that selects or makes a table. Layers can be given as `Layer` +objects or by name; tools take layers by name, so of two layers with the same name the upper one +is used: ```python s.describe("buffer")["params"] # input, distance, units, dissolve, output @@ -147,9 +158,13 @@ The GeoLibre and Whitebox tools run as they do in the app, through the `geolibre ## Data in and out ```python -s = rustgis.Session(data=["parcels.gpkg", "roads.fgb"]) -roads = s.add_data("roads.fgb", name="Roads") # each add_* returns its Layer -sites = s.add_geojson({"type": "FeatureCollection", "features": [...]}, name="Sites") +s = rustgis.Session(sample=True, data=["parcels.gpkg"]) +roads = s.add_data("roads.fgb", name="Roads") +sites = s.add_geojson({ + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": {"type": "Point", "coordinates": [10, 50]}, + "properties": {"name": "Site A"}}], +}, name="Sites") gdf = s["Cities"].to_geopandas() # a GeoDataFrame in EPSG:4326 big = s.add_geopandas(gdf[gdf.pop_max > 5_000_000], name="Big cities") @@ -158,6 +173,10 @@ big.to_file("big-cities.gpkg", crs="EPSG:3857") # GeoJSON, CSV, FlatGeobuf, ``` `add_geojson` also takes any object with `__geo_interface__` (a Shapely geometry, a GeoSeries). +`add_data`, `add_geojson` and `add_geopandas` return a `Layer` for a single layer, a list for +multiple layers, or the command result for standalone tables. For example, a CSV without +geometry is a table. Extra `add_data` keyword arguments go to `layer.addData`, such as `layer=` +to choose a GeoPackage table, or `x=` and `y=` to choose coordinate columns. Every format the app reads works here: GeoJSON, CSV, Shapefile, GeoPackage, GeoParquet, FlatGeobuf, KML/KMZ, GPX, GeoTIFF and the rest of [Supported Data @@ -165,13 +184,28 @@ Formats](user-guide/data-formats.md). A large file read in place keeps only the last view in memory; run `layer.loadAll` on it before `to_geojson` or `to_geopandas` to get all of them. +Open an existing project with `Session(project="atlas.rgis")` or `s.open("atlas.rgis")`. +`s.project_json()` returns its JSON text; `s.run("project.openText", text=...)` opens project +JSON without an intermediate file. `to_png()` returns PNG bytes even when it also writes a +file; omit the path to keep the image in memory. + ## From source -The package lives in `apps/rustgis-python`, built with [maturin](https://www.maturin.rs): +The package lives in `apps/rustgis-python`, built with [maturin](https://www.maturin.rs). +Install the [source-build prerequisites](installation.md#from-source) and use a Python 3.12+ +virtual environment. From the repository root: ```sh -pip install ./apps/rustgis-python # or: cd apps/rustgis-python && maturin develop +python -m pip install "./apps/rustgis-python[test]" python -m pytest apps/rustgis-python/tests ``` +For an editable development install, activate the virtual environment, then: + +```sh +python -m pip install "maturin>=1.9.3,<2" +cd apps/rustgis-python +maturin develop --release +``` + Its version is the RustGIS version, and each release publishes wheels to PyPI. diff --git a/docs/sponsor.md b/docs/sponsor.md index 7512e02b6..7504eed83 100644 --- a/docs/sponsor.md +++ b/docs/sponsor.md @@ -2,8 +2,8 @@ RustGIS is free and open-source software, released under the [Apache License 2.0](https://github.com/opengeos/RustGIS/blob/main/LICENSE) and developed in -the open. There is no paid tier, no seat licence and no telemetry in the app: everything on this -site is available to everyone, for good. +the open. There is no paid tier or seat licence. The desktop app and command line collect no +analytics or usage telemetry; the hosted websites use analytics, as described in [Privacy](privacy.md). That model only works if the work behind it is sustainable. If RustGIS saves you or your team time, sponsorship is the most direct way to keep it moving. @@ -28,7 +28,8 @@ Both go to [Qiusheng Wu](https://github.com/giswqs), the maintainer of RustGIS a up with fast-moving dependencies such as egui, wgpu and the GeoLibre tool suite. - **Shipping to every platform**: the Apple developer account, code signing and notarization behind the macOS app, the Microsoft Store listing, and building the Windows and Linux - [packages](installation.md), which the project pays for whether or not anyone sponsors it. + [packages](installation.md) and the [Python package](python.md), which the project pays for + whether or not anyone sponsors it. - **Hosting**: [rustgis.app](https://rustgis.app), the [web app](https://web.rustgis.app), the [plugin registry](https://plugins.rustgis.app), the [download mirror](https://downloads.rustgis.app) and the sample data and fonts at @@ -53,7 +54,7 @@ Sponsorship is not the only currency, and none of these cost anything: worth a great deal. - **Contribute code or plugins**: see [Contributing](contributing.md) and [Plugins](plugins.md#writing-a-plugin). -- **Translate the interface**: RustGIS ships in 16 languages; see [Languages](i18n.md) to add or +- **Translate the interface**: RustGIS ships in 17 languages; see [Languages](i18n.md) to add or improve one. ## Thank you diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 50118534a..9a2e76bba 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -16,10 +16,10 @@ Features**, `select.layerFromSelection`). ++ctrl+t++ (or **Home › Selection › Attributes**) docks the active layer's table under the map. Click a column header to sort by it (a second click reverses the order, a third unsorts), click a row to select it (++ctrl++ or -++shift++ adds it to the selection), and double-click a cell to change its value. **Selected only** -keeps to the selected rows and **Filter rows…** to the rows with a value containing the words typed. -The toolbar opens Select By Attributes and zooms to, switches, clears or deletes the selection, -and adds or calculates a field. +++shift++ toggles it without clearing the other rows), and double-click a cell to change its value. **Selected only** +keeps to the selected rows and **Filter rows…** keeps rows where any value contains the text typed, +ignoring letter case. The toolbar opens Select By Attributes and zooms to, switches, clears or +deletes the selection, adds or calculates a field, and calculates geometry attributes.
![The attribute table docked under the map: the 14 African countries of more than 30 million people, selected by attributes, highlighted on the map and in the table](https://assets.rustgis.app/images/attribute-table.webp) @@ -43,10 +43,11 @@ gdp_md IS NULL
The Analyze tab's tool gallery and the Tools pane running Buffer.
-Open a tool from the **Analyze** tab's gallery, **Analyze › Tools** (the Tools pane, -which searches every tool) or Command Search. Fill in its parameters and click **Run**: the -result is added to the map, and the run is listed under **Analyze › History**. From the command -line or a script (`gp.run`), a built-in tool's parameter with a list of choices, such as Buffer's +Open a tool from **Analyze › Portal › Feature Analysis** or **Raster Analysis**, **Analyze › Tools** (the Tools pane, +which searches every tool) or Command Search. Fill in its parameters and click **Run**: new +layers or tables are added to the project; selection tools select existing rows and Calculate +Geometry Attributes updates the input layer. Each run is listed under **Analyze › History**. +From the command line or a script (`gp.run`), a built-in tool's parameter with a list of choices, such as Buffer's units, takes one of them in any letter case (`"meters"` for `Meters`); any other value is an error that names the choices (Buffer's units no longer take the abbreviations `m`, `mi` or `metres`). Buffer and Select Layer By Location accept **Kilometers**, **Meters**, **Miles** and **Feet**. Feet are international feet @@ -70,34 +71,70 @@ written some other way (a token typed into a SQL query, say) can still be there: before sharing it. A point buffered where the map was clicked is copied as the command that buffered it, with the point. -The built-in tools: - -| Tool | Toolbox | -| --- | --- | -| Buffer | Analysis › Proximity | -| Clip, Erase | Analysis › Extract / Overlay | -| Dissolve | Data Management › Generalization | -| Minimum Bounding Geometry, Feature To Point, XY Table To Point, Calculate Geometry Attributes | Data Management › Features | -| Simplify | Cartography › Generalization | -| Tissot's Indicatrix | Cartography › Projections | -| A5 Grid, Aggregate Points to A5 | Analysis › DGGS | -| Select Layer By Location / Attribute | Data Management › Layers and Table Views | -| Summarize Within, Summary Statistics | Analysis › Summarize / Statistics | -| Export Features | Conversion | -| IDW Interpolation | Analysis › Interpolation | - -Tools honour the current selection: with features selected, only those are processed. - -**Buffer** distances, and the distance of **Select Layer By Location**, are geodesic: every point -of a buffer's edge is the given distance from the feature, measured on the Earth (a sphere of -radius 6,371 km), wherever the feature is and however far it spans. Each feature is buffered in a -projection centred on it, and a long line or a large polygon in stretches of a few hundred -kilometres, so a 1000 km circle at 60°N reaches as far north as south. +There are 20 built-in tools; GeoLibre and plugin tools join them when available. +Use these ids in `gp.run`'s `tool` parameter (`gp.tools` lists the available tools and their parameters): + +| Tool | Id | Toolbox | +| --- | --- | --- | +| Buffer | `buffer` | Analysis › Proximity | +| Clip | `clip` | Analysis › Extract | +| Erase | `erase` | Analysis › Overlay | +| Spatial Join | `spatialJoin` | Analysis › Overlay | +| Dissolve | `dissolve` | Data Management › Generalization | +| Minimum Bounding Geometry | `convexHull` | Data Management › Features | +| Feature To Point | `featureToPoint` | Data Management › Features | +| XY Table To Point | `xyTableToPoint` | Data Management › Features | +| Calculate Geometry Attributes | `calculateGeometry` | Data Management › Features | +| Simplify | `simplify` | Cartography › Generalization | +| Tissot's Indicatrix | `tissot` | Cartography › Projections | +| A5 Grid | `a5Grid` | Analysis › DGGS | +| Aggregate Points to A5 | `a5Aggregate` | Analysis › DGGS | +| Select Layer By Location | `selectByLocation` | Data Management › Layers and Table Views | +| Select Layer By Attribute | `selectByAttributes` | Data Management › Layers and Table Views | +| Summarize Within | `countPoints` | Analysis › Summarize | +| Summary Statistics | `summaryStatistics` | Analysis › Statistics | +| Export Features | `exportFeatures` | Conversion | +| IDW Interpolation | `idwInterpolation` | Analysis › Interpolation | +| Ordinary Kriging | `kriging` | Analysis › Interpolation | + +Most feature tools process only the selected rows when an input has a selection. Selection tools +instead replace the input's selection; Select Layer By Location honours the selecting layer's +selection. Summarize Within counts the selected points against **all** input polygons, and +Calculate Geometry Attributes updates **all** rows. A built-in tool's `output` is a layer name in +the project, never a file path. + +**Buffer** distances, and the distance of **Select Layer By Location**, are measured on the Earth +(a sphere of radius 6,371 km), not in the map's display projection. Each feature is buffered in +an azimuthal equidistant projection centred on it; long lines and large polygon outlines are +buffered in stretches of a few hundred kilometres to limit projection error. Negative Buffer +distances shrink polygons. Buffer and Select Layer By Location limit distances to 5,000 km. **IDW Interpolation** works like the QGIS tool of the same name: every point weighs on every cell by 1/distanceᴾ, where P is the distance coefficient (2 by default). Lines and polygons contribute their vertices. Leave the extent empty to use the input's, or give it as -`xmin,ymin,xmax,ymax` in degrees. The result is a Float32 GeoTIFF layer. +`xmin,ymin,xmax,ymax` in degrees. `pixelSize` sets the cell size in degrees; when omitted it +starts at 300 cells across the extent and coarsens for large inputs. The result is a Float32 +GeoTIFF layer. The **Geostatistical Wizard** is a separate IDW workflow: it uses 12 nearest +neighbours by default (`neighbors`), rather than every point. + +**Ordinary Kriging** (`kriging`) estimates both a value and its uncertainty. Set `input` and a +numeric `field`, then choose a `model`: **Spherical** (default), **Exponential** or **Gaussian**. +`neighbours` defaults to 12 (minimum 2, capped at 32). Optional `nugget`, `sill` and `range` +override the defaults: zero nugget, the sample variance (1 for near-constant values), and a +third of the extent's diagonal. Coordinates, `range`, `extent` and `pixelSize` are in degrees +(WGS 84). Points at the same location are averaged; multipoints contribute their points, while +lines and polygons contribute their bounding-box centres. At least two distinct locations with +numeric values are needed. It adds **two separate Float32 GeoTIFF layers**: the prediction, +named by `output`, and `_variance`. + +**Spatial Join** (`spatialJoin`, also `gp.spatialJoin`) copies attributes from `join` features +to `target` features in the same coordinate system. `match` is **Intersects** (default), +**Contains** (target contains join) or **Within** (target lies within join). +**Join one to one** (the default `operation`) copies the first matching feature's attributes, +not their sum or mean; **Join one to many** makes a row for each match. Both add `join_count`, +the total matches for that target, and prefix joined fields with `join_` (change `prefix` to +override it). `keepAll` defaults to **Keep all**, leaving unmatched joined values null; choose +**Keep only matching** to drop those targets. Selections in both layers are honoured.
![Tissot's indicatrix on the Robinson projection: circles of 800 km every 30°, stretched and sheared toward the poles](https://assets.rustgis.app/images/tissot.webp) @@ -145,18 +182,20 @@ layers. The suite is an optional download, so RustGIS itself stays small: the **GeoLibre runner**, a single `geolibre` program (about 20 MB to download). In the Tools pane, click -**Install GeoLibre tools** under *GeoLibre tools*: RustGIS downloads the latest runner for your +**Install Whitebox and GeoLibre tools** under *Whitebox and GeoLibre tools*: RustGIS downloads the latest runner for your platform from the [geolibre-rust releases](https://github.com/opengeos/geolibre-rust/releases/latest), checks it against its published SHA-256 checksum (which catches a corrupted download; the release itself is trusted as when you download it by hand) and installs it in its data folder -(`~/.local/share/rustgis/geolibre` on Linux, `~/Library/Application Support/RustGIS/geolibre` -on macOS, `%LOCALAPPDATA%\RustGIS\geolibre` on Windows). From the command line, -`gp.installGeolibre` does the same. +(by default `~/.local/share/rustgis/geolibre` on Linux, `~/Library/Application Support/RustGIS/geolibre` +on macOS, `%LOCALAPPDATA%\RustGIS\geolibre` on Windows). `RUSTGIS_APP_DATA_DIR` overrides the +data folder; Linux also honours `XDG_DATA_HOME`. From the command line, `gp.installGeolibre` +does the same. To remove it, click **Uninstall** beside *GeoLibre* at the top of the Tools pane, then -**Confirm uninstall** (`gp.uninstallGeolibre` from the command line): RustGIS deletes its -`geolibre` data folder. A runner you located yourself, or one on your `PATH`, is not deleted; -RustGIS goes on using it. You can install the tools again at any time. In the browser, +**Confirm uninstall** (`gp.uninstallGeolibre` from the command line): RustGIS deletes the runner +it installed and removes the `geolibre` folder only if it is empty. Other files there, a runner +you located yourself, and one on your `PATH` are left alone; RustGIS looks for a runner again. +You can install the tools again at any time. In the browser, **Remove** unloads the tools and stops loading them when RustGIS starts. To install it yourself instead, download the archive for your platform @@ -172,14 +211,14 @@ then e.g. **GeoLibre › Terrain › Derivatives**). A runner too old to list su them by category only. Find Tools searches them. - Raster inputs are passed as their own files; vector layers (or their selection) are written - in the first raster input's coordinate system, or the UTM zone around the data, so distances - and areas are in metres. Data spanning more than 40° of longitude stays in degrees. + in the first raster input's coordinate system, or, with no raster input, the UTM zone around + the data. The latter uses metres; data spanning more than 40° of longitude stays in degrees. - Tools that take several layers (a composite's bands, a mosaic's tiles) list them in order: **Add** picks a layer, and ▲ ▼ change its place (the order of the bands, for a composite). From the command line, name them separated by `;`: `"inputs":"red;green;blue"`. In the desktop app a file not open as a layer can be added too: type its path below the list and press Enter. -- Raster outputs are kept inside the project, so they save and reopen with it. Text and table - reports appear in the tool's messages. +- Raster outputs are kept inside the project, so they save and reopen with it. Table outputs + become standalone tables; text reports appear in the tool's messages. - LiDAR and other inputs RustGIS doesn't open as layers take a file path, read like Add Data reads one. Outputs are written to a fresh scratch folder and loaded from there. - Each run is a separate `geolibre` process, so a tool that fails can't take RustGIS down. @@ -187,7 +226,7 @@ them by category only. Find Tools searches them. `gp.tools` lists every tool's parameters, `gp.geolibre` shows the runner in use and `gp.setGeolibre={"path":"…"}` picks one. -In the web app, click **Load GeoLibre tools** in the Tools pane instead. RustGIS +In the web app, click **Load Whitebox and GeoLibre tools** in the Tools pane instead. RustGIS loads geolibre-rust's WebAssembly runner (the `geolibre-wasm` package, from jsDelivr, about 25 MB the first time, then from the browser's cache) and the tools run in the page, in an in-memory folder: nothing is uploaded. The web app remembers this and loads them again on your @@ -207,12 +246,12 @@ click them on the map: | Workflow | What it does | | --- | --- | -| Raster Functions | Slope, aspect, hillshade, NDVI, band math (`(b4-b3)/(b4+b3)`), thresholds, resampling | +| Raster Functions | Slope, aspect, hillshade, NDVI, band math (`(b4-b3)/(b4+b3)`), remapping, thresholds, resampling | | Function Editor | Chains raster functions, each on the previous output | | Suitability Modeler | Scores rasters 1–10 and adds them with weights | | Visibility Analysis | A viewshed from a point on an elevation raster | | Exploratory 3D Analysis | A line of sight between two points, split into seen and hidden parts | -| Geostatistical Wizard | An IDW surface from point values | +| Geostatistical Wizard | An IDW surface from feature values, using 12 nearest neighbours by default | | Network Analysis | The shortest (or cheapest, by a cost field) route over a line layer | | Neighborhood Explorer | What lies within a distance of a point, with sums of numeric fields | | Simulation | Random points in polygons (a fixed number, or from a field × rate) | @@ -221,9 +260,16 @@ click them on the map: | Embeddings | The features most like the selected one, by their numeric attributes | | Data Interop | KML, GPX, CSV with WKT, GeoJSON or CSV | -Rasters these make are kept in the project. **Environments** limit tools to the map view, the full -extent or everywhere, and set the cell size of new rasters. The **Script** pane runs commands from -text, one per line (`id {json}`); its box grows with the script down to the bottom of the pane, +Rasters these make are kept in the project. **Environments** (`gp.env`) set an extent from the map +view, full extent, a layer or a longitude/latitude box (or `null` to clear it). For tools that +honour it, this restricts an unselected main feature input by bounding-box intersection, not by +clipping its geometry; an existing selection takes precedence and overlay inputs stay whole. +The Geostatistical Wizard uses the environment's extent and raster cell size (degrees). +IDW Interpolation and Ordinary Kriging instead use their own `extent` and `pixelSize` parameters; +the environment does not set their output grids. Raster Functions › Resample matches a target +raster's grid. + +The **Script** pane runs commands from text, one per line (`id {json}`); its box grows with the script down to the bottom of the pane, short of the buttons, and then scrolls, so **Run** stays in view. It colours them as you type, in both themes: `#` comments in green italics, the command id, and the JSON's strings, numbers and `true`/`false`/`null`; an id no command has, or one that can't run in a script (`ui.*`, `script.*`, diff --git a/docs/user-guide/assistant.md b/docs/user-guide/assistant.md index 4044b8aa9..5397b61bb 100644 --- a/docs/user-guide/assistant.md +++ b/docs/user-guide/assistant.md @@ -14,13 +14,20 @@ RustGIS commands and geoprocessing tools for you: - "Buffer the cities by 200 km and colour the result by population." - "Switch to a dark basemap and hide the rivers." -Each step it takes shows in the conversation (click one to see its parameters and result), and -each change is one undo step, so Ctrl+Z takes back anything you don't want. It never says it -changed the map unless a command succeeded. Commands that would read or write files, connect to -an address on the internet, run a plugin, script or task, or replace the project wait for you: -**Allow** or **Decline**. File and network access can be allowed for the rest of the -conversation, for that exact command and file or address only. Enter sends, Shift+Enter -starts a new line, Up and Down recall earlier messages, and **Stop** ends a run. +Each step it takes shows in the conversation (click one to see its parameters and result). +Project edits use the usual undo history, so Ctrl+Z takes them back; undo does not remove +exported files or reverse actions outside the project. Check the tool results and map rather +than relying only on the model's reply. Commands that would read or write files, connect to +an address on the internet, run a plugin, script, task or model, or replace the project wait +for you: **Allow** or **Decline**. SQL queries also ask, because SQL can access files and the +network. **Allow such actions for this conversation** remembers the exact command and all +its parameters, not general access to a file or server. Plugin commands, user automation and +project replacement ask every time. + +Enter sends, Shift+Enter starts a new line, and Up and Down recall earlier messages when the +message box is empty or showing a recalled message. Drag the grip above it to resize it. +**Stop** ends the current assistant run; background local-agent threads have their own Stop +controls. From the command line, `rustgis-cli ask` sends one request (see [Command Line](../cli.md#asking-the-ai-assistant)). @@ -37,13 +44,16 @@ Click the model name or the gear to open the settings. The assistant works with: | Google Gemini | `gemini-3.8-flash` | [aistudio.google.com](https://aistudio.google.com/apikey), or `GEMINI_API_KEY` / `GOOGLE_API_KEY` | | OpenRouter | `openrouter/auto` | [openrouter.ai](https://openrouter.ai/keys), or `OPENROUTER_API_KEY` | | Ollama (local) | `qwen3` | none; the server from `OLLAMA_BASE_URL` (default `http://localhost:11434`) | -| OpenAI-compatible | — | LM Studio, vLLM, llama.cpp…: the address from `OPENAI_COMPATIBLE_BASE_URL`, an optional `OPENAI_COMPATIBLE_API_KEY` | +| OpenAI-compatible | — | LM Studio, vLLM, llama.cpp…: `OPENAI_COMPATIBLE_BASE_URL` (default `http://localhost:1234/v1`), with an optional `OPENAI_COMPATIBLE_API_KEY` | The ↻ button lists the provider's models. The provider and model you last chose in the settings are used at start. Until you choose one, `RUSTGIS_ASSISTANT_PROVIDER` and `RUSTGIS_ASSISTANT_MODEL` pick them, and otherwise the desktop uses the first provider with a key -in the environment. Saved settings win over the environment. Models that call tools well give the best results; small local models may stop early or -skip steps. +in the environment, falling back to Anthropic when none has a key. ChatGPT sign-in is used +only when you choose that provider. Saved settings win over the environment. The **Address** +field changes the endpoint for providers other than ChatGPT; use the OpenAI-compatible API +path (usually `/v1`) for local servers. Models that call tools well give the best results; +small local models may stop early or skip steps. ### Signing in with ChatGPT @@ -81,9 +91,8 @@ Signing in needs the desktop app or the command line (it runs a short-lived serv Live testing of this integration was limited to a **ChatGPT Pro account on Windows 11**. Plus, Team/Business, Enterprise and Edu accounts, macOS and Linux have not been live-verified. -Plus has a five-hour usage limit shared across apps using the plan. Team/Business, Enterprise -and Edu workspace policy may block plan usage. Eligibility and model catalogs depend on the -selected account and workspace. +Plan usage limits and model catalogs depend on the selected account and workspace. +Team/Business, Enterprise and Edu workspace policy may block plan usage. Weekly usage opens ChatGPT's settings page. RustGIS does not show a live percentage: there is no documented usage endpoint, and the tested live responses supplied no rate-limit headers. @@ -131,8 +140,8 @@ complete instructions do not fit. RustGIS comes with these: | `add-data` | Adding files, web addresses, DuckDB SQL queries, STAC catalogs and Overture Maps | | `share-web-map` | Sharing the map or a layer as files that work without a server: a web map, a web layer, a style, an image | -The settings' **Skills** list shows every skill and where it comes from, with a switch to turn -each one on or off: +On the desktop, the settings' **Skills** list shows every skill and where it comes from, +with a switch to turn each one on or off: - **Built in**: the skills above. - **Plugins**: skills a plugin carries, named `:`, while the plugin is enabled @@ -150,7 +159,8 @@ approval (files, the network, plugins) still ask. A skill is a folder holding `SKILL.md` in the [Agent Skills](https://agentskills.io) format: front matter giving its `name` (lower-case letters, digits and hyphens) and a `description` that says -when to use it, then the instructions in Markdown. The assistant reads only `SKILL.md`. +when to use it, then the instructions in Markdown. Each `SKILL.md` is at most 64 KiB; the +assistant reads only that file, and importing a folder or ZIP does not copy its other files. Descriptions can be single-line values or indented blocks introduced by `>` or `|`. For a description spanning several lines, use a block; plain multiline YAML values are not supported. @@ -200,6 +210,15 @@ files belong to its examples/references and are not installed separately. To imp skills, put each in a sibling folder and omit a root `SKILL.md`. A failure during the final rename phase can leave earlier skills installed; the error names those completed installs. +## Subagents + +For a self-contained task, the assistant can use `delegate` to start a fresh conversation +with the same provider and model, acting on the same project. The subagent receives the task, +not the whole conversation, and returns a short report. Its tool calls appear in the main +transcript, and file, network and other gated actions still need approval. It inherits the +conversation's exact-command approvals. This works without installing a local coding agent; +it is distinct from the background threads below. + ## Agents on this computer On the desktop, the assistant can hand a task to a coding agent you already have, such as Claude @@ -251,9 +270,10 @@ fetch the USGS stream gauges in Texas and add them to the map." ## Privacy -Requests go from RustGIS straight to the provider you chose, with your key. They carry your -messages, the map's layer names, fields and extent, and any rows the assistant reads to answer -you; nothing goes anywhere else. A key typed in the settings is used for the session only unless +Assistant requests go directly to the provider and address you chose, using your API key or +ChatGPT sign-in. They carry your messages, the map's layer names, fields and extent, and tool +results such as rows the assistant reads. Local agents use their own providers and tools, +with their own data handling. A key typed in the settings is used for the session only unless you tick **Remember the key on this computer**, which saves it unencrypted in the app's preferences (in the browser's storage on the web); keys from the environment are never saved. A ChatGPT sign-in is kept in `chatgpt-auth.json` in the app's settings folder (owner-only on @@ -269,6 +289,15 @@ OpenRouter accept that; a local Ollama server must allow the page's origin ## Retries and long conversations +The assistant stops after 16 rounds of tool use per message, shared with subagents. Provider +retries and conversation summaries are separate. Reply "continue" to go on. + +RustGIS first trims older tool results and reasoning when the conversation grows too large. +If that is not enough, or the provider rejects the context length, it asks the same model to +summarize earlier turns. **Compact** requests this explicitly while the assistant is idle; +**New conversation** clears the chat and its remembered approvals and ends local-agent +threads, without resetting the map. + Live streaming text is available on the desktop. The browser currently buffers the complete response (up to the response limit, 64 MiB for SSE) before displaying it; it has no live streaming preview. Cross-origin retry timing headers are available only when the provider exposes them diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 153b938ce..6660794d7 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -44,6 +44,11 @@ the colour picker's opacity control can also make a fill visible again. By comma layer.noFill={"layer":"Countries"} ``` +The ribbon's **Color** control changes Unique Values or Graduated Colors back to Single Symbol. +To keep the classification, edit a class's colour in the Symbology pane instead. A polygon's +fill and outline are independent: changing its fill leaves its outline intact. +For raster renderers and band stretches, see [Rasters](data.md#rasters). + ### Edit a class With Unique Values or Graduated Colors, each class in the Symbology pane's list has a colour button @@ -82,7 +87,7 @@ Layer/group transparency still applies on top of the symbol's opacity in maps an ### Line strokes A line layer draws one stroke by default. The **Strokes** section of the Symbology pane stacks -several in one symbol, for road casings, parallel lines and offsets: **Add Stroke** puts one on +up to 16 in one symbol, for road casings, parallel lines and offsets: **Add Stroke** puts one on top, and each stroke has its own **Color**, **Width**, **Offset**, **Cap** (round, flat or square) and **Join** (round, miter or bevel), with buttons to move it up or down or remove it. The list shows the top stroke first, and the preview above it shows the symbol along a line drawn left to @@ -115,16 +120,18 @@ same). By command, name the class as `layer.classStyle` does, with `value`, `ind layer.lineStrokes={"layer":"Roads","value":"Motorway","strokes":[{"color":"#5A3A1E","width":7},{"color":"#F28C28","width":5},{"color":"#FFFFFF","width":0.6}]} ``` -Each feature draws its whole stack in turn, so where two roads meet, the second one's casing -cuts across the first one's core. **Draw strokes in levels** (symbol levels) draws every -feature's strokes of one level before any of the next instead, so the casings join underneath +By default, each feature draws its whole stack in turn, so where two roads meet, the second one's +casing cuts across the first one's core. **Draw strokes in levels** (symbol levels) draws all +stacked strokes at a lower level before those at a higher one, so the casings join underneath and the cores run through. A stroke's level is its place in the stack (the bottom one 0) unless -it is given one: a **Level** row shows on each stroke while levels are on, and strokes of -different classes that share a level go down together, such as the casings of all road classes -before any core. A layer read in place from its file draws its levels a batch of features at a -time. By command (`"level"` on a stroke, `null` for its place in the stack): +it is given one: a **Level** row shows on each stroke while levels are on; turn off **Auto** to +set it from 0 to 255. Strokes of different classes that share a level go down together, such as +the casings of all road classes before any core. A layer read in place from its file draws +its levels a batch of features at a time. By command (`"level"` on a stroke, `null` for its +place in the stack): ```text +layer.lineStrokes={"layer":"Roads","value":"Motorway","strokes":[{"color":"#5A3A1E","width":7,"level":0},{"color":"#F28C28","width":5,"level":1}]} layer.strokeLevels={"layer":"Roads","on":true} ``` @@ -204,22 +211,24 @@ it unchanged. ## Labeling -The **Labeling pane** has every setting below in one place, for the selected layer: right-click a -feature layer in the Layers pane › **Labeling Properties…**, or View › Windows › **Labeling**, or -Style › Labels › **Properties**. It runs the same commands as the ribbon, so each change undoes on -its own (a dragged size or a colour pick is one step). +The **Labeling pane** brings together the Default label class's field, SQL clause, text symbol, +placement and visibility limits, plus the map-wide label display controls. Open it for a +feature layer with Layers › right-click › **Labeling Properties…**, **View › Windows › Labeling**, +or **Style › Labels › Properties**. It runs the same commands as the ribbon, so each setting +change undoes on its own (a dragged size or a colour pick is one step). Additional label classes +are managed with commands, as described below. Turn labels on per layer (View › Labeling › Label, or the Style tab), then: - **Label field and text**: the field the labels show - (`labels.field={"layer": "Cities", "field": "name"}`), and the text symbol's size, colour, halo - and bold (`labels.style={"layer": "Cities", "size": 12, "color": "#5A1E0A", "halo": true}`). + (`labels.field={"layer": "Cities", "field": "name"}`), and the text symbol's size (4–72 points), + colour, halo and bold (`labels.style={"layer": "Cities", "size": 12, "color": "#5A1E0A", "halo": true}`). - **SQL**: label only the features that match a SQL clause (the language of definition queries), such as `pop_max > 5000000`. In the Labeling pane, Class › **SQL** › **New expression**: write the clause (the field and operator buttons add to it), **Check** says whether it reads and how many of the features the layer draws at the map's scale match (after its definition query, display filters and time window), and **Apply** sets it in one - undo step; **Clear** labels every remaining feature again + undo step; **Clear** removes the Default class's clause, leaving other classes unchanged (`labels.class.update={"layer": "Cities", "where": "pop_max > 5000000"}`). - **Visibility Range**: draw labels only between two scales (Out Beyond / In Beyond), or between two viewing distances (Closest / Farthest). Clear Limits removes all four. @@ -230,24 +239,31 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: labels for faster navigation; **View Unplaced** draws the labels that didn't fit in red. - **Convert** turns a layer's labels into an annotation layer: one point per label, carrying its text in a `TEXT` field, which you can then edit, move or delete like any other features. -- **Label classes** label different features differently, such as capitals larger than other - cities. Each class has a SQL clause (the language of definition queries) choosing its - features, and its own field, text symbol, placement and visibility range. As in ArcGIS Pro, - each class labels every feature its clause matches, the **Default** class too (with no - clause, every feature), so a feature two classes match gets a label from each. To label the - capitals only once, give the classes clauses that don't overlap: `pop_max > 5000000` for - Capitals and `pop_max <= 5000000` for Default. When labels compete for room, the class - listed first is placed first, Default last. A class that is hidden, or out of its - visibility range, labels nothing; the other classes still label their features. For now, - classes are made with commands: `labels.class.add={"layer": "Cities", "name": "Capitals", - "where": "pop_max > 5000000"}`, then `labels.class.rename`, `labels.class.update` (its - `where` or `visible`), `labels.class.move` (its place in the list), `labels.class.remove` - and `labels.class.clear` (every class but Default; no error when there are none, so a script - can start with it). The other label commands take a `class` (`labels.style={"layer": - "Cities", "class": "Capitals", "size": 14}`), and change Default without one (a renamed - Default is named by its new name, or left out). Convert keeps the classes, with a point for - each label and a `CLASS` field saying which class it is; a web style (Share › Web Style) - labels with Default only, and has no labels when Default is hidden. +- **Label classes** label different features differently, such as large cities in larger text. + Each class has a SQL clause choosing its features, and its own field, text symbol, placement + and visibility range. Each class labels every feature its clause matches, the **Default** + class too (with no clause, every feature). A feature matched by two classes produces a label + candidate for each; collision rules still determine which labels fit. To avoid duplicate + labels, use clauses that do not overlap: `pop_max > 5000000` for Large cities and + `pop_max <= 5000000` for Default. Classes are placed in list order, Default last; a hidden + class or one outside its visibility range labels nothing without excluding features from + other classes. There can be up to 64 classes besides Default. Make them with + `labels.class.add` (a new class copies Default's field, text symbol, placement and range, or + those of the class named by `from`), then `labels.class.rename`, `labels.class.update` (`where` or `visible`), + `labels.class.move` (`to`: 0 first), `labels.class.remove` and `labels.class.clear` (removes + every class but Default, even when it has been renamed; no error when there are none). + Field, text symbol, placement and range commands take a `class`; without one they change + Default, whatever its name. For example: + + ```text + labels.class.add={"layer":"Cities","name":"Large cities","where":"pop_max > 5000000"} + labels.class.update={"layer":"Cities","where":"pop_max <= 5000000"} + labels.style={"layer":"Cities","class":"Large cities","size":14,"bold":true} + ``` + + Convert keeps the classes, with a point for each matching class and a `CLASS` field identifying + it. Web Style and Web Layer exports (**Publish › Share As**) use Default only, and have no labels + when Default is hidden.
![Europe with the Countries and Cities labelled; the city names sit above and to the right of their points](https://assets.rustgis.app/images/labels.webp) @@ -256,22 +272,26 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: ## Layer effects -These draw on top of the symbology. On the Style tab: **Masking** shows a layer only inside another layer's polygons; +On the **Style** tab, **Masking** shows a layer only inside another layer's polygons; **Display Filters** show different features at different scales (a SQL condition per scale -range); **Aggregation** draws points as hexagon or square bins coloured by count; **Extrusion** -raises polygons by a height or a numeric field in an oblique 2.5D view, with **Face Culling**, -**Lighting** (walls shaded by the way they face the light) and **Cast Shadow**. On the Raster -Layer tab: **Layer Blend** (multiply, screen, overlay, darken, lighten, difference), **Swipe** -(the layer shows only left of a divider you slide, to compare it with what's beneath), -**Masking** and **Rotation**. +range); **Aggregation** draws points as hexagon or square bins coloured by count. +**Extrusion** raises polygons by a constant height or numeric field (in metres), multiplied by +**Factor**, with **Face Culling**, **Lighting** and **Cast Shadow** controls. It draws in an +oblique view on an untilted flat map and in perspective on a tilted map; the globe draws the +polygons flat. See [Rotating and tilting the map](maps.md#navigating). + +For a raster layer, the **Style** tab also has **Layer Blend** (multiply, screen, overlay, +darken, lighten, difference), **Swipe** (the layer shows only left of a divider you slide, to +compare it with what's beneath), **Masking** and **Rotation**. ## Graphics -Edit › Graphics draws shapes in a graphics layer (Home › Add Data › Graphics Layer, or one is -made with the first graphic). Point, Line, Curve and Polygon are clicked vertex by vertex -(double-click to finish); Freehand, Lasso, Rectangle, Circle and Ellipse are dragged. Text places -a text where you click, as a layout's text is placed: it says "Text", is selected, and opens in -the **Format** pane (beside the other right-hand panes) with its text selected, so you type over +**Edit › Graphics** draws shapes in a graphics layer (**Home › Add › Add Data › Graphics Layer**, or +one is made with the first graphic). Click once for a Point. Line, Curve and Polygon are clicked +vertex by vertex (double-click to finish); Freehand, Lasso, Rectangle, Circle and Ellipse are +dragged. **Edit › Graphics and Text › Text** places a text where you click, as a layout's text +is placed: it says "Text", is selected, and opens in the **Format** pane (beside the other +right-hand panes) with its text selected, so you type over it. Shift+Enter starts a new line, and Enter ends the text. Dynamic Text is a menu of values that update as the map changes (map name, scale, date, time, coordinate system, number of layers, map centre): pick one, then click the map, and the text starts with its token (`{map}`, `{scale}`, diff --git a/docs/user-guide/data-formats.md b/docs/user-guide/data-formats.md index 077e13961..548b95ef8 100644 --- a/docs/user-guide/data-formats.md +++ b/docs/user-guide/data-formats.md @@ -4,15 +4,18 @@ This page lists the data formats RustGIS reads and writes, how to add each one, limits. [Data](data.md) covers adding data, large files and rasters in more detail; [Maps and Basemaps](maps.md#basemaps) covers web tile layers. -Most formats open in the desktop app and in the [web app](https://web.rustgis.app) alike. Those -that need C libraries (GDAL, DuckDB) or files on disk are desktop-only, as the tables say. The -web app reads GeoPackages without SQLite, with a small SQLite file reader written in Rust. +Most native readers work in the desktop app and the [web app](https://web.rustgis.app). +Formats read through external GDAL or DuckDB programs need the desktop app or `rustgis-cli`; +they aren't available in the web app. Database connections and folder-based File Geodatabases +are desktop-only too. Browser files are uploaded rather than opened by path; GeoPackages use a +pure-Rust SQLite file reader there. Reprojection supports the coordinate systems RustGIS knows, +not every EPSG code; azimuthal equidistant systems such as Equi7 need the desktop app. ## Vector and tabular files | Format | Extensions or source | How to add it | Notes | | --- | --- | --- | --- | -| GeoJSON | `.geojson`, `.json` | Home › Add Data, or drop the file | Features with typed attributes; a `GeometryCollection` keeps its first member. On the desktop, a FeatureCollection larger than 32 MB is read in place, a view at a time, however large; otherwise files up to 256 MB load whole. | +| GeoJSON | `.geojson`, `.json` | Home › Add Data, or drop the file | Features with typed attributes; a `GeometryCollection` keeps its first readable member. Desktop FeatureCollections larger than 32 MiB use an in-place reader; layers with more than 100,000 features stream a view at a time. Other GeoJSON files load whole, up to 256 MiB. | | Delimited text | `.csv`, `.tsv`, `.txt` | Add Data, or Add Data › Delimited Text… to choose the columns from the file's header | Comma, semicolon, tab or pipe separated (found from the header, or `"delimiter"`); `info.fileColumns` lists the columns and the ones found. Points from x/y columns (named like longitude/latitude or easting/northing, or chosen with `"x"` and `"y"`), or any geometry from a WKT/EWKT column (named like `wkt` or `geometry`, or `"wkt"`). Coordinates in another system are converted from `"crs"` (`EPSG:2263`, or WKT) or an EWKT `SRID=`; without either, coordinates outside longitude/latitude are pointed out. A file without coordinates opens as a standalone table. | | Shapefile | `.shp` with `.dbf` (and `.shx`), or zipped (`.zip`) | Add Data or drop the file; in the web app, pick or drop the `.shp` and `.dbf` together, with the `.shx` and `.prj` if there are (they open as one layer), or the zip | A zip may hold several Shapefiles, in folders, and other vector files (GeoJSON, CSV, GeoParquet, FlatGeobuf); each becomes a layer, at most 32 for the whole archive (Shapefiles first, so other files are left out when Shapefiles fill it). `.txt` files and macOS `__MACOSX` entries are passed over, and a member that can't be read is skipped and named in a notice. Converted to longitude/latitude from the coordinate system in its `.prj` (EPSG, Esri or OGC WKT, or WKT2 as PROJ and QGIS write it; Web Mercator, UTM, State Plane and other common projections); without a `.prj` it must be in longitude/latitude. On the desktop, one with more than 100,000 shapes (counted from its `.shx`) is read in place, a view at a time. | | KML / KMZ | `.kml`, `.kmz` | Add Data, drop the file, or upload it in the web app | A layer for each kind of geometry (points, lines, polygons), with each placemark's name, description, folder path and extended data. Styles are kept: the layer is drawn by unique values of its `style` field, with each KML style's line and fill colours, widths and icon colours (up to 64 styles). Multi-geometries and `gx:Track`s are read. Ground overlays (pictures on a `LatLonBox`, turned by its `rotation`) are raster layers kept in the project: the picture inside a KMZ, or a file beside a KML (not a web address, nor outside its folder); a `gx:LatLonQuad` isn't drawn. | @@ -22,16 +25,16 @@ web app reads GeoPackages without SQLite, with a small SQLite file reader writte | LandXML | `.landxml`, or `.xml` | Add Data, drop the file, or upload | Coordinate geometry points, parcels (polygons), alignments (lines; curves as chords) and TIN surfaces (a triangle per face, with its mean elevation), converted from the file's `epsgCode` or `"crs"`. | | Encoded polylines | text | `layer.addEncodedPolyline` with `"polyline"` (one or a list) | Google's encoding, precision 5, or 6 (`"precision"`) as OSRM and Valhalla can give: a line layer. | | GML | `.gml` | Add Data, drop the file, or upload | Simple features in GML 2 and 3 (WFS answers, OGR's GML): a layer per feature type, its simple elements as fields. Converted from the `srsName` (EPSG codes, URNs and URLs; the URN and URL forms of geographic systems are latitude first). | -| MapInfo Interchange | `.mif` with its `.mid` (or both zipped) | Add Data, drop the file, or upload the zip | Points, lines, polylines, regions (holes found by nesting), rectangles and ellipses; text and arcs are rows without geometry. Converted from the `CoordSys`: longitude/latitude, Transverse Mercator, Lambert Conformal Conic, Albers or Mercator. | -| OpenStreetMap PBF | `.osm.pbf` | Add Data, drop the file, or upload | Points (tagged nodes), lines and multipolygons (closed ways that are areas, and multipolygon and boundary relations), with common tags as fields and the rest in `other_tags`, as GDAL gives them. Up to 40 million nodes (4 million in the web app); on the desktop, extracts over 256 MB open through GDAL when it is installed. | -| TopoJSON | `.topojson`, or `.json` | Add Data, drop the file, or upload | A layer per object, its geometries rebuilt from the shared arcs (quantized or not). | +| MapInfo Interchange | `.mif` with its `.mid` (or both zipped) | Add Data, drop the file, or upload the zip | Points, lines, polylines, regions (holes found by nesting), rectangles and ellipses; text and arcs are rows without geometry. Converted from the `CoordSys`: longitude/latitude, Transverse Mercator, Lambert Conformal Conic, Albers or Mercator. A loose `.mif` upload has no `.mid` attributes; zip both files to keep them in the web app. | +| OpenStreetMap PBF | `.osm.pbf`, or `.pbf` containing OSM data | Add Data, drop the file, or upload | Points (tagged nodes), lines and multipolygons (closed ways that are areas, and multipolygon and boundary relations), with common tags as fields and the rest in `other_tags`, as GDAL gives them. Up to 40 million nodes (4 million in the web app); on the desktop, extracts over 256 MiB open through GDAL when it is installed. Vector-tile `.pbf` files use Add Tile Layer instead. | +| TopoJSON | `.topojson`, or `.json` | Add Data, drop the file, or upload | Shared arcs (quantized or not) are rebuilt into geometries. A `.topojson` adds a layer per object, up to 64; a topology named `.json` adds its first object and names the others in a notice. Use `.topojson` to add them all. | | AutoCAD DXF | `.dxf` (ASCII) | Add Data, drop the file, or upload | Points, lines and polygons layers from the drawing's entities: POINT, LINE, LWPOLYLINE and POLYLINE (bulges drawn as arcs; closed ones stay lines; polyface meshes become polygons), CIRCLE, ARC, ELLIPSE, SPLINE, HELIX, LEADER, MLINE (its centre line), TEXT, MTEXT, ATTRIB and constant ATTDEF (points with their text, at the alignment point of justified text), HATCH boundaries (holes found by nesting), SOLID, TRACE and 3DFACE (polygons). Blocks (named in any case) are drawn where INSERTs place them (scaled, rotated, in arrays, nested up to 8 deep), and so are DIMENSIONs' blocks (a linear dimension without one draws its lines and measured text). Entities in an object's own coordinate system are turned into the drawing's. Fields: `layer`, `entity`, `color` (ACI, BYLAYER and BYBLOCK resolved), `linetype`, `text`, `height`, `rotation`, `block`, `elevation` and `paperspace`. The drawing's layouts (paper space: title blocks, viewport frames) are left out, with a notice, unless `"paperSpace": true`. A drawing never says its coordinate system: give `"crs"` (such as `EPSG:2263`), or its coordinates are kept as they are, with a notice. `"layer"` picks one of the drawing's own layers, and `info.fileLayers` lists them. Up to 2 million features (500,000 in the web app). Not read: binary DXF, MULTILEADER, WIPEOUT, IMAGE and ACIS solids (3DSOLID, REGION), named in a notice. | | Geotagged photos | `.jpg`, `.jpeg` | Add Data (several at once in the web app), drop them, upload, or `layer.addPhotos` with `"paths"` or a `"folder"` | JPEGs with a GPS position in their EXIF become points in the **Photos** layer (photos added one at a time join it): each with its `name`, `file`, `time` (when it was taken), `altitude`, `direction` (where the camera faced), `camera` and a `thumbnail` of at most 160 pixels, kept with the feature so the project needs no other file. Identify shows the thumbnail. Photos without a position are named in a notice and left out; a layer holds up to 2,000 photos. A JPEG is always read as a photo unless `layer.addData` or `layer.addDataBytes` is given `"as": "scan"` (a phone photo of a paper map, say: see [Scans with no coordinates](#rasters)). | -| LiDAR point clouds | `.las`, `.laz` (COPC too) | Add Data, drop the file, upload, or From URL… | LAS 1.0–1.4 (point formats 0–10) and LAZ, on the desktop and in the web app: a point layer with each point's `z`, `intensity`, `classification`, `return` and colour (`red`, `green`, `blue`), coloured by ASPRS class (or by elevation when the cloud has one class). A cloud past 2 million points (500,000 in the web app) shows an evenly spread sample of them. Converted from the coordinate system its WKT or GeoTIFF-key record names (the horizontal part of a compound one); a file that names none keeps its coordinates, with a notice. Read in place, so a project keeps the file's path, not its points. | -| GeoPackage | `.gpkg` | Add Data, or upload in the web app | Every feature table becomes a layer, up to 32 at once (the result counts the rest in `skippedCount` and names up to 100 of them in `skipped`); `"layer"` picks one, and `info.fileLayers` lists them all. Read in place by its R-tree; any EPSG system is reprojected. The web app reads the file with its own SQLite reader (tables scanned, the envelope in each geometry header tested first; no R-tree); UTF-16 databases aren't read there. | -| GeoParquet | `.parquet`, `.geoparquet` | Add Data, or upload in the web app | WKB geometry, with the CRS from the file's `geo` metadata. Row groups are pruned by the bbox covering. Snappy, gzip, LZ4 and zstd compression are read, in the browser too; Brotli isn't. | +| LiDAR point clouds | `.las`, `.laz` (COPC too) | Add Data, drop the file, upload, or From URL… | LAS 1.0–1.4 (point formats 0–10) and LAZ, on desktop and web: points with `z`, `intensity`, `classification`, `return` and colour (`red`, `green`, `blue`), coloured by ASPRS class or elevation. An evenly spread sample is read once, at most 2 million points (500,000 in the web app); views use that sample, not the COPC spatial index. LAZ decompression reads at most the first 400 million points (40 million on the web), with a notice when capped. Converted from the file's WKT or GeoTIFF-key record; without one, coordinates are kept with a notice. A local project keeps the path; uploads keep the file bytes. Remote files download whole. | +| GeoPackage | `.gpkg` | Add Data, or upload in the web app | Local files and browser uploads add every feature table, up to 32 (with the rest counted in `skippedCount` and up to 100 named in `skipped`); `"layer"` picks one, and `info.fileLayers` lists them. Desktop byte uploads and downloads add the first table unless `"layer"` names another. Large local tables use their R-tree and reproject supported EPSG systems. Browser tables are scanned, testing each geometry header's envelope first; UTF-16 databases aren't read there. Raster tile tables aren't imported. | +| GeoParquet | `.parquet`, `.geoparquet` | Add Data, or upload in the web app | WKB geometry, with the CRS from the file's `geo` metadata. A binary geometry column is required; plain non-spatial Parquet tables and GeoArrow-native geometry encodings aren't read by this importer. Row groups are pruned by the bbox covering. Snappy, gzip, LZ4 and zstd compression are read, in the browser too; Brotli isn't. | | FlatGeobuf | `.fgb` | Add Data, or upload | Read in place by its packed R-tree. | -| Esri File Geodatabase | `.gdb` folder (desktop) | A file dialog can't pick a folder: in Add Data pick any `.gdbtable` file inside it; or double-click the `.gdb` in the Browser, or drop the folder on the window | Read without GDAL: every feature class becomes a layer and every table a standalone table, with their fields (dates as text) and coordinate system (by its code, EPSG or Esri; else its WKT). `"layer"` picks one, and `info.fileLayers` lists them. 3D multipatches (such as swissBUILDINGS3D's buildings) are drawn as their footprints, the union of their faces seen from above, with their lowest and highest Z in `z_min` and `z_max` and the difference in `height`, which an extrusion (Style › Extrusion) can stand them up by; one with no face seen from above (only walls) is drawn as the outline of its points. Curves are drawn as straight segments between their ends. Compressed tables, raster fields and some newer row indexes aren't read: with GDAL installed, such a geodatabase opens through GDAL instead (see below). Loaded whole, up to 2 GB a table. | +| Esri File Geodatabase | `.gdb` folder (desktop) | In Add Data pick a `.gdbtable` file inside it; or double-click the `.gdb` in the Browser, or drop the folder | The native reader loads up to 512 feature classes and tables, up to 2 GiB per table. Feature classes become layers and non-spatial tables standalone tables; dates are text. Coordinates use the EPSG/Esri code, else WKT. `"layer"` picks one; `info.fileLayers` lists them. Multipatches become footprints with `z_min`, `z_max` and `height`; large or wall-only ones may use a convex hull. Curves become straight segments between their ends. Unreadable tables (compressed storage, raster fields or unsupported row indexes) are tried through GDAL when installed; otherwise they are named in a notice. | | Standalone tables | CSV, or any layer without geometry | Add Data | Listed under the map in Layers; join or relate them to layers. | Shapefile text uses the encoding in its `.cpg` sidecar first, then the language-driver code in @@ -66,9 +69,12 @@ GDAL](data.md#formats-through-gdal). ## Files on the web **Home › Add Data › From URL…** (`layer.addUrl` with `"url"`) downloads a file by its `https://` -(or `s3://`, see [S3 buckets](data.md#s3-buckets)) address and adds it like a file from this computer: GeoJSON, CSV, GeoParquet, FlatGeobuf, -GeoPackage, zipped Shapefiles, KML/KMZ, GPX or GeoTIFF/COG, up to 512 MB on the desktop and 256 MB -in the web app (where the server must allow requests from web pages, CORS). On the desktop, with +(or `http://` or `s3://`, see [S3 buckets](data.md#s3-buckets)) address and adds it like an +uploaded file. Native formats include GeoJSON, delimited text, GeoParquet, FlatGeobuf, GeoPackage, +zipped Shapefiles, KML/KMZ, GPX, spreadsheets, GeoRSS, GML, DXF, TopoJSON, LiDAR and rasters. +Downloads are limited to 512 MiB on desktop and 256 MiB on the web; individual readers may have +lower limits (the whole-file vector readers use 256 MiB). In the web app the server must allow +requests from web pages (CORS). On the desktop, with [GDAL](https://gdal.org) installed, a JPEG 2000 file (`.jp2`, `.j2k`: Sentinel-2's imagery on S3) is downloaded too, converted to a GeoTIFF that the project keeps, and added. Desktop byte uploads (`layer.addDataBytes`) of `.jp2` and `.j2k` use the same conversion. The web app rejects both JPEG @@ -92,16 +98,16 @@ unresponsive. The original JPEG 2000 byte buffer is released once it has been st before GDAL runs or the GeoTIFF is read back. The converted GeoTIFF and decoded raster still occupy memory together; the 512 MiB file limit is not a limit on total process memory. -A cloud-optimised GeoTIFF (a tiled `.tif` with overviews, as most published imagery is) is read -in place rather than downloaded: its header first, then, by HTTP range requests, only the tiles of -the overview each part of the view needs, so a Sentinel-2 scene of 300 MB draws in a couple of -seconds and full detail comes as you zoom in. It is a tile layer (`map.addTiles` kind `cog`, -saved in the project by its address), drawn under the map's own layers like other tile layers: -RGB(A) 8-bit images as they are, other images in grey stretched between the 2nd and 98th -percentiles of their coarsest overview. A GeoTIFF in strips, with bands stored apart, or without -tiles is downloaded whole as a raster layer, as is any GeoTIFF added with `"whole": true` (for -the raster tools and symbology). The web app needs the server to allow range requests from web -pages (CORS with the `Range` header), as public buckets such as Sentinel-2's do. +A tiled GeoTIFF is read in place rather than downloaded: its header first, then, by HTTP range +requests, the tiles of the overview each part of the view needs. Overviews make zoomed-out reads +smaller. It is a tile layer (`map.addTiles` kind `cog`, saved by its address), drawn under the map's +own layers: RGB(A) 8-bit images in colour, other images stretched between the 2nd and 98th +percentiles of their coarsest overview. Planar files (bands stored apart), signed-byte data and +bottom-up images work too. `map.setTiles` selects one or three `"bands"` (from 1) and a fixed +`"range"`; see [Satellite embeddings](web-data.md#satellite-embeddings). +A GeoTIFF in strips, a rotated image or an unsupported tiled image downloads whole as a raster layer, as does a +GeoTIFF added with `"whole": true` (for raster tools and symbology). The web app needs the server +to allow range requests (CORS with the `Range` header). A GeoParquet or FlatGeobuf file by its address is read in place too, a view at a time, however large: the layer is streamed like a large local file, and the view's features are fetched in the @@ -111,20 +117,18 @@ view. A GeoParquet's footer is read first, then the row groups the view meets, b box covering column (`bbox`) and its statistics: the covering of groups on the view's edge, then the geometry and attribute columns of the rows taken. A view holding more than a read takes (60,000 features on the desktop, 30,000 in the web app) shows an evenly spread sample of it, in patches; -zoom in for all of them. So a 12 GB FlatGeobuf of 11 million areas opens after 65 KB and -draws a city in about a second from 9 MB, and a 525 MB Overture buildings file opens after 1 MB. -The project keeps the address and reads it the same way when it opens. A FlatGeobuf without an +zoom in for more detail. The project keeps the address and reads it the same way when it opens. +A FlatGeobuf without an index, a GeoParquet without a covering column, or a server that doesn't answer range requests means the file is downloaded whole instead, as is any file added with `"whole": true`; Load All Features and the geoprocessing tools fetch the whole file on the desktop, while in the web app they need the file downloaded first. -`layer.addUrl` waits for the download, up to a minute, which suits the command line, MCP and the -desktop app's assistant; a larger file comes through the From URL… dialog, which downloads in the -background. In the web app, where nothing may wait, it adds `.pmtiles` and GeoTIFF addresses only -(a GeoTIFF read in place); other files, GeoParquet and FlatGeobuf read in place among them, -come through the From URL… dialog (`ui.addUrl`) or a `?data=` link. GeoJSON, CSV, KML and GPX files are read up to -256 MB; larger downloads (GeoParquet, FlatGeobuf, GeoPackage, GeoTIFF) up to the limits above. +On desktop, `layer.addUrl` waits for downloads, up to a minute; the From URL… dialog (`ui.addUrl`) +works in the background. In the web app the same dialog handles asynchronous downloads. +`layer.addUrl` can add PMTiles and COG tile layers directly, but an ordinary download returns +`{"pending": true, "url": "…"}` and is added later by the app. To open a remote GeoParquet or +FlatGeobuf whose header hasn't been fetched yet, use the From URL… dialog or a `?data=` link. ## Map and feature services @@ -247,12 +251,12 @@ databases](data.md#sql-queries-and-duckdb-databases). | Format | Extensions or source | How to add it | Notes | | --- | --- | --- | --- | -| GeoTIFF / Cloud-optimised GeoTIFF | `.tif`, `.tiff` | Add Data, or upload | Regular or cloud-optimised, striped or tiled; grey, RGB(A), palette (with its colour table) or multiband; Deflate, LZW, PackBits, JPEG or Zstandard compression. Any EPSG system, or the file's own projection from its GeoKeys (Albers, LCC, transverse Mercator…), reprojected as it draws. A rotated or skewed image (a transformation matrix with rotation terms) is resampled to a north-up grid as it's read. A large file opens from its overview, or one made as it is read when it has none. | +| GeoTIFF / Cloud-optimised GeoTIFF | `.tif`, `.tiff` | Add Data, or upload | Regular or cloud-optimised, striped or tiled; grey, RGB(A), palette (with its colour table) or multiband; Deflate, LZW, PackBits, JPEG or Zstandard compression. Supported EPSG systems or the file's own GeoKey projection are reprojected as it draws. Rotated or skewed images are resampled north-up. Large files open from an overview, or one made while reading. Browser uploads over 256 MiB read only the parts needed for a fitting overview, provided those parts fit the upload limit; the project keeps those parts, not the full-resolution file. | | JPEG 2000 | `.jp2`, `.j2k` | Add Data, byte upload (`layer.addDataBytes`), or From URL… (desktop, with [GDAL](https://gdal.org) installed) | Converted through GDAL. Uploads and downloads keep the converted GeoTIFF in the project, so reopening it needs neither GDAL nor the original file or address; their output and workspace limits are described under [Files on the web](#files-on-the-web). The web app rejects JPEG 2000 uploads and downloads with an explicit GDAL limitation: use GeoTIFF instead. | -| Scans with no coordinates | a `.tif` with no GeoTIFF tags, `.png`; any `.tif`, `.png` or `.jpg`/`.jpeg` with `"as": "scan"` | Add Data, or upload (`layer.addData`, `layer.addDataBytes`) | Keeps its pixel size and bands (grey, RGB(A); 8 or 16 bits). An untagged TIFF or a PNG opens as a scan by default; a JPEG only with `"as": "scan"` (otherwise it is a photo), which also drops the coordinates of a GeoTIFF. It has no coordinates, so it is drawn over the current view, the command's result says so (`georeferenced: false`), and the layer is marked not georeferenced until **Georeference** places it. Until then it can't be exported, and tools that would give a result a place on the ground (raster functions, pixel edits, clip, resample, mosaic) refuse it. A PNG or JPEG is decoded whole: up to four times the sample limit in decoded bytes is read at every second pixel, and anything larger is refused, so save it as a TIFF (read at a reduced size). A TIFF is reduced as a GeoTIFF is. | -| ASCII grid | `.asc` (with an optional `.prj`) | Add Data, or upload | | -| Zarr | a store's folder (`.zarr`) or web address, format 2 (consolidated) or 3 | Add Data › Zarr… (`layer.addZarr`), Add Data on the folder, or a `?zarr=` link | A variable's latitude/longitude (or y/x) map at the steps picked of its other dimensions (time, level), read chunk by chunk in pure Rust (blosc, zlib, gzip, zstd, LZ4; sharded arrays too), on the desktop and the web. See [Zarr stores](data.md#zarr-stores). | -| Rasters GDAL reads | `.img`, `.hgt`, `.dem`, `.dt0`–`.dt2`, `.nc`, `.hdf`, `.h5`, `.bil`, `.grd`, `.sdat`, `.kea`, `.ers`, `.rst`, `.ntf`, `.ecw`, `.sid`… | Add Data (desktop, with [GDAL](https://gdal.org) installed: `gdal_translate`, `gdalinfo` and `gdalwarp`) | Converted once to a cloud-optimised GeoTIFF in the cache, then read as one. A NetCDF or HDF file's variables are its subdatasets: the first is added, the others are named in a notice, and `"layer"` picks one. A file that doesn't say its coordinate system is read as WGS 84 when its coordinates look like longitude/latitude (0–360° longitudes are moved to -180–180°); otherwise give `"crs"`. | +| Scans with no coordinates | an untagged `.tif` or `.png`; any `.tif`, `.png` or `.jpg`/`.jpeg` with `"as": "scan"` | Add Data, or upload (`layer.addData`, `layer.addDataBytes`) | Grey or RGB(A), 8- or 16-bit pixels. An untagged TIFF or PNG opens as a scan by default; a JPEG needs `"as": "scan"` (otherwise it is a photo). This also discards a GeoTIFF's coordinates. The scan initially covers the current view and is marked not georeferenced (`georeferenced: false`) until **Georeference** places it. Export and tools needing ground coordinates refuse it until then. PNG/JPEG decoding is bounded and may reduce the image; large TIFFs can be read at reduced resolution. | +| ASCII grid | `.asc` (with an optional `.prj` on desktop) | Add Data, or upload | A single numeric band. Desktop files use the neighbouring `.prj`; uploaded and downloaded grids have no sidecar and must have coordinates that fit WGS 84 longitude/latitude. | +| Zarr | a store's folder (`.zarr`, desktop) or web address (desktop and browser), format 2 (consolidated) or 3 | Add Data › Zarr… (`layer.addZarr`), Add Data on a local folder, or a `?zarr=` link | A variable's latitude/longitude (or y/x) map at the steps picked of its other dimensions (time, level), read chunk by chunk in pure Rust (blosc, zlib, gzip, zstd, LZ4; sharded arrays too). See [Zarr stores](data.md#zarr-stores). | +| Rasters GDAL reads | `.img`, `.hgt`, `.dem`, `.dt0`–`.dt2`, `.nc`, `.nc4`, `.hdf`, `.hdf5`, `.h5`, `.he5`, `.bil`, `.bsq`, `.bip`, `.envi`, `.grd`, `.rst`, `.sdat`, `.kea`, `.ers`, `.ntf`, `.nitf`, `.ecw`, `.sid`, `.gsb`, `.bt`, `.rsw` | Add Data (desktop, with [GDAL](https://gdal.org): `gdal_translate`, `gdalinfo` and `gdalwarp`; the installed build must include the driver) | Converted once to a cached cloud-optimised GeoTIFF. NetCDF/HDF variables are subdatasets: the first is added, the others named in a notice; `"layer"` picks one. Without a declared coordinate system, longitude/latitude-like coordinates use WGS 84 (0–360° longitudes are moved to −180–180°); otherwise give `"crs"`. JPEG 2000 is listed separately above. | ## Tiles and web services @@ -260,32 +264,32 @@ databases](data.md#sql-queries-and-duckdb-databases). | --- | --- | --- | --- | | Raster tiles | XYZ/TMS template (`{z}/{x}/{y}`, `{-y}`, `{s}`, `{q}`), WMS (`{bbox-epsg-3857}`) | Home › Add › Basemap › Add Tile Layer | PNG, JPEG and WebP. | | Vector tiles | Mapbox Vector Tiles (`.pbf`, `.mvt`) template, or TileJSON | Add Tile Layer | Drawn in a built-in style that knows the OpenMapTiles, Protomaps and Shortbread schemas. | -| MapLibre styles | `style.json` | Add Tile Layer | The style's vector and raster sources, sprite, expressions and zoom functions. | -| PMTiles | `.pmtiles`: a web address, or a file on the desktop | Add Tile Layer (or its **Tile Archive File…** button), Add Data, or drop the file | Raster or vector, as the archive says; uncompressed, gzip, Brotli or Zstandard. Read a tile at a time by byte ranges (the server must allow them, and CORS in the web app). The map moves to the archive's bounds when it is added (not for a world archive). A vector archive is drawn in the built-in style when it is a basemap, otherwise with each of its layers in its own colour. | +| MapLibre styles | `style.json` | Add Tile Layer | A supported subset of vector and raster sources, sprites, expressions and zoom functions; see [Maps and Basemaps](maps.md#basemaps) for styling limits. | +| PMTiles | `.pmtiles`: a web address, or a desktop file | Add Tile Layer (or **Tile Archive File…**), Add Data, or drop a local file on desktop | Version 3 archives only: PNG, JPEG or WebP rasters, or MVT vectors; uncompressed, gzip, Brotli or Zstandard. Read a tile at a time by byte ranges (CORS on the web). Browser local-file uploads aren't supported; use an archive's web address. The map moves to the archive's bounds, except a world archive. Vector archives use the built-in basemap style, or a colour per layer when added as an overlay. | | MBTiles | `.mbtiles`: a file | Add Data, drop the file, Add Tile Layer's **Tile Archive File…** button, or upload in the web app | Raster (PNG, JPEG, WebP) or vector tiles, as its metadata says, drawn as PMTiles archives are. The desktop reads the file in place, a tile at a time. The web app reads an upload into memory (the plain `tiles` table and the deduplicated layouts of mbutil, TileMill and Planetiler) and keeps it for the session: a project saved there asks for the file again. | ## Writing | Format | How to write it | Notes | | --- | --- | --- | -| GeoJSON, CSV | Data › Export (`share.exportLayer`) | The Export Features dialog picks the format; the file is saved in it whatever extension is typed. In the web app it downloads (any format below but GeoPackage; a Shapefile as one zip). A streamed layer writes its whole source (as every format below does). | -| GeoPackage | Data › Export to `.gpkg` (desktop) | One feature table (an attributes table for a layer without geometry) in the layer's coordinate system (see below), with an R-tree spatial index and the GeoPackage 1.4 triggers that keep it current when other software edits the file. Feature ids are kept. | -| GeoParquet | Data › Export to `.parquet` | GeoParquet 1.1: WKB geometry in WGS 84, Snappy compression, row groups of 65,536 features and a `bbox` covering column so readers can skip rows by extent. | +| GeoJSON (`.geojson`, `.json`), CSV | Data › Export (`share.exportLayer`) | The Export Features dialog picks the format; the chosen extension is appended when needed. In the web app it downloads any vector format below except GeoPackage (a Shapefile as one zip). Feature export loads a streamed layer's whole source; remote sources on the web must be downloaded first. | +| GeoPackage | Data › Export to `.gpkg` (desktop) | One feature table (an attributes table for a layer without geometry) in the layer's coordinate system (see below), with an R-tree and GeoPackage 1.4 triggers to maintain it. Unique feature ids that fit signed 64-bit integers are kept; otherwise SQLite assigns new ids. | +| GeoParquet (`.parquet`, `.geoparquet`) | Data › Export | GeoParquet 1.1: WKB geometry in WGS 84, Snappy compression, row groups of 65,536 features and a `bbox` covering column. A standalone table without geometry writes plain Parquet. | | Shapefile | Data › Export to `.shp` (or `.zip` for the files zipped) | Writes `.shp`, `.shx`, `.dbf`, `.prj` (the coordinate system, see below) and `.cpg` (UTF-8). Field names are cut to 10 characters (made unique), text to 254 bytes, and numbers too wide for a dBase field are left empty; features without geometry, and lines of a single point, are left out (and reported). | | FlatGeobuf | Data › Export to `.fgb` | In the layer's coordinate system (see below), with a spatial index; features without geometry are left out (and reported). | | KML, KMZ, GPX | Data › Export to `.kml`, `.kmz` or `.gpx` | Placemarks named by a `name` field, if there is one; features without geometry are left out. | -| KML, GPX, CSV with WKT | Analyze › Workflows › Data Interop | | -| GeoTIFF | Data › Export | | -| PNG | Publish › Export Map (`share.exportMap`) | At any DPI, with labels, scale bar and tile credits. | +| GeoJSON, KML, GPX, CSV, CSV with WKT | Analyze › Workflows › Data Interop (`data.convert`; `"format": "geojson"`, `"kml"`, `"gpx"`, `"csv"` or `"wkt"`) | Returns text when `"path"` is omitted; writes a file on desktop when it is given. | +| GeoTIFF / Cloud-optimised GeoTIFF | Data › Export Raster (`share.exportRaster`) | Untiled GeoTIFF by default; `"cog": true` writes 512-pixel tiles, Deflate compression and overviews. Writes the loaded raster's resolution (an overview when that is what was opened), the selected band or its colour bands. The web app's Export Raster downloads an ordinary GeoTIFF. | +| PNG | Publish › Export Map (`share.exportMap`) | Configurable DPI, with labels, scale bar and tile credits. | | PDF | Layouts, reports and presentations | | | Wavefront OBJ, glTF | Publish › Export To 3D, Share As › Web Scene | Polygons extruded by a field. | | Web map, OGC API Features, MapLibre style | Publish › Share As | Open-format files that need no server: see [Sharing](sharing.md). | -| Raster tiles | Analyze › Create Hosted Imagery | | +| Raster tiles | `imagery.hostedImagery` | A zip of `tiles/z/x/y.png` tiles, `tiles.json` and a viewer page; at most 5,000 tiles and 256 MiB of tile data. | -GeoPackage, Shapefile and FlatGeobuf exports are written in the coordinate system the layer was -read in: a Shapefile's `.prj`, a GeoPackage's or FlatGeobuf's own system, or the `crs` a CSV was -added with (EPSG:25832 data goes back out as EPSG:25832). A layer read in longitude/latitude, or -made in RustGIS, is written in WGS 84 (EPSG:4326). `share.exportLayer` takes `"crs"` to choose +GeoPackage, Shapefile and FlatGeobuf exports default to the layer's original coordinate system +when it has an EPSG code. Shapefiles can also retain a custom WKT system; GeoPackage and FlatGeobuf +need an EPSG code and otherwise default to WGS 84. A layer made in RustGIS defaults to WGS 84. +`share.exportLayer` takes `"crs"` to choose another, such as `{"layer": "Parcels", "path": "parcels.gpkg", "crs": "EPSG:4647"}`. GeoJSON, KML, GPX, GeoParquet and CSV are always written in WGS 84. diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index d4c07ef95..62ecccf22 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -8,9 +8,9 @@ Formats](data-formats.md). | Format | Read | Write | | --- | --- | --- | | GeoJSON (`.geojson`, `.json`) | Yes | Yes | -| Delimited text (`.csv`, `.tsv`, `.txt`) with x/y columns in any coordinate system, or WKT geometry | Yes | Yes | -| Shapefile (`.shp` + `.dbf`, or zipped in a `.zip`) | Yes (the web app opens zips) | Yes (`share.exportLayer`) | -| GeoPackage (`.gpkg`, every feature table) | Yes | Yes (desktop) | +| Delimited text (`.csv`, `.tsv`, `.txt`) with x/y columns in a supported coordinate system, or WKT geometry | Yes | Yes | +| Shapefile (`.shp` + `.dbf`, or zipped in a `.zip`) | Yes (the web app also accepts sidecar files picked or dropped together) | Yes (`share.exportLayer`) | +| GeoPackage (`.gpkg`, feature tables) | Yes | Yes (desktop) | | GeoParquet (`.parquet`, `.geoparquet`, WKB geometry) | Yes | Yes | | FlatGeobuf (`.fgb`) | Yes | Yes (`share.exportLayer`) | | KML/KMZ (with their styles), GPX | Yes | Yes | @@ -22,10 +22,11 @@ Formats](data-formats.md). | Through GDAL: AutoCAD DWG, MapInfo TAB, OpenStreetMap XML, File Geodatabases RustGIS can't read itself… | Yes (desktop, when GDAL is installed) | No | | PostgreSQL/PostGIS databases, read live (large tables a view at a time) | Yes (desktop) | No | | MySQL and MariaDB databases, read live (large tables a view at a time) | Yes (desktop) | No | +| SQL Server and Azure SQL databases, read live (large tables a view at a time) | Yes (desktop) | No | | DuckDB databases (`.duckdb`, `.ddb`) and SQL queries | Yes (desktop, when DuckDB is installed) | No | -| KML, GPX, CSV with WKT geometry | No | Yes (Analyze › Workflows › Data Interop) | | GeoTIFF and cloud-optimised GeoTIFF (`.tif`, `.tiff`) | Yes | Yes | | ASCII grid (`.asc`, with an optional `.prj`) | Yes | No | +| Unreferenced TIFF and PNG scans; JPEG with `"as": "scan"` | Yes | GeoTIFF after georeferencing | | Through GDAL: JPEG 2000, ERDAS Imagine, SRTM HGT, DTED, NetCDF, HDF, ENVI, SAGA… | Yes (desktop, when GDAL is installed) | No | | Web tiles: XYZ/TMS/WMS rasters, Mapbox Vector Tiles, MapLibre styles, PMTiles and MBTiles archives | Yes | Yes (Analyze › Create Hosted Imagery) | | RustGIS project (`.rgis`; older `.rgisx` files open too) | Yes | Yes | @@ -50,11 +51,30 @@ the project, from two fields of coordinates and their coordinate system. In a te **Home › Add Data › Graphics Layer** adds a layer for [drawn graphics](cartography.md#graphics), and **Data › Layer Templates** adds empty layers with ready-made schemas to draw into. -Vector coordinates are WGS 84 longitude/latitude and are displayed in Web Mercator. GeoPackage, -GeoParquet and FlatGeobuf files in another coordinate system (any EPSG code) are converted as they -are read. Rasters keep -their own coordinate system (any EPSG code, such as UTM zones or national grids) and are -reprojected on the fly as they draw. +Vector geometry is stored as WGS 84 longitude/latitude. Data in another supported coordinate +system is converted as it is read; Shapefiles use their `.prj`, and formats such as GeoPackage, +GeoParquet and FlatGeobuf carry their coordinate system in the file. Rasters keep their own +coordinate system and are reprojected as they draw. The map uses Web Mercator by default, but +**View › Projection** can change it; see [Maps](maps.md). + +Shapefile text uses a supported `.cpg` encoding, otherwise the `.dbf` code page, with UTF-8 as +the fallback. Keep the sidecar files together. + +## Attribute tables and fields + +Open a feature layer's table with ++ctrl+t++ (++cmd+t++ on macOS), **Home › Selection › Attributes** +or **Attribute Table** on a feature layer's **Data** tab (`table.open`). Rows and map selections stay in sync. +Standalone tables open from their right-click menu; their cells are read only. See [The attribute table](analysis.md#the-attribute-table) for +sorting, filtering, selection and the SQL `WHERE` clause syntax used by **Select By Attributes** +and **Definition Query**. These clauses filter a layer; they are not DuckDB queries. + +For an editable feature layer, double-click a cell to change it. The table's toolbar also adds +fields, calculates field expressions and opens **Calculate Geometry** (`edit.calculateGeometry`). +The geometry calculator fills a new double field or an existing numeric field with area, +perimeter, length or point X/Y, for all or selected features. Point coordinates can be WGS 84 +degrees or a supported projected EPSG system's units. See [Editing](editing.md) for field +calculations and data-design rules. Streamed layers must first be loaded with **Load All +Features**. ## Formats through GDAL @@ -116,12 +136,14 @@ column becomes a layer (`"layer"` picks one). ### PostgreSQL and PostGIS -**Home › Add Data › Database…** (`layer.addDatabase`) connects to a PostgreSQL database with -PostGIS and reads its tables live. RustGIS talks to the server itself (nothing else to install; -TLS as libpq does it). `sslmode=prefer` (the default) and `require` encrypt the connection but -don't check the server's certificate, so they don't protect against a server in the middle: -use `verify-full` (or `verify-ca`, which here checks the host name too) on networks you don't -trust. The certificate is checked against `sslrootcert` (a private authority's PEM file) or the +**Home › Add Data › Database…** (`layer.addDatabase`, desktop) connects to a PostgreSQL database with +PostGIS and reads its tables live. The dialog takes a saved connection or a typed one, a password, +a table (**List Tables**, or all tables with geometry) and a coordinate system for geometry that +doesn't say its own. RustGIS talks to the server itself (nothing else to install; +TLS as libpq does it). `sslmode=prefer` (the default) uses TLS only when the server offers it, and otherwise connects +without encryption, so a server in the middle can force a plaintext connection. `require` always +uses TLS but doesn't check the server's certificate. On networks you don't trust, use +`verify-full` or `verify-ca`; both check the host name here. The certificate is checked against `sslrootcert` (a private authority's PEM file) or the web's roots. `disable` turns TLS off. The connection is a libpq connection string (`host=localhost port=5432 dbname=gis user=postgres`) or a `postgresql://user@host/dbname` address; what it leaves out comes from `PGHOST`, `PGPORT`, `PGUSER` and `PGDATABASE`, as with @@ -272,8 +294,8 @@ page of their own: [Web Data](web-data.md). ## Geocoding addresses -**Home › Add Data › Geocode Addresses…** (`layer.geocode`) turns a table's addresses into points: -a CSV of addresses without coordinates is added as a standalone table, then each row's address +**Home › Add Data › Geocode Addresses…** (`layer.geocode`) turns a standalone table's or a +layer's addresses into points: a CSV of addresses without coordinates is added as a standalone table, then each row's address fields (`"address"`: a field, or several such as street, city and country, joined with commas) are looked up, each distinct address once. The result is a new point layer holding every field plus `geocoded`, what the geocoder called the place; rows that weren't found stay in its table @@ -331,8 +353,8 @@ before it connects or lists; plugins can't connect, and need the network permiss ## Data Sources -The **Data Sources** pane (**View › Windows › Data Sources**, `ui.pane.sources`) gathers the -places data comes from: +The **Data Sources** pane (**View › Windows › Data Sources**, `ui.pane.sources`; **Home › Add +Data › Connections…** also opens it) gathers the places data comes from: - **Favourites**: any source, right-clicked and added. - **Recent**: the last 20 sources added (files, web addresses, tile layers, services, database @@ -370,16 +392,17 @@ database is kept without its password. The commands `sources.list`, `sources.sav ### Named connections -In the pane, **New Connection** and **Edit…** let you set the name, group and address. -Leave a new connection's name blank to derive it from the address. **Test** checks the form -without saving it: web-service, catalogue, STAC and S3 requests run in the background on both -desktop and web; database tests run synchronously on desktop. Database rows remain visible -but cannot be opened or tested in the web app. Earth Search and Planetary Computer remain -available under **Library**. - -**From Service…** (including **Catalogue search (CSW)**), **From Database…**, **STAC Search** -and **S3…** can use saved connections directly. Service, database and S3 dialogs offer a -**Saved connection** picker; STAC includes saved connections in its existing API/catalog list. +In the pane, **New Connection ▾** and **Edit…** let you set the name, group and address; +**Home › Add Data › New Connection** opens the same form for a chosen kind. Leave a new +connection's name blank to derive it from the address. **Test** checks the form without saving +it: web-service, catalogue, STAC and S3 requests run in the background on both desktop and web; +database tests run synchronously on desktop. The web app doesn't offer database kinds for new +connections; saved database rows remain visible but cannot be opened or tested there. Earth +Search and Planetary Computer remain available under **Library**. + +**From Service…** (including **Catalogue search (CSW)**), **Database…**, **STAC Search…** and +**S3…** can use saved connections directly. Service, database and S3 dialogs offer a **Saved +connection** picker; STAC includes saved connections in its existing API/catalog list. Picking one keeps its identity, not just its address: two connections can point to the same server with different names or users. @@ -474,8 +497,9 @@ features are read in place rather than loaded whole, avoiding an in-memory copy dataset. The layer reads features in view in the background as the map moves, using spatial indexes: the GeoPackage R-tree, the FlatGeobuf packed R-tree, GeoParquet row-group bounding boxes, or a cached Shapefile R-tree built from the shapes' bounding boxes. When a -view holds more than 250,000 features (100,000 in the web app), the layer draws an evenly spread -sample of them and its tooltip in Layers says so: zoom in to see them all. Smaller files load +local file's view holds more than 250,000 features (100,000 in the web app), the layer draws an +evenly spread sample of them and its tooltip in Layers says so: zoom in to see them all. Remote files have lower limits (see +[Data formats](data-formats.md)). Smaller files load whole; `layer.addData` with `"stream": true` or `false` overrides the choice. On the desktop, opening a large Shapefile for the first time scans its record headers to build @@ -505,8 +529,10 @@ file over 256 MB through a buffer, so it is never held whole), and each view par features it shows. A single feature, a bare geometry or TopoJSON is loaded whole. A streamed layer works like any other for symbology, labels, pop-ups, selection and the -attribute table (which lists the features read for the view). Geoprocessing tools read the whole -file. Edits are refused, since the features are read again as the view moves: right-click the +attribute table (which lists the features read for the view). Geoprocessing tools, exports and +**Load All Features** read the whole source, up to 2 million features (500,000 in the web app); +a larger source is refused rather than processed as a sample. A tool given a streamed layer with +a selection uses the selected features read for the view. Edits are refused, since the features are read again as the view moves: right-click the layer in Layers and choose **Load All Features** (`layer.loadAll`) to load it into memory and edit it. Projects save the layer's file path, not its features, and read the file again when they open; a browser upload is kept in the project. @@ -549,8 +575,10 @@ serialization continue to use the original source data and existing read limits.
Add a GeoTIFF or an ASCII grid with **Add Data**, by dropping it on the window, or in the web app -by picking it. An ASCII grid takes its coordinate system from a `.prj` file next to it (as a Shapefile does); -otherwise, and always in the web app, which only sees the one file, it must be in +by picking it. Local ASCII grids can be up to 1 GiB; ASCII-grid uploads are limited to 256 MiB in the web app. +A TIFF or PNG without coordinates opens as a scan to place with **Georeference**; see +[Supported Data Formats](data-formats.md). An ASCII grid takes its coordinate system from a `.prj` +file next to it (as a Shapefile does); otherwise, and always for uploads, it must be in longitude/latitude degrees. On the desktop, a GDAL `.aux.xml` file next to a raster (`dem.tif.aux.xml` or `dem.aux.xml`) names its bands from their descriptions, gives the no-data value when the file has none, and supplies the band statistics when only an overview of the raster is read. @@ -568,9 +596,11 @@ might not. Exported to a file it is written tile by tile, never held whole in me builds it in memory to download it). GeoTIFFs may be regular or cloud-optimised: grey, RGB(A), palette (coloured by the file's colour table, as land cover such as the NLCD is) or multiband, of any pixel type, tiled or striped, and -compressed with Deflate, LZW, PackBits, JPEG or Zstandard. The coordinate system is an EPSG code, +compressed with Deflate, LZW, PackBits, JPEG or Zstandard. JPEG-compressed YCbCr images are shown +as RGB; other YCbCr and CMYK images aren't read. A rotated or skewed image is resampled to a +north-up grid as it is read. The coordinate system is an EPSG code, or the file's own projection described in its GeoKeys (Albers, Lambert conformal conic, transverse -Mercator, Mercator, Lambert azimuthal, stereographic, equirectangular and others), which an +Mercator, Mercator, Lambert azimuthal, stereographic, equirectangular, sinusoidal and others), which an exported GeoTIFF keeps. A raster too large to hold in memory (more than 120 million samples, 32 million in the web app) opens from its largest overview that fits; one without overviews opens as an overview made as it is read, a strip or tile at a time (every second, fourth… pixel each way), @@ -709,14 +739,17 @@ RustGIS saves pieces of a project as files you can share and reuse: | Styles (saved symbology) | `.rgisstyle` | Style › Styles › New Style, then Publish › Save As › Style Items | Style › Styles › Add Style File | | [Color ramps](cartography.md#custom-color-ramps) | `.rgisramp` (also reads QGIS `.xml`/`.qml`, GDAL color `.txt` and GMT `.cpt`) | Publish › Save As › Color Ramps, or by hand | Style › Styles › Add Color Ramp File | | Project template (the setup without features) | `.rgistemplate` | Home › Templates, or Publish › Save As | Home › Templates › Open Custom Template | -| Project package (everything, rasters included) | `.rgispkg` | Publish › Package › Project | Project › Open, or drop it on the window | +| Project package (the project, with linked raster files embedded while they fit) | `.rgispkg` | Publish › Package › Project | Project › Open, or drop it on the window | | Map package / mobile map package | `.rgismpk` | Publish › Package › Map or Mobile Map | Home › Import | | Results package (geoprocessing history and outputs) | `.rgisgpk` | Publish › Package › Results | any zip tool | | Task (guided command steps) | `.rgistask` | Analyze › Task, Publish › Save As › Task Item | Analyze › Task › Import Task | | Bookmarks (as longitude, latitude and scale) | `.rgisbookmarks` | Home › Navigate › Bookmarks › Export Bookmarks | Home › Navigate › Bookmarks › Import Bookmarks | | Layout | `.rgislayout` | Publish › Save Layout File | Home › New Layout › Import Layout | -Packages are zip files. A mobile map package keeps only the visible layers and simplifies their +Packages are zip files. Project and map packages can newly embed a combined 512 MiB of linked +raster files on the desktop (128 MiB in the web app); a raster that doesn't fit stays linked. +Already embedded uploads are retained. Local streamed vector files, databases and web layers +stay linked to their sources. A mobile map package keeps only the visible layers and simplifies their geometry, for phones and tablets. Styles can also be imported from the layers of a layer file, map file or project. Data › Layer Templates adds empty layers with ready-made schemas (places, routes, areas, observations…) to draw into. Add Folder connects a folder to the Browser, which diff --git a/docs/user-guide/editing.md b/docs/user-guide/editing.md index b31a468f6..892ce71e0 100644 --- a/docs/user-guide/editing.md +++ b/docs/user-guide/editing.md @@ -3,14 +3,20 @@ ## Creating features **Edit › Create** draws new points, lines and polygons into the layer selected in Layers: -click each vertex and double-click to finish (++esc++ cancels). **Delete** (++delete++) removes -the selected features. Edit attributes in the attribute table (++ctrl+t++): double-click a cell +click once for a point, or click each vertex and double-click to finish a line or polygon +(++esc++ cancels). **Delete** (++delete++) removes the selected features across the map's layers. +Edit attributes in the attribute table (++ctrl+t++): double-click a cell to change it. **Data › Data Design** (and **Data › Fields**) adds and deletes fields and calculates a field from an expression; **Calculate Geometry** (in the attribute table's toolbar) fills a new or existing numeric field with each feature's area, perimeter, length or X/Y coordinate, in the units you choose, for all features or the selected ones. Length and perimeter include feet and miles; area includes square feet (`ft2`), acres (`ac`) and square miles (`mi2`). Every edit is one undo step. +Undo (++ctrl+z++) and Redo (++ctrl+y++ or ++ctrl+shift+z++) restore project changes, not just +feature edits; use ++cmd++ instead of ++ctrl++ on macOS. While typing in a text field, those +shortcuts undo or redo the typing instead. Saving keeps the undo history; opening or creating +a project clears it. Undo does not reverse files already written by an export. + ## Modifying features
@@ -18,8 +24,9 @@ area includes square feet (`ft2`), acres (`ac`) and square miles (`mi2`). Every
Edit Vertices: drag a square handle to move a vertex, a round one to add a vertex.
-The Edit tab's **Edit** button turns editing on or off for the map; with it off, the map is -read-only. The Editing pane (Settings, Templates, Status, Modify) holds the rest. Its **Distance units** +The Edit tab's **Edit** button turns feature editing on or off for the map; with it off, +feature creation, geometry changes and attribute edits are disabled. The Editing pane +(Settings, Templates, Status, Modify) holds the rest. Its **Distance units** selector sets the units used for Move and constant elevation. The choice is remembered independently of the live map's measurement units. The entered numbers are interpreted in the selected units when you click **Move**, **Constant** or **Apply elevation**. The current elevation stays in effect while @@ -41,13 +48,17 @@ you edit the value or choose units; click **Apply elevation** to apply the new i - **Feature templates**: named default values for new features, made from a selected feature; the Create tool uses the chosen template. - **Snapping** (**Edit › Snapping**): new and dragged vertices snap to nearby vertices, edges and - line ends (magenta marker). Each of the three can be turned off, and the tolerance is how near, - in screen pixels, a vertex must come to snap (10 by default, 1 to 100): - `edit.snapping={"on": true, "tolerancePx": 12, "edge": false}` (without `"on"` it turns - snapping on or off). + line ends of visible feature layers at the current scale (magenta marker; graphics and rasters + are not targets). Vertices and ends take priority over edges within the tolerance. Snapping + starts off; its vertex, edge and end options start on. The tolerance is how near, in screen + pixels, a vertex must come to snap (10 by default, 1 to 100): + `edit.snapping={"on": true, "tolerancePx": 12, "edge": false}` (without `"on"` it toggles + snapping, even when other options are supplied). `edit.snap={"lon":12.5,"lat":41.9}` queries + the current snap target without editing a feature. - **Settings**: **Show Vertices** marks the vertices of the selected features even when Edit Vertices isn't on (`edit.settings={"showVertices": true}`). -- **Elevation**: new features get a `z` value, constant or sampled from a raster surface. +- **Elevation**: new features get a `z` attribute, constant or sampled from a raster surface at + the feature's label point; this is not a change to its geometry's coordinates. - **Ground to grid**: a scale factor and rotation applied to entered distances and directions (Move, and `edit.traverse`, which draws a line or polygon from distances and bearings). - **Topology**: rules (must not overlap, must not have gaps, must not self-intersect, must not @@ -82,11 +93,17 @@ attribute edits, Calculate Field): ## Clipboard and pop-ups -Copy (++ctrl+c++) and Cut (++ctrl+x++) take the selected features. Paste (++ctrl+v++) adds them to -the layer selected in Layers, matching fields by name; Paste Special adds them as a new layer. -Copied features also go on the system clipboard as GeoJSON, so they can be pasted into another -RustGIS window or a text editor, and GeoJSON copied elsewhere pastes into RustGIS. Copy Path copies -the selected layer's data source path. +Copy (++ctrl+c++) and Cut (++ctrl+x++) take selected features from one layer: the active layer +if it has a selection, otherwise the first layer with selected features. Paste (++ctrl+v++) +adds them to the layer selected in Layers, matching fields by name and converting values to the +destination field types; unmatched fields stay empty. Geometry types must match. **Paste Special** +adds them as a new layer instead. Copied features also go on the system clipboard as GeoJSON, so +they can be pasted into another RustGIS window or a text editor, and GeoJSON copied elsewhere +pastes into RustGIS. **Copy Path** copies the selected layer's data source path. + +Whole-layer copying is separate: use **Copy Layer** or **Copy Group** and **Paste Layer** in the +Layers pane's context menus to preserve symbology and other layer settings, including when +moving layers between maps. Pop-ups (Home › Navigate) choose which layers a click with Explore identifies: the topmost layer under the click, every visible layer, only selectable layers, only the layer selected in Layers, or diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index e248f1e1c..970e8d62f 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -4,13 +4,13 @@ This guide walks through RustGIS by task. Each page covers one part of the app: | Page | What it covers | | --- | --- | -| [Data](data.md) | Vector and raster formats, adding data, large files, raster display, project items | +| [Data](data.md) | Adding files, databases, saved connections, Data Sources, large files, rasters, Zarr and project items | | [Web Data](web-data.md) | Public web services and catalogs: STAC, Overture Maps, OpenStreetMap, NASA, open-data portals, satellite embeddings | | [Supported Data Formats](data-formats.md) | Every format RustGIS reads and writes, how to add it, and its limits | | [Maps and Basemaps](maps.md) | Basemaps and web tile layers, projections, the 3D globe, view effects and navigation | | [Cartography](cartography.md) | Symbology, labeling, layer effects and graphics | | [Editing](editing.md) | Creating and modifying features, data design, the clipboard and pop-ups | -| [Analysis](analysis.md) | Selection and SQL queries, geoprocessing, the GeoLibre tools, guided workflows, imagery | +| [Analysis](analysis.md) | Selection and SQL queries, geoprocessing, the GeoLibre tools, guided workflows, imagery, data cubes and time series | | [Layouts and Reports](layouts.md) | Page layouts, map series, reports, presentations and notebooks | | [Sharing](sharing.md) | Exports, packages and open-format web maps, layers, styles and scenes | | [Workspace](workspace.md) | Linked views, the Browser view, animation, profiles, offline areas, customizing the ribbon | @@ -23,8 +23,8 @@ This guide walks through RustGIS by task. Each page covers one part of the app: ## The window -- **Ribbon**: tabs of commands grouped by task. The **Project** button opens the backstage (new, - open, save, About). +- **Ribbon**: tabs of commands grouped by task. The **Project** button opens the backstage + (Start, New, Open, Save, Save As and About). - **Quick Access Toolbar** (top left): New, Open, Save, Undo and Redo, plus any commands you add under **Settings › Customize › Quick Access**. - **Layers pane** (left): the map's layers in drawing order, with their legends. Tick a layer to @@ -43,8 +43,8 @@ This guide walks through RustGIS by task. Each page covers one part of the app: outlined, is the one the Style and Data tabs act on. Right-click one of them to zoom to them all, show or hide them, allow or block selecting their features on the map, move them to the top or bottom of the drawing order (one in a group, to the top or bottom of its group), put them in - a group or take them out of one, or remove them (++delete++ does too); a layer the filter hides - is left out. A new group made from the menu asks for its name. In scripts, `layer.select` takes + a group or take them out of one, or remove them; a layer the filter hides is left out. + A new group made from the menu asks for its name. In scripts, `layer.select` takes a list and a `mode` (`replace`, `toggle` or `extend`), and the commands that remove, show, move, group or zoom to a layer take a list in `layer`, or the picked layers when it is left out. **Group layers** to keep related ones together, such as one dataset drawn as several layers @@ -68,20 +68,23 @@ This guide walks through RustGIS by task. Each page covers one part of the app: `layer.copy` and `layer.paste`. The Style and Data tabs act on the layer selected here. The tabs at the top of the left pane switch it between **Layers**, **Browser** and **Data Sources** (also in **View › Windows**). + The planet button above the list switches the map between the Earth and other celestial bodies. - **Map view**: one tab per map and layout. The buttons at the top right zoom in, zoom out and go to the full extent; the north arrow and scale bar sit in the corners (on a rotated or tilted map, click the north arrow to face north and look straight down); tile credits at the bottom right. -- **Panes** (right): Tools (geoprocessing), Symbology, History, Details, Editing, Workflows, - Script, the AI Assistant and more. Open them from **View › Windows** or the commands that use +- **Panes** (right): Tools (geoprocessing), Symbology, Labeling, History, Details, Bookmarks, + Editing, Workflows, Script, SQL, the AI Assistant and more. Open them from **View › Windows** or the commands that use them. Each pane opened gets a tab at the top, in the order opened: click a tab to show its pane, and its **✕** to close it (the next tab shows; closing the last hides the side). Right-click a tab to close it, the others or all; the **✕** at the header's end closes every pane. Tabs that don't fit are listed under **»**. A ribbon button for a pane shows it when it is open but not in view, and closes it when it is. **Pane Sets** keep the tabs open. - **Status bar**: the scale (type one, or pick from the list), the zoom level, bearing and pitch - (click them to face north and look straight down again), the coordinate system (click it to - change the projection), the pointer's coordinates (click them to switch between decimal - degrees and DMS), the selection count and the draw time. + (click them to face north and look straight down again), the pointer's coordinates (click them + to switch between decimal degrees and DMS), the coordinate system (click it to change the + projection), the selection count (with a button to clear it), the latest notice, the last draw + time, the frame rate and a **GPU**/**CPU** renderer switch. While agents control the desktop app, + it also shows the MCP relay or file bridge. **Command Search** (++ctrl+k++, or the box at the top right) finds commands and geoprocessing tools by name. Commands with an interactive entry point open their dialog, file picker or pane @@ -101,31 +104,36 @@ ribbon, arrows at its ends scroll it. | Tab | Groups | | --- | --- | -| **Home** | Project (New Map, New Layout, Toolbox, Import, Add Folder, Templates), Navigate (Pan, Bookmarks, extents, Zoom In/Out, Go To XY, Identify), Add (Basemap, Add Data, [Web Data](web-data.md)), Selection, Inspect (Measure, Locate, Zoom Box, Infographics) | -| **Data** | Layer Templates, Definition Query, Table, Selection, Data Design, Archiving, Relationship, Export (for a raster layer: Information, Export), Offline, Feature Cache | -| **Style** | For the layer selected in Layers: visibility range, transparency, symbology, drawing effects, extrusion and labels (labels, label range, placement, label class, text symbol) for a vector layer; visibility range, transparency, swipe, raster effects, rendering and enhancement for a raster layer; Styles | -| **Edit** | Manage Edits (Edit, Save, Undo), Snapping, Features (Create, Modify, Delete), Tools, Elevation, Corrections, Graphics, Graphics and Text, Clipboard | -| **Analyze** | Geoprocessing (History, Tools, ModelBuilder, Script, SQL, Environments), Tasks (the Tasks pane; Task: Record Task, New Task From History, Import Task, run a task), AI (AI Assistant), Portal (Feature and Raster Analysis), Workflows, Raster (Raster Functions, Function Editor), the tool gallery, Selection, Notebook, the imagery groups (Ortho Mapping, Reality Mapping, Alignment, Image Analysis, Image Classification, Imagery Sharing, Mensuration, Imagery Tools) | -| **View** | Display, View (Globe, Projection, Global, Local), Effects, Scene, Windows, Labeling, Navigation, Link, Workspace, Animation, Time, Profile | -| **Publish** | While a layout is open, its groups first (Page, Map Frames, Map Surrounds, Text, Export); then Report, Presentation, Output (Export Map, Print, Capture), Package, Share As, Layout, Save As, Thumbnail | -| **Plugins** | The Plugin Manager, plus a tab for each [plugin](../plugins.md) that adds one | -| **Settings** | Customize (ribbon tabs, Quick Access Toolbar), Language (interface language and worldview), Start-up (start with the sample, a blank map, the last project or one you choose, projection, extent) | -| **Help** | Performance (Diagnostic Monitor), Learn, Help (About, Check for Updates on the desktop, Command Search, Shortcuts) | +| **Home** | Project (New Map, Toolbox, Import Project, New Layout, Browser, Add Data, Add Folder, Import, Templates), Navigate (Pan, Bookmarks, Full Extent, Zoom In/Out, Previous/Next, Go To XY, Planar/First-Person, Identify, Find Place, Directions), Add (Basemap, Add Data, [Web Data](web-data.md)), Selection (Select, Select By Attributes, Select By Location, Clear, Zoom To, Attributes), Inspect (Measure, Locate, Zoom Box, Infographics) | +| **Data** | Layer Templates; for a feature layer: Definition Query, Table, Selection, Data Design, Archiving, Relationship and Export; for a raster layer: Information and Export; then Offline and Feature Cache | +| **Style** | For the layer selected in Layers. A feature layer: Visibility Range, Effects, Drawing, Drawing Effects, Extrusion for polygons (with Faces and Light and Shadow once on), Layer, Labels, Label Range, Label Placement, Label Class and Text Symbol. A raster layer: Visibility Range, Effects, Compare, Raster Effects, Rendering, Enhancement and Layer. Both: Styles | +| **Edit** | Manage Edits (Edit, Save, Undo, Redo, Status, Settings, Templates, Topology), Snapping, Features (Create, Delete, Edit Vertices, Modify), Tools, Elevation, Corrections, Graphics, Graphics and Text, Clipboard | +| **Analyze** | Geoprocessing (History, Tools, Model Builder, Script, SQL, Environments), Tasks (the Tasks pane; Task: Record Task, New Task From History, Import Task, run a task), AI (AI Assistant), Portal (Feature Analysis, Raster Analysis), Workflows, Raster (Raster Functions, Function Editor), Tools (the tool gallery), Selection, Notebook, the imagery groups (Ortho Mapping, Reality Mapping, Alignment, Image Analysis, Image Classification, Imagery Sharing, Mensuration, Imagery Tools) | +| **View** | Display (Graticule, Dark Mode, GPU Rendering), View (Globe, Projection, Global; Local on the desktop), Effects, Scene, Windows, Labeling, Navigation, Link, Workspace, Animation, Time (Time Slider), Profile (Profile; Time Series on the desktop) | +| **Publish** | While a layout is open, its groups first (Page, Map Frames, Map Surrounds, Shapes, Text, Export); then Report, Presentation, Output (Export Map, Export Features, Print Map, Capture To Clipboard, Report, Presentation, Export To 3D), Package, Share As, Layout, Save As, Thumbnail | +| **Plugins** | Plugins (Plugin Manager, Install From File, Browse Plugins), plus a group for each enabled [plugin](../plugins.md) with commands or tools; a plugin with its own tab adds that tab instead | +| **Settings** | Customize (Ribbon, Quick Access), Language (interface language and worldview), Start-up (Start With, Projection, Extent) | +| **Help** | Performance (Diagnostic Monitor), Learn (Learning Resources), Help (About, Check for Updates on the desktop, Command Search, Shortcuts) | The tabs stay the same whatever is selected: Style and Data act on the layer selected in Layers. ## Projects A project (`.rgis`) holds maps, layouts, reports, presentations, notebooks, styles, tasks and -models. Vector data added from files is stored inside the project, so a project file is -self-contained; rasters are referenced by path on the desktop and stored inside the project in -the web app. **Project › New** starts a blank map or the World Atlas sample; **Save** and **Save -As** write the project, named after its file; a project opened from a file is named after that -file too. See [Project items](data.md#project-items) for the other files -RustGIS reads and writes. +models. Vector data loaded whole is stored inside the project. On the desktop, large files read +in place, rasters and live databases are referenced by path or address; files uploaded in the +web app are embedded. Saved web addresses drop passwords, keys and tokens. A project package +(`.rgispkg`) also embeds linked rasters within a size limit, while large files read in place, +database and web layers stay linked; it opens as a new, unsaved project. -Every change is a command, and every command can be undone (++ctrl+z++) and redone -(++ctrl+y++). +On the backstage's **Start** or **New** page, choose a blank **Map**, the **World Atlas** sample or +**Open…**. **Save** and **Save As** write the project, named after its file; a project opened +from a file is named after that file too. See [Project items](data.md#project-items) for the +other files RustGIS reads and writes. + +Changes to the project are commands, and each can be undone (++ctrl+z++) and redone +(++ctrl+y++). Undo keeps the last 40 changes; starting or opening another project clears it. +Exports and other files already written are not undone. ## Keyboard shortcuts @@ -141,8 +149,9 @@ Every change is a command, and every command can be undone (++ctrl+z++) and redo | ++ctrl+equal++ / ++ctrl+minus++ | Zoom in / out | | ++home++ | Full extent | | ++esc++ | Back to Explore; cancel a sketch | -| ++delete++ | Delete selected features | +| ++delete++ | Delete selected features; with Edit Vertices, the chosen vertex; in a layout, the selected element | | ++ctrl+d++ | Duplicate the selected layout element | +| Arrow keys / ++shift++ + arrow keys | Nudge the selected layout element / nudge it further | | ++ctrl+c++ / ++ctrl+x++ / ++ctrl+v++ | Copy / Cut / Paste features | On macOS, ++cmd++ takes the place of ++ctrl++. **Help › Shortcuts** lists them all in the app, diff --git a/docs/user-guide/layouts.md b/docs/user-guide/layouts.md index 1676eebed..469c108b5 100644 --- a/docs/user-guide/layouts.md +++ b/docs/user-guide/layouts.md @@ -7,9 +7,10 @@
A page layout with a map frame, title, legend, north arrow and scale bar.
-Home › New Layout adds a page layout (Letter to A0, landscape or portrait) with a map frame, -title, legend, north arrow, scale bar and a dynamic credits line. Layouts open as tabs next to -the maps; while one is open, its groups lead the Publish tab: +Home › New Layout adds a page layout (Letter, Legal, Tabloid or A5 to A0, landscape or portrait) +with a map frame, title, legend, north arrow, scale bar and a dynamic credits line. The command +`layout.new` also accepts custom `width` and `height` in millimetres and `"template": "blank"`. +Layouts open as tabs next to the maps; while one is open, its groups lead the Publish tab: - **Publication layouts**: Home › New Layout › **Publication Layout** builds a finished sheet or figure of the map view in one step, with `layout.publication` behind it: @@ -26,12 +27,12 @@ the maps; while one is open, its groups lead the Publish tab: thesis (160 × 225 or 160 × 110 mm): the map edge to edge with a line of its projection and datum under it, at 7 pt. A figure's title and sources belong in its caption, so they are left out, and the result says so. - - **The map**: the `extent` given (or the view) fitted at a round scale, two significant - figures, rounded up so it still fits within the strips the graticule's labels take inside - the frame. A graticule has its step picked from the extent, at - most five lines across (2° for a state, 15′ for a city), and labels on every edge of a sheet. + - **The map**: the `extent` given (or the view) fitted north-up, without tilt, at a round scale, + two significant figures, rounded up so it still fits within the strips the graticule's labels + take inside the frame. A graticule's step follows the extent (2° for a state, 15′ for a city), + with labels on every edge of a sheet. The north arrow, the legend (sized to what it lists), the scale bar with the numeric scale - under it and the locator sit in the frame's corners, on pale boxes, clear of the graticule's + under it and the locator sit in the frame's corners, clear of the graticule's labels. Each takes the corner where it covers the least of what the map draws (its layers and labels, not the basemap), so a legend leaves a corner the data fills; when it makes no difference they keep theirs: the locator at the top left, the arrow at the top right, the @@ -39,9 +40,14 @@ the maps; while one is open, its groups lead the Publish tab: corners, one warning names those left on the data, to move by hand if that matters (data under every corner alike, such as a context layer filling the frame, isn't worth a warning); two that overlap (a frame too small) are said too. + The top visible raster gets a colorbar beneath the vector legend if its renderer has a colour + scale; RGB and untinted hillshade do not. If the legend or colorbar cannot fit, the result + warns about clipping or resizing. A streamed layer still loading gets a fixed-size legend + to adjust by hand. - **Decimal comma**: `"decimal": ","` prints the graticule, scale bar and colorbar numbers and - `{scale}` with a decimal comma and points between thousands (`0,05`, `1:6.300.000`), as maps - in Portuguese, Spanish, French or German expect. `layout.page` sets it on any layout. + `{scale}` with a decimal comma and points between thousands (`0,05`, `1:6.300.000`). + `layout.page={"decimal": ","}` sets it on any layout; `"."` restores decimal points. + This changes generated numbers, not numbers typed into text. - **Texts**: the title, subtitle, footer lines and authorship are measured with the layout's font (the title and headings bold, as drawn) and wrapped to their box, so none runs past it, and each box grows to hold them. `{projection}`, `{datum}` and @@ -49,9 +55,9 @@ the maps; while one is open, its groups lead the Publish tab: tokens stay live. The reference defaults to the projection, datum and graticule, and the authorship to "Made with RustGIS · {date}" (`"author": ""` leaves it out). `headings` renames the two columns. - **The locator** (at the frame's top left unless the data is there): `"locator": "globe"` (a - sheet's default) adds a map of the map's polygon layers that show on the globe (those held in - the project: a large file streamed in place, which would be read again for the whole world, is - left out), unlabelled, over the map's basemap (not its web tiles), in a round frame, the globe + sheet's default) adds a separate globe map with simplified copies of the map's visible polygon + layers held in the project. Streamed layers, rasters, points, lines and labels are left out. + It uses the map's basemap, not its web tiles, in a round frame, the globe nearly filling it and the area outlined in red; `"inset"` shows the surrounding region (about four times the area across) on the map itself, outlined the same way; `"none"` (a figure's default) leaves it out. The legend stops short of it; a frame with no room for a legend beside @@ -71,8 +77,9 @@ the maps; while one is open, its groups lead the Publish tab: in darker blocks with amber headings. `"accent"` (`#RRGGBB`) colours the band, the panel's tint, the headings and rules, and a poster's page (a deep brown, green or blue by default). A figure leaves the style to its caption. - - Every element is an ordinary one, to move or restyle afterwards. Running it again with - another `profile` makes another layout of the same map. + - Every element is an ordinary one, to move or restyle afterwards. The command returns element + IDs for `layout.update`, and `warnings`. Running it again with another `profile` makes + another layout of the same map. ``` layout.publication={"profile": "a4-portrait", "extent": [-46.8, -11.6, -38.5, -2.2], @@ -138,9 +145,10 @@ the maps; while one is open, its groups lead the Publish tab: renamed or removed (it is kept by name, like a table or chart frame's layer) until another is picked. From a command: `layout.addElement={"kind":"colorbar","layer":"DEM","title":"Elevation (m)","orientation":"vertical","ticks":3}`. -- **Shapes**: **Rectangle**, **Circle**, **Ellipse** and **Line** (horizontal, vertical or - either diagonal) add a shape in the middle of the page, to frame, highlight or divide parts of - it. A circle is an ellipse in a square box. The properties set a shape's fill, outline and +- **Shapes**: **Rectangle**, **Circle**, **Ellipse** and **Line** add a shape in the middle of + the page, to frame, highlight or divide parts of it. The Line menu starts a horizontal, vertical + or diagonal line; turn it to any angle with its rotation handle or properties. + A circle is an ellipse in a square box. The properties set a shape's fill, outline and outline width (0 for none; a fill with no opacity leaves only the outline), a rectangle's rounded corners (`"radius"`, in mm), and a line's colour, width, arrowheads and angle. From a command: @@ -185,16 +193,17 @@ the maps; while one is open, its groups lead the Publish tab: series still turns its pages. **Duplicate** makes an unlocked copy, and a map frame isn't deleted while one of its surrounds is locked. Scripts lock and unlock with `layout.update` `{"locked": true}` or `{"locked": false}`. -- **Rulers and guides**: rulers along the top and left show millimetres from the page's corner. +- **Rulers and guides**: rulers start on along the top and left and show millimetres from the page's corner. Drag out of a ruler to add a guide, drag a guide to move it, and drag it back onto a ruler to remove it. Guides are saved with the layout but never printed or exported. **Clear Guides** removes them all, and **Rulers** in the Page group hides the rulers. -- **Snap**: a dragged or resized element snaps to the guides, the page's edges and centre, and +- **Snap** starts on: a dragged or resized element snaps to the guides, the page's edges and centre, and the other elements' edges and centres, and the line it snapped to is highlighted. Hold Alt to place an element freely, or turn **Snap** off in the Page group. -- **Export** writes PDF (every map series page) or PNG; **Print** opens the PDF in the system - viewer; **Save Layout File** and **Import Layout** move layouts between projects - (`.rgislayout`). +- **Export** writes PDF (every map series page by default, up to 500) or PNG of the current page; + `layout.export` accepts `dpi` and `"allPages": false` for just the current PDF + page. **Print** opens the PDF in the system viewer on desktop; **Save Layout File** and + **Import Layout** move layouts between projects (`.rgislayout`). ## Reports @@ -226,11 +235,14 @@ previews, edits and plays it. coloured page, an **Image** page a picture with a caption, and a **Video** page a video file or URL with an optional poster picture. - Each page has a title, speaker notes and a duration (for **Play**), and can move up or down. -- **Publish › Presentation › Export Presentation** writes a self-contained HTML slideshow (arrow +- **Publish › Output › Presentation › Export Presentation** writes an HTML slideshow (arrow keys, `N` for notes, `P` to play; videos play in place), a PDF, or the first page as PNG. - **Save As Presentation File** keeps it to import into other projects. + Video sources remain file paths or URLs, so keep them accessible beside the exported slideshow. + **Save As Presentation File** in the same menu keeps it to import into other projects (`.rgispres`). **Analyze › New Notebook** starts a notebook of text and command cells; **Add and Open** opens a RustGIS (`.rgisnb`) or Jupyter (`.ipynb`) notebook. A command cell runs one command per line (`id {json}`, as in scripts) and shows each command's result beneath it. Notebooks save in either format. +The cells contain RustGIS commands, not Python, even in an `.ipynb` file. Notebooks opened from +a file or an existing project cannot run commands that read or write files or run other scripts. diff --git a/docs/user-guide/maps.md b/docs/user-guide/maps.md index 3057ee952..135dc8468 100644 --- a/docs/user-guide/maps.md +++ b/docs/user-guide/maps.md @@ -1,7 +1,10 @@ # Maps and Basemaps -A project holds one or more maps (**Home › New Map**), each with its own layers, basemap, -projection, bookmarks and view settings. Maps open as tabs above the map view. +A project holds one or more maps (**Home › Project › New Map**), each with its own layers, basemap, +projection, bookmarks and view settings. Maps open as tabs above the map view. In the Browser, +right-click a map to rename, duplicate or delete it; the project always keeps at least one map. +For selecting, grouping, reordering and copying layers between maps, see +[The window](index.md#the-window). ## Navigating @@ -12,8 +15,8 @@ latitude. **New Bookmark…** saves the current view under a name, with an optio the place. The Bookmarks menu removes a bookmark with the button beside it, and exports or imports them as a `.rgisbookmarks` file. Imported bookmarks show the same place on a map in another projection; places that projection can't show are skipped. Type a scale in the status bar to jump -to it. More navigation tools are under [View -effects](#view-effects) below. +to it. Beside it are the zoom level, bearing and pitch; click them to face north and look straight +down again. More navigation tools are under [View effects](#view-effects) below. **Measurement units.** In **Home › Inspect › Measure**, choose **Metric** or **Imperial**. The choice is remembered and updates existing distance and area measurements, the live @@ -92,7 +95,8 @@ at its scale and **Pan To** centres it at the current scale; a bookmark of anoth in view to the same place. **Update** saves the current view into it (only on its own map, in view), and **Delete** removes it. The chevron shows its description: click it to write or edit it. Double-click a name to rename it, and right-click it to rename, describe, update, move it up or -down, or delete it. Everything the pane does is a `bookmark.*` command and undoes with ++ctrl+z++. +down, or delete it. Bookmark edits run `bookmark.*` commands and undo with ++ctrl+z++; +**Zoom To** and **Pan To** change the view without adding an undo step. **Find Place** (`map.findPlace`) looks up a place or an address by name with OpenStreetMap's [Nominatim](https://nominatim.org) (or a [Photon](https://photon.komoot.io) server, or a @@ -155,6 +159,9 @@ apart; coordinates to FOSSGIS's Valhalla), so the AI Assistant asks before runni rustgis-cli run --sample --cmd 'map.route={"from":"Brandenburg Gate, Berlin","to":"Alexanderplatz, Berlin","mode":"bike"}' ``` +On the web, plan routes in the Directions pane; `map.route` cannot fetch a route or look up +place names there. On the desktop, its optional `url` selects another Valhalla server. + ## Basemaps
@@ -172,9 +179,10 @@ Ocean, Atlas, Blank), which need no network, and web tile layers over them: a built-in style that knows the OpenMapTiles, Protomaps and Shortbread schemas. - **PMTiles archives** (`.pmtiles`) of raster or vector tiles: a web address, read a tile at a time by byte ranges (the server must allow range requests, and CORS for the web app), or on the - desktop a file (**Tile Archive File…**, Add Data, or drop it on the window). Uncompressed, - gzip, Brotli and Zstandard archives are read. The map moves to what the archive covers once its - header is read, unless it covers the whole world, and right-clicking its row in Contents gives + desktop a file (**Tile Archive File…**, Add Data, or drop it on the window). Version 3 + archives are read, uncompressed or with gzip, Brotli or Zstandard. The map moves to what the + archive covers once its header is read, unless it covers the whole world, and right-clicking + its row in Layers gives **Zoom To Layer**. A vector archive that is a basemap is drawn in the built-in style; any other is drawn with each of its layers in a colour of its own. - **MBTiles files** (`.mbtiles`) of raster or vector tiles, drawn as PMTiles archives are: on @@ -213,6 +221,13 @@ let the terrain show through. Overlays (labels, roads) stay on top of the basema removes the web basemaps but keeps the overlays. Tile layers are saved with the project, and their credits are shown in the corner of the map and drawn on exports. +Use Explore to click features in vector PMTiles, MBTiles or MVT layers added as data and see +their source-layer names and attributes in a pop-up. This works with **Topmost Layer** or +**Visible Layers** pop-up mode, not with catalog basemaps or MapLibre styles. Tiles are not +selectable or editable; a tile not yet loaded may need another click once it arrives. +**Reload** in the Basemap gallery (`map.reloadWebLayers`) clears cached tiles and styles and +fetches them again. + Tiles load in the background and the map fills in as they arrive, with coarser tiles standing in meanwhile; exports and `rustgis-cli` wait for them. On the globe and in other projections the tiles are reprojected. From scripts: `map.addTiles`, `map.removeTiles`, `map.setTiles`, @@ -274,9 +289,9 @@ System** dialog, which also takes a projected EPSG code (such as `3035`, `32633` `ESRI:102033` for South America Albers Equal Area Conic, or `ESRI:54030` for World Robinson). A code whose projection method or datum grid RustGIS can't use (Polyconic such as `5880`, Cassini, oblique Mercator, or a NAD27 code that needs a grid) is refused with the reason, rather than drawn -in another projection. Of Esri's projected systems, RustGIS knows the 1,986 it draws within a -metre of PROJ; the rest, such as World Miller (`ESRI:54003`), are refused as unknown codes. The -presets: +in another projection. RustGIS supports a subset of Esri's projected systems, checked against +PROJ at representative points; unsupported codes such as World Miller (`ESRI:54003`) are refused +as unknown codes. The presets: | Preset | Kind | | --- | --- | @@ -286,6 +301,7 @@ presets: | North / South Polar Stereographic | Polar | | Europe Equal Area (ETRS89 LAEA), USA Contiguous Albers Equal Area | Regional, equal-area | | Albers Equal Area Conic, Lambert Conformal Conic | Conic, for a mid-latitude region | +| VN-2000 / TM-3 (Vietnam), VN-2000 / UTM zones 48N and 49N | Vietnam, transverse Mercator | The world and polar presets take a central meridian, and the two conics also their standard parallels: pick the preset in the **Coordinate System** dialog and set **Central meridian** (and @@ -300,6 +316,14 @@ to it) make no cone and are refused, as is a value a preset doesn't take. The tw keep their standard definitions. A conic's area of use runs 35° beyond its parallels and 90° either side of its meridian. +For Vietnam, **VN-2000 / TM-3** takes a central meridian (105°E by default), with province +shortcut buttons in the Coordinate System dialog. A province alias such as `vn2000:hanoi` +sets its meridian; scripts can use +`map.projection={"type":"vn2000tm3","lon_0":105.75}`. The UTM presets use fixed zones: +`vn2000Utm48` (`EPSG:3405`) and `vn2000Utm49` (`EPSG:3406`). These systems include the +seven-parameter datum shift to WGS 84. A bare `VN-2000` is ambiguous and is refused: name TM-3 +or a UTM zone. + Layers are projected on the fly. A feature that crosses the edge of a world projection (the meridian opposite its central one, such as 90°E in Equal Earth Americas) is cut there and drawn on both sides of the map. Each projection has an area of use: polar projections show @@ -353,29 +377,23 @@ Graticule** (`map.graticule`) draws latitude and longitude lines over the basema **Global** shows a globe; **Local** a flat view centred where you are (azimuthal equidistant; not in the web app, which doesn't include that projection). -**Wireframe** draws polygons as outlines, **Depth Priority** lifts a layer above others whatever -its order, **View Clipping** hides features beyond a distance from the centre, and **Speed -Display** draws without antialiasing. The **Navigator** adds a compass, zoom buttons and a pan +**Wireframe** draws polygons as outlines; **Raise Priority** and **Lower Priority** change a +layer's drawing priority independently of its order. **View Clipping** hides features beyond a +distance from the centre, and **Speed Display** draws without antialiasing. +The **Navigator** adds a compass, zoom buttons and a pan pad; **Camera** shows and sets the position and scale; the **Magnifier** shows twice the map under the pointer. On the Home tab, **First-Person** navigation walks with W A S D and flies up and down with Q and E. The interface follows the system's light or dark setting; **View › Display › Dark Mode** switches it by hand. -**View › Display › GPU Rendering** (beta) draws the flat map on the GPU instead of the CPU: vector -layers are tessellated once and re-projected per frame, so panning and zooming stay smooth on -large datasets. Raster tile layers (XYZ, TMS and WMS tiles, and raster PMTiles, MBTiles and -styles) are placed again for every frame the map shows, from textures uploaded once and kept in -a fixed pool, as MapLibre draws its tiles: panning and zooming never redraw the map for them, the -tiles of each zoom are placed as the zoom passes, and parents and cached children stand in until -they arrive (the view's tiles a few levels up are fetched too, so a coarse picture is always at -hand). Vector tile styles and blended tile layers are still drawn as a picture of the -view, shifted and scaled while the map moves. GPU Rendering is off by default, and the setting is -remembered across sessions (`ui.map.gpu` is the command; `RUSTGIS_GPU=1` forces it on for -development). The globe, view effects and the colour-vision simulator always render on the CPU, -and exports are unaffected — the choice only changes the on-screen flat map. A turned or tilted -map is drawn on the GPU too, in perspective with its fog and sky, when everything it shows is -drawn there: no tiles, rasters, see-through, masked, swiped or blended layers, aggregations, -graphics, stacked line strokes, labels with icons, view effects or colour-vision simulation, and -extrusions only on a tilted map; otherwise it stays on the CPU. Extruded layers stand up on a -tilted GPU map with a depth buffer, as MapLibre's fill extrusions do, so features that interlock -or overlap are drawn right. +**View › Display › GPU Rendering** (beta) draws supported parts of the flat map on the GPU +for smoother navigation on large datasets. Unsupported geometry and layer effects fall back +to the CPU. Raster tiles and opaque vector tile styles can be drawn directly on the GPU on a +north-up Web Mercator map; translucent or blended tiles use cached pictures during navigation. + +GPU Rendering is off by default and remembered across sessions (`ui.map.gpu` is the command; +`RUSTGIS_GPU=1` forces it on for development). The globe, view effects and colour-vision +simulation stay on the CPU, and exports are unaffected. A rotated or tilted map uses the GPU +only when every visible layer can be drawn there: tiles, rasters, terrain, transparency, +masking, swipe, blending, aggregation, graphics, stacked strokes or labels with icons keep it +on the CPU. Extrusions use the GPU only on a tilted map. diff --git a/docs/user-guide/sharing.md b/docs/user-guide/sharing.md index 20479ab7a..e412098b1 100644 --- a/docs/user-guide/sharing.md +++ b/docs/user-guide/sharing.md @@ -2,35 +2,56 @@ ## Exporting -**Publish › Output › Export Map** writes the map view as a PNG at any DPI, with labels, the scale -bar and tile credits (`share.exportMap` on the command line); **Print Map** sends it through -the system's PDF viewer, **Capture To Clipboard** copies it, and **Export To 3D** writes a layer -as a Wavefront OBJ model with polygons extruded by a field. **Data › Export** (or the Export -Features tool) writes a layer to GeoJSON, CSV, GeoPackage, GeoParquet, Shapefile, FlatGeobuf, KML, -KMZ or GPX (see [Writing](data-formats.md#writing)), **Data › Export** a raster to GeoTIFF, and -**Data Interop** converts layers to KML, GPX or CSV with WKT. Layouts, reports and presentations -export to PDF from their own tabs and panes (see [Layouts and Reports](layouts.md)). +**Publish › Output › Export Map** writes the map view as a PNG with labels, the scale bar and +tile credits. The ribbon exports at 192 DPI; `share.exportMap` accepts `width` and `height` +in pixels and a `dpi` value (96 by default). **Print Map** opens a PDF in the system viewer +on desktop; **Capture To Clipboard** copies a map image. **Export To 3D** writes a layer as a +Wavefront OBJ model with polygons extruded by a field. + +**Data › Export** (or `share.exportLayer`, **Export Features**) writes a layer to GeoJSON, CSV, +GeoPackage, GeoParquet, Shapefile (including a zip), FlatGeobuf, KML, KMZ or GPX (see +[Writing](data-formats.md#writing)). GeoPackage, Shapefile and FlatGeobuf use the layer's source +coordinate system by default; the `crs` parameter chooses another. A streamed layer exports its +whole source, not just the features loaded for the view. `share.exportTable` exports a standalone +table. **Data › Export** also writes rasters to GeoTIFF, and **Data Interop** converts layers to +KML, GPX or CSV with WKT. Layouts, reports and presentations export from their own tabs and panes +(see [Layouts and Reports](layouts.md)). Packages and project item files are described under [Project items](data.md#project-items). ## Sharing as open formats -**Publish › Share As** writes open-format files that work without a server, to a chosen file or the -first **data store** (a folder added with **Add Data Store…** in the Shared Items pane): - -- **Web Map**: a self-contained HTML page with the map's layers (rasters as images), pan and zoom, - a layer list and pop-ups. -- **Web Layer**: the selected layer as a static OGC API – Features collection, with its style and - a viewer page, in a zip. **Geodata Service** does the same for every layer and table. -- **Web Style**: the selected layer's symbology as a MapLibre style (unique values and classes as - expressions). -- **Locator**: the selected layer's names as a place-search file; `locator.find` searches it and - can zoom to the best match. -- **Web Tool**: the last model as a tool file, which `model.import` adds to another project. -- **Web Scene**: the map in 3D as glTF, polygons extruded by their layer's extrusion setting, - with a camera that looks at the map's centre from its bearing and pitch. -- **Web Link Chart**: the features joined by the map's relationships, as a node-link page. - -**Publish › Share As › Jobs** (the Shared Items pane) lists every share; **Save Web Map**, **Replace Web -Layer**, **Save Web Scene** and **Save Web Link Chart** update a share in place, and **Upload File** -copies a file into a data store. +**Publish › Share As** writes open-format files to a chosen file on desktop, or downloads them +in the browser. The Share As commands accept a `path`; without one they use the first +**data store** (a folder added with **Add Data Store…** in the Shared Items pane). + +- **Web Map** (`share.webMap`): an HTML page of visible layers (rasters as embedded images), + pan and zoom, a layer list and pop-ups. It uses a flat Mercator view, not the project's + projection, and does not include the basemap's web tiles. +- **Web Layer** (`share.webLayer`): the selected layer as a static OGC API – Features collection, + with its style and a viewer page, in a zip. **Geodata Service** (`share.geodataService`) writes + the map's non-raster, non-graphics layers and the project's standalone tables as collections. + These are files, not running services with query or editing endpoints. +- **Web Style** (`share.webStyle`): the selected layer's symbology as a MapLibre style (unique + values and classes as expressions). Labels use only the Default label class, without its SQL + clause; a hidden Default class exports no labels. The same limit applies to a Web Layer's style. +- **Locator** (`share.locator`): the selected layer's names as a `.rgisloc` place-search file; + text fields are searched by default, or specify `fields`. `locator.find` searches it and can + zoom to the best match with `"zoom": true`. +- **Web Tool** (`share.webTool`): the last model as a `.rgistool` file, which `model.import` adds + to another project; `model` chooses a model by name. +- **Web Scene** (`share.webScene`): the visible vector layers as glTF, polygons extruded by their + layer's extrusion setting, with a camera that looks at the map's centre from its bearing and pitch. +- **Web Link Chart** (`share.linkChart`): the features joined by the map's relationships as a + node-link HTML page; `relationship` limits it to one relationship. + +**Publish › Share As › Jobs** (the Shared Items pane) lists desktop shares; **Save Web Map**, +**Replace Web Layer**, **Save Web Scene** and **Save Web Link Chart** update a share in place +(`share.saveWebMap`, `share.replaceWebLayer`, `share.saveWebScene`, `share.saveWebLinkChart`). +The `item` parameter chooses the share by ID or name; omitting it uses the latest of that kind. +**Upload File** (`share.upload`) copies a file into a data store; it does not upload to a remote server. + +After opening a project, register its data stores again before writing to them, and use +**Share As** to choose each item's destination again before updating it. Saved share records do +not grant permission to overwrite their paths. Undo changes the project record, not files +already written. diff --git a/docs/user-guide/web-data.md b/docs/user-guide/web-data.md index b8a1915ba..ebecf7907 100644 --- a/docs/user-guide/web-data.md +++ b/docs/user-guide/web-data.md @@ -3,9 +3,11 @@ **Home › Web Data** gathers public web services and catalogs: the ones that cover the whole world in one alphabetical list, and those that cover one country in a submenu named after it. Each opens in the Web Data pane on the right, beside the map it searches. Unless a section says -otherwise, they work in the web app too: their documents and tiles are fetched like any other. -They reach the network, so the [AI Assistant](assistant.md) asks before it runs their commands, -and plugins need the network permission. `ui.dialog` opens a panel on a search with `"search"` +otherwise, the panes work in the web app too, provided the servers allow browser requests +(CORS). The command-line examples below are for the desktop CLI; many network commands cannot +fetch synchronously in the browser, where the panes fetch in the background instead. +Commands that reach the network need the [AI Assistant](assistant.md)'s approval when it runs +them, and the network permission when a plugin does. `ui.dialog` opens a panel on a search with `"search"` (`{"name": "nasaEarthdata", "search": "sea surface temperature"}`). | Entry | What it gives | Section | @@ -42,29 +44,32 @@ Data added by its address, from a web server or an S3 bucket, is described in **Home › Web Data › STAC Search…** searches a [STAC](https://stacspec.org) API for imagery and other assets: by collection, dates, the most cloud cover and (by default) where the map is. The -default API is Element 84's [Earth Search](https://earth-search.aws.element84.com/v1) -(Sentinel-2, Landsat, NAIP, Copernicus DEM and Sentinel-1 on AWS); Microsoft's [Planetary -Computer](https://planetarycomputer.microsoft.com/api/stac/v1) is offered beside it, and any -STAC API with item search works. **Collections** lists what the API offers, and **Find Items** shows up to 50 items as cards +default API is Element 84's [Earth Search](https://earth-search.aws.element84.com/v1); +Microsoft's [Planetary Computer](https://planetarycomputer.microsoft.com/api/stac/v1) is offered +beside it, and other APIs with GET item search work. **Collections** lists what the API offers, +and **Find Items** shows up to 50 items as cards (with "Showing 50 of 1234 items" when the API says how many match) and draws their footprints as a layer. Each card has the item's preview picture when it has one, its id, a collection · date · cloud cover line, and **Zoom** (to the item), the asset to add, **Add** and **Details** (the item's properties). The asset chosen at first is `visual` (true colour) when there is one, else the first -GeoTIFF, else the first point cloud, else the first data asset. A cloud-optimised GeoTIFF is read in place, the tiles each view needs by range -(see [Files on the web](data-formats.md#files-on-the-web)); other assets download whole. +GeoTIFF, else the first point cloud, else the first data asset, preferring HTTP(S) assets. +Cloud-optimised GeoTIFF, GeoParquet and FlatGeobuf assets are read in place, and PMTiles a tile +at a time; other assets download whole (see [Files on the web](data-formats.md#files-on-the-web)). The commands do the same: `stac.collections` (`"url"`), `stac.search` (`"collections"`, `"bbox"` as `[west, south, east, north]` or `"view"`, `"datetime"` such as -`2024-06-01/2024-06-30`, `"limit"`, `"maxCloud"`, `"footprints"`, and `"properties": true` for -every property of each item rather than its date and cloud cover), and `stac.addAsset` with -an item `stac.search` returned (`"item"`) or an item's address (`"url"`) and `"asset"`: +`2024-06-01/2024-06-30`, `"limit"` from 1 to 200, default 10, `"maxCloud"`, `"footprints"`, and +`"properties": true` for the scalar item properties rather than just date and cloud cover), +and `stac.addAsset` with an item returned by `stac.search` (`"item"`) or an item's address +(`"url"`) and `"asset"`. API searches return one page and a `"next"` address when there is another: ```sh rustgis-cli run --cmd 'stac.search={"collections":"sentinel-2-l2a","bbox":[7.4,46.9,7.5,47],"datetime":"2024-07-01/2024-07-31","maxCloud":20}' --cmd 'stac.addAsset={"url":"https://earth-search.aws.element84.com/v1/collections/sentinel-2-l2a/items/S2B_32TLS_20240727_0_L2A","asset":"scl"}' ``` -Assets are added by their web address: one in a cloud store's own protocol (`s3://`) is refused -with a message saying so, and an item on the web can't point into the local network. A Planetary +Assets can use HTTP(S) or `s3://` addresses. S3 assets use the saved connection serving their +bucket, or anonymous AWS access when none matches; see [S3 buckets](data.md#s3-buckets). +An item on the web can't point into the local network. A Planetary Computer asset (in Azure Blob Storage) is signed first: RustGIS asks the Planetary Computer's token service for its storage container's token and reuses it for half an hour. The token is part of the layer's address, so a project saved with one reads it again only while the token lasts @@ -90,8 +95,8 @@ and band, such as INPE's [Brazil Data Cube](https://data.inpe.br/bdc/) or Sentin scale and offset turn it into the files' stored values. Without one, each file is stretched on its own, between its 2nd and 98th percentiles. - **Saved with the project:** each tile's dates and addresses. -- **Web app:** a server that doesn't allow the browser to fetch its files (CORS), as the Brazil Data - Cube's doesn't, works on the desktop only. +- **Web app:** creating a cube with **Add as Time Series** is desktop-only. Saved cube tile + layers can draw in the web app when their servers allow browser requests (CORS). - **Its trend:** the same search, given to Trend Analysis (`cube.trend` with `collection`, `asset`, `bbox`, `start` and `end`), reads the values over the area and works out each cell's trend: see [Trend analysis](analysis.md#trend-analysis). @@ -125,7 +130,7 @@ time. address), and `stac.search` walks one, with `"phase"` (`"pre"` or `"post"`) for catalogs of events: ```sh -rustgis-cli run --cmd 'stac.search={"url":"https://vantor-opendata.s3.amazonaws.com/events/Mexico-Hurricane-Simon-Oct-2026/collection.json","phase":"post"}' +rustgis-cli run --cmd 'stac.search={"url":"https://vantor-opendata.s3.amazonaws.com/events/catalog.json","phase":"post"}' ``` The services below are STAC Search opened on one API or catalog, with the dialog titled after @@ -191,8 +196,8 @@ latest release (or one named with `"release"`). Two ways: GeoParquet by address (see [Files on the web](data-formats.md#files-on-the-web)), and each becomes a layer read a view at a time. Zoom to a city or smaller: a view that takes more than six files is refused (a country takes dozens). -- **Map tiles of the whole world**, from the theme's PMTiles archive: every zoom, drawn a layer per - type. Some themes' tiles hold their features only when zoomed in (places from zoom 14). +- **Map tiles of the whole world**, from the theme's PMTiles archive, drawn a layer per type. + Some themes' tiles hold their features only when zoomed in (places from zoom 14). `overture.add` does the same (`"theme"`, `"type"`, `"bbox"` or the view, `"tiles": true` for the tiles): @@ -210,9 +215,10 @@ places); the layer says which. featured products (or an account's), browses a product's folders and adds a file by its address on the data proxy (`https://data.source.coop///…`): GeoParquet, FlatGeobuf, cloud-optimised GeoTIFFs and PMTiles are read in place, other files downloaded. -`sourcecoop.products` (`"account"`) and `sourcecoop.list` (`"account"`, `"product"`, `"path"`) -list them; a listed file's `"url"` is added with `layer.addUrl`. Like STAC searches, these reach the -network: the AI Assistant asks first and plugins need the network permission. +`sourcecoop.products` (`"account"`) and `sourcecoop.list` (`"account"`, `"product"`, `"path"`, +and `"token"` for the next page returned by a listing) list them; a listed file's `"url"` is +added with `layer.addUrl`. These commands reach the network: the AI Assistant asks first and +plugins need the network permission. ## Natural Earth @@ -311,9 +317,9 @@ as GeoLibre's Earthaccess panel does, through NASA's Common Metadata Repository view** (`earthdata.granules`): the granules where the map is, newest first, 25 at a time (**Load more**). Their footprints are drawn as a layer. 3. Each granule is a card with its preview, date, size, cloud cover and number of files, and - **Add** (its first GeoTIFF, added as a COG read in place), **Download** (its first file, in - the browser), **Zoom** and **Details** (its page on Earthdata Search). **Files** lists every file, - each GeoTIFF with its own **Add**. + **Add** (its first GeoTIFF, desktop only, read in place when tiled), **Download** (a link to + its first file), **Zoom** and **Details** (its page on Earthdata Search). **Files** lists every + file, each GeoTIFF with its own desktop **Add**. Searching needs no account, but NASA's data servers ask for an [Earthdata Login](https://urs.earthdata.nasa.gov) token to read most files. Paste one in the pane's @@ -322,6 +328,8 @@ the project, and sent only over HTTPS to NASA's Earthdata data servers (`*.earth and `*.earthdata.nasa.gov`), never to the login server itself; a redirect away from them (to the presigned address the file is served from) goes without it. A file the token doesn't open says so (an expired token, or a dataset whose EULA you haven't accepted on Earthdata Login). +`earthdata.status` reports whether a token is kept and its expiry when present; +`earthdata.logout` forgets it. In the web app, searching and footprints work, but NASA's data servers don't let web pages read their files (they send no CORS headers), so GeoTIFFs are added in the desktop app; **Download** @@ -338,10 +346,11 @@ rustgis-cli run --sample --cmd 'earthdata.login={"token":"…"}' --cmd 'layer.ad **Home › Web Data › Search Open Data…** (`portal.search`) finds datasets by text in an open-data portal and lists, for each, the resources RustGIS can add, the best format first: -- **data.gov and CKAN catalogues**: [catalog.data.gov](https://catalog.data.gov) by default, or a - CKAN site by its address (`https://open.canada.ca/data`, `https://data.gov.uk`…). GeoJSON, - FlatGeobuf, GeoParquet, GeoPackage, KML, zipped Shapefiles, GeoTIFF, CSV, WMS, WFS and ArcGIS - REST resources are offered; web pages, PDFs and the like are left out. +- **data.gov and CKAN catalogues**: [catalog.data.gov](https://catalog.data.gov) by default, + using data.gov's own DCAT search, or a CKAN site by its address + (`https://open.canada.ca/data`, `https://data.gov.uk`…). GeoJSON, FlatGeobuf, GeoParquet, + GeoPackage, KML/KMZ, zipped Shapefiles, GeoTIFF, CSV, WMS, WFS and ArcGIS REST resources are + offered; web pages, PDFs and the like are left out. - **Socrata**: every Socrata site through its discovery API, or one site such as `data.cityofnewyork.us`. Datasets with a location column are added as their GeoJSON export (up to 50,000 rows). @@ -378,9 +387,9 @@ Several **Home › Web Data** entries open the same search on one service, with | **Hugging Face…** (`huggingface`) | Datasets on [Hugging Face](https://huggingface.co/datasets) by name, or one by `owner/name`; **Browse Files** (`portal.files`) lists its GeoTIFFs, GeoParquet, GeoJSON, FlatGeobuf, GeoPackage, PMTiles, LiDAR and other files (public, not gated) | The file | | **Ocean Data Platform…** (`ocean-data`) | [HUB Ocean](https://app.hubocean.earth)'s marine datasets, searched by words | Features, up to 10,000 | | **OpenAerialMap…** (`openaerialmap`) | Drone and aerial images on [OpenAerialMap](https://openaerialmap.org) where the map is, newest first | A COG, read in place | -| **France › IGN LiDAR HD…** (`ign-lidar`) | [IGN](https://geoservices.ign.fr/lidarhd)'s 1 km LiDAR HD tiles where the map is (a view of at most 2 square degrees) | A COPC point cloud, read in place | +| **France › IGN LiDAR HD…** (`ign-lidar`) | [IGN](https://geoservices.ign.fr/lidarhd)'s 1 km LiDAR HD tiles where the map is (a view of at most 2 square degrees) | A COPC point cloud downloaded whole and sampled, like [USGS LiDAR](#usgs-lidar) | | **United States › Tennessee GIS…** | Tennessee's own portal, [Tennessee GeoData](https://geodata.tn.gov): its public items, newest first, as it opens | The service, or the file | -| **United States › US Federal GIS…**, **US State GIS…**, **US Local GIS…** | The public GIS portals of 24 federal agencies (by department), every state and the District of Columbia, and 146 counties and cities (by state): pick a group and a portal; an ArcGIS organisation's public items (`arcgis-portal` with `"org"`), or a Socrata site | The service, or the file | +| **United States › US Federal GIS…**, **US State GIS…**, **US Local GIS…** | Curated public GIS portals of federal agencies (by department), states and the District of Columbia, and counties and cities (by state): pick a group and a portal; an ArcGIS organisation's public items (`arcgis-portal` with `"org"`), or a Socrata site | The service, or the file | | **United States › USGS 3DEP DEM…** (`usgs-dem`) | USGS elevation models from [The National Map](https://apps.nationalmap.gov) where the map is: 1 m, 1/9, 1/3 (the default) or 1 arc-second, or Alaska's 5 m (`"dataset"`), each tile's newest current edition | A GeoTIFF, read in place | Results are cards, as in GeoLibre: a picture when the service has one, what the item is, who diff --git a/docs/user-guide/workspace.md b/docs/user-guide/workspace.md index d54d6d435..ec46c3163 100644 --- a/docs/user-guide/workspace.md +++ b/docs/user-guide/workspace.md @@ -2,12 +2,57 @@ Tools for working across maps and views, and for setting up the app the way you like it. -- **Side by Side** shows a second map beside the active one; **Link Views** keeps maps at the same - place and scale, and **Link Cursors** marks the pointer's place on the other map. +## Projects + +**Project › New**, **Open**, **Save** and **Save As** manage `.rgis` project files; older +`.rgisx` projects still open. A project keeps its maps, layers, styles, layouts, tables, tasks, +models and other project items together. In-memory feature data is saved in the project; +streamed layers reopen their sources, and file-backed rasters reopen their files. Keep those +sources accessible when moving a project. See [Project items](data.md#project-items) for +templates, packages and files for individual items. + +**Open** also accepts a project package (`.rgispkg`), as do drag-and-drop and the desktop +command line. A package opens as a new, unsaved project; Save writes a plain `.rgis` file. +Opening or creating a project clears undo history; saving does not. + +Saved layer addresses omit passwords, access keys, tokens and signatures. The open session +keeps them, but after restarting RustGIS a protected source may need you to sign in to its +connection again or add it again with credentials. + +## Panes and command search + +The left pane has tabs for **Layers**, **Browser** and **Data Sources**. Right-hand panes +such as **Tools**, **Symbology**, **Labeling**, **Format** and **AI Assistant** remain open as +tabs: select a tab to bring it forward, or its × to close it. Right-click a tab to close it, +the other panes or all panes; an overflow menu lists tabs that do not fit. + +Right-click a map or layout tab (or its entry in Browser) to **Rename**, **Duplicate** or +**Delete** it. Deletion asks for confirmation; a project must keep at least one map. + +Click **Command Search** at the right of the title bar or on the Help tab, or press ++ctrl+k++ +(++cmd+k++ on macOS). Search by command name, ID or ribbon location; analysis tools, workflow +names, plugin commands and plugin panels are searchable too. Click a result, or press Enter to +choose the first available one. Commands that need inputs open their dialog, pane or workflow when one +is available; unavailable commands stay visible but disabled, with the reason on hover. +Use scripts or the assistant for parameterized commands without an interactive entry point. + +## Keyboard shortcuts + +See the [keyboard shortcut reference](index.md#keyboard-shortcuts) for project, map and editing +shortcuts, macOS bindings and text-field undo behavior. **Help › Shortcuts** lists the active +bindings in the app. Feature editing is described in [Editing](editing.md); layout deletion, +duplication and nudging have their own [layout shortcuts](layouts.md#layouts). + +## Views and tools + +- **Side by Side** shows a second project map beside the active one (add another map first); + **Link Views** keeps maps at the same place and scale, and **Link Cursors** marks the + pointer's place on the other map. - **Browser View** shows the project's maps, layouts, tables, styles, tasks, models, folders and offline areas as cards (double-click to open); **Item Pop-ups** add hover details. -- **Pane Sets** save the panes you have open and bring them back. The **Workflow Manager** tracks - jobs (new, in progress, on hold, done) with the area each covers. +- **Pane Sets** (View › Workspace) save the left pane, open right-hand pane tabs, active pane + and ribbon tab and bring them back. The **Workflow Manager** tracks jobs (new, in progress, + on hold, done) with the area each covers. - **Connections** (Home › Add Data › **Connections…**) opens the Data Sources pane on its saved connections; it only ever opens the pane, so it is safe to choose when the pane is already open (**View › Windows › Data Sources** still shows or hides it). **Add Data › New Connection** lists @@ -47,7 +92,8 @@ Tools for working across maps and views, and for setting up the app the way you as writing files, so write `Buffer 1500 m` rather than `Buffer 1.5 km`. GeoLibre and plugin tools' outputs are refused. - **Thumbnails** for the project and for each map, from the view or an image. -- **Animation**: add keyframes (views), play them, and export the moves as PNG frames. +- **Animation**: add keyframes (views), play them, and export the moves as a zip of PNG frames + on desktop. - **Time Slider** (View › Time) shows data through time, in a timeline docked under the map.
@@ -77,7 +123,7 @@ Tools for working across maps and views, and for setting up the app the way you `"format"`…), `time.goTo` (`"date"`, or `"step"` from 0), `time.step` (`"by"`, negative goes back), `time.fields` (a layer's date fields) and `time.unbind`: `rustgis-cli run --cmd 'layer.addData={"path":"quakes.csv"}' --cmd 'time.bind={"layer":"quakes","unit":"year","show":"cumulative"}' --cmd 'time.goTo={"date":"2018"}'`. - - **Export** writes the steps as an animated GIF (`time.export` also writes a zip of PNG + - **Export** on desktop writes the steps as an animated GIF (`time.export` also writes a zip of PNG frames). The slider, its window and the bindings are saved with the project; the off button turns it off and every feature shows again. - **Time Series**: a Zarr variable's values through time at points clicked on the map, as lines on @@ -88,12 +134,13 @@ Tools for working across maps and views, and for setting up the app the way you - **Offline**: Download Map takes the data in view into an offline area; open it to edit, then Sync sends changed, new and deleted features back and refreshes it. **Feature Cache** prepares every layer's geometry for the current projection (Fill, Empty, Cancel, Auto Cache). -- **Help**: choose the ribbon's tabs and the Quick Access Toolbar's buttons, open the user guide, - and watch the **Diagnostic Monitor** (frame rate, draw time, data size). +- **Settings › Customize** chooses the ribbon's tabs and the Quick Access Toolbar's extra + buttons. Settings and Help always remain visible. **Help** opens the user guide and the + **Diagnostic Monitor** (frame rate, draw time, data size). - **Plugins**: **Plugins › Plugin Manager** installs, updates and removes [plugins](../plugins.md) from the registry at [plugins.rustgis.app](https://plugins.rustgis.app) or from a file. - **Language**: **Settings › Language** switches the interface among - [16 languages](../i18n.md) and chooses the [worldview](maps.md#worldview) of the built-in + [17 languages](../i18n.md) and chooses the [worldview](maps.md#worldview) of the built-in borders. - **Start-up**: **Settings › Start-up** sets what RustGIS opens with: **Start With** the World Atlas sample or a blank map, or, in the desktop app, **Last Project** (the `.rgis` project last opened From 4e46cededab522a5fe8bc45562c02c35cf6671fc Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:23:12 +0200 Subject: [PATCH 077/297] feat(secrets): an OS keychain secret store behind Services::secrets (#767) A SecretStore trait, an opaque Secret and an in-memory store in rustgis-data (wasm-safe); a Services::secrets hook; and a desktop implementation on keyring-core (macOS Keychain, Windows Credential Manager, the Secret Service over pure-Rust D-Bus). None in the browser and the CLI. No secret moves yet. Refs #754 --- Cargo.lock | 166 +++++++++++++++++++++ THIRD-PARTY-LICENSES.txt | 277 ++++++++++++++++++++++++++++++----- apps/rustgis-web/src/web.rs | 2 + apps/rustgis/Cargo.toml | 11 ++ apps/rustgis/src/keychain.rs | 133 +++++++++++++++++ apps/rustgis/src/main.rs | 3 + crates/data/src/lib.rs | 1 + crates/data/src/secrets.rs | 184 +++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + docs/architecture.md | 23 +++ 10 files changed, 769 insertions(+), 34 deletions(-) create mode 100644 apps/rustgis/src/keychain.rs create mode 100644 crates/data/src/secrets.rs diff --git a/Cargo.lock b/Cargo.lock index ed1f1b445..2ea63200c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,6 +146,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + [[package]] name = "ahash" version = "0.8.12" @@ -230,6 +241,17 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "approx" version = "0.5.1" @@ -691,6 +713,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -875,6 +906,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.7.0" @@ -930,6 +970,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1122,6 +1172,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2411,6 +2467,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.13.0" @@ -2658,6 +2723,16 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "io-enum" version = "1.3.0" @@ -2780,6 +2855,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" +dependencies = [ + "log", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3222,6 +3306,20 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.8", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3266,6 +3364,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.8", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4447,9 +4566,11 @@ dependencies = [ name = "rustgis" version = "0.3.0" dependencies = [ + "apple-native-keyring-store", "eframe", "egui", "embed-resource", + "keyring-core", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -4458,7 +4579,9 @@ dependencies = [ "rustgis-ui-egui", "serde_json", "tiny-skia 0.12.0", + "windows-native-keyring-store", "winit", + "zbus-secret-service-keyring-store", ] [[package]] @@ -4885,6 +5008,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2 0.11.1", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6684,6 +6826,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -7162,6 +7317,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + [[package]] name = "zbus_macros" version = "5.19.0" diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index ce2553cf2..545e3cac7 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -8,46 +8,47 @@ by `cargo xtask notices`. ==================================================================================================== Apache License 2.0 Used by: a5, ab_glyph, ab_glyph_rasterizer, accesskit, accesskit_atspi_common, accesskit_consumer, -accesskit_macos, accesskit_unix, accesskit_windows, accesskit_winit, ahash, allocator-api2, approx, -arboard, arc-swap, arrayvec, as-raw-xcb-connection, async-channel, async-executor, async-io, +accesskit_macos, accesskit_unix, accesskit_windows, accesskit_winit, aes, ahash, allocator-api2, +approx, arboard, arc-swap, arrayvec, as-raw-xcb-connection, async-channel, async-executor, async-io, async-lock, async-process, async-recursion, async-signal, async-task, async-trait, atomic-waker, atspi, atspi-common, atspi-proxies, autocfg, aws-lc-sys, base64, bit-set, bit-vec, bitflags, -block-buffer, blocking, btoi, bufstream, bumpalo, bytemuck, bytemuck_derive, cc, cfg-if, chacha20, -cmake, cmov, codepage, codespan-reporting, color, concurrent-queue, console_log, const-oid, -const-random, const-random-macro, core-foundation, core-foundation-sys, core-graphics, -core-graphics-types, core_detect, cpufeatures, crossbeam-deque, crossbeam-epoch, crossbeam-queue, -crossbeam-utils, crypto-common, ctutils, der, der_derive, derive_utils, digest, dispatch2, -displaydoc, document-features, downcast-rs, dunce, ecolor, eframe, egui, egui-wgpu, egui-winit, -egui_extras, either, emath, encoding_rs, enum-map, enum-map-derive, epaint, equivalent, errno, -euclid, event-listener, event-listener-strategy, fastrand, fdeflate, fearless_simd, filetime, -find-msvc-tools, flagset, flatbuffers, flate2, form_urlencoded, futures-lite, futures-timer, geo, -geo-traits, geo-types, geozero, gethostname, gif, gl_generator, glow, guillotiere, half, hash32, -hashbrown, hashlink, heapless, heck, hmac, httparse, hybrid-array, i_overlay, idna, idna_adapter, -image, image-webp, indexmap, io-enum, itertools, itoa, jobserver, js-sys, khronos-egl, khronos_api, -kurbo, laz, lazy_static, leb128fmt, libc, linebender_resource_handle, linux-raw-sys, litrs, -lock_api, log, lyon, lyon_algorithms, lyon_geom, lyon_path, lyon_tessellation, md-5, md5, -miniz_oxide, multiversion_no_op, mysql, mysql_common, naga, naga-types, named_pipe, nohash-hasher, -num-bigint, num-conv, num-integer, num-traits, objc2-app-kit, objc2-core-foundation, -objc2-core-graphics, objc2-metal, objc2-quartz-core, objc2-system-configuration, once_cell, -openssl-probe, ordered-stream, owned_ttf_parser, parking, parking_lot, parking_lot_core, parquet, -peniko, percent-encoding, pin-project, pin-project-internal, pin-project-lite, piper, pkg-config, -png, polling, pollster, polycool, postgres, postgres-protocol, postgres-types, presser, proc-macro2, +block-buffer, block-padding, blocking, btoi, bufstream, bumpalo, bytemuck, bytemuck_derive, cbc, cc, +cfg-if, chacha20, cipher, cmake, cmov, codepage, codespan-reporting, color, concurrent-queue, +console_log, const-oid, const-random, const-random-macro, core-foundation, core-foundation-sys, +core-graphics, core-graphics-types, core_detect, cpubits, cpufeatures, crossbeam-deque, +crossbeam-epoch, crossbeam-queue, crossbeam-utils, crypto-common, ctutils, der, der_derive, +derive_utils, digest, dispatch2, displaydoc, document-features, downcast-rs, dunce, ecolor, eframe, +egui, egui-wgpu, egui-winit, egui_extras, either, emath, encoding_rs, enum-map, enum-map-derive, +epaint, equivalent, errno, euclid, event-listener, event-listener-strategy, fastrand, fdeflate, +fearless_simd, filetime, find-msvc-tools, flagset, flatbuffers, flate2, form_urlencoded, +futures-lite, futures-timer, geo, geo-traits, geo-types, geozero, gethostname, gif, gl_generator, +glow, guillotiere, half, hash32, hashbrown, hashlink, heapless, heck, hkdf, hmac, httparse, +hybrid-array, i_overlay, idna, idna_adapter, image, image-webp, indexmap, inout, io-enum, itertools, +itoa, jobserver, js-sys, khronos-egl, khronos_api, kurbo, laz, lazy_static, leb128fmt, libc, +linebender_resource_handle, linux-raw-sys, litrs, lock_api, log, lyon, lyon_algorithms, lyon_geom, +lyon_path, lyon_tessellation, md-5, md5, miniz_oxide, multiversion_no_op, mysql, mysql_common, naga, +naga-types, named_pipe, nohash-hasher, num, num-bigint, num-complex, num-conv, num-integer, +num-iter, num-rational, num-traits, objc2-app-kit, objc2-core-foundation, objc2-core-graphics, +objc2-metal, objc2-quartz-core, objc2-system-configuration, once_cell, openssl-probe, +ordered-stream, owned_ttf_parser, parking, parking_lot, parking_lot_core, parquet, peniko, +percent-encoding, pin-project, pin-project-internal, pin-project-lite, piper, pkg-config, png, +polling, pollster, polycool, postgres, postgres-protocol, postgres-types, presser, proc-macro2, profiling, proj4rs, proj4rs-geodesic, pyo3, pyo3-build-config, pyo3-ffi, pyo3-macros, pyo3-macros-backend, quote, rand, range-alloc, raw-window-handle, raw-window-metal, rayon, rayon-core, regex, regex-automata, regex-syntax, renderdoc-sys, ring, robust, roxmltree, rstar, rustc-hash, rustc_version, rustix, rustls, rustls-native-certs, rustls-pemfile, rustversion, ryu, -scoped-tls, scopeguard, secrecy, security-framework, security-framework-sys, self_cell, semver, -seq-macro, serde, serde_core, serde_derive, serde_json, serde_repr, sha1, sha2, shlex, sif-itree, -signal-hook-registry, simdutf8, siphasher, smallvec, smol_str, socket2, spade, spirv, spki, -stable_deref_trait, static_assertions, syn, sys-locale, tar, tempfile, thiserror, thiserror-impl, -tiberius-macros, time, time-core, tinyvec, tokio-postgres, ttf-parser, type-map, typed-path, -unicode-bidi, unicode-ident, unicode-normalization, unicode-properties, unicode-segmentation, -unicode-width, ureq, ureq-proto, url, utf8-zero, utf8_iter, uuid, vcpkg, vello_common, vello_cpu, -version_check, wasm-bindgen, wasm-bindgen-futures, wasm-bindgen-macro, wasm-bindgen-macro-support, -wasm-bindgen-shared, wasm-encoder, wasmi, wasmi_collections, wasmi_core, wasmi_ir, wasmparser, wast, -wat, web-sys, weezl, wgpu, wgpu-core, wgpu-core-deps-apple, wgpu-core-deps-wasm, -wgpu-core-deps-windows-linux-android, wgpu-hal, wgpu-naga-bridge, wgpu-types, whoami, winauth, wkt, -x11rb, x11rb-protocol, x509-cert, xattr, zeroize, zune-core, zune-jpeg +scoped-tls, scopeguard, secrecy, secret-service, security-framework, security-framework-sys, +self_cell, semver, seq-macro, serde, serde_core, serde_derive, serde_json, serde_repr, sha1, sha2, +shlex, sif-itree, signal-hook-registry, simdutf8, siphasher, smallvec, smol_str, socket2, spade, +spirv, spki, stable_deref_trait, static_assertions, syn, sys-locale, tar, tempfile, thiserror, +thiserror-impl, tiberius-macros, time, time-core, tinyvec, tokio-postgres, ttf-parser, type-map, +typed-path, unicode-bidi, unicode-ident, unicode-normalization, unicode-properties, +unicode-segmentation, unicode-width, ureq, ureq-proto, url, utf8-zero, utf8_iter, uuid, vcpkg, +vello_common, vello_cpu, version_check, wasm-bindgen, wasm-bindgen-futures, wasm-bindgen-macro, +wasm-bindgen-macro-support, wasm-bindgen-shared, wasm-encoder, wasmi, wasmi_collections, wasmi_core, +wasmi_ir, wasmparser, wast, wat, web-sys, weezl, wgpu, wgpu-core, wgpu-core-deps-apple, +wgpu-core-deps-wasm, wgpu-core-deps-windows-linux-android, wgpu-hal, wgpu-naga-bridge, wgpu-types, +whoami, winauth, wkt, x11rb, x11rb-protocol, x509-cert, xattr, zeroize, zune-core, zune-jpeg ==================================================================================================== Apache License @@ -252,6 +253,214 @@ x11rb, x11rb-protocol, x509-cert, xattr, zeroize, zune-core, zune-jpeg See the License for the specific language governing permissions and limitations under the License. +==================================================================================================== +Apache License 2.0 +Used by: apple-native-keyring-store, keyring-core, windows-native-keyring-store, +zbus-secret-service-keyring-store +==================================================================================================== + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [2017] [keyring developers] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + ==================================================================================================== Apache License 2.0 Used by: ash diff --git a/apps/rustgis-web/src/web.rs b/apps/rustgis-web/src/web.rs index 63b260987..d231d8339 100644 --- a/apps/rustgis-web/src/web.rs +++ b/apps/rustgis-web/src/web.rs @@ -815,6 +815,8 @@ fn services(inbox: Inbox, font_inbox: Inbox, notes: Notes, ctx: egui::Context, e Services { load_pref: Some(load_pref), save_pref: Some(save_pref), + // No keychain in the browser: script on the page could read anything stored (#754). + secrets: None, fetch_font: Some(Box::new(move |url: &str| { let (url, inbox, ctx) = (url.to_string(), font_sink.clone(), font_ctx.clone()); wasm_bindgen_futures::spawn_local(async move { diff --git a/apps/rustgis/Cargo.toml b/apps/rustgis/Cargo.toml index d6d0b3235..3d506cccb 100644 --- a/apps/rustgis/Cargo.toml +++ b/apps/rustgis/Cargo.toml @@ -17,6 +17,8 @@ eframe = { workspace = true, features = ["default_fonts", "links", "wgpu", "acce egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +# The system's secret store (`Services::secrets`): the generic API, and one backend per OS below. +keyring-core = "1" # The Windows exe icon (build.rs). Gated on the host, so other platforms build neither. [target.'cfg(windows)'.build-dependencies] @@ -28,6 +30,12 @@ embed-resource = "3" objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSUserDefaults"] } +# The login Keychain behind `Services::secrets`. +apple-native-keyring-store = { version = "1", features = ["keychain"] } + +# Windows Credential Manager behind `Services::secrets`. +[target.'cfg(windows)'.dependencies] +windows-native-keyring-store = "1" # Linux: GNOME on Wayland leaves the title bar to the app. winit draws an Adwaita one (title, # minimise, maximise, close, following the light/dark theme) instead of its plain grey fallback. @@ -35,3 +43,6 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std # winit` lists one winit). A different one would build a second winit and lose the title bar. [target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))'.dependencies] winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita"] } +# The Secret Service (GNOME Keyring, KWallet) behind `Services::secrets`, over pure-Rust D-Bus: +# no openssl, libdbus or tokio. Keep the `cfg` list identical to the one in `src/keychain.rs`. +zbus-secret-service-keyring-store = { version = "1", features = ["rt-async-io-crypto-rust"] } diff --git a/apps/rustgis/src/keychain.rs b/apps/rustgis/src/keychain.rs new file mode 100644 index 000000000..23838eff9 --- /dev/null +++ b/apps/rustgis/src/keychain.rs @@ -0,0 +1,133 @@ +//! The system's secret store (macOS Keychain, Windows Credential Manager, the Secret Service on +//! Linux and the BSDs) behind `Services::secrets`. + +use std::sync::{Arc, OnceLock}; + +use rustgis_engine::data::secrets::{SERVICE, Secret, SecretError, SecretStore, check_account}; + +/// The OS credential store, opened on the first call: start-up never touches D-Bus and never +/// triggers a prompt. +#[derive(Default)] +pub struct Keychain { + store: OnceLock, String>>, +} + +#[cfg(target_os = "macos")] +fn open() -> Result, String> { + let store: Arc = apple_native_keyring_store::keychain::Store::new().map_err(|e| e.to_string())?; + Ok(store) +} + +#[cfg(windows)] +fn open() -> Result, String> { + let store: Arc = windows_native_keyring_store::Store::new().map_err(|e| e.to_string())?; + Ok(store) +} + +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))] +fn open() -> Result, String> { + let store: Arc = zbus_secret_service_keyring_store::Store::new().map_err(|e| e.to_string())?; + Ok(store) +} + +#[cfg(not(any( + target_os = "macos", + windows, + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" +)))] +fn open() -> Result, String> { + Err("no system keychain on this platform".into()) +} + +/// Keeps a credential on this computer instead of the default roaming `Enterprise` persistence. +#[cfg(windows)] +fn build(store: &keyring_core::CredentialStore, account: &str) -> keyring_core::Result { + store.build(SERVICE, account, Some(&std::collections::HashMap::from([("persistence", "Local")]))) +} + +#[cfg(not(windows))] +fn build(store: &keyring_core::CredentialStore, account: &str) -> keyring_core::Result { + store.build(SERVICE, account, None) +} + +impl Keychain { + fn entry(&self, account: &str) -> Result { + check_account(account)?; + match self.store.get_or_init(open) { + Ok(store) => build(&**store, account).map_err(map_error), + Err(why) => Err(SecretError::Unavailable(why.clone())), + } + } +} + +/// Never formats a payload that holds a secret's bytes. `NoEntry` is for the callers to handle. +fn map_error(e: keyring_core::Error) -> SecretError { + use keyring_core::Error; + match e { + Error::NoStorageAccess(_) => SecretError::Denied, + Error::NoDefaultStore => SecretError::Unavailable("no credential store".into()), + Error::PlatformFailure(err) => SecretError::Failed(err.to_string()), + Error::BadEncoding(_) | Error::BadDataFormat(..) => SecretError::Failed("the stored value isn't text".into()), + e => SecretError::Failed(e.to_string()), + } +} + +impl SecretStore for Keychain { + fn load(&self, account: &str) -> Result, SecretError> { + match self.entry(account)?.get_password() { + Ok(value) => Ok(Some(Secret::new(value))), + Err(keyring_core::Error::NoEntry) => Ok(None), + Err(e) => Err(map_error(e)), + } + } + + fn save(&self, account: &str, value: &Secret) -> Result<(), SecretError> { + self.entry(account)?.set_password(value.expose()).map_err(map_error) + } + + fn delete(&self, account: &str) -> Result<(), SecretError> { + match self.entry(account)?.delete_credential() { + Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), + Err(e) => Err(map_error(e)), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn errors_never_show_the_secret() { + let e = keyring_core::Error::BadEncoding(b"hunter2".to_vec()); + let mapped = map_error(e); + assert!(!mapped.to_string().contains("hunter2")); + assert!(!format!("{mapped:?}").contains("hunter2")); + let denied = map_error(keyring_core::Error::NoStorageAccess("locked".into())); + assert_eq!(denied, SecretError::Denied); + } + + #[test] + fn bad_accounts_never_reach_the_store() { + assert_eq!(Keychain::default().load("bad"), Err(SecretError::InvalidAccount("bad".into()))); + } + + #[test] + #[ignore = "needs the system keychain; run with --ignored"] + fn keychain_round_trip() { + let keychain = Keychain::default(); + let account = "test/keychain-smoke"; + keychain.delete(account).unwrap(); + keychain.save(account, &Secret::new("rustgis-smoke-1")).unwrap(); + assert_eq!(keychain.load(account).unwrap(), Some(Secret::new("rustgis-smoke-1"))); + keychain.save(account, &Secret::new("rustgis-smoke-2")).unwrap(); + assert_eq!(keychain.load(account).unwrap(), Some(Secret::new("rustgis-smoke-2"))); + keychain.delete(account).unwrap(); + assert_eq!(keychain.load(account).unwrap(), None); + } +} diff --git a/apps/rustgis/src/main.rs b/apps/rustgis/src/main.rs index 500a5af27..2377d413e 100644 --- a/apps/rustgis/src/main.rs +++ b/apps/rustgis/src/main.rs @@ -12,6 +12,8 @@ use serde_json::json; #[cfg(target_os = "macos")] mod macos; +mod keychain; + /// The app, and this run's marker (see [`running`]), cleared when eframe closes it. struct App(RustGisApp, running::Shared); @@ -52,6 +54,7 @@ mod prefs { log::warn!("could not save preferences: {e}"); } })), + secrets: Some(std::sync::Arc::new(crate::keychain::Keychain::default())), ..Services::default() } } diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 390ded4df..dc5e438d5 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -24,6 +24,7 @@ pub mod osm; pub mod photo; pub mod query; pub mod raster; +pub mod secrets; pub mod sheets; pub mod source; pub mod sqlite; diff --git a/crates/data/src/secrets.rs b/crates/data/src/secrets.rs new file mode 100644 index 000000000..e643a3167 --- /dev/null +++ b/crates/data/src/secrets.rs @@ -0,0 +1,184 @@ +//! Opaque secrets and stores shared by desktop and wasm callers. + +use std::{collections::BTreeMap, fmt, sync::Mutex}; + +/// The service name used by every RustGIS secret store. +pub const SERVICE: &str = "RustGIS"; + +/// A secret whose debug output never reveals its value. +#[derive(Clone, PartialEq, Eq)] +pub struct Secret(String); + +impl Secret { + pub fn new(value: impl Into) -> Secret { + Secret(value.into()) + } + + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Secret()") + } +} + +/// Store failures never carry a secret value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SecretError { + Unavailable(String), + Denied, + InvalidAccount(String), + Failed(String), +} + +impl fmt::Display for SecretError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unavailable(why) => write!(f, "the system keychain isn't available: {why}"), + Self::Denied => f.write_str("the system keychain is locked or access to it was refused"), + Self::InvalidAccount(account) => write!(f, "invalid secret name \"{account}\""), + Self::Failed(why) => write!(f, "the system keychain failed: {why}"), + } + } +} + +impl std::error::Error for SecretError {} + +/// Accepts `owner/name`, with a lowercase ASCII owner and at most 200 bytes, without controls. +pub fn check_account(account: &str) -> Result<(), SecretError> { + let valid = account.len() <= 200 + && !account.chars().any(char::is_control) + && account.split_once('/').is_some_and(|(owner, name)| { + !owner.is_empty() && !name.is_empty() && owner.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }); + if valid { + return Ok(()); + } + let mut end = account.len().min(200); + while !account.is_char_boundary(end) { + end -= 1; + } + Err(SecretError::InvalidAccount(account.get(..end).unwrap_or_default().to_string())) +} + +/// A store for secrets by account (`owner/name`), under [`SERVICE`]. Implementations call +/// [`check_account`] first and never write a value anywhere but their store. +pub trait SecretStore: Send + Sync { + /// The secret, or `None` when none is stored for `account`. + fn load(&self, account: &str) -> Result, SecretError>; + /// Stores `value` for `account`, replacing any earlier one. + fn save(&self, account: &str, value: &Secret) -> Result<(), SecretError>; + /// Forgets `account`'s secret; succeeds when there was none. + fn delete(&self, account: &str) -> Result<(), SecretError>; +} + +/// An in-memory store for tests of code that uses secrets; never a fallback for the OS store. +#[derive(Default)] +pub struct MemorySecrets { + values: Mutex>, + fail: Option, +} + +impl MemorySecrets { + pub fn new() -> MemorySecrets { + Self::default() + } + + /// A store whose every call fails, after validating the account. + pub fn failing(error: SecretError) -> MemorySecrets { + Self { fail: Some(error), ..Self::default() } + } +} + +impl SecretStore for MemorySecrets { + fn load(&self, account: &str) -> Result, SecretError> { + check_account(account)?; + if let Some(error) = &self.fail { + return Err(error.clone()); + } + Ok(self.values.lock().unwrap_or_else(|e| e.into_inner()).get(account).cloned().map(Secret::new)) + } + + fn save(&self, account: &str, value: &Secret) -> Result<(), SecretError> { + check_account(account)?; + if let Some(error) = &self.fail { + return Err(error.clone()); + } + self.values.lock().unwrap_or_else(|e| e.into_inner()).insert(account.to_string(), value.expose().to_string()); + Ok(()) + } + + fn delete(&self, account: &str) -> Result<(), SecretError> { + check_account(account)?; + if let Some(error) = &self.fail { + return Err(error.clone()); + } + self.values.lock().unwrap_or_else(|e| e.into_inner()).remove(account); + Ok(()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn account_rules() { + for account in ["assistant/openai", "connection/3f2a-9b", "a/nested/name", "a/é"] { + assert_eq!(check_account(account), Ok(())); + } + for account in ["", "assistant", "/x", "assistant/", "Assistant/x", "a/b\n", "a/b\u{7f}"] { + assert_eq!(check_account(account), Err(SecretError::InvalidAccount(account.into()))); + } + assert!(check_account(&format!("a/{}", "x".repeat(198))).is_ok()); + let long = format!("a/{}", "x".repeat(199)); + assert_eq!(check_account(&long), Err(SecretError::InvalidAccount(long.get(..200).unwrap().into()))); + let unicode = format!("a/{}é", "x".repeat(197)); + assert_eq!(check_account(&unicode), Err(SecretError::InvalidAccount(format!("a/{}", "x".repeat(197))))); + } + + #[test] + fn round_trip() { + let store = MemorySecrets::new(); + let account = "assistant/openai"; + assert_eq!(store.load(account).unwrap(), None); + store.save(account, &Secret::new("first")).unwrap(); + assert_eq!(store.load(account).unwrap(), Some(Secret::new("first"))); + store.save(account, &Secret::new("second")).unwrap(); + assert_eq!(store.load(account).unwrap(), Some(Secret::new("second"))); + store.delete(account).unwrap(); + assert_eq!(store.load(account).unwrap(), None); + assert_eq!(store.delete(account), Ok(())); + } + + #[test] + fn accounts_are_independent() { + let store = MemorySecrets::new(); + store.save("assistant/openai", &Secret::new("one")).unwrap(); + store.save("connection/3f2a-9b", &Secret::new("two")).unwrap(); + store.delete("assistant/openai").unwrap(); + assert_eq!(store.load("assistant/openai").unwrap(), None); + assert_eq!(store.load("connection/3f2a-9b").unwrap(), Some(Secret::new("two"))); + } + + #[test] + fn failing_store_validates_first() { + let store = MemorySecrets::failing(SecretError::Denied); + assert_eq!(store.load("assistant/openai"), Err(SecretError::Denied)); + assert_eq!(store.save("assistant/openai", &Secret::new("value")), Err(SecretError::Denied)); + assert_eq!(store.delete("assistant/openai"), Err(SecretError::Denied)); + let invalid = SecretError::InvalidAccount("bad".into()); + assert_eq!(store.load("bad"), Err(invalid.clone())); + assert_eq!(store.save("bad", &Secret::new("value")), Err(invalid.clone())); + assert_eq!(store.delete("bad"), Err(invalid)); + } + + #[test] + fn secret_debug_is_redacted() { + assert!(!format!("{:?}", Secret::new("hunter2")).contains("hunter2")); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1fe308d02..1bff96e66 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -213,6 +213,9 @@ pub struct Services { pub load_pref: Option Option>>, /// Saves a preference. pub save_pref: Option>, + /// The system's secret store (see `docs/architecture.md` › Secrets): the desktop's keychain. + /// `None` in the browser and on the command line, where secrets last for the session only. + pub secrets: Option>, /// Downloads a font by URL (web); the bytes land in `font_inbox`. pub fetch_font: Option>, pub font_inbox: Option, diff --git a/docs/architecture.md b/docs/architecture.md index 822f56219..a8b74bdf9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,6 +41,29 @@ unimplemented, todo and unreachable lints. Readers treat file contents and comma exercise engine commands and built-in geoprocessing tools; some file-writing and network commands are skipped. +## Secrets + +`Services::secrets` is the only way to the OS credential store. The desktop app implements it +with the login Keychain on macOS, Windows Credential Manager (persistence Local), and the Secret +Service (GNOME Keyring, KWallet) over pure-Rust D-Bus on Linux and the BSDs. It is `None` in the +browser and the CLI, where callers keep a secret for the session only, as before. Crates below +`apps/` use only the `rustgis_data::secrets` trait and never an OS API. + +- **Keys.** The service is `RustGIS`. The account is `owner/name` with a lowercase owner (for + example `assistant/` or `connection/`); each feature defines its own. Values are + opaque strings in `Secret`. +- **Errors, not fallbacks.** A missing or locked store, or a refused prompt, returns a + `SecretError` to show the user. Never write the secret anywhere else instead. +- **No leaks.** `Secret`'s `Debug` is redacted and it has no `Display`. Never log a secret, and + never put one in command parameters, History, workspace recordings or assistant tool output. +- **Blocking.** A call can wait on an OS prompt or D-Bus. Make it on a user action or a worker + thread, never every frame, and keep the value in memory for the session. Don't access one + account from two threads at once. +- **macOS.** The system asks again for access when the app's code signature changes, which + happens with unsigned or re-signed builds. +- **Tests.** Use `MemorySecrets`, and `MemorySecrets::failing(..)` for error paths. The desktop + backend's smoke test is `#[ignore]`d: `cargo test -p rustgis keychain_round_trip -- --ignored`. + ## Quality gates ```sh From a53ccc11b175ed9caf0d364ed0d0d0e4cbcef5b1 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:01:11 +0200 Subject: [PATCH 078/297] fix(symbology): choose compatible fields when switching renderers (#772) * fix(symbology): choose compatible renderer fields * fix(ui): clear pending renderer when layer changes * fix(ui): keep symbology retries scoped and explained * fix(ui): end symbology retries with the pane, layer or renderer --- crates/engine/src/cmd/layer.rs | 21 ++- crates/engine/src/tests.rs | 42 ++++++ crates/ui-egui/src/panes.rs | 249 ++++++++++++++++++++++++++++++++- docs/user-guide/cartography.md | 15 +- 4 files changed, 316 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 0ec7d5631..57adbf1c0 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -2621,15 +2621,24 @@ fn symbology(s: &mut Session, p: &Value) -> CmdResult { } else { base }; - let field = || -> Result { + let field = |numeric_only: bool| -> Result { if let Some(x) = ps(p, "field") { return Ok(x.to_string()); } l.renderer .field() + .filter(|name| { + !numeric_only || l.dataset.field_index(name).and_then(|i| l.dataset.fields.get(i)).is_some_and(|f| f.ty.is_numeric()) + }) .map(str::to_string) - .or_else(|| l.dataset.fields.iter().find(|f| f.ty.is_numeric()).map(|f| f.name.clone())) - .ok_or_else(|| "field is required".into()) + .or_else(|| l.dataset.fields.iter().find(|f| !numeric_only || f.ty.is_numeric()).map(|f| f.name.clone())) + .ok_or_else(|| { + if numeric_only { + "no numeric field is available for this symbology".into() + } else { + "no field is available for this symbology".into() + } + }) }; // Classes and sizes describe the rows the definition query lets through, taken once: changing // the query later keeps them until the symbology is applied again. @@ -2642,15 +2651,15 @@ fn symbology(s: &mut Session, p: &Value) -> CmdResult { }; let mut r = match kind.as_str() { "single" | "singlesymbol" => Renderer::Single(base), - "unique" | "uniquevalues" => Renderer::unique(&l.dataset, &rows()?, &field()?, ramp.unwrap_or(Ramp::Harbour), base)?, + "unique" | "uniquevalues" => Renderer::unique(&l.dataset, &rows()?, &field(false)?, ramp.unwrap_or(Ramp::Harbour), base)?, "graduated" | "graduatedcolors" => { - Renderer::graduated(&l.dataset, &rows()?, &field()?, classes, method, ramp.unwrap_or(Ramp::Ember), base)? + Renderer::graduated(&l.dataset, &rows()?, &field(true)?, classes, method, ramp.unwrap_or(Ramp::Ember), base)? } "proportional" | "proportionalsymbols" => { if g != GeometryType::Point { return Err("Proportional Symbols needs a point layer".into()); } - Renderer::proportional(&l.dataset, &rows()?, &field()?, base)? + Renderer::proportional(&l.dataset, &rows()?, &field(true)?, base)? } other => return Err(format!("unknown symbology '{other}'")), }; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 158f5f66e..25c905adf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2035,6 +2035,48 @@ fn terrain_is_set_from_a_dem_or_tiles() { assert!(s.map().view.terrain.is_none()); } +#[test] +fn graduated_symbology_skips_a_current_text_field() { + let mut s = world(); + s.run("layer.symbology", &json!({"layer": "Countries", "type": "unique", "field": "income_grp"})).unwrap(); + + s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated"})).unwrap(); + let layer = s.map().layer_by_name("Countries").unwrap(); + let rustgis_map::Renderer::Graduated { field, .. } = &layer.renderer else { panic!("Graduated Colors renderer expected") }; + assert_ne!(field, "income_grp", "the text field from the previous renderer is not reused"); + assert!(layer.dataset.field_index(field).and_then(|i| layer.dataset.fields.get(i)).is_some_and(|f| f.ty.is_numeric())); +} + +#[test] +fn symbology_chooses_fields_by_renderer_requirements() { + let mut s = Session::new(Project::default()); + s.run( + "layer.addDataText", + &json!({ + "fileName": "text-fields.geojson", + "text": r#"{"type":"FeatureCollection","features":[ + {"type":"Feature","properties":{"g_id":"61259","g_name":"Liezen"},"geometry":{"type":"Point","coordinates":[14.24,47.57]}}, + {"type":"Feature","properties":{"g_id":"61260","g_name":"Graz"},"geometry":{"type":"Point","coordinates":[15.44,47.07]}} + ]}"# + }), + ) + .unwrap(); + + s.run("layer.symbology", &json!({"layer": "text-fields", "type": "unique"})).unwrap(); + let rustgis_map::Renderer::Unique { field, classes, .. } = &s.map().layer_by_name("text-fields").unwrap().renderer else { + panic!("Unique Values renderer expected") + }; + assert_eq!(field, "g_id"); + assert_eq!(classes.len(), 2); + + let before = s.map().layer_by_name("text-fields").unwrap().renderer.clone(); + let depth = s.undo_depth(); + let err = s.run("layer.symbology", &json!({"layer": "text-fields", "type": "graduated"})).unwrap_err(); + assert_eq!(err, "no numeric field is available for this symbology"); + assert_eq!(s.map().layer_by_name("text-fields").unwrap().renderer, before, "failed mode switch leaves current styling intact"); + assert_eq!(s.undo_depth(), depth, "failed mode switch adds no undo step"); +} + #[test] fn symbology_classes_follow_the_definition_query() { let mut s = world(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 614b852aa..254f20482 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -47,6 +47,12 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { if app.ui.right != Some(RightPane::TimeSeries) && app.workflow.pick.as_ref().is_some_and(|p| p.purpose == "ts_point") { app.workflow.pick = None; } + // A failed renderer switch waits for a field only while the Symbology pane shows its layer. + if app.ui.right == Some(RightPane::Symbology) { + let _ = pending_symbology(app, ui.ctx()); + } else { + clear_pending_symbology(ui.ctx()); + } // A Zarr summary or a trend running on its thread lands whatever pane is in view (both are // looked at: `|`, not `||`), and the frames go on while one runs so it's taken when it ends. #[cfg(not(target_arch = "wasm32"))] @@ -3288,6 +3294,39 @@ fn ramp_swatch(ui: &mut Ui, ramp: &Ramp, w: f32, selected: bool, t: &Tokens) -> resp } +const PENDING_SYMBOLOGY: &str = "symbology_pending_field"; + +/// A renderer switch that failed, kept so the pane can offer a field to retry with. It belongs to +/// one layer of one map in one project, and to the renderer the layer had when it failed. +#[derive(Clone)] +struct PendingSymbology { + project: u64, + map: usize, + layer: u32, + kind: &'static str, + error: std::sync::Arc, + renderer: std::sync::Arc, +} + +fn clear_pending_symbology(ctx: &egui::Context) { + ctx.data_mut(|data| data.remove::(egui::Id::new(PENDING_SYMBOLOGY))); +} + +/// The pending retry, unless another project, map or layer is in view or the layer's renderer +/// changed since the switch failed (an edit from the pane, the ribbon, an agent or undo +/// supersedes it); a stale one is dropped. +fn pending_symbology(app: &RustGisApp, ctx: &egui::Context) -> Option { + let pending = ctx.data(|data| data.get_temp::(egui::Id::new(PENDING_SYMBOLOGY)))?; + let current = pending.project == app.session.opened + && pending.map == app.session.project.active_map + && Some(pending.layer) == app.session.active_layer + && app.session.map().layer(pending.layer).is_some_and(|l| l.renderer == *pending.renderer); + if !current { + clear_pending_symbology(ctx); + } + current.then_some(pending) +} + /// Shows the active layer's renderer controls, including No Fill for polygon layers. fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let Some(l) = app.session.active_layer.and_then(|id| app.session.map().layer(id)).cloned() else { @@ -3323,6 +3362,7 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }; let sel = cur_kind == *label; let (r, resp) = ui.allocate_exact_size(vec2(68.0, 56.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, sel, tl!(*label))); ui.painter().rect_filled( r, CornerRadius::same(6), @@ -3350,7 +3390,20 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let short = tl!(label).split(' ').next().unwrap_or(label); ui.painter().text(pos2(r.center().x, r.max.y - 11.0), Align2::CENTER_CENTER, short, regular(11.0), t.text); if resp.on_hover_text(tl!(*label)).clicked() && !sel { - let _ = app.run("layer.symbology", json!({"type": code})); + match app.run("layer.symbology", json!({"type": code})) { + Err(error) => { + let pending = PendingSymbology { + project: app.session.opened, + map: app.session.project.active_map, + layer: l.id, + kind: code, + error: error.into(), + renderer: std::sync::Arc::new(l.renderer.clone()), + }; + ui.data_mut(|data| data.insert_temp(egui::Id::new(PENDING_SYMBOLOGY), pending)); + } + Ok(_) => clear_pending_symbology(ui.ctx()), + } } } }); @@ -3363,6 +3416,29 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } let numeric: Vec = l.dataset.fields.iter().filter(|f| f.ty.is_numeric()).map(|f| f.name.clone()).collect(); let all: Vec = l.dataset.fields.iter().map(|f| f.name.clone()).collect(); + if let Some(pending) = pending_symbology(app, ui.ctx()) { + ui.label(egui::RichText::new(pending.error.as_ref()).color(ui.visuals().error_fg_color)); + let fields = if pending.kind == "unique" { &all } else { &numeric }; + if !fields.is_empty() { + ui.horizontal(|ui| { + ui.label(tl!("Choose a field to apply this renderer:")); + egui::ComboBox::from_id_salt("symbology_pending_field_choice").selected_text(tl!("Select a field")).show_ui(ui, |ui| { + for field in fields { + if ui.selectable_label(false, field).clicked() { + match app.run("layer.symbology", json!({"type": pending.kind, "field": field})) { + Ok(_) => clear_pending_symbology(ui.ctx()), + Err(error) => { + let pending = PendingSymbology { error: error.into(), ..pending.clone() }; + ui.data_mut(|data| data.insert_temp(egui::Id::new(PENDING_SYMBOLOGY), pending)); + } + } + } + } + }); + }); + } + ui.add_space(6.0); + } match &l.renderer { Renderer::Single(s) => symbol_editor(app, ui, *s, g, t), Renderer::Unique { field, classes, ramp, .. } => { @@ -4994,6 +5070,177 @@ mod tests { assert_eq!((app.ui.layer_filter.as_str(), app.ui.layer_filter_hid), ("", None)); } + fn symbology_retry_harness() -> egui_kittest::Harness<'static, RustGisApp> { + let mut app = RustGisApp::new(Session::new(rustgis_engine::Project::default())); + app.run( + "layer.addDataText", + json!({ + "fileName": "retry.geojson", + "text": r#"{"type":"FeatureCollection","features":[ + {"type":"Feature","properties":{"a_empty":null,"b_empty":null,"c_valid":10},"geometry":{"type":"Point","coordinates":[14,47]}}, + {"type":"Feature","properties":{"a_empty":null,"b_empty":null,"c_valid":20},"geometry":{"type":"Point","coordinates":[15,48]}}, + {"type":"Feature","properties":{"a_empty":100,"b_empty":200,"c_valid":30},"geometry":{"type":"Point","coordinates":[16,49]}} + ]}"# + }), + ) + .unwrap(); + app.run("layer.definitionQuery", json!({"where": "c_valid < 30"})).unwrap(); + app.ui.open_right(RightPane::Symbology); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + h + } + + /// A different populated field fixes classification without changing the query or styling on failure. + #[test] + fn failed_symbology_switch_retries_another_numeric_field() { + use egui_kittest::kittest::Queryable; + let mut h = symbology_retry_harness(); + let id = layer(h.state(), "retry"); + let before = h.state().session.map().layer(id).unwrap().renderer.clone(); + let undo = h.state().session.undo_depth(); + h.get_by_label("Graduated Colors").click(); + h.run_steps(3); + assert_eq!(h.state().session.map().layer(id).unwrap().renderer, before); + assert_eq!(h.state().session.undo_depth(), undo); + assert!(h.query_all_by_label("'a_empty' has no numeric values").next().is_some()); + + for field in ["b_empty", "c_valid"] { + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Select a field")).unwrap().click(); + h.run_steps(3); + h.get_by_label(field).click(); + h.run_steps(3); + if field == "b_empty" { + assert_eq!(h.state().session.map().layer(id).unwrap().renderer, before); + assert_eq!(h.state().session.undo_depth(), undo); + assert!(h.query_all_by_label("'b_empty' has no numeric values").next().is_some()); + } + } + let Renderer::Graduated { field, breaks, .. } = &h.state().session.map().layer(id).unwrap().renderer else { + panic!("the populated field must apply Graduated Colors"); + }; + assert_eq!(field, "c_valid"); + assert_eq!(breaks.last(), Some(&20.0), "only the rows kept by the query are classified"); + assert_eq!(h.state().session.undo_depth(), undo + 1); + assert!(h.query_all_by_label("Choose a field to apply this renderer:").next().is_none()); + } + + /// No numeric candidates means an explanation, not an empty field selector. + #[test] + fn failed_symbology_without_numeric_fields_has_no_empty_picker() { + use egui_kittest::kittest::Queryable; + let mut h = symbology_retry_harness(); + h.state_mut() + .run( + "layer.addDataText", + json!({ + "fileName": "text-only.geojson", + "text": r#"{"type":"Feature","properties":{"name":"Liezen"},"geometry":{"type":"Point","coordinates":[14,47]}}"# + }), + ) + .unwrap(); + h.run_steps(3); + h.get_by_label("Graduated Colors").click(); + h.run_steps(3); + assert!(h.query_all_by_label("no numeric field is available for this symbology").next().is_some()); + assert!( + h.query_all_by_role(egui::accesskit::Role::ComboBox).all(|n| n.value().as_deref() != Some("Select a field")), + "an empty picker cannot help choose a numeric field" + ); + let id = layer(h.state(), "text-only"); + assert!(matches!(h.state().session.map().layer(id).unwrap().renderer, Renderer::Single(_))); + } + + /// A failed switch is not offered again after the pane closes, or after a layer or map round + /// trip back to the same layer id. + #[test] + fn pending_symbology_ends_with_the_pane_or_its_layer() { + use egui_kittest::kittest::Queryable; + let mut h = symbology_retry_harness(); + let map = h.state().session.map().name.clone(); + let id = layer(h.state(), "retry"); + h.state_mut() + .run("layer.addDataText", json!({"fileName": "other.csv", "text": "x,y,name\n1,2,Other\n", "x": "x", "y": "y"})) + .unwrap(); + let other = layer(h.state(), "other"); + h.state_mut().run("map.new", json!({"name": "Other map"})).unwrap(); + h.state_mut() + .run("layer.addDataText", json!({"fileName": "collision.csv", "text": "x,y,name\n1,2,Other\n", "x": "x", "y": "y"})) + .unwrap(); + assert_eq!(layer(h.state(), "collision"), id, "layer ids overlap across maps"); + h.state_mut().run("map.activate", json!({"name": map})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": id})).unwrap(); + h.run_steps(3); + + for change in ["close the pane", "layer round trip", "map round trip"] { + h.get_by_label("Graduated Colors").click(); + h.run_steps(3); + assert!(h.query_all_by_label("Choose a field to apply this renderer:").next().is_some()); + match change { + "close the pane" => { + h.state_mut().ui.right = None; + h.run_steps(3); + h.state_mut().ui.open_right(RightPane::Symbology); + } + "layer round trip" => { + h.state_mut().session.active_layer = Some(other); + h.run_steps(3); + h.state_mut().session.active_layer = Some(id); + } + _ => { + // The other map's active layer has the same id: only the map tells them apart. + h.state_mut().run("map.activate", json!({"name": "Other map"})).unwrap(); + assert_eq!(h.state().session.active_layer, Some(id)); + h.run_steps(3); + h.state_mut().run("map.activate", json!({"name": map})).unwrap(); + h.state_mut().run("layer.select", json!({"layer": id})).unwrap(); + } + } + h.run_steps(3); + assert!(h.query_all_by_label("Choose a field to apply this renderer:").next().is_none(), "{change}"); + assert!(matches!(h.state().session.map().layer(id).unwrap().renderer, Renderer::Single(_))); + } + } + + /// Both the existing field editor and commands from outside the pane supersede a failed switch. + #[test] + fn successful_symbology_edits_supersede_a_pending_switch() { + use egui_kittest::kittest::Queryable; + let mut h = symbology_retry_harness(); + let id = layer(h.state(), "retry"); + for edit in ["pane field", "ribbon", "agent"] { + h.state_mut().run("layer.symbology", json!({"type": "unique", "field": "a_empty"})).unwrap(); + h.run_steps(3); + h.get_by_label("Graduated Colors").click(); + h.run_steps(3); + assert!(h.query_all_by_label("Choose a field to apply this renderer:").next().is_some()); + let params = json!({"type": "unique", "field": "c_valid"}); + match edit { + "pane field" => { + h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("a_empty")).unwrap().click(); + h.run_steps(3); + h.get_by_label("c_valid").click(); + } + "ribbon" => drop(h.state_mut().run("layer.symbology", params).unwrap()), + // An attached agent's command runs on the session, not through the app. + _ => drop(h.state_mut().session.run("layer.symbology", ¶ms).unwrap()), + } + h.run_steps(3); + let Renderer::Unique { field, .. } = &h.state().session.map().layer(id).unwrap().renderer else { + panic!("the successful edit must keep Unique Values"); + }; + assert_eq!(field, "c_valid"); + assert!(h.query_all_by_label("Choose a field to apply this renderer:").next().is_none()); + } + } + /// The Symbology pane's Transparency slider changes the layer while it's dragged, not only /// when it's let go, and the whole drag is one undo step back to where it started. #[test] diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 6660794d7..a50178c82 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -10,14 +10,21 @@ Select a layer in Layers and open **Style › Symbology** (or the Symbology pane): - **Single Symbol**: one fill, outline and marker for every feature. -- **Unique Values**: one colour per distinct value of a field. The Layers pane and layout legends - list only the classes that drawn features use (after the definition query and display filters); +- **Unique Values**: one colour per distinct value of a field. If no field is chosen, the current + renderer's field is kept when available; otherwise the first available field is used, including + text fields. The Layers pane and layout legends list only the + classes that drawn features use (after the definition query and display filters); `` appears only when a drawn feature falls outside the classes and its symbol is not fully transparent. A layout legend can leave it out with **Hide other values** (see [Layouts](layouts.md)). - **Graduated Colors**: 1 to 12 classes of a numeric field by natural breaks (Jenks), equal - interval or quantile. -- **Proportional Symbols**: point size grows with a numeric field. + interval or quantile. If no field is chosen, the current renderer's field is kept when it is + numeric; otherwise the first numeric field is used. If the layer has no numeric field, or the + switch fails for another reason, the pane shows why and keeps the current renderer. When numeric + fields exist, a field picker lets you try another one; the error stays visible if a retry fails. + A problem no field can fix, such as a definition query that keeps no features, needs fixing + first. Closing the pane or selecting another layer drops the retry. +- **Proportional Symbols**: point size grows with a numeric field, chosen as for Graduated Colors. Pick a colour scheme from the pane's swatches, listed in groups: **Perceptually Uniform** (Viridis, Plasma, Inferno, Magma, Cividis, Batlow and Aurora: even steps in lightness, so they read in From 87bb73c81acdc3ab134b8f8ac526ddc763c594fb Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Fri, 23 Oct 2026 18:47:07 -0400 Subject: [PATCH 079/297] test(gpu): a settled planet frame without its tile yet hides the overlay too (#776) planet_layer_toggles_show_in_the_next_frame_and_stale_jobs_do_not_restore_them checked the settled frame's centre with red > green, which asks whether the Moon tile is there rather than whether the hidden overlay came back. On macOS CI a settled frame showed the body's plain grey (227, 227, 227) at the centre and failed. Check the overlay's green, as the preview checks above it do. --- crates/ui-egui/tests/gpu_fastpath.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/gpu_fastpath.rs b/crates/ui-egui/tests/gpu_fastpath.rs index f3ce47b70..5a95e911a 100644 --- a/crates/ui-egui/tests/gpu_fastpath.rs +++ b/crates/ui-egui/tests/gpu_fastpath.rs @@ -234,7 +234,9 @@ fn planet_layer_toggles_show_in_the_next_frame_and_stale_jobs_do_not_restore_the let image = h.render().expect("settled frame"); let center = h.state().map.view_rect.expect("map").0.center(); let p = image.get_pixel(center.x.round() as u32, center.y.round() as u32); - assert!(p[0] > p[1], "an old worker restored a hidden overlay: {p:?}"); + // The overlay's green, as above: a tile still to come shows the body's plain grey, + // which is no overlay either (macOS CI drew one such frame). + assert!(u16::from(p[1]) <= u16::from(p[0]) * 2, "{projection} GPU={gpu}: an old worker restored a hidden overlay: {p:?}"); } } } From e5f8555927b61ca2789feb747c9110cf38ca7bf9 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Fri, 23 Oct 2026 21:43:13 -0300 Subject: [PATCH 080/297] Analysis: summarise any data cube over time, by year or month of the year (#579) (#774) * Add cube.reduce: a statistic over time of any data cube (#579) cube.reduce summarises each cell over the dates of any series cube.trend takes (layers, a raster's bands, a Zarr variable, a STAC collection's band): mean, min, max, sum, std, count or countAbove, for the whole period or one layer per calendar year ("by": "year") or month of the year ("by": "month"), with start/end/months and minValid as a share of each group's dates. The slices are folded into running totals as they are read, so a long period takes no more memory than a short one; a STAC area is read in pieces and stitched as for a trend. The reader loop, the grid check and the stitching move out of TrendRun into shared helpers, and zarr.reduce uses the same Stat and running totals. Co-Authored-By: Claude Opus 5.5 * cube.reduce: every key at the top of its description, so none is said to be ignored Co-Authored-By: Claude Opus 5.5 * cube.reduce: a STAC collection's band summarised, tested A local STAC API and range-served COGs, as for the STAC trend: the mean of the whole period and of each month matches each file's own scale and offset, cell by cell, on the full images' grid. Co-Authored-By: Claude Opus 5.5 * cube.reduce: a group's totals made at its first date, finished at its last Every group's running totals were made at once, up to 20 bytes a cell for std, so a summary by year near the 32-million-value cap could hold some 640 MB of totals. A group's totals are now made when its first date is read and turned into its output when its last is, so a summary by year holds one year's at a time; a group with no data in a piece gives what empty totals would. The collection's note names its keys, as cube.trend's does (#774 review). Co-Authored-By: Claude Opus 5.5 * cube.reduce: classified for plugins and recorded steps as cube.trend is A plugin with 'files' alone could reach a STAC API through cube.reduce's url, since only cube.trend and zarr.reduce were in DATABASES; it now needs 'files' and 'network' for both, tested. cube.reduce defaults to the active layer, so it joins DEFAULTS_TO_ACTIVE_LAYER, which every_optional_layer_is_ classified asks for. Co-Authored-By: Claude Opus 5.5 * cube.reduce: Summarise, as zarr.reduce's label and the guide spell it Co-Authored-By: Claude Opus 5.5 * cube.reduce: the size cap checked across pieces; the order of arrival said A cube that doesn't know its size had only each piece checked against MAX_REDUCED; the cells of the pieces read so far are now checked too, as the trend does. The notes say what the order of arrival does: read one at a time a summary by year holds one year's totals, with several readers (a STAC cube's) those of the dates in flight, and an output's last bit at most can move (#774 review). Co-Authored-By: Claude Opus 5.5 * cube.reduce: a date that can't be read refused, not put in group 0; junk reaches its keys A step whose date isn't finite would have gone to year or month 0; it is now refused with what to give. hostile_params_never_panic gains two junk sets with a statistic, so cube.reduce's threshold, groups, share, months and dates are reached (#774 review). Co-Authored-By: Claude Opus 5.5 * cube.reduce: a cube a trend can't take is summarised; each run names itself cube_from took cube.trend's limits for both commands: four dates at least and a Zarr axis in increasing order, and its errors said "trend". It now takes what the run needs (TREND or SUMMARY): a summary takes two layers or bands, or a Zarr axis in any order, and the messages say trend or summary (#774 review). Co-Authored-By: Claude Opus 5.5 * cube.reduce: a period named from its earliest date to its latest A Zarr axis in decreasing order, which a summary takes, put its last date first in the outputs' names; the names now take the earliest and latest of the dates read, tested. The STAC cube's date cap, reached by both runs, says trend or summary (#774 review). Co-Authored-By: Claude Opus 5.5 * cube.reduce: a summary takes two dates or more, layers and bands alike A stack of one layer was taken while a raster of one band wasn't, and the message read "1 layers". A summary now needs two dates either way; the note on Needs says why (#774 review). Co-Authored-By: Claude Opus 5.5 * cube.reduce: a STAC summary compared within its last bit, not to the bit A STAC cube's dates come as read, eight at a time, so the totals' order and an output's last bit can differ between runs; the comparison of a summary read whole, in pieces and by year failed once in CI for that. It now allows 1e-6. Co-Authored-By: Claude Opus 5.5 * cube.reduce: the totals' memory by month said, in the code and the guide A summary by month keeps every month's running totals while it reads: up to five times its outputs' memory for std, three for the other statistics, some 640 MB for std at the cap on the desktop. MAX_REDUCED's note and the guide's Size paragraph now say so (#774 review). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/cube.rs | 612 ++++++++++++++++++++++++++----- crates/engine/src/cmd/plugins.rs | 1 + crates/engine/src/cmd/stac.rs | 2 +- crates/engine/src/cmd/zarr.rs | 106 +----- crates/engine/src/tests.rs | 252 +++++++++++++ crates/ui-egui/src/lib.rs | 1 + docs/user-guide/analysis.md | 30 ++ 7 files changed, 812 insertions(+), 192 deletions(-) diff --git a/crates/engine/src/cmd/cube.rs b/crates/engine/src/cmd/cube.rs index bc2e8cd94..7895df4bf 100644 --- a/crates/engine/src/cmd/cube.rs +++ b/crates/engine/src/cmd/cube.rs @@ -9,6 +9,8 @@ //! //! `cube.trend` reads the slices into one block, cell by date, and works out each cell's trend //! with [`rustgis_analysis::trend`]: Sen's slope and the modified Mann–Kendall test. +//! `cube.reduce` folds the slices into running totals as they are read: a statistic over time, +//! for the whole period or a year (or a month of the year) at a time. use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -23,6 +25,9 @@ use crate::{CmdResult, CommandSpec as C, Session}; pub fn commands() -> Vec { vec![C::new("cube.trend", "Trend Analysis", "Analyze › Image Analysis", trend_cmd).params( r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer (or "path" | "url", "variable", "index", "crs" as for zarr.reduce) or a raster of several bands, a date each (default: the active layer) | "collection": a STAC collection (desktop; read in place over an area, in pieces when large; with url, asset, bbox, maxCloud, cellSize and resampling below, and "start", "end"), "url"?: the collection's STAC API (as for stac.addCube), "asset"?: the collection's band, such as NDVI, "bbox"?: [w, s, e, n] | "view" (the collection's area), "maxCloud"?: a collection's cloud cover cap, "cellSize"?: a collection's cell size in the files' units (the overview level nearest it is read), "resampling"?: "nearest" (default: that overview's pixels as stored) | "average" (each cell the mean of the full images' pixels in it, which reads them), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "start"?, "end"?: dates (a whole number from 1000 to 9999 is a year; an end given as a year or a month takes all of it), "months"?: [1–12] (one season across years), "test"?: "hamedRao" (default) | "yueWang" | "seasonal" | "original", "seasons"?: parts of a year for the seasonal test (default 12), "alpha"?: 0.05 (above 0, below 0.5), "minValid"?: dates a cell needs (default: half of them, at least 4; the seasonal test also needs 4 in seasons of two or more), "per"?: "year" (default) | "decade", "name"?: text, "ran"?: (internal: a trend the app has worked out)} → four raster layers: Sen's slope, the test's Z and p-value, and the slope where p < alpha"#, + ), + C::new("cube.reduce", "Summarise a Data Cube over Time", "Analyze › Image Analysis", reduce_cmd).params( + r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer or a raster of several bands, a date each (default: the active layer) | "path"?: a Zarr store's folder | "url"?: a Zarr store's address, or a STAC collection's API (as for stac.addCube) | "collection": a STAC collection (desktop; read in place over an area, in pieces when large; with url, asset, bbox, maxCloud, cellSize and resampling below, and "start", "end"), "variable"?, "index"?, "crs"?: a Zarr variable's (as for zarr.reduce), "asset"?: the collection's band, such as NDVI, "bbox"?: [w, s, e, n] | "view" (the collection's area), "maxCloud"?: a collection's cloud cover cap, "cellSize"?: a collection's cell size in the files' units (the overview level nearest it is read), "resampling"?: "nearest" (default) | "average" (as for cube.trend), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "stat": "mean"|"min"|"max"|"sum"|"std" (the population standard deviation, n)|"count"|"countAbove", "above"?: number (countAbove's threshold), "by"?: "all" (default: one layer) | "year" (a layer for each calendar year) | "month" (a layer for each month of the year, across years), "start"?, "end"?: dates (as for cube.trend), "months"?: [1–12] (a season across years), "minValid"?: 0–1 (a cell with a smaller share of its group's dates is left empty; default 0), "name"?: text} → raster layers of the statistic, one for each group"#, )] } @@ -228,17 +233,33 @@ fn date_list(p: &Value) -> Result>, String> { } } +/// What a run asks of its cube, and its name in what it says: a trend needs a few dates and a +/// Zarr axis in time order (the test's sign and count follow it); a summary takes two dates or more +/// in any order (a raster of one band is no cube, and one date has nothing to summarise). +#[derive(Clone, Copy, Debug)] +pub struct Needs { + pub what: &'static str, + pub min_dates: usize, + pub ordered: bool, +} + +/// What `cube.trend` asks of its cube. +pub const TREND: Needs = Needs { what: "trend", min_dates: trend::MIN_VALUES, ordered: true }; +/// What `cube.reduce` asks of its cube. +pub const SUMMARY: Needs = Needs { what: "summary", min_dates: 2, ordered: false }; + /// The cube a command's parameters name: a stack of layers, a Zarr variable, or a raster's bands. -pub fn cube_from(s: &Session, p: &Value) -> Result, String> { +pub fn cube_from(s: &Session, p: &Value, needs: Needs) -> Result, String> { let dates = date_list(p)?; + let what = needs.what; if let Some(list) = p.get("layers").filter(|v| !v.is_null()) { let list = list.as_array().ok_or("layers is a list of raster layers")?; - if list.len() < trend::MIN_VALUES { - return Err(format!("a stack needs {} layers or more, a date each", trend::MIN_VALUES)); + if list.len() < needs.min_dates { + return Err(format!("a {what}'s stack needs {} layers or more, a date each", needs.min_dates)); } // At most a run's dates, refused before each is looked up and checked against the others. if list.len() > MAX_DATES { - return Err(format!("{} layers; a trend takes {MAX_DATES} dates at most", list.len())); + return Err(format!("{} layers; a {what} takes {MAX_DATES} dates at most", list.len())); } if dates.as_ref().is_some_and(|d| d.len() != list.len()) { return Err("dates has a date for each layer, in their order".into()); @@ -286,7 +307,7 @@ pub fn cube_from(s: &Session, p: &Value) -> Result, String> { // A STAC collection's band, read from its files over an area (#579): named by its collection. if given("collection") { #[cfg(target_arch = "wasm32")] - return Err("in the web app, a STAC collection's trend can't be worked out yet (the desktop app can)".into()); + return Err(format!("in the web app, a STAC collection's {what} can't be worked out yet (the desktop app can)")); #[cfg(not(target_arch = "wasm32"))] return Ok(Box::new(super::stac::stac_cube(s, p)?)); } @@ -302,13 +323,13 @@ pub fn cube_from(s: &Session, p: &Value) -> Result, String> { if given("path") || given("url") || zarr_layer { // The web app reads no Zarr store along its time axis yet: said, not taken for bands. #[cfg(target_arch = "wasm32")] - return Err("in the web app, a Zarr variable's trend can't be worked out yet (the desktop app can)".into()); + return Err(format!("in the web app, a Zarr variable's {what} can't be worked out yet (the desktop app can)")); #[cfg(not(target_arch = "wasm32"))] { let cube = super::zarr::zarr_cube(s, p)?; // A trend takes the steps in time order: an axis out of order (descending) or with a - // date twice would turn the test's sign or count a date twice. - if !cube.zt.dates.windows(2).all(|w| w[0] < w[1]) { + // date twice would turn the test's sign or count a date twice. A summary doesn't mind. + if needs.ordered && !cube.zt.dates.windows(2).all(|w| w[0] < w[1]) { return Err(format!( "{}'s time axis isn't in increasing order without repeats, so a trend can't be read from it", cube.zt.variable @@ -320,12 +341,12 @@ pub fn cube_from(s: &Session, p: &Value) -> Result, String> { let id = s.layer_id(p)?; let l = s.map().layer(id).ok_or("no such layer")?; let r = l.raster.as_ref().and_then(|r| r.data.clone()).ok_or_else(|| format!("{} isn't a raster", l.name))?; - if r.bands.len() < trend::MIN_VALUES { + if r.bands.len() < needs.min_dates { return Err(format!( - "{} has {} band(s): a cube is a raster of {} bands or more (a date each), a Zarr variable with a time dimension, or \"layers\"", + "{} has {} band(s): a {what}'s cube is a raster of {} bands or more (a date each), a Zarr variable with a time dimension, or \"layers\"", l.name, r.bands.len(), - trend::MIN_VALUES + needs.min_dates )); } if dates.as_ref().is_some_and(|d| d.len() != r.bands.len()) { @@ -532,29 +553,13 @@ impl TrendRun { return Err(e); } let need = self.min_valid.unwrap_or((n / 2).max(trend::MIN_VALUES)).max(trend::MIN_VALUES); - let mut first_piece: Option> = None; - let mut rows = 0usize; + let mut stitch = Stitch::default(); // The four outputs, a band each, filled piece by piece (never held interleaved whole). let mut bands: [Vec; OUTPUTS] = Default::default(); let mut cells_done = 0usize; for piece in 0..self.cube.pieces().max(1) { let (grid, block, cells) = self.read_piece(piece, cancel, done, whole.is_none().then_some(&too_long))?; - // Stitched under the first piece's georeferencing: every piece on its columns, pixel - // size and coordinate system (its rows follow on from the one above). - if let Some(f) = &first_piece { - let near = |a: f64, b: f64| (a - b).abs() <= f.transform.pixel_width.abs() * 1e-3; - let (a, b) = (&f.transform, &grid.transform); - // Its top on the row after the pieces above it. - let top = a.origin_y + rows as f64 * a.pixel_height; - if (f.width, f.crs) != (grid.width, grid.crs) - || !near(a.origin_x, b.origin_x) - || !near(top, b.origin_y) - || !near(a.pixel_width, b.pixel_width) - || !near(a.pixel_height, b.pixel_height) - { - return Err("the cube's pieces aren't one grid".into()); - } - } + stitch.check(&grid)?; // Progress through all the cells: a part of the bar every `tick` cells. let tick = whole.unwrap_or(cells).div_ceil(CELL_PARTS).max(1); let part = self.work_out(&block, cells, cells_done, tick, need, ×, &seasons, cancel, done)?; @@ -571,10 +576,9 @@ impl TrendRun { band.extend(part.chunks(OUTPUTS).map(|o| o.get(k).copied().unwrap_or(f32::NAN))); } cells_done += cells; - rows += grid.height as usize; - first_piece.get_or_insert(grid); + stitch.add(grid); } - let grid = first_piece.ok_or("no date to read")?; + let (grid, height) = stitch.whole()?; if let Some(e) = self.cube.nothing_read() { return Err(e); } @@ -586,7 +590,6 @@ impl TrendRun { format!("{} p-value ({})", self.name, self.test.code()), format!("{} significant slope per {unit} (p < {})", self.name, self.alpha), ]; - let height = u32::try_from(rows).map_err(|_| "too many rows")?; names .into_iter() .zip(bands) @@ -635,17 +638,7 @@ impl TrendRun { block = vec![f32::NAN; cells * n]; grid = Some(r.clone()); } - Some(g) if (g.width, g.height, g.crs) != (r.width, r.height, r.crs) || !same_grid(&g.transform, &r.transform) => { - return Err(format!( - "the slice at {} is {} × {} on another grid than the first ({} × {}): put them on one grid first (Raster Functions › Resample)", - rustgis_data::time::format(self.cube.dates().get(i).copied().unwrap_or(f64::NAN), "YYYY-MM-DD"), - r.width, - r.height, - g.width, - g.height - )); - } - Some(_) => {} + Some(g) => off_grid(self.cube.as_ref(), i, g, &r)?, } let nodata = r.nodata.map(|v| v as f32); for (c, v) in vals.iter().enumerate().take(cells) { @@ -659,55 +652,7 @@ impl TrendRun { done.fetch_add(1, Ordering::Relaxed); Ok(()) }; - // One at a time in the browser, which has no threads to read on. - let readers = if cfg!(target_arch = "wasm32") { 1 } else { self.cube.readers().clamp(1, 16).min(n.max(1)) }; - if readers == 1 { - for (k, &i) in self.steps.iter().enumerate() { - if cancel.load(Ordering::Relaxed) { - return Err("cancelled".into()); - } - place(k, self.cube.slice_of(i, piece)?)?; - } - } else { - // Several dates read at once, placed here as they come; a few wait at most, so the - // slices held stay a handful. An error (or Cancel) drops the receiver, which stops the - // readers at their next date. A reader in a read when that happens finishes it first: - // on a stalled server the scope waits out its timeout (two minutes, `cog`'s blocking - // reads have no cancel hook), so Cancel can take that long to return. - #[cfg(not(target_arch = "wasm32"))] - { - let next = AtomicUsize::new(0); - std::thread::scope(|scope| -> Result<(), String> { - type Read = Result, usize)>, String>; - let (tx, rx) = std::sync::mpsc::sync_channel::<(usize, Read)>(readers); - for _ in 0..readers { - let (tx, next) = (tx.clone(), &next); - scope.spawn(move || { - loop { - let k = next.fetch_add(1, Ordering::Relaxed); - let Some(&i) = self.steps.get(k) else { break }; - if cancel.load(Ordering::Relaxed) || tx.send((k, self.cube.slice_of(i, piece))).is_err() { - break; - } - } - }); - } - drop(tx); - for (k, read) in rx { - if cancel.load(Ordering::Relaxed) { - return Err("cancelled".into()); - } - place(k, read?)?; - } - Ok(()) - })?; - } - } - // Cancel stops the readers, which ends their dates early without an error: the block is - // only partly read, so the run stops here rather than work out what it has. - if cancel.load(Ordering::Relaxed) { - return Err("cancelled".into()); - } + read_slices(self.cube.as_ref(), &self.steps, piece, cancel, &mut place)?; let grid = grid.ok_or("no date to read")?; Ok((grid, block, cells)) } @@ -790,6 +735,122 @@ impl TrendRun { } } +/// Reads piece `piece` of each of `steps`' slices and hands it to `place` with its place among the +/// steps: in their order when read one at a time, as they come when the cube's +/// [`TimeCube::readers`] read several at once. `cancel` stops it between slices. +fn read_slices( + cube: &dyn TimeCube, + steps: &[usize], + piece: usize, + cancel: &AtomicBool, + place: &mut dyn FnMut(usize, Option<(Arc, usize)>) -> Result<(), String>, +) -> Result<(), String> { + // One at a time in the browser, which has no threads to read on. + let readers = if cfg!(target_arch = "wasm32") { 1 } else { cube.readers().clamp(1, 16).min(steps.len().max(1)) }; + if readers == 1 { + for (k, &i) in steps.iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + return Err("cancelled".into()); + } + place(k, cube.slice_of(i, piece)?)?; + } + } else { + // Several dates read at once, placed here as they come; a few wait at most, so the + // slices held stay a handful. An error (or Cancel) drops the receiver, which stops the + // readers at their next date. A reader in a read when that happens finishes it first: + // on a stalled server the scope waits out its timeout (two minutes, `cog`'s blocking + // reads have no cancel hook), so Cancel can take that long to return. + #[cfg(not(target_arch = "wasm32"))] + { + let next = AtomicUsize::new(0); + std::thread::scope(|scope| -> Result<(), String> { + type Read = Result, usize)>, String>; + let (tx, rx) = std::sync::mpsc::sync_channel::<(usize, Read)>(readers); + for _ in 0..readers { + let (tx, next) = (tx.clone(), &next); + scope.spawn(move || { + loop { + let k = next.fetch_add(1, Ordering::Relaxed); + let Some(&i) = steps.get(k) else { break }; + if cancel.load(Ordering::Relaxed) || tx.send((k, cube.slice_of(i, piece))).is_err() { + break; + } + } + }); + } + drop(tx); + for (k, read) in rx { + if cancel.load(Ordering::Relaxed) { + return Err("cancelled".into()); + } + place(k, read?)?; + } + Ok(()) + })?; + } + } + // Cancel stops the readers, which ends their dates early without an error: the slices are + // only partly read, so the run stops here rather than work out what it has. + if cancel.load(Ordering::Relaxed) { + return Err("cancelled".into()); + } + Ok(()) +} + +/// Refuses slice `r`, at date `i`, when it isn't on the grid of the run's first slice `g`. +fn off_grid(cube: &dyn TimeCube, i: usize, g: &Raster, r: &Raster) -> Result<(), String> { + if (g.width, g.height, g.crs) == (r.width, r.height, r.crs) && same_grid(&g.transform, &r.transform) { + return Ok(()); + } + Err(format!( + "the slice at {} is {} × {} on another grid than the first ({} × {}): put them on one grid first (Raster Functions › Resample)", + rustgis_data::time::format(cube.dates().get(i).copied().unwrap_or(f64::NAN), "YYYY-MM-DD"), + r.width, + r.height, + g.width, + g.height + )) +} + +/// A run's pieces put together row by row, under the first piece's georeferencing. +#[derive(Default)] +struct Stitch { + first: Option>, + rows: usize, +} + +impl Stitch { + /// Whether the next piece follows on from those above it: on the first's columns, pixel size + /// and coordinate system, its top on the row after theirs. + fn check(&self, grid: &Raster) -> Result<(), String> { + let Some(f) = &self.first else { return Ok(()) }; + let near = |a: f64, b: f64| (a - b).abs() <= f.transform.pixel_width.abs() * 1e-3; + let (a, b) = (&f.transform, &grid.transform); + let top = a.origin_y + self.rows as f64 * a.pixel_height; + if (f.width, f.crs) != (grid.width, grid.crs) + || !near(a.origin_x, b.origin_x) + || !near(top, b.origin_y) + || !near(a.pixel_width, b.pixel_width) + || !near(a.pixel_height, b.pixel_height) + { + return Err("the cube's pieces aren't one grid".into()); + } + Ok(()) + } + + /// The next piece, below the others. + fn add(&mut self, grid: Arc) { + self.rows += grid.height as usize; + self.first.get_or_insert(grid); + } + + /// The whole grid: the first piece's georeferencing, and the rows of them all. + fn whole(self) -> Result<(Arc, u32), String> { + let grid = self.first.ok_or("no date to read")?; + Ok((grid, u32::try_from(self.rows).map_err(|_| "too many rows")?)) + } +} + /// Prepares `cube.trend`: the cube, the dates, the test and its options checked. pub fn trend_start(s: &Session, p: &Value) -> Result { // Read for the seasonal test only: another test leaves it alone, whatever it holds. @@ -815,7 +876,7 @@ pub fn trend_start(s: &Session, p: &Value) -> Result { Some("decade") => 10.0, Some(x) => return Err(format!("per is year or decade, not {x}")), }; - let cube = cube_from(s, p)?; + let cube = cube_from(s, p, TREND)?; let period = cube.period(); let steps = steps(cube.dates(), period, p)?; if steps.len() < trend::MIN_VALUES { @@ -876,7 +937,7 @@ fn trend_cmd(s: &mut Session, p: &Value) -> CmdResult { add_outputs(s, rasters, run.steps.len()) } -/// Adds a trend's layers, each as a GeoTIFF the project keeps. +/// Adds a trend's (or a summary's) layers, each as a GeoTIFF the project keeps. pub fn add_outputs(s: &mut Session, rasters: Vec, dates: usize) -> CmdResult { let mut layers = Vec::new(); for r in rasters { @@ -890,6 +951,367 @@ pub fn add_outputs(s: &mut Session, rasters: Vec, dates: usize) -> CmdRe Ok(json!({ "layers": layers, "dates": dates })) } +// --- A summary over time of any cube ------------------------------------------------------------- + +/// What a summary over time takes from each cell's values (`cube.reduce`, `zarr.reduce`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Stat { + Mean, + Min, + Max, + Sum, + Std, + Count, + /// How many of the values are above the threshold. + CountAbove(f64), +} + +impl Stat { + pub(crate) fn code(self) -> &'static str { + match self { + Stat::Mean => "mean", + Stat::Min => "min", + Stat::Max => "max", + Stat::Sum => "sum", + Stat::Std => "std", + Stat::Count => "count", + Stat::CountAbove(_) => "countAbove", + } + } + + /// The statistic asked for: `stat`, and countAbove's threshold, `above`. + pub(crate) fn parse(p: &Value) -> Result { + Ok(match ps(p, "stat").map(str::trim).ok_or("stat is required (mean, min, max, sum, std, count or countAbove)")? { + x if x.eq_ignore_ascii_case("mean") => Stat::Mean, + x if x.eq_ignore_ascii_case("min") || x.eq_ignore_ascii_case("minimum") => Stat::Min, + x if x.eq_ignore_ascii_case("max") || x.eq_ignore_ascii_case("maximum") => Stat::Max, + x if x.eq_ignore_ascii_case("sum") => Stat::Sum, + x if x.eq_ignore_ascii_case("std") => Stat::Std, + x if x.eq_ignore_ascii_case("count") => Stat::Count, + x if x.eq_ignore_ascii_case("countAbove") => { + Stat::CountAbove(super::f(p, "above").ok_or("countAbove takes \"above\", the threshold")?) + } + x => return Err(format!("unknown stat '{x}' (mean, min, max, sum, std, count or countAbove)")), + }) + } +} + +/// The share of its dates a cell needs for a summary's value (`minValid`, 0 to 1; 0 by default). +pub(crate) fn min_share(p: &Value) -> Result { + match p.get("minValid").filter(|v| !v.is_null()) { + None => Ok(0.0), + Some(v) => v.as_f64().filter(|v| (0.0..=1.0).contains(v)).ok_or_else(|| "minValid is a share from 0 to 1".into()), + } +} + +/// A summary's running totals over a run of cells, only those its statistic needs: each cell's +/// count of values, one running value (the mean, the least, the most, the sum or the count above) +/// and, for std, the squared deviations (Welford's method). 4 to 20 bytes a cell. +pub(crate) struct Totals { + stat: Stat, + n: Vec, + acc: Vec, + m2: Vec, +} + +impl Totals { + pub(crate) fn new(stat: Stat, cells: usize) -> Totals { + let acc = match stat { + Stat::Count => Vec::new(), + Stat::Min => vec![f64::INFINITY; cells], + Stat::Max => vec![f64::NEG_INFINITY; cells], + _ => vec![0.0; cells], + }; + let m2 = if matches!(stat, Stat::Std) { vec![0.0; cells] } else { Vec::new() }; + Totals { stat, n: vec![0; cells], acc, m2 } + } + + /// How many cells it totals. + pub(crate) fn cells(&self) -> usize { + self.n.len() + } + + /// Cell `c`'s next value, a finite one. + pub(crate) fn add(&mut self, c: usize, v: f64) { + let Some(k) = self.n.get_mut(c) else { return }; + *k += 1; + let k = f64::from(*k); + let (Some(a), m) = (self.acc.get_mut(c), self.m2.get_mut(c)) else { return }; + match self.stat { + Stat::Mean | Stat::Std => { + let d = v - *a; + *a += d / k; + if let Some(m) = m { + *m += d * (v - *a); + } + } + Stat::Min => *a = a.min(v), + Stat::Max => *a = a.max(v), + Stat::Sum => *a += v, + Stat::CountAbove(t) if v > t => *a += 1.0, + Stat::CountAbove(_) | Stat::Count => {} + } + } + + /// Each cell's statistic over `dates` dates: empty with fewer values than `min_share` of them, + /// except that with no share asked for a count is 0 where a cell never had a value. + pub(crate) fn finish(&self, min_share: f64, dates: usize) -> Vec { + let need = (min_share.clamp(0.0, 1.0) * dates as f64 - 1e-9).ceil().max(1.0) as u32; + let count = matches!(self.stat, Stat::Count | Stat::CountAbove(_)); + self.n + .iter() + .enumerate() + .map(|(c, k)| { + if *k < need && !(count && min_share == 0.0) { + return f32::NAN; + } + let a = self.acc.get(c).copied().unwrap_or(f64::NAN); + (match self.stat { + Stat::Std => (self.m2.get(c).copied().unwrap_or(f64::NAN) / f64::from((*k).max(1))).sqrt(), + Stat::Count => f64::from(*k), + _ => a, + }) as f32 + }) + .collect() + } +} + +/// The most values a summary's outputs hold (cells × outputs): 32 million on the desktop, 8 +/// million in the browser, checked before reading when the cube knows its size and as the pieces +/// are read when it doesn't. Its running totals are a piece's at a time and only for the groups +/// whose dates are being read: one year's in a summary by year read one date at a time (those of +/// the dates in flight with several readers), but every month's in one by month. They take 4 bytes +/// a cell for count, 12 for mean, min, max, sum and countAbove, and 20 for std, so a summary by +/// month at the cap holds up to five times its outputs' 4 bytes a value: some 640 MB for std on the +/// desktop. A STAC cube is read in pieces whose cells × dates stay within [`MAX_VALUES`], so a long +/// series keeps each piece's totals small. +pub const MAX_REDUCED: u64 = MAX_VALUES / 2; + +/// How a summary groups its dates: one output for them all, one for each calendar year, or one for +/// each month of the year across the years (a climatology). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum By { + All, + Year, + Month, +} + +/// A summary over time of any cube (#579), prepared on the session and computed anywhere with +/// [`ReduceRun::run`]: the slices folded into running totals as they are read, so a long period +/// takes no more memory than a short one. +pub struct ReduceRun { + cube: Box, + steps: Vec, + stat: Stat, + min_share: f64, + /// Each step's group, an index into `groups`. + group_of: Vec, + /// Each group's output name and how many of the steps it has. + groups: Vec<(String, usize)>, +} + +impl ReduceRun { + /// The work, for a progress bar: the slices read, every piece's. + pub fn total(&self) -> usize { + self.steps.len() * self.cube.pieces().max(1) + } + + /// How many dates it reads. + pub fn dates(&self) -> usize { + self.steps.len() + } + + /// Reads the slices piece by piece ([`TimeCube::pieces`]), each folded into its group's + /// running totals, and puts the pieces' outputs together row by row: a raster for each group. + /// `done` counts the slices read; `cancel` stops it between slices. + pub fn run(&self, cancel: &AtomicBool, done: &AtomicUsize) -> Result, String> { + let outputs = self.groups.len(); + let too_many = |cells: usize| { + let values = (cells as u64).saturating_mul(outputs as u64); + (values > MAX_REDUCED).then(|| { + format!("{cells} cells × {outputs} outputs is {values} values, more than {MAX_REDUCED}: take a smaller area, a coarser cell size or fewer outputs") + }) + }; + // A cube that knows its size is refused before it reads anything. + let whole = self.cube.size().map(|(w, h)| w.saturating_mul(h)); + if let Some(e) = whole.and_then(too_many) { + return Err(e); + } + let mut stitch = Stitch::default(); + let mut bands: Vec> = vec![Vec::new(); outputs]; + // The cells of the pieces read so far, for a cube that didn't know its size. + let mut read_cells = 0usize; + for piece in 0..self.cube.pieces().max(1) { + let mut grid: Option> = None; + // A group's totals are made at its first date read and turned into its output at its + // last. Dates are asked for in order: read one at a time they come in order, so a + // summary by year holds one year's totals at a time; with several readers (a STAC + // cube's) they come as read, and the groups open are those of the dates in flight, a + // couple of years' for 16-day composites. Totals are f64 and the outputs f32, so the + // order of arrival can move an output's last bit at most, and only then. + let mut totals: Vec> = (0..outputs).map(|_| None).collect(); + let mut seen = vec![0usize; outputs]; + let mut finished: Vec>> = vec![None; outputs]; + let mut place = |k: usize, slice: Option<(Arc, usize)>| -> Result<(), String> { + let g = self.group_of.get(k).copied().filter(|g| *g < outputs).ok_or("a date in no group")?; + // A date with nothing written only lowers the counts. + if let Some((r, b)) = slice { + let i = self.steps.get(k).copied().unwrap_or(0); + let vals = &r.bands.get(b).ok_or("the slice has no such band")?.data; + match &grid { + None => { + if let Some(e) = too_many(vals.len()) { + return Err(e); + } + grid = Some(r.clone()); + } + Some(first) => off_grid(self.cube.as_ref(), i, first, &r)?, + } + let t = totals.get_mut(g).ok_or("a date in no group")?.get_or_insert_with(|| Totals::new(self.stat, vals.len())); + let nodata = r.nodata.map(|v| v as f32); + for (c, v) in vals.iter().enumerate().take(t.cells()) { + if v.is_finite() && Some(*v) != nodata { + t.add(c, f64::from(*v)); + } + } + } + let n = seen.get_mut(g).ok_or("a date in no group")?; + *n += 1; + let dates = self.groups.get(g).map_or(0, |(_, d)| *d); + if *n >= dates + && let (Some(t), Some(out)) = (totals.get_mut(g).and_then(Option::take), finished.get_mut(g)) + { + *out = Some(t.finish(self.min_share, dates)); + } + done.fetch_add(1, Ordering::Relaxed); + Ok(()) + }; + read_slices(self.cube.as_ref(), &self.steps, piece, cancel, &mut place)?; + let grid = grid.ok_or("none of the dates in the period has data written")?; + stitch.check(&grid)?; + // Their full length when the cube knows it, so they don't grow by doubling. + if let (0, Some(total)) = (piece, whole) { + for band in &mut bands { + band.reserve_exact(total); + } + } + let cells = (grid.width as usize).saturating_mul(grid.height as usize); + read_cells = read_cells.saturating_add(cells); + if whole.is_none() + && let Some(e) = too_many(read_cells) + { + return Err(e); + } + for (g, band) in bands.iter_mut().enumerate() { + let dates = self.groups.get(g).map_or(0, |(_, d)| *d); + // A group with no data written in this piece: no values, as its totals would give. + let out = match (finished.get_mut(g).and_then(Option::take), totals.get_mut(g).and_then(Option::take)) { + (Some(out), _) => out, + (None, Some(t)) => t.finish(self.min_share, dates), + (None, None) => Totals::new(self.stat, cells).finish(self.min_share, dates), + }; + band.extend(out); + } + stitch.add(grid); + } + let (grid, height) = stitch.whole()?; + if let Some(e) = self.cube.nothing_read() { + return Err(e); + } + done.store(self.total(), Ordering::Relaxed); + self.groups + .iter() + .zip(bands) + .map(|((name, _), band)| { + Raster::new( + name.clone(), + grid.width, + height, + vec![band], + grid.transform, + grid.crs, + None, + raster::ColorHint::Gray, + "Float32", + ) + .map_err(|e| e.to_string()) + }) + .collect() + } +} + +/// Prepares `cube.reduce`: the cube, the statistic, and the dates of the period (and months) +/// asked for, in their groups. +pub fn reduce_start(s: &Session, p: &Value) -> Result { + let stat = Stat::parse(p)?; + let min_share = min_share(p)?; + let by = match ps(p, "by").map(str::trim) { + None | Some("") => By::All, + Some(x) if x.eq_ignore_ascii_case("all") => By::All, + Some(x) if x.eq_ignore_ascii_case("year") => By::Year, + Some(x) if x.eq_ignore_ascii_case("month") => By::Month, + Some(x) => return Err(format!("by is all, year or month, not {x}")), + }; + let cube = cube_from(s, p, SUMMARY)?; + let steps = steps(cube.dates(), cube.period(), p)?; + let date = |i: usize| cube.dates().get(i).copied().unwrap_or(f64::NAN); + // A date that can't be read would go to no year or month: said, not put in a group of its own. + if let Some(&i) = steps.iter().find(|&&i| !date(i).is_finite()) { + return Err(format!("the cube's date {} can't be read: give the dates (\"dates\")", i + 1)); + } + let part = |i: usize, f: &str| rustgis_data::time::format(date(i), f).parse::().unwrap_or(0); + // Each step's group: its calendar year, its month, or the one group. + let keys: Vec = steps + .iter() + .map(|&i| match by { + By::All => 0, + By::Year => part(i, "YYYY"), + By::Month => part(i, "MM"), + }) + .collect(); + let mut found: Vec = keys.clone(); + found.sort_unstable(); + found.dedup(); + let group_of: Vec = keys.iter().map(|k| found.binary_search(k).unwrap_or(0)).collect(); + // The outputs' names: the cube's label, the statistic and the period, or the name given. + let day = |i: usize| rustgis_data::time::format(date(i), "YYYY-MM-DD"); + // The earliest and the latest date, which a Zarr axis out of order doesn't put first and last. + let earliest = steps.iter().copied().min_by(|x, y| date(*x).total_cmp(&date(*y))); + let latest = steps.iter().copied().max_by(|x, y| date(*x).total_cmp(&date(*y))); + let (a, z) = (earliest.unwrap_or(0), latest.unwrap_or(0)); + let months = months(p)?; + let season = if months.is_empty() { + String::new() + } else { + format!(" (months {})", months.iter().map(u32::to_string).collect::>().join(",")) + }; + let given = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(|n| n.chars().take(200).collect::()); + let base = format!("{} {}", cube.label(), stat.code()); + let groups = found + .iter() + .map(|&key| { + let name = match (by, &given) { + (By::All, Some(n)) => n.clone(), + (By::All, None) => format!("{base} {}–{}{season}", day(a), day(z)), + (By::Year, Some(n)) => format!("{n} {key}"), + (By::Year, None) => format!("{base} {key}{season}"), + (By::Month, Some(n)) => format!("{n} month {key:02}"), + (By::Month, None) => format!("{base} {}–{} month {key:02}", part(a, "YYYY"), part(z, "YYYY")), + }; + (name, keys.iter().filter(|k| **k == key).count()) + }) + .collect(); + Ok(ReduceRun { cube, steps, stat, min_share, group_of, groups }) +} + +/// `cube.reduce`: the summary computed here (blocking), its layers added, each kept in the project +/// as a GeoTIFF. +fn reduce_cmd(s: &mut Session, p: &Value) -> CmdResult { + let run = reduce_start(s, p)?; + let rasters = run.run(&AtomicBool::new(false), &AtomicUsize::new(0))?; + add_outputs(s, rasters, run.dates()) +} + #[cfg(test)] mod tests { /// The outputs' names: the date taken out, a MODIS date's A with it, and no other final A. diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index bc00ee10b..9e6830931 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -306,6 +306,7 @@ const DATABASES: &[&str] = &[ "zarr.series", "zarr.reduce", "cube.trend", + "cube.reduce", ]; /// What a command reaches beyond the project when run with some parameters. diff --git a/crates/engine/src/cmd/stac.rs b/crates/engine/src/cmd/stac.rs index 3a6f167b2..6ffe3cc3c 100644 --- a/crates/engine/src/cmd/stac.rs +++ b/crates/engine/src/cmd/stac.rs @@ -597,7 +597,7 @@ pub fn stac_cube(s: &Session, p: &Value) -> Result { } } if by_day.len() > super::cube::MAX_DATES { - return Err(format!("{} dates; a trend takes {} at most: take a shorter period", by_day.len(), super::cube::MAX_DATES)); + return Err(format!("{} dates; a trend or summary takes {} at most: take a shorter period", by_day.len(), super::cube::MAX_DATES)); } // The first file's header gives the coordinate system, the grid and its overviews. let first = by_day.values().flatten().next().map(|(h, ..)| h.clone()).ok_or("no file to read")?; diff --git a/crates/engine/src/cmd/zarr.rs b/crates/engine/src/cmd/zarr.rs index 4612fe08b..e0a4a4943 100644 --- a/crates/engine/src/cmd/zarr.rs +++ b/crates/engine/src/cmd/zarr.rs @@ -866,34 +866,8 @@ fn series(s: &mut Session, p: &Value) -> CmdResult { // --- A summary over time (opengeos/RustGIS#556) ---------------------------------------------------- -/// What a summary over time takes from each cell's values. #[cfg(not(target_arch = "wasm32"))] -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum Stat { - Mean, - Min, - Max, - Sum, - Std, - Count, - /// How many of the values are above the threshold. - CountAbove(f64), -} - -#[cfg(not(target_arch = "wasm32"))] -impl Stat { - fn code(self) -> &'static str { - match self { - Stat::Mean => "mean", - Stat::Min => "min", - Stat::Max => "max", - Stat::Sum => "sum", - Stat::Std => "std", - Stat::Count => "count", - Stat::CountAbove(_) => "countAbove", - } - } -} +pub use super::cube::Stat; /// A summary over time, prepared on the session (`zarr.reduce`'s parameters checked and the /// steps chosen) and computed anywhere with [`ReduceRun::run`], which reads one slice at a time: @@ -926,9 +900,7 @@ impl ReduceRun { use std::sync::atomic::Ordering; // The first slice's grid (its size, place and coordinate system), all the run keeps of it. let mut first: Option<(u32, u32, raster::GeoTransform, Crs)> = None; - // Only the totals the statistic needs: each cell's count, one running value (the mean, - // the least, the most, the sum or the count above), and for std the squared deviations. - let (mut n, mut acc, mut m2) = (Vec::::new(), Vec::::new(), Vec::::new()); + let mut totals = super::cube::Totals::new(self.stat, 0); // Steps never written (no chunk at all), read as having no value. let mut unwritten = 0usize; for &i in &self.steps { @@ -958,16 +930,7 @@ impl ReduceRun { if len > MAX_REDUCE_CELLS { return Err(format!("{len} cells, more than a summary takes ({MAX_REDUCE_CELLS})")); } - n = vec![0; len]; - acc = match self.stat { - Stat::Count => Vec::new(), - Stat::Min => vec![f64::INFINITY; len], - Stat::Max => vec![f64::NEG_INFINITY; len], - _ => vec![0.0; len], - }; - if matches!(self.stat, Stat::Std) { - m2 = vec![0.0; len]; - } + totals = super::cube::Totals::new(self.stat, len); first = Some((r.width, r.height, r.transform, r.crs)); } // One grid for every step: the same size and coordinate system, its place within a @@ -975,35 +938,16 @@ impl ReduceRun { Some(f) if (f.0, f.1, f.3) != (r.width, r.height, r.crs) || !super::cube::same_grid(&f.2, &r.transform) - || vals.len() != n.len() => + || vals.len() != totals.cells() => { return Err(format!("step {} is {} × {}, unlike the first ({} × {})", i, r.width, r.height, f.0, f.1)); } Some(_) => {} } // The totals are the first slice's length, which every slice is checked to have. - for (c, v) in vals.iter().enumerate().take(n.len()) { - if !v.is_finite() { - continue; - } - let v = f64::from(*v); - let Some(k) = n.get_mut(c) else { continue }; - *k += 1; - let k = f64::from(*k); - let (Some(a), m) = (acc.get_mut(c), m2.get_mut(c)) else { continue }; - match self.stat { - Stat::Mean | Stat::Std => { - let d = v - *a; - *a += d / k; - if let Some(m) = m { - *m += d * (v - *a); - } - } - Stat::Min => *a = a.min(v), - Stat::Max => *a = a.max(v), - Stat::Sum => *a += v, - Stat::CountAbove(t) if v > t => *a += 1.0, - Stat::CountAbove(_) | Stat::Count => {} + for (c, v) in vals.iter().enumerate().take(totals.cells()) { + if v.is_finite() { + totals.add(c, f64::from(*v)); } } done.fetch_add(1, Ordering::Relaxed); @@ -1016,23 +960,7 @@ impl ReduceRun { } })?; // Too few values: empty, unless the statistic is a count. - let need = (self.min_valid.clamp(0.0, 1.0) * self.steps.len() as f64 - 1e-9).ceil().max(1.0) as u32; - let out: Vec = n - .iter() - .enumerate() - .map(|(c, k)| { - let count = matches!(self.stat, Stat::Count | Stat::CountAbove(_)); - if *k < need && !(count && self.min_valid == 0.0) { - return f32::NAN; - } - let a = acc.get(c).copied().unwrap_or(f64::NAN); - (match self.stat { - Stat::Std => (m2.get(c).copied().unwrap_or(f64::NAN) / f64::from((*k).max(1))).sqrt(), - Stat::Count => f64::from(*k), - _ => a, - }) as f32 - }) - .collect(); + let out = totals.finish(self.min_valid, self.steps.len()); // One band: a project keeps the result as a GeoTIFF of one band (the count is a statistic of its own). let mut r = raster::Raster::new(&self.name, first.0, first.1, vec![out], first.2, first.3, None, raster::ColorHint::Gray, "Float32") @@ -1046,22 +974,8 @@ impl ReduceRun { /// asked for. At most the time slider's 20,000 steps. #[cfg(not(target_arch = "wasm32"))] pub fn reduce_start(s: &Session, p: &Value) -> Result { - let stat = match ps(p, "stat").map(str::trim).ok_or("stat is required (mean, min, max, sum, std, count or countAbove)")? { - x if x.eq_ignore_ascii_case("mean") => Stat::Mean, - x if x.eq_ignore_ascii_case("min") || x.eq_ignore_ascii_case("minimum") => Stat::Min, - x if x.eq_ignore_ascii_case("max") || x.eq_ignore_ascii_case("maximum") => Stat::Max, - x if x.eq_ignore_ascii_case("sum") => Stat::Sum, - x if x.eq_ignore_ascii_case("std") => Stat::Std, - x if x.eq_ignore_ascii_case("count") => Stat::Count, - x if x.eq_ignore_ascii_case("countAbove") => { - Stat::CountAbove(super::f(p, "above").ok_or("countAbove takes \"above\", the threshold")?) - } - x => return Err(format!("unknown stat '{x}' (mean, min, max, sum, std, count or countAbove)")), - }; - let min_valid = match p.get("minValid").filter(|v| !v.is_null()) { - None => 0.0, - Some(v) => v.as_f64().filter(|v| (0.0..=1.0).contains(v)).ok_or("minValid is a share from 0 to 1")?, - }; + let stat = Stat::parse(p)?; + let min_valid = super::cube::min_share(p)?; let ZarrCube { zt, period } = zarr_cube(s, p)?; let steps = super::cube::steps(&zt.dates, period, p)?; let months = super::cube::months(p)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 25c905adf..a3cea6946 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1313,6 +1313,9 @@ fn hostile_params_never_panic() { // API on a closed local port, so nothing is read. json!({"collection": "c", "asset": "NDVI", "url": "http://127.0.0.1:9/v1", "bbox": "nowhere", "cellSize": -1e308, "resampling": 3, "start": "2020", "end": "2021"}), json!({"collection": "c", "asset": "NDVI", "url": "http://127.0.0.1:9/v1", "bbox": [1e308, -91, 7, "x"], "start": "2020", "end": "2021"}), + // A summary over time: a statistic given, so its threshold, groups and share are reached. + json!({"stat": "countAbove", "above": "x", "by": 7, "minValid": -1e308, "months": [13, "x", null], "name": {"a": 1}}), + json!({"stat": "std", "by": "month", "minValid": f64::NAN, "dates": ["x", 7, null]}), // MySQL addresses that can't be read, so nothing connects. json!({"database": "mysql://u:p#@[::1/x?ssl-mode=nope", "password": 7, "layer": ":", "crs": "EPSG:-1"}), json!({"database": "MARIADB://u@h:99999999/%zz?ssl-mode=VERIFY_CA&ssl-ca=/nonexistent", "sql": "SELECT 1"}), @@ -5101,6 +5104,20 @@ fn a_failed_panel_refresh_leaves_the_values() { rustgis_plugins::unload("eng-badstate"); } +/// A data cube can be a Zarr store's folder or a STAC API's address, so a plugin needs both +/// `files` and `network` to summarise one or work out its trend. +#[test] +fn plugin_data_cubes_need_files_and_network() { + let mut s = world(); + plugin("eng-cube-files", &["edit", "files"]); + for id in ["cube.reduce", "cube.trend"] { + let p = json!({"url": "https://example.com/stac/v1", "collection": "c", "asset": "NDVI", "stat": "mean"}); + let e = forward(&mut s, "eng-cube-files", id, p).unwrap_err(); + assert!(e.contains("'files' and 'network' permissions"), "{id}: {e}"); + } + rustgis_plugins::unload("eng-cube-files"); +} + #[test] fn plugin_tile_layers_need_network() { let mut s = world(); @@ -8510,6 +8527,37 @@ fn a_trend_over_a_stac_collection() { assert!(a.bands[0].data.iter().zip(&b.bands[0].data).all(|(x, y)| x == y || (x.is_nan() && y.is_nan())), "{}", a.name); assert!(crate::cmd::cube::same_grid(&a.transform, &b.transform)); } + // A summary of the same cube, whole and in pieces: each cell's mean over the six dates, its + // stored v times 3.5 ten-thousandths and 0.025, the same cell for cell. + let mut mean = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-03", "stat": "mean"}); + let summary = |s: &mut Session, p: &Value| { + let r = s.run("cube.reduce", p).unwrap(); + s.map().layer(r["layers"][0].as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap() + }; + // Every key a summary of a collection takes is read, none said to be ignored. + let mark = s.notice_mark(); + let read_whole = summary(&mut s, &mean); + let ignored: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).filter(|t| t.contains("ignores")).collect(); + assert!(ignored.is_empty(), "{ignored:?}"); + assert_eq!((read_whole.width, read_whole.height), (512, 512)); + for (x, y) in [(10, 20), (300, 100), (511, 511)] { + let got = f64::from(read_whole.bands[0].data[y * 512 + x]); + let want = ((x + y) % 256) as f64 * 3.5e-4 + 0.025; + assert!((got - want).abs() < 1e-5, "({x}, {y}): {got} vs {want}"); + } + PIECE_VALUES.with(|v| v.set(Some(512 * 300 * 6))); + let in_pieces = summary(&mut s, &mean); + // A year at a time: one year here, the same values. + mean["by"] = json!("year"); + let by_year = summary(&mut s, &mean); + PIECE_VALUES.with(|v| v.set(None)); + // A STAC cube's dates come as read, eight at a time, so the totals' order (and an output's last + // bit) can differ from run to run: the same values, within that. + for other in [&in_pieces, &by_year] { + let same = |x: &f32, y: &f32| (x - y).abs() <= 1e-6 || (x.is_nan() && y.is_nan()); + assert!(read_whole.bands[0].data.iter().zip(&other.bands[0].data).all(|(x, y)| same(x, y))); + assert!(crate::cmd::cube::same_grid(&read_whole.transform, &other.transform)); + } // Cancelled, the readers stop early and the run says so, rather than work out a block only // partly read. let job = crate::cmd::cube::trend_start(&s, ¶ms).unwrap(); @@ -9139,6 +9187,210 @@ fn a_trend_over_a_data_cube() { } } +/// A data cube summarised over time (#579): each statistic over a stack of layers, a year or a +/// month of the year at a time, a cell needing a share of its group's dates, a raster's bands, and +/// a Zarr variable as `zarr.reduce` sums it up; each output kept in the project. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_data_cube_summarised_over_time() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; + let mut s = Session::new(Project::default()); + let gt = GeoTransform { origin_x: -44.0, origin_y: -7.0, pixel_width: 0.01, pixel_height: -0.01 }; + // March and September, 2020 to 2022: cell 0 is the date's place (0 to 5), cell 1 has no value + // in 2021, cell 2 is the file's no-data value in March, and cell 3 never has a value. + let dates = ["2020-03-01", "2020-09-01", "2021-03-01", "2021-09-01", "2022-03-01", "2022-09-01"]; + let mut names = Vec::new(); + for (k, d) in dates.iter().enumerate() { + let name = format!("ndvi_{d}"); + let gap = if d.starts_with("2021") { f32::NAN } else { 1.0 }; + let march = if k % 2 == 0 { -9999.0 } else { 7.0 }; + let cells = vec![k as f32, gap, march, f32::NAN]; + let r = Raster::new(&name, 2, 2, vec![cells], gt, Crs::LonLat, Some(-9999.0), ColorHint::Gray, "Float32").unwrap(); + let bytes = geotiff::write(&r, 0).unwrap(); + crate::cmd::raster::add_raster(&mut s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); + names.push(name); + } + let data = |s: &Session, id: &Value| s.map().layer(id.as_u64().unwrap() as u32).unwrap().raster.as_ref().unwrap().data.clone().unwrap(); + let name = |s: &Session, id: &Value| s.map().layer(id.as_u64().unwrap() as u32).unwrap().name.clone(); + let one = |s: &mut Session, p: Value| { + let r = s.run("cube.reduce", &p).unwrap(); + assert_eq!(r["layers"].as_array().map(Vec::len), Some(1), "{r}"); + data(s, &r["layers"][0]).bands[0].data.clone() + }; + for (stat, want) in [("mean", 2.5), ("min", 0.0), ("max", 5.0), ("sum", 15.0), ("count", 6.0)] { + assert_eq!(one(&mut s, json!({"layers": names, "stat": stat}))[0], want, "{stat}"); + } + let std = one(&mut s, json!({"layers": names, "stat": "std"}))[0]; + assert!((std - 1.707_825).abs() < 1e-4, "{std}"); + assert_eq!(one(&mut s, json!({"layers": names, "stat": "countAbove", "above": 2.5}))[0], 3.0); + // Gaps and no-data are skipped: cell 1 has four values, cell 2 three, cell 3 none (no mean, + // a count of 0). + assert_eq!(one(&mut s, json!({"layers": names, "stat": "count"})), [6.0, 4.0, 3.0, 0.0]); + let mean = one(&mut s, json!({"layers": names, "stat": "mean"})); + assert_eq!(&mean[..3], [2.5, 1.0, 7.0]); + assert!(mean[3].is_nan()); + assert!(s.map().layer_by_name("ndvi mean 2020-03-01–2022-09-01").is_some()); + // A layer a year, named by it, each the mean of its two dates; cell 1 empty in 2021. + let r = s.run("cube.reduce", &json!({"layers": names, "stat": "mean", "by": "year"})).unwrap(); + let ids = r["layers"].as_array().unwrap().clone(); + assert_eq!(r["dates"], 6); + assert_eq!(ids.iter().map(|id| name(&s, id)).collect::>(), ["ndvi mean 2020", "ndvi mean 2021", "ndvi mean 2022"]); + assert_eq!(ids.iter().map(|id| data(&s, id).bands[0].data[0]).collect::>(), [0.5, 2.5, 4.5]); + assert!(data(&s, &ids[1]).bands[0].data[1].is_nan() && data(&s, &ids[2]).bands[0].data[1] == 1.0); + // A share of each year's dates: 0.6 of two is two, so cell 2 (one value a year) is empty; + // the name given, with the year. + let r = s.run("cube.reduce", &json!({"layers": names, "stat": "count", "by": "year", "minValid": 0.6, "name": "anos"})).unwrap(); + let ids = r["layers"].as_array().unwrap().clone(); + assert_eq!(name(&s, &ids[0]), "anos 2020"); + let first = data(&s, &ids[0]).bands[0].data.clone(); + assert!(first[0] == 2.0 && first[1] == 2.0 && first[2].is_nan() && first[3].is_nan(), "{first:?}"); + // A layer a month of the year, across the years: March's cell 2 is always no-data. + let r = s.run("cube.reduce", &json!({"layers": names, "stat": "mean", "by": "month"})).unwrap(); + let ids = r["layers"].as_array().unwrap().clone(); + assert_eq!(ids.iter().map(|id| name(&s, id)).collect::>(), ["ndvi mean 2020–2022 month 03", "ndvi mean 2020–2022 month 09"]); + let (march, september) = (data(&s, &ids[0]).bands[0].data.clone(), data(&s, &ids[1]).bands[0].data.clone()); + assert!(march[0] == 2.0 && march[2].is_nan() && september[0] == 3.0 && september[2] == 7.0, "{march:?} {september:?}"); + // A season across the years, a year at a time. + let r = s.run("cube.reduce", &json!({"layers": names, "stat": "max", "by": "year", "months": [9]})).unwrap(); + let ids = r["layers"].as_array().unwrap().clone(); + assert_eq!(name(&s, &ids[0]), "ndvi max 2020 (months 9)"); + assert_eq!(ids.iter().map(|id| data(&s, id).bands[0].data[0]).collect::>(), [1.0, 3.0, 5.0]); + // A raster whose bands are the dates. + let bands: Vec> = (0..6).map(|k| vec![k as f32 * 2.0]).collect(); + let mut cube = Raster::new("stack", 1, 1, bands, gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + for (b, d) in cube.bands.iter_mut().zip(&dates) { + b.name = format!("NDVI {d}"); + } + let bytes = geotiff::write(&cube, 0).unwrap(); + let id = + crate::cmd::raster::add_raster(&mut s, "stack", rustgis_map::RasterLayer::from_bytes(cube, "stack.tif", bytes)).unwrap()["layer"] + .clone(); + assert_eq!(one(&mut s, json!({"layer": id, "stat": "mean"})), [5.0]); + // A Zarr variable: the same values as zarr.reduce's, cell for cell. + let (dir, path) = zarr_store_over_time("cube-reduce"); + for stat in ["mean", "std", "max", "count"] { + let r = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": stat})).unwrap(); + let zarr = data(&s, &r["layer"]).bands[0].data.clone(); + let cube = one(&mut s, json!({"path": path, "variable": "t", "stat": stat})); + assert!(zarr.iter().zip(&cube).all(|(a, b)| a == b || (a.is_nan() && b.is_nan())) && zarr.len() == cube.len(), "{stat}"); + } + let _ = std::fs::remove_dir_all(dir); + // Kept in the project as GeoTIFFs, read again. + let text = serde_json::to_string(&s.project).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.openText", &json!({"text": text})).unwrap(); + let again = t.map().layer_by_name("anos 2020").and_then(|l| l.raster.as_ref()).and_then(|r| r.data.clone()).unwrap(); + assert_eq!(again.bands[0].data[0], 2.0); + // Cancelled, it says so. + let job = crate::cmd::cube::reduce_start(&s, &json!({"layers": names, "stat": "mean"})).unwrap(); + let e = job.run(&std::sync::atomic::AtomicBool::new(true), &std::sync::atomic::AtomicUsize::new(0)).unwrap_err(); + assert_eq!(e, "cancelled"); + // Refused, nothing added. + let before = s.map().layers.len(); + for bad in [ + json!({"layers": names}), + json!({"layers": names, "stat": "median"}), + json!({"layers": names, "stat": "countAbove"}), + json!({"layers": names, "stat": "mean", "by": "week"}), + json!({"layers": names, "stat": "mean", "minValid": 1.5}), + json!({"layers": names, "stat": "mean", "start": "2030"}), + ] { + assert!(s.run("cube.reduce", &bad).is_err(), "{bad}"); + } + assert_eq!(s.map().layers.len(), before); +} + +/// A summary takes a cube a trend can't: two dates are enough for a mean or a maximum, while a +/// trend still asks for its few, and each says which it is. +#[test] +fn a_short_cube_is_summarised_but_not_trended() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; + let mut s = Session::new(Project::default()); + let gt = GeoTransform { origin_x: -44.0, origin_y: -7.0, pixel_width: 0.01, pixel_height: -0.01 }; + let mut names = Vec::new(); + for (k, d) in ["2020-03-01", "2021-03-01"].iter().enumerate() { + let name = format!("ndvi_{d}"); + let r = Raster::new(&name, 1, 1, vec![vec![k as f32 * 4.0 + 1.0]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let bytes = geotiff::write(&r, 0).unwrap(); + crate::cmd::raster::add_raster(&mut s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); + names.push(name); + } + for (stat, want) in [("mean", 3.0), ("max", 5.0)] { + let r = s.run("cube.reduce", &json!({"layers": names, "stat": stat})).unwrap(); + let id = r["layers"][0].as_u64().unwrap() as u32; + assert_eq!(s.map().layer(id).unwrap().raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].data[0], want, "{stat}"); + } + let e = s.run("cube.trend", &json!({"layers": names})).unwrap_err(); + assert!(e.contains("trend") && !e.contains("summary"), "{e}"); + // One date has nothing to summarise. + let e = s.run("cube.reduce", &json!({"layers": [names[0]], "stat": "mean"})).unwrap_err(); + assert!(e.contains("summary's stack needs 2 layers"), "{e}"); +} + +/// A Zarr axis in decreasing order: a trend refuses it, a summary takes it and names its period from +/// the earliest date to the latest. +#[test] +fn a_summary_over_a_decreasing_zarr_axis_names_its_period_in_order() { + let (dir, path) = zarr_store_at("cube-reduce-desc", &[59, 31, 0]); + let mut s = Session::new(Project::default()); + let e = s.run("cube.trend", &json!({"path": path, "variable": "t"})).unwrap_err(); + assert!(e.contains("increasing order"), "{e}"); + let r = s.run("cube.reduce", &json!({"path": path, "variable": "t", "stat": "mean"})).unwrap(); + let name = s.map().layer(r["layers"][0].as_u64().unwrap() as u32).unwrap().name.clone(); + assert!(name.contains("2001-01-01–2001-03-01"), "{name}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// A STAC collection's band summarised over time, read in place as for a trend: each file's own +/// scale and offset taken, the whole period or a month at a time. +#[test] +fn a_stac_collection_summarised_over_time() { + let (cog, _) = serve_ranges(rustgis_data::raster::cog::example(512, 256)); + let dates = ["2024-01-01", "2024-01-17", "2024-02-02", "2024-02-18", "2024-03-05", "2024-03-21"]; + let features: Vec = dates + .iter() + .enumerate() + .map(|(k, d)| { + format!( + r#"{{"type": "Feature", "id": "n{k}", "collection": "mod", "bbox": [10, 45, 11, 46], "geometry": null, + "properties": {{"datetime": "{d}T00:00:00Z"}}, + "assets": {{"NDVI": {{"href": "{cog}/n{k}.tif", "eo:bands": [{{"scale": {}, "offset": {}}}]}}}}}}"#, + (k + 1) as f64 * 1e-4, + k as f64 * 0.01 + ) + }) + .collect(); + let search = format!(r#"{{"type": "FeatureCollection", "features": [{}]}}"#, features.join(",")); + let api = format!("{}/v1", serve_routes(vec![("/v1/search", search)], 8)); + let mut s = Session::new(Project::default()); + let mut run = |by: &str| { + let p = json!({"url": api, "collection": "mod", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024-01-01", "end": "2024-03", "stat": "mean", "by": by}); + let r = s.run("cube.reduce", &p).unwrap(); + let ids: Vec = r["layers"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap() as u32).collect(); + ids.iter().map(|id| s.map().layer(*id).unwrap().raster.as_ref().unwrap().data.clone().unwrap()).collect::>() + }; + // Pixel (x, y) stores (x + y) % 256; date k reads it at a scale of k + 1 ten-thousandths and + // an offset of k hundredths, so dates k0 to k1 average to (k0 + k1 + 2) / 2 ten-thousandths + // of it plus (k0 + k1) / 2 hundredths. + let mean = |x: usize, y: usize, k0: usize, k1: usize| { + ((x + y) % 256) as f64 * (k0 + k1 + 2) as f64 / 2.0 * 1e-4 + (k0 + k1) as f64 / 2.0 * 0.01 + }; + let whole = run("all"); + assert_eq!(whole.len(), 1); + assert_eq!((whole[0].width, whole[0].height), (512, 512), "the area on the full images' grid"); + let months = run("month"); + assert_eq!(months.len(), 3, "January, February and March"); + for (x, y) in [(10, 20), (300, 100), (0, 0), (511, 511)] { + let at = y * 512 + x; + let got = f64::from(whole[0].bands[0].data[at]); + assert!((got - mean(x, y, 0, 5)).abs() < 1e-4, "({x}, {y}): {got} vs {}", mean(x, y, 0, 5)); + for (m, (k0, k1)) in [(0, 1), (2, 3), (4, 5)].into_iter().enumerate() { + let got = f64::from(months[m].bands[0].data[at]); + assert!((got - mean(x, y, k0, k1)).abs() < 1e-4, "month {m} ({x}, {y}): {got} vs {}", mean(x, y, k0, k1)); + } + } +} + /// A Zarr variable follows the time slider (#554): the slice drawn is the step the slider is at, /// the stretch is the same at every step, and the project keeps the layer by its store. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1bff96e66..5734d9157 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3533,6 +3533,7 @@ const DEFAULTS_TO_ACTIVE_LAYER: &[&str] = &[ "share.locator", "zarr.reduce", "cube.trend", + "cube.reduce", ]; #[cfg(test)] diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 9a2e76bba..c52991422 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -457,6 +457,36 @@ rustgis-cli run --cmd 'cube.trend={"layers":["ndvi_2015","ndvi_2016","ndvi_2017" rustgis-cli run --cmd 'cube.trend={"url":"https://data.inpe.br/bdc/stac/v1","collection":"mod13q1-6.1","asset":"NDVI","bbox":[-46,-10.95,-40.35,-2.7],"start":"2001","end":"2025","cellSize":1000,"test":"seasonal","seasons":23,"per":"decade"}' --save piaui.rgis ``` +### Summarising a data cube over time + +**Summarise a Data Cube over Time** (`cube.reduce`) makes rasters of one statistic of each cell +over the dates of any of the series Trend Analysis takes (layers, a raster's bands, a Zarr +variable, a STAC collection's band): **mean**, **min**, **max**, **sum**, **std** (the population +standard deviation), **count** of values, or **countAbove** a threshold (`"above"`), as Summarise +over time does for a Zarr variable (see [Data](data.md)). + +- **Groups:** `"by": "all"` (the default) makes one layer of the whole period, `"year"` one for + each calendar year (an annual mean NDVI from 16-day composites), `"month"` one for each month of + the year across the years (a climatology). `"start"`, `"end"` and `"months"` pick the dates as for + a trend. +- **Missing values:** no-data and missing values are skipped cell by cell. A cell keeps no value + where it had fewer values than `"minValid"` (a share of its group's dates, 0 by default: 0.5 + wants half of a year's composites); with the default, **count** and **countAbove** give 0 where + a cell never had a value. +- **Size:** the slices are folded into running totals as they are read, so a long period takes no + more memory than a short one; a STAC collection's area is read in pieces, as for a trend. The + outputs hold up to 32 million values (cells times groups; 8 million in the web app). A summary by + month keeps every month's totals while it reads, up to five times the outputs' memory for + **std** (three for mean, min, max, sum and countAbove), so a large area by month may need a + coarser cell size or a smaller area. The layers + are named after what they hold (`NDVI mean 2001`, `NDVI mean 2001–2025 month 03`, or the + `"name"` given and the group) and kept in the project as GeoTIFFs. + +```sh +rustgis-cli run --cmd 'cube.reduce={"layers":["ndvi_2015","ndvi_2016","ndvi_2017","ndvi_2018"],"stat":"max"}' +rustgis-cli run --cmd 'cube.reduce={"url":"https://data.inpe.br/bdc/stac/v1","collection":"mod13q1-6.1","asset":"NDVI","bbox":[-44.8,-7.5,-44.3,-7.0],"start":"2001","end":"2025","cellSize":1000,"stat":"mean","by":"year","minValid":0.5,"name":"NDVI"}' --save urucui.rgis +``` + ## Deep learning and hosted imagery **Analyze › Deep Learning Tools** trains a neural network (a multilayer perceptron) on the pixels From e5265d1a26311346dc1e6c06d4481b04614f6163 Mon Sep 17 00:00:00 2001 From: Mavrick Fitzgerald Date: Fri, 23 Oct 2026 19:44:39 -0500 Subject: [PATCH 081/297] feat(data): define source projection for Shapefile layers (#778) * feat(data): define source projection for Shapefile layers * fix(data): normalize CRS options and report import failures --- apps/rustgis-web/src/web.rs | 5 +- crates/data/src/io.rs | 89 ++++++++- crates/data/src/lib.rs | 24 ++- crates/data/src/source/mod.rs | 3 + crates/data/src/source/shp.rs | 16 +- crates/engine/src/cmd/layer.rs | 56 +++++- crates/engine/src/cmd/plugins.rs | 1 + crates/engine/src/project.rs | 14 +- crates/engine/tests/define_projection.rs | 213 ++++++++++++++++++++++ crates/map/src/source.rs | 18 +- crates/plugins/src/tools.rs | 1 + crates/ui-egui/src/chrome.rs | 4 + crates/ui-egui/src/dialogs.rs | 53 +++++- crates/ui-egui/src/lib.rs | 47 ++++- crates/ui-egui/src/panes.rs | 6 + crates/ui-egui/tests/define_projection.rs | 199 ++++++++++++++++++++ docs/user-guide/data-formats.md | 39 ++++ 17 files changed, 766 insertions(+), 22 deletions(-) create mode 100644 crates/engine/tests/define_projection.rs create mode 100644 crates/ui-egui/tests/define_projection.rs diff --git a/apps/rustgis-web/src/web.rs b/apps/rustgis-web/src/web.rs index d231d8339..961a4c8b7 100644 --- a/apps/rustgis-web/src/web.rs +++ b/apps/rustgis-web/src/web.rs @@ -844,12 +844,15 @@ fn services(inbox: Inbox, font_inbox: Inbox, notes: Notes, ctx: egui::Context, e let tool_exts = purpose.strip_prefix(rustgis_ui_egui::TOOL_FILE_PURPOSE); let mark = match (purpose, tool_exts) { ("text", _) => rustgis_ui_egui::DELIMITED_PICK, + ("shapefile", _) => rustgis_ui_egui::SHAPEFILE_PICK, (_, Some(_)) => rustgis_ui_egui::TOOL_FILE_PICK, _ => "", }; let dialog = if let Some(exts) = tool_exts { let exts: Vec<&str> = exts.split(',').filter(|e| !e.is_empty()).collect(); if exts.is_empty() { rfd::AsyncFileDialog::new() } else { rfd::AsyncFileDialog::new().add_filter(exts.join(", "), &exts) } + } else if purpose == "shapefile" { + rfd::AsyncFileDialog::new().add_filter("Shapefile", &["shp", "dbf", "shx", "prj", "cpg", "zip"]) } else if purpose == "project" { rfd::AsyncFileDialog::new().add_filter("RustGIS project", &["rgis", "rgisx", "rgispkg"]) } else if purpose == "item" { @@ -884,7 +887,7 @@ fn services(inbox: Inbox, font_inbox: Inbox, notes: Notes, ctx: egui::Context, e rfd::AsyncFileDialog::new().add_filter("Vector and raster data", DATA_EXTS) }; // Data may come several files at once (photos gather in one layer). - let many = purpose == "data"; + let many = matches!(purpose, "data" | "shapefile"); wasm_bindgen_futures::spawn_local(async move { let files: Vec = if many { dialog.pick_files().await.unwrap_or_default() } else { dialog.pick_file().await.into_iter().collect() }; diff --git a/crates/data/src/io.rs b/crates/data/src/io.rs index 1939598f5..b541a193b 100644 --- a/crates/data/src/io.rs +++ b/crates/data/src/io.rs @@ -352,6 +352,37 @@ pub fn project(ds: &Dataset, crs: Crs) -> Result { Ok(out) } +/// Defines the CRS of the dataset's native coordinates without changing those numeric values. +/// Geometry stored in longitude/latitude is converted back through the previous CRS, then +/// interpreted in the new one. Builds a copy so failed transformations leave the input intact. +/// Source geometry discarded during import cannot be recovered this way. +pub fn define_projection(ds: &Dataset, crs: Crs) -> Result { + if ds.crs_incomplete { + return Err(DataError::Format("geometry was lost during import; import the original file again with a crs override".into())); + } + let from = ds.crs.unwrap_or(Crs::LonLat); + let native = project(ds, from)?; + let to = crs.transformer().map_err(DataError::Format)?; + let mut out = native; + for f in &mut out.features { + if let Some(g) = f.geometry.take() { + let g = g.map_coords(&|c| to.to_lonlat(c).unwrap_or(Coord::new(f64::NAN, f64::NAN))); + let mut valid = true; + g.for_each_coord(|c| valid &= c.is_finite() && c.x.abs() <= 180.0 && c.y.abs() <= 90.0); + if !valid { + return Err(DataError::Format(format!( + "feature {} can't be interpreted in {}; import the original file with a crs override", + f.id, + crs.label() + ))); + } + f.geometry = Some(g); + } + } + out.crs = Some(crs); + Ok(out) +} + /// Refuses a system without an EPSG code for a GeoPackage or FlatGeobuf: their readers (RustGIS's /// among them) go by the code, and would read the file as longitude/latitude. fn check_projected(ext: &str, crs: Crs) -> Result<()> { @@ -1308,12 +1339,24 @@ pub fn to_csv(ds: &Dataset) -> Result { // --------------------------------------------------------------------------------------------- // Shapefile +/// Reads a loose Shapefile using its sidecar CRS, retaining the default reader's behavior. fn read_shapefile(path: &Path, name: &str) -> Result<(Dataset, ReadReport)> { + read_shapefile_in(path, name, None) +} + +/// Reads a Shapefile with an optional source CRS replacing its `.prj`, before any geometry is +/// converted. This changes only interpretation; the files on disk are never modified. +pub fn read_shapefile_in(path: &Path, name: &str, crs: Option) -> Result<(Dataset, ReadReport)> { + check_size(path)?; let dbf = path.with_extension("dbf"); if !dbf.is_file() { return Err(DataError::Format(format!("Shapefile: {} is missing", dbf.display()))); } + check_size(&dbf)?; let shx = path.with_extension("shx"); + if shx.is_file() { + check_size(&shx)?; + } let mut notes = Vec::new(); let parts = ShapefileParts { shp: std::fs::read(path)?, @@ -1322,7 +1365,7 @@ fn read_shapefile(path: &Path, name: &str) -> Result<(Dataset, ReadReport)> { prj: crate::raster::read_prj(path), cpg: crate::dbf::read_cpg(path, &mut notes), }; - let (ds, mut report) = read_shapefile_parts(parts, name)?; + let (ds, mut report) = read_shapefile_parts(parts, name, crs)?; report.messages.extend(notes); Ok((ds, report)) } @@ -1398,6 +1441,18 @@ pub const MAX_ZIP_LAYERS: usize = 32; /// [`MAX_ZIP_LAYERS`]. Folders inside are fine; macOS /// resource forks (`__MACOSX/`, `._*`) are ignored. pub fn read_zip(file_name: &str, bytes: &[u8]) -> Result> { + read_zip_in(file_name, bytes, None) +} + +/// Reads a ZIP, optionally overriding the source CRS of every Shapefile in it. Other formats +/// retain their own coordinate systems. The override is applied before reading any shapes. +pub fn read_zip_in(file_name: &str, bytes: &[u8], crs: Option) -> Result> { + if bytes.len() as u64 > MAX_BYTES { + return Err(DataError::Format(format!("file is too large ({} bytes)", bytes.len()))); + } + if let Some(c) = crs { + c.transformer().map_err(DataError::Format)?; + } let lower = |n: &str| n.to_ascii_lowercase(); let is_fork = |n: &str| n.split('/').any(|part| part == "__MACOSX" || part.starts_with("._")); let ext_of = |n: &str| Path::new(n).extension().and_then(|e| e.to_str()).map(lower).unwrap_or_default(); @@ -1437,7 +1492,7 @@ pub fn read_zip(file_name: &str, bytes: &[u8]) -> Result { read.0.source = Some(format!("{file_name}/{n}")); out.push(read); @@ -1505,7 +1560,9 @@ struct ShapefileParts { cpg: Option>, } -fn read_shapefile_parts(parts: ShapefileParts, name: &str) -> Result<(Dataset, ReadReport)> { +/// Decodes attributes and shapes together, tracking geometry lost during CRS conversion so +/// a later projection definition cannot silently repair an incomplete layer. +fn read_shapefile_parts(parts: ShapefileParts, name: &str, crs: Option) -> Result<(Dataset, ReadReport)> { use shapefile::Shape; use shapefile::dbase::FieldValue; use std::io::Cursor; @@ -1542,11 +1599,18 @@ fn read_shapefile_parts(parts: ShapefileParts, name: &str) -> Result<(Dataset, R let mut ds = Dataset::new(name, GeometryType::None, fields); let mut report = ReadReport::default(); report.messages.extend(encoding_notes); - let to_lonlat = shapefile_crs(parts.prj.as_deref(), &mut report).map(|(crs, t)| { - ds.crs = Some(crs); - t - }); + let to_lonlat = match crs { + Some(c) => { + ds.crs = Some(c); + Some(c.transformer().map_err(DataError::Format)?) + } + None => shapefile_crs(parts.prj.as_deref(), &mut report).map(|(crs, t)| { + ds.crs = Some(crs); + t + }), + }; let (mut converted, mut unconverted) = (0usize, 0usize); + let mut source_kind = GeometryType::None; let pt = |x: f64, y: f64| Coord::new(x, y); for item in reader.iter_shapes_and_records() { let Ok((shape, record)) = item else { @@ -1578,6 +1642,11 @@ fn read_shapefile_parts(parts: ShapefileParts, name: &str) -> Result<(Dataset, R continue; } }; + if source_kind == GeometryType::None + && let Some(g) = &geom + { + source_kind = g.kind(); + } let geom = match (&to_lonlat, geom) { (Some(t), Some(g)) => { let mut ok = true; @@ -1608,6 +1677,11 @@ fn read_shapefile_parts(parts: ShapefileParts, name: &str) -> Result<(Dataset, R ds.push_next(geom, attrs); } ds.geometry_type = dominant_type(&ds); + // A bad .prj must not turn a spatial layer into an attribute-only table and hide the + // conversion warning. Its shapes can be recovered by importing with an override. + if ds.geometry_type == GeometryType::None && unconverted > 0 { + ds.geometry_type = source_kind; + } integral_numeric_to_integer(&mut ds); let e = ds.extent(); if !e.is_empty() && (e.min_x < -180.5 || e.max_x > 180.5 || e.min_y < -90.5 || e.max_y > 90.5) { @@ -1616,6 +1690,7 @@ fn read_shapefile_parts(parts: ShapefileParts, name: &str) -> Result<(Dataset, R if report.skipped > 0 { report.messages.push(format!("{} shapes were unreadable or unsupported", report.skipped)); } + ds.crs_incomplete = unconverted > 0; if unconverted > 0 && converted == 0 { report.messages.push("no shape could be converted to longitude/latitude: the .prj doesn't seem to match the coordinates".into()); } else if unconverted > 0 { diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index dc5e438d5..6390df580 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -180,6 +180,10 @@ pub struct Dataset { /// always longitude/latitude; this is what an export writes by default. #[serde(default, skip_serializing_if = "Option::is_none")] pub crs: Option, + /// Import lost geometry while interpreting source coordinates. Defining a projection cannot + /// recover it; import the original file with a CRS override instead. Kept across project saves. + #[serde(default)] + pub crs_incomplete: bool, /// The features are a window onto a source read in place (the part of a large file in view), /// so they aren't saved: the source is read again instead. #[serde(skip)] @@ -187,9 +191,10 @@ pub struct Dataset { } impl Serialize for Dataset { + /// Saves CRS recovery state with the dataset, but omits disposable streamed rows. fn serialize(&self, s: S) -> std::result::Result { use serde::ser::SerializeStruct; - let mut st = s.serialize_struct("Dataset", 6)?; + let mut st = s.serialize_struct("Dataset", 7)?; st.serialize_field("name", &self.name)?; st.serialize_field("geometry_type", &self.geometry_type)?; st.serialize_field("fields", &self.fields)?; @@ -201,6 +206,11 @@ impl Serialize for Dataset { } else { st.skip_field("crs")?; } + if self.crs_incomplete { + st.serialize_field("crs_incomplete", &true)?; + } else { + st.skip_field("crs_incomplete")?; + } st.end() } } @@ -213,8 +223,18 @@ pub struct ReadReport { } impl Dataset { + /// Creates an empty, complete dataset whose coordinates are assumed to be WGS 84. pub fn new(name: impl Into, geometry_type: GeometryType, fields: Vec) -> Self { - Dataset { name: name.into(), geometry_type, fields, features: Features::default(), source: None, crs: None, windowed: false } + Dataset { + name: name.into(), + geometry_type, + fields, + features: Features::default(), + source: None, + crs: None, + crs_incomplete: false, + windowed: false, + } } /// Index of a field by case-insensitive name. diff --git a/crates/data/src/source/mod.rs b/crates/data/src/source/mod.rs index 63a1a22c9..fde47661c 100644 --- a/crates/data/src/source/mod.rs +++ b/crates/data/src/source/mod.rs @@ -469,6 +469,9 @@ pub fn materialize(src: &dyn FeatureSource) -> Result<(Dataset, ReadReport)> { let batch = src.read(&Request::all())?; let mut ds = Dataset::new(info.name.clone(), info.geometry_type, info.fields.clone()); ds.crs = info.coordinate_system(); + // A streamed Shapefile skips unconvertible shapes rather than retaining empty geometry. + // Once materialized, it must still direct CRS repairs back to the original file. + ds.crs_incomplete = info.format == "Shapefile" && batch.skipped > 0; ds.features = batch.features.into(); let mut report = ReadReport { skipped: batch.skipped, messages: info.notes.iter().map(|n| format!("{}: {n}", info.name)).collect() }; if !batch.complete { diff --git a/crates/data/src/source/shp.rs b/crates/data/src/source/shp.rs index 38fc830df..3f5e5ef90 100644 --- a/crates/data/src/source/shp.rs +++ b/crates/data/src/source/shp.rs @@ -72,6 +72,15 @@ const BUFFER: usize = 1 << 20; impl ShpSource { /// Opens `path` (the `.shp`) with the `.dbf` and optional `.prj` and `.cpg` beside it. pub fn open_path(path: &Path, name: &str) -> Result { + Self::open_path_in(path, name, None) + } + + /// Opens a Shapefile with an optional source CRS replacing its `.prj`. The spatial index + /// stays in native coordinates; requests and results use the chosen CRS for conversion. + pub fn open_path_in(path: &Path, name: &str, crs: Option) -> Result { + if let Some(c) = crs { + c.transformer().map_err(DataError::Format)?; + } let dbf = path.with_extension("dbf"); if !dbf.is_file() { return Err(bad(format!("{} is missing", dbf.display()))); @@ -108,7 +117,10 @@ impl ShpSource { if table.rows != index.count { notes.push(format!("the .shp holds {} shapes and the .dbf {} rows; rows are matched by position", index.count, table.rows)); } - let reproject = shp_reproject(crate::raster::read_prj(path).as_deref(), &mut notes); + let reproject = match crs { + Some(c) => Reproject::from_crs(c, &mut notes), + None => shp_reproject(crate::raster::read_prj(path).as_deref(), &mut notes), + }; let extent = if native.is_empty() || !native.min_x.is_finite() { Extent::EMPTY } else { reproject.extent_to_lonlat(&native) }; if reproject.is_lonlat() && !extent.is_empty() @@ -129,7 +141,7 @@ impl ShpSource { fields, extent, count: Some(index.count), - crs: reproject.label(), + crs: crs.and_then(|c| c.custom_def()).map(|d| d.proj4.clone()).unwrap_or_else(|| reproject.label()), indexed: index.indexed(), notes, }; diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 57adbf1c0..3b2e032fb 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -14,7 +14,7 @@ use crate::{CmdResult, CommandSpec as C, Session}; pub fn commands() -> Vec { vec![ C::new("layer.addData", "Add Data", "Home › Add", add_data) - .params(r#"{"path": string, "name"?: string, "layer"?: "GeoPackage, GDAL, DuckDB or DXF layer", "paperSpace"?: "bool: a DXF's layout entities too", "stream"?: bool, "as"?: "scan" (a TIFF, PNG or JPEG, even a photo or a GeoTIFF, as a picture with no coordinates), "crs"?: "EPSG:… (or WKT) for a CSV's coordinates, or a GDAL or DuckDB file that doesn't say", "x"?: "CSV x/longitude column", "y"?: "CSV y/latitude column", "wkt"?: "CSV WKT geometry column", "delimiter"?: ","|";"|"\\t"|"|"}"#), + .params(r#"{"path": string, "name"?: string, "layer"?: "GeoPackage, GDAL, DuckDB or DXF layer", "paperSpace"?: "bool: a DXF's layout entities too", "stream"?: bool, "as"?: "scan" (a TIFF, PNG or JPEG, even a photo or a GeoTIFF, as a picture with no coordinates), "crs"?: "EPSG:… (or WKT) for Shapefile coordinates (overrides .prj), a CSV, or a GDAL or DuckDB file that doesn't say", "x"?: "CSV x/longitude column", "y"?: "CSV y/latitude column", "wkt"?: "CSV WKT geometry column", "delimiter"?: ","|";"|"\\t"|"|"}"#), C::new("layer.addPhotos", "Add Photos", "Home › Add", add_photos).params( r#"{"paths"?: [JPEG photo files], "folder"?: "a folder of photos (every JPEG in it)", "name"?: "the layer (default Photos; photos join it when it exists)"}"#, ), @@ -33,6 +33,9 @@ pub fn commands() -> Vec { C::new("info.databaseTables", "List Database Tables", "Home › Add › Add Data From Database", database_tables) .view() .params(r#"{"database": "as layer.addDatabase", "password"?: string}"#), + C::new("layer.defineProjection", "Define Projection", "Layers", define_projection) + .when(need_feature_layer) + .params(r#"{"layer"?: name|id, "crs": "EPSG:… or WKT; reinterpret native coordinates, without rewriting the source file"}"#), C::new("layer.loadAll", "Load All Features", "Layers", load_all).when(need_streamed).params(r#"{"layer"?: name|id}"#), C::new("layer.addUrl", "Add Data From URL", "Home › Add", add_url).params( r#"{"url": "https://…/data.geojson|.csv|.parquet|.fgb|.gpkg|.zip|.kml|.kmz|.gpx|.tif|.jp2 (JPEG 2000: desktop, through GDAL) (the format from the address, else the contents) | a .pmtiles archive", "name"?: text, "layer"?: "one layer of a zip, KML or GPX", "zoom"?: "bool: true zooms to what was added; false keeps the view of a GeoParquet or FlatGeobuf read in place (zoomed to only when the map was empty)", "x"?, "y"?, "wkt"?, "crs"?, "delimiter"?, "paperSpace"?, "stream"?, "as"? (as layer.addData), "whole"?: "bool: download a cloud-optimised GeoTIFF whole (as a raster layer) instead of reading it in place", "start"?, "end"?, "unit"?, "interval"?, "dates"?: "a GeoTIFF address with date placeholders ({date:YYYY}, {YYYY}{MM}{DD}), one file per date: the dates it spans and steps through (as time.set), turning the time slider on"}"#, @@ -623,6 +626,8 @@ fn add_geodatabase(s: &mut Session, p: &Value, path: &str) -> CmdResult { Ok(out) } +/// Opens a file through its format's reader. A Shapefile CRS override is applied before any +/// conversion in both whole-file and streamed paths; the source file is never rewritten. fn add_data(s: &mut Session, p: &Value) -> CmdResult { let path = s_req(p, "path")?; let path = &*gdb_path(path); @@ -675,6 +680,23 @@ fn add_data(s: &mut Session, p: &Value) -> CmdResult { Err(e) => return Err(format!("{path}: {e}")), } } + #[cfg(not(target_arch = "wasm32"))] + if path.to_ascii_lowercase().ends_with(".shp") + && let Some(crs) = ps(p, "crs").map(str::trim).filter(|c| !c.is_empty()) + { + let crs = rustgis_data::raster::Crs::parse(crs)?; + let name = Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("layer"); + let stream = b(p, "stream").unwrap_or_else(|| { + rustgis_data::source::shp::record_count(Path::new(path)).is_some_and(|n| n > rustgis_map::source::STREAM_ABOVE) + }); + if stream { + let src = rustgis_data::source::shp::ShpSource::open_path_in(Path::new(path), name, Some(crs)).map_err(|e| e.to_string())?; + return add_source_with(s, p, Origin::Path(path), Some(Box::new(src))); + } + let mut read = rustgis_data::io::read_shapefile_in(Path::new(path), name, Some(crs)).map_err(|e| e.to_string())?; + read.0.source = Some(path.to_string()); + return add_read(s, p, read); + } // A large Shapefile is read in place, as a GeoPackage is; smaller ones are loaded whole. #[cfg(not(target_arch = "wasm32"))] if path.to_ascii_lowercase().ends_with(".shp") @@ -775,6 +797,14 @@ pub(crate) fn add_layers(s: &mut Session, p: &Value, file_name: &str, bytes: &[u // A DXF drawing: its coordinate system (it never says) and one of its own layers. let dxf = rustgis_data::dxf::is_dxf_name(file_name); let mut reads = match crs.filter(|_| landxml) { + _ if lower.ends_with(".zip") && crs.is_some() => { + let crs = crs.map(rustgis_data::raster::Crs::parse).transpose()?; + rustgis_data::io::read_zip_in(file_name, bytes, crs) + .map_err(|e| e.to_string())? + .into_iter() + .map(|(dataset, report)| rustgis_data::io::LayerRead { dataset, report, styles: Vec::new() }) + .collect() + } _ if dxf => { let opts = rustgis_data::dxf::Options { crs: crs.map(rustgis_data::raster::Crs::parse).transpose()?, @@ -1167,7 +1197,7 @@ fn add_source_with( _ => info.name.clone(), }; if stream { - let layer = match &origin { + let mut layer = match &origin { Origin::Path(path) => { let full = std::path::absolute(path).map(|p| p.display().to_string()).unwrap_or_else(|_| path.to_string()); rustgis_map::source::SourceLayer::from_path(full, table.clone(), src) @@ -1177,6 +1207,10 @@ fn add_source_with( Origin::Mysql(o) => rustgis_map::source::SourceLayer::from_mysql((*o).clone(), src), Origin::Sqlserver(o) => rustgis_map::source::SourceLayer::from_sqlserver((*o).clone(), src), }; + if info.format == "Shapefile" { + layer.shapefile_crs = + ps(p, "crs").map(str::trim).filter(|c| !c.is_empty()).map(rustgis_data::raster::Crs::parse).transpose()?; + } let unique = unique_name(s, &name); for n in &info.notes { s.notify(format!("{unique}: {n}"), false); @@ -1873,6 +1907,24 @@ fn need_streamed(s: &Session) -> Option<&'static str> { } } +/// Reinterprets the layer's native coordinates in a replacement CRS. Validate the complete +/// replacement before mutating the layer; undo then restores geometry, metadata and caches. +fn define_projection(s: &mut Session, p: &Value) -> CmdResult { + let id = s.layer_id(p)?; + let crs = rustgis_data::raster::Crs::parse(s_req(p, "crs")?)?; + let l = s.map().layer(id).ok_or("no such layer")?; + if l.is_raster() || l.is_streamed() { + return Err( + "Define Projection needs a vector layer loaded into memory; import a Shapefile with a crs override for a streamed layer".into(), + ); + } + let ds = rustgis_data::io::define_projection(&l.dataset, crs).map_err(|e| e.to_string())?; + let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; + l.dataset = ds; + l.touch_geometry(); + Ok(json!({"layer": id, "crs": crs.label()})) +} + /// Loads a streamed layer's features into memory, so it can be edited like any other layer. fn load_all(s: &mut Session, p: &Value) -> CmdResult { let id = s.layer_id(p)?; diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index 9e6830931..26a7c0829 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -444,6 +444,7 @@ impl Bridge<'_> { features: rows.iter().take(limit).filter_map(|&i| l.dataset.features.get(i).cloned()).collect(), source: None, crs: None, + crs_incomplete: false, windowed: false, }; let mut gj = rustgis_data::io::to_geojson(&ds); diff --git a/crates/engine/src/project.rs b/crates/engine/src/project.rs index b37e08bd2..12841ef43 100644 --- a/crates/engine/src/project.rs +++ b/crates/engine/src/project.rs @@ -180,7 +180,19 @@ struct TableFile { impl From for Table { fn from(t: TableFile) -> Self { let TableFile { id, name, geometry_type, fields, features, source } = t; - Table { id, dataset: Dataset { name, geometry_type, fields, features: features.into(), source, crs: None, windowed: false } } + Table { + id, + dataset: Dataset { + name, + geometry_type, + fields, + features: features.into(), + source, + crs: None, + crs_incomplete: false, + windowed: false, + }, + } } } diff --git a/crates/engine/tests/define_projection.rs b/crates/engine/tests/define_projection.rs new file mode 100644 index 000000000..6c80f766a --- /dev/null +++ b/crates/engine/tests/define_projection.rs @@ -0,0 +1,213 @@ +//! Define Projection reinterprets native coordinates; it never edits the source files. +use rustgis_engine::{ + Project, Session, + data::{Coord, Dataset, Field, FieldType, Geometry, GeometryType, Value, io, raster::Crs}, +}; +use serde_json::json; + +/// A small projected point layer with a fractional coordinate. The writer +/// receives native coordinates, so fixture construction does not use the conversion under test. +fn native() -> Dataset { + let mut ds = Dataset::new("parcels", GeometryType::Point, vec![Field::new("owner", FieldType::Text)]); + ds.push(Some(Geometry::Point(Coord::new(451_219.33, 5_411_550.20))), vec![Value::Text("Paris".into())]); + ds +} + +/// Writes the native numbers with a deliberately chosen (or missing) CRS sidecar. +fn zip(crs: Option) -> Vec { + let parts = io::shapefile_parts_in(&native(), crs.unwrap_or(Crs::LonLat)).unwrap().0; + let parts = parts + .into_iter() + .filter(|(ext, _)| *ext != "prj" || crs.is_some()) + .map(|(ext, bytes)| (format!("data/PARCELS.{}", ext.to_ascii_uppercase()), bytes)) + .collect::>(); + rustgis_engine::data::zip::write(&parts).unwrap() +} + +/// Uploads exercise the browser/ZIP command path, not the on-disk reader. +fn upload(s: &mut Session, bytes: &[u8], crs: Option<&str>) -> u32 { + use base64::Engine; + let mut p = json!({"fileName": "parcels.zip", "data": base64::engine::general_purpose::STANDARD.encode(bytes)}); + if let Some(c) = crs { + p["crs"] = json!(c); + } + s.run("layer.addDataBytes", &p).unwrap()["layer"].as_u64().unwrap() as u32 +} + +/// Reads the first fixture point without deriving an expected result through a transformer. +fn point(ds: &Dataset) -> Coord { + ds.features[0].geometry.as_ref().unwrap().label_point().unwrap() +} + +/// Checks the known surveyed coordinate and its attribute survived the public workflow. +fn paris(ds: &Dataset) { + let c = point(ds); + assert!((c.x - 2.335).abs() < 1e-6 && (c.y - 48.855).abs() < 1e-6, "{c:?}"); + assert_eq!(ds.value(0, 0), &Value::Text("Paris".into())); +} + +#[test] +fn missing_prj_layer_command_preserves_native_numbers_and_undoes_as_one_step() { + let mut s = Session::new(Project::default()); + let id = upload(&mut s, &zip(None), None); + s.map_mut().layer_mut(id).unwrap().dataset.push(None, vec![Value::Text("No shape".into())]); + let before = s.map().layer(id).unwrap().clone(); + s.run("select.rows", &json!({"layer": id, "rows": [0]})).unwrap(); + s.run("layer.defineProjection", &json!({"layer": id, "crs": "EPSG:32631"})).unwrap(); + let layer = s.map().layer(id).unwrap(); + paris(&layer.dataset); + assert_eq!(layer.selection, vec![0]); + assert!(layer.dataset.features[1].geometry.is_none()); + assert!(layer.geometry_rev > before.geometry_rev); + let exported = io::project(&layer.dataset, Crs::parse("EPSG:32631").unwrap()).unwrap(); + let c = point(&exported); + assert!((c.x - 451_219.33).abs() < 1e-5 && (c.y - 5_411_550.20).abs() < 1e-5, "{c:?}"); + assert_eq!(io::default_export_crs(&layer.dataset, "shp"), Crs::parse("EPSG:32631").unwrap()); + let saved = s.project.saved_text().unwrap().0; + let restored = Project::from_json(&saved).unwrap(); + paris(&restored.maps[0].layers[0].dataset); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.map().layer(id).unwrap().dataset, before.dataset); + s.run("edit.redo", &json!({})).unwrap(); + paris(&s.map().layer(id).unwrap().dataset); +} + +#[test] +fn wrong_prj_can_be_replaced_without_reprojecting_native_numbers() { + let mut s = Session::new(Project::default()); + let id = upload(&mut s, &zip(Some(Crs::WebMercator)), None); + assert!((point(&s.map().layer(id).unwrap().dataset).x - 2.335).abs() > 0.1); + s.run("layer.defineProjection", &json!({"layer": id, "crs": "EPSG:32631"})).unwrap(); + paris(&s.map().layer(id).unwrap().dataset); +} + +#[test] +fn zip_override_precedes_wrong_prj_and_missing_geometry_requires_reimport() { + // This nonzero datum shift rejects projected numbers mislabelled as geographic. + let wrong = Crs::parse("EPSG:4277").unwrap(); + assert!(wrong.transformer().unwrap().to_lonlat(point(&native())).is_none(), "fixture must lose geometry with its wrong CRS"); + let bytes = zip(Some(wrong)); + let mut s = Session::new(Project::default()); + let id = upload(&mut s, &bytes, None); + assert!(s.map().layer(id).unwrap().dataset.crs_incomplete); + let before = s.map().layer(id).unwrap().dataset.clone(); + let error = s.run("layer.defineProjection", &json!({"layer": id, "crs": "EPSG:32631"})).unwrap_err(); + assert!(error.contains("import the original file"), "{error}"); + assert_eq!(s.map().layer(id).unwrap().dataset, before); + let saved = s.project.saved_text().unwrap().0; + let restored = Project::from_json(&saved).unwrap(); + assert!(restored.maps[0].layers[0].dataset.crs_incomplete); + let repaired = upload(&mut s, &bytes, Some("EPSG:32631")); + paris(&s.map().layer(repaired).unwrap().dataset); + assert!(!s.map().layer(repaired).unwrap().dataset.crs_incomplete); +} + +#[test] +fn invalid_crs_or_unconvertible_coordinate_leaves_layer_unchanged() { + let mut s = Session::new(Project::default()); + let id = upload(&mut s, &zip(None), None); + let before = s.map().layer(id).unwrap().dataset.clone(); + for crs in ["", "garbage", "EPSG:99999999", "EPSG:4326"] { + assert!(s.run("layer.defineProjection", &json!({"layer": id, "crs": crs})).is_err(), "{crs}"); + assert_eq!(s.map().layer(id).unwrap().dataset, before); + } +} + +#[test] +fn loose_and_streamed_overrides_survive_project_reopen_and_do_not_write_prj() { + let dir = std::env::temp_dir().join(format!("rustgis-m1-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let parts = io::shapefile_parts_in(&native(), Crs::parse("EPSG:4269").unwrap()).unwrap().0; + for (ext, bytes) in &parts { + std::fs::write(dir.join(format!("parcels.{ext}")), bytes).unwrap(); + } + let prj = std::fs::read(dir.join("parcels.prj")).unwrap(); + for crs in ["EPSG:32631", "+proj=utm +zone=31 +datum=WGS84 +units=m +no_defs"] { + for stream in [false, true] { + let mut s = Session::new(Project::default()); + let result = s.run("layer.addData", &json!({"path": dir.join("parcels.shp"), "crs": crs, "stream": stream})).unwrap(); + let id = result["layer"].as_u64().unwrap() as u32; + paris(&s.map().layer(id).unwrap().load_all().unwrap()); + if stream { + assert!( + s.run("layer.defineProjection", &json!({"layer": id, "crs": "EPSG:32631"})).unwrap_err().contains("loaded into memory") + ); + let saved = s.project.saved_text().unwrap().0; + let restored = Session::new(Project::from_json(&saved).unwrap()); + let restored_data = restored.map().layer(id).unwrap().load_all().unwrap(); + paris(&restored_data); + assert!(restored_data.crs.is_some(), "the native CRS must remain the export default"); + } + assert_eq!(std::fs::read(dir.join("parcels.prj")).unwrap(), prj); + } + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn import_override_accepts_missing_prj_wkt_and_custom_proj_definitions() { + let utm = Crs::parse("EPSG:32631").unwrap(); + let wkt = rustgis_engine::data::raster::wkt::to_wkt(utm).unwrap(); + for crs in ["EPSG:32631", &wkt, "+proj=utm +zone=31 +datum=WGS84 +units=m +no_defs"] { + let mut s = Session::new(Project::default()); + let id = upload(&mut s, &zip(None), Some(crs)); + paris(&s.map().layer(id).unwrap().dataset); + let saved = s.project.saved_text().unwrap().0; + let restored = Session::new(Project::from_json(&saved).unwrap()); + paris(&restored.map().layer(id).unwrap().dataset); + assert!(restored.map().layer(id).unwrap().dataset.crs.is_some()); + } +} + +#[test] +fn failure_on_a_later_feature_does_not_apply_a_partial_projection() { + let mut ds = Dataset::new("Mixed", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(0.0, 0.0))), vec![]); + ds.push(Some(Geometry::Point(Coord::new(f64::MAX, 0.0))), vec![]); + let mut s = Session::new(Project::default()); + let id = s.map_mut().add_dataset(ds.clone()); + s.active_layer = Some(id); + assert!(s.run("layer.defineProjection", &json!({"crs": "EPSG:3857"})).is_err()); + assert_eq!(s.map().layer(id).unwrap().dataset, ds); +} + +#[test] +fn zip_override_applies_to_each_shapefile_and_keeps_geojson_coordinates() { + let mut entries = Vec::new(); + for name in ["one", "two"] { + for (ext, bytes) in io::shapefile_parts(&native()).unwrap().0 { + entries.push((format!("{name}/parcels.{ext}"), bytes)); + } + } + entries.push(("other.geojson".into(), br#"{"type":"Point","coordinates":[10,20]}"#.to_vec())); + let bytes = rustgis_engine::data::zip::write(&entries).unwrap(); + let read = io::read_zip_in("several.zip", &bytes, Some(Crs::parse("EPSG:32631").unwrap())).unwrap(); + assert_eq!(read.len(), 3); + paris(&read[0].0); + paris(&read[1].0); + assert_eq!(point(&read[2].0), Coord::new(10.0, 20.0)); +} + +#[test] +fn blank_shapefile_crs_keeps_prj_for_whole_and_streamed_imports() { + let dir = std::env::temp_dir().join(format!("rustgis-m1-blank-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (ext, bytes) in io::shapefile_parts_in(&native(), Crs::parse("EPSG:32631").unwrap()).unwrap().0 { + std::fs::write(dir.join(format!("parcels.{ext}")), bytes).unwrap(); + } + for crs in ["", " \t ", " EPSG:32631 "] { + for stream in [false, true] { + let mut s = Session::new(Project::default()); + let result = s.run("layer.addData", &json!({"path": dir.join("parcels.shp"), "crs": crs, "stream": stream})).unwrap(); + let id = result["layer"].as_u64().unwrap() as u32; + paris(&s.map().layer(id).unwrap().load_all().unwrap()); + let saved = s.project.saved_text().unwrap().0; + let restored = Session::new(Project::from_json(&saved).unwrap()); + paris(&restored.map().layer(id).unwrap().load_all().unwrap()); + } + } + let mut s = Session::new(Project::default()); + assert!(s.run("layer.addData", &json!({"path": dir.join("parcels.shp"), "crs": "not a CRS"})).is_err()); + assert!(s.map().layers.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/map/src/source.rs b/crates/map/src/source.rs index 5f455f230..da3b1c1b5 100644 --- a/crates/map/src/source.rs +++ b/crates/map/src/source.rs @@ -44,6 +44,9 @@ pub struct SourceLayer { /// The GeoPackage table. #[serde(default)] pub table: Option, + /// A Shapefile's source CRS override, reapplied when its path is reopened. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shapefile_crs: Option, /// The original file, kept in the project when there is no path (a browser upload). #[serde(default, skip_serializing_if = "Option::is_none", with = "crate::raster::b64")] pub embedded: Option>>, @@ -397,7 +400,20 @@ impl SourceLayer { let name = self.file_name.as_deref().unwrap_or("layer.parquet"); rustgis_data::source::open_shared(name, bytes.clone(), self.table.as_deref()) } else if let Some(p) = &self.path { - let opened = rustgis_data::source::open_path(std::path::Path::new(p), self.table.as_deref()); + #[cfg(not(target_arch = "wasm32"))] + let overridden = self.shapefile_crs.filter(|_| p.to_ascii_lowercase().ends_with(".shp")); + let opened = { + #[cfg(not(target_arch = "wasm32"))] + if let Some(crs) = overridden { + let name = std::path::Path::new(p).file_stem().and_then(|s| s.to_str()).unwrap_or("layer"); + rustgis_data::source::shp::ShpSource::open_path_in(std::path::Path::new(p), name, Some(crs)) + .map(|src| Box::new(src) as Box) + } else { + rustgis_data::source::open_path(std::path::Path::new(p), self.table.as_deref()) + } + #[cfg(target_arch = "wasm32")] + rustgis_data::source::open_path(std::path::Path::new(p), self.table.as_deref()) + }; match (opened, &self.gdal) { // The converted copy is gone (a cleared cache, another machine): convert again. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/plugins/src/tools.rs b/crates/plugins/src/tools.rs index d14ea5f7b..42ee75553 100644 --- a/crates/plugins/src/tools.rs +++ b/crates/plugins/src/tools.rs @@ -181,6 +181,7 @@ fn layer_geojson(inputs: &dyn Inputs, name: &str) -> Result { features: sel.iter().filter_map(|&i| ds.features.get(i).cloned()).collect(), source: None, crs: None, + crs_incomplete: false, windowed: false, }; Ok(rustgis_data::io::to_geojson(&sub)) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 6eb7506c8..301655e3a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -642,6 +642,7 @@ pub(crate) const UI_COMMANDS: &[(&str, &str, &str)] = &[ ("ui.pane.bookmarks", "Bookmarks Pane", "Home › Navigate › Bookmarks"), ("ui.theme", "Toggle Dark Mode", "View"), ("ui.map.gpu", "Toggle GPU Rendering", "View"), + ("ui.importShapefile", "Import Shapefile with Projection…", "Home › Add"), ("ui.measurementUnits", "Toggle Imperial Measurements", "Home › Inspect › Measure"), ("ui.exportMap", "Export Map…", "Publish › Output"), ("ui.checkUpdate", "Check for Updates…", "Help › Help"), @@ -658,6 +659,7 @@ enum SearchAction { Workflow(&'static str), } +/// Routes commands requiring user input to their dialogs rather than executing empty params. fn search_action(id: &str) -> Option { use crate::RightPane; use SearchAction::{Command, Dialog, OpenItem, Pane, SaveItem, Workflow}; @@ -695,6 +697,8 @@ fn search_action(id: &str) -> Option { "layer.addEncodedPolyline" => Dialog("addPolyline"), "layer.geocode" => Dialog("geocode"), "layer.rename" => Dialog("renameLayer"), + "layer.defineProjection" => Dialog("defineProjection"), + "ui.importShapefile" => Dialog("addShapefile"), "layer.definitionQuery" => Dialog("definitionQuery"), "layer.symbology" | "raster.symbology" | "layer.color" | "layer.outline" | "layer.lineStrokes" | "layer.ramp" => { Pane(RightPane::Symbology) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 849a814fc..71a0c9eaa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,6 +10,10 @@ use crate::theme::{Tokens, semibold}; #[derive(Default)] pub struct Dialogs { + /// The layer captured when Define Projection opens; changing selection must not retarget it. + pub projection_layer: Option, + /// Map-local layer IDs require the dialog to retain its original project and map as well. + pub projection_map: Option<(u64, usize, String)>, pub geometry: Option, pub open: Option<&'static str>, pub layer: String, @@ -45,6 +49,7 @@ pub enum CsvSource { } impl Dialogs { + /// Opens a dialog on the current selection, capturing the target of CRS changes. pub fn open(&mut self, which: &str, s: &Session) -> Result<(), String> { let active = s.active_layer.and_then(|id| s.map().layer(id)); let name: &'static str = match which { @@ -60,6 +65,8 @@ impl Dialogs { "renameServiceGroup" => "renameServiceGroup", "renameGroup" => "renameGroup", "coordinateSystem" => "coordinateSystem", + "defineProjection" => "defineProjection", + "addShapefile" => "addShapefile", "camera" => "camera", "addTiles" => "addTiles", "basemap" => "basemap", @@ -79,7 +86,9 @@ impl Dialogs { "saveProject" => "saveProject", other => return Err(format!("unknown dialog '{other}'")), }; - if matches!(name, "addField" | "calculateField" | "definitionQuery" | "renameLayer" | "exportLayer") && active.is_none() { + if matches!(name, "addField" | "calculateField" | "definitionQuery" | "renameLayer" | "exportLayer" | "defineProjection") + && active.is_none() + { return Err("select a layer in the Layers pane first".into()); } if name == "exportLayer" && active.is_some_and(|l| l.is_raster()) { @@ -91,6 +100,14 @@ impl Dialogs { *self = Dialogs { open: Some(name), mode: "new", ..Dialogs::default() }; self.layer = active.or_else(|| s.map().layers.first()).map(|l| l.name.clone()).unwrap_or_default(); match name { + "defineProjection" => { + self.projection_layer = active.map(|l| l.id); + self.projection_map = Some((s.opened, s.project.active_map, s.map().name.clone())); + self.text = active + .and_then(|l| l.dataset.crs) + .map(|c| c.custom_def().map(|d| d.proj4.clone()).unwrap_or_else(|| c.label())) + .unwrap_or_else(|| "EPSG:4326".into()); + } "coordinateSystem" => { self.text = s.map().projection.code(); // An EPSG code goes in the code box; presets are picked from the list. @@ -129,6 +146,7 @@ impl Dialogs { } } +/// Draws the active dialog and leaves it open on validation failure or a newly opened dialog. pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { let Some(which) = app.dialogs.open else { return }; if which == "calculateGeometry" { @@ -143,6 +161,8 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "calculateField" => "Calculate Field", "definitionQuery" => "Definition Query", "coordinateSystem" => "Coordinate System", + "defineProjection" => "Define Projection", + "addShapefile" => "Import Shapefile with Projection", "camera" => "Camera", "addTiles" => "Add Tile Layer", "basemap" => "Basemap", @@ -210,6 +230,37 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { }); } "coordinateSystem" => close = coordinate_system(app, ui, &t), + "addShapefile" => { + ui.label(tl!("Choose the source coordinate system before importing. It replaces a missing or incorrect .prj without changing the original file.")); + ui.label(tl!("Source coordinate system")); + let response = ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:2263").desired_width(f32::INFINITY)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Source coordinate system"))); + ui.label(tl!("In the browser, pick the .shp and .dbf together, with any sidecars, or pick a ZIP. The override applies to every Shapefile in the ZIP.")); + close = buttons(app, ui, "Choose File…", |app| app.run("ui.importShapefile", json!({"crs": app.dialogs.text.trim()})).map(|_| ())); + } + "defineProjection" => { + ui.label(egui::RichText::new(&app.dialogs.layer).strong()); + ui.label(tl!("Interpret the layer's native coordinates in this coordinate system. This changes where it draws; the source file is unchanged.")); + ui.label(tl!("Source coordinate system")); + let label = ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:2263").desired_width(f32::INFINITY)); + label.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Source coordinate system"))); + ui.label(egui::RichText::new(tl!("Geometry lost during import cannot be recovered here. Use Import Original File to choose its coordinate system before reading it. Streamed layers must first be loaded into memory.")).weak()); + if ui.button(tl!("Import Original File…")).clicked() { + let crs = app.dialogs.text.clone(); + if app.dialogs.open("addShapefile", &app.session).is_ok() { + app.dialogs.text = crs; + } + } + close = buttons(app, ui, "Apply", |app| { + if !app.dialogs.projection_map.as_ref().is_some_and(|(project, index, name)| { + *project == app.session.opened && *index == app.session.project.active_map && *name == app.session.map().name + }) { + return Err(tl!("The active project or map changed; reopen Define Projection for the intended layer.").into()); + } + let id = app.dialogs.projection_layer.ok_or(tl!("No layer selected"))?; + app.run("layer.defineProjection", json!({"layer": id, "crs": app.dialogs.text.trim()})).map(|_| ()) + }); + } "basemap" | "body" | "addSample" | "layerTemplate" => { // Menu items close their nearest closable parent. Keep that local to the // gallery so picking an item doesn't persistently collapse the Window's body. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5734d9157..fdaa1f255 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -93,6 +93,10 @@ pub type Inbox = std::sync::Arc)>>>; /// inbox, so the dialog's options go with that file and no other (a drop, another picker). pub const DELIMITED_PICK: &str = "\u{1}delimited:"; +/// Marks files returned by the source-CRS picker, so its override never applies to a drop or +/// another picker. Several selected Shapefiles share the same import options. +pub const SHAPEFILE_PICK: &str = "\u{1}shapefile:"; + /// The `open_async` purpose for a tool's file parameter, followed by the extensions it offers /// (`toolfile:nc,nc4`; none for any file). pub const TOOL_FILE_PURPOSE: &str = "toolfile:"; @@ -204,7 +208,8 @@ pub fn parse_extent(text: &str) -> Option<[f64; 4]> { /// supplies an asynchronous file picker (results arrive in `inbox`) and browser downloads. #[derive(Default)] pub struct Services { - /// Opens a file picker for `"data"` or `"project"`; the chosen file lands in `inbox`. + /// Opens a file picker for `"data"`, `"project"`, or `"shapefile"`; results land in `inbox`. + /// Shapefile picks carry [`SHAPEFILE_PICK`] after sidecars are gathered into ZIPs. pub open_async: Option>, /// Hands bytes to the user as a download named `file_name`. pub download: Option)>>, @@ -514,6 +519,8 @@ pub struct UiState { /// Add Delimited Text, for the file being picked (web): `null` opens its dialog for the file, /// a script's options are read with it. pub pending_csv: Option, + /// CRS override for the next Shapefile picked by Import Shapefile with Projection. + pub pending_shapefile: Option, /// Data downloading from web addresses (Add Data › From URL): the address, the parameters to /// add it with, and where the download lands. pub downloads: Vec<(u64, String, Value, Slot, String>>)>, @@ -668,6 +675,7 @@ impl Default for UiState { pending_open: None, web_file: None, pending_csv: None, + pending_shapefile: None, downloads: Vec::new(), project_download: None, sql: Default::default(), @@ -1076,12 +1084,25 @@ impl RustGisApp { /// Opens a file from the inbox: one the `"text"` picker marked ([`DELIMITED_PICK`]) opens Add /// Delimited Text for it, or is read with a script's options; one the tool file picker marked - /// ([`TOOL_FILE_PICK`]) goes to the Tools pane. + /// ([`TOOL_FILE_PICK`]) goes to the Tools pane. Source-CRS picks carry [`SHAPEFILE_PICK`]. pub fn open_picked(&mut self, name: &str, bytes: &[u8]) -> Result { if let Some(name) = name.strip_prefix(TOOL_FILE_PICK) { panes::received_file(self, name, bytes); return Ok(Value::Null); } + if let Some(name) = name.strip_prefix(SHAPEFILE_PICK) { + if !name.to_ascii_lowercase().ends_with(".zip") { + let error = tl!("Choose a zipped Shapefile, or pick its .shp and .dbf together.").to_string(); + self.session.notify(error.clone(), true); + return Err(error); + } + let Some(options) = self.ui.pending_shapefile.clone() else { + let error = tl!("Choose the source coordinate system before importing").to_string(); + self.session.notify(error.clone(), true); + return Err(error); + }; + return self.open_bytes_with(name, bytes, Some(options)); + } match name.strip_prefix(DELIMITED_PICK) { Some(name) => match self.ui.pending_csv.take() { Some(Value::Null) => dialogs::open_csv(self, name, dialogs::CsvSource::Bytes(bytes.to_vec())), @@ -1091,6 +1112,7 @@ impl RustGisApp { } } + /// Opens file bytes with picker options, forwarding source CRS overrides before ZIP parsing. fn open_bytes_with(&mut self, name: &str, bytes: &[u8], csv: Option) -> Result { let lower = name.to_ascii_lowercase(); // A plugin package: ask before installing it. @@ -1175,8 +1197,9 @@ impl RustGisApp { use base64::Engine; let data = base64::engine::general_purpose::STANDARD.encode(bytes); let mut p = json!({"fileName": name, "data": data}); - // A spreadsheet picked for Add Delimited Text takes its options. - if let Some(Value::Object(opts)) = csv.filter(|_| rustgis_engine::data::sheets::is_sheet_name(name)) { + // A picked spreadsheet or Shapefile ZIP takes its import options. + if let Some(Value::Object(opts)) = csv.filter(|_| rustgis_engine::data::sheets::is_sheet_name(name) || lower.ends_with(".zip")) + { for (k, v) in opts { p[k] = v; } @@ -2108,7 +2131,7 @@ impl RustGisApp { Box::new(move |r| *land.lock().unwrap_or_else(|e| e.into_inner()) = Some(r)), ); let mut q = json!({}); - for k in ["name", "layer"] { + for k in ["name", "layer", "crs"] { if let Some(v) = p.get(k).filter(|v| v.as_str().is_some_and(|s| !s.trim().is_empty())) { q[k] = v.clone(); } @@ -2193,6 +2216,20 @@ impl RustGisApp { failure: None, }); } + "ui.importShapefile" => { + let text = p.get("crs").and_then(Value::as_str).ok_or(tl!("Source coordinate system is required"))?; + rustgis_engine::data::raster::Crs::parse(text)?; + if let Some(open) = &self.services.open_async { + self.ui.pending_layout_picture = false; + self.ui.pending_picture = None; + self.ui.pending_open = None; + self.ui.pending_csv = None; + self.ui.pending_shapefile = Some(json!({"crs": text})); + open("shapefile"); + } else if let Some(path) = dialogs::pick_open(&["shp", "zip"], tl!("Shapefile")) { + return self.run("layer.addData", json!({"path": path, "crs": text})); + } + } "ui.addData" => { if let Some(open) = &self.services.open_async { // A picker cancelled earlier mustn't claim this file. diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 254f20482..f59568111 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1876,6 +1876,10 @@ fn layer_menu(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, reorder: bool) { let _ = app.run("ui.dialog", json!({"name": "renameLayer"})); ui.close(); } + if !l.is_raster() && ui.button(tl!("Define Projection…")).clicked() { + let _ = app.run("ui.dialog", json!({"name": "defineProjection"})); + ui.close(); + } // Layer groups (#413): a service's collections stay in their service group. if !service_child { let groups: Vec<(u32, String)> = app.session.map().groups.iter().map(|g| (g.id, g.name.clone())).collect(); @@ -4284,6 +4288,7 @@ fn save_history_script(app: &mut RustGisApp) { } } +/// Shows the selected layer's source metadata alongside its internal longitude/latitude extent. fn details(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let Some(l) = app.session.active_layer.and_then(|id| app.session.map().layer(id)) else { ui.label(egui::RichText::new(tl!("Select a layer in the Layers pane.")).color(t.text_dim)); @@ -4303,6 +4308,7 @@ fn details(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ("Selected", l.selection.len().to_string()), ("Symbology", tl!(l.renderer.kind_label()).to_string()), ("Source", l.dataset.source.clone().unwrap_or_else(|| tl!("In project").into())), + ("Source coordinate system", l.dataset.crs.map(|c| c.label()).unwrap_or_else(|| "EPSG:4326".into())), ("West", format!("{:.4}°", e.min_x)), ("East", format!("{:.4}°", e.max_x)), ("South", format!("{:.4}°", e.min_y)), diff --git a/crates/ui-egui/tests/define_projection.rs b/crates/ui-egui/tests/define_projection.rs new file mode 100644 index 000000000..7391882d8 --- /dev/null +++ b/crates/ui-egui/tests/define_projection.rs @@ -0,0 +1,199 @@ +//! Drive the real Define Projection dialog and its async import handoff. +use egui_kittest::kittest::Queryable; +use rustgis_engine::{ + Project, Session, + data::{Coord, Dataset, Geometry, GeometryType}, +}; +use rustgis_ui_egui::{RustGisApp, Services}; +use serde_json::json; + +/// Two layers let a selection change expose accidental retargeting while a dialog is open. +fn harness() -> egui_kittest::Harness<'static, RustGisApp> { + let mut s = Session::new(Project::default()); + for name in ["Parcels", "Other"] { + let mut ds = Dataset::new(name, GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(451_219.33, 5_411_550.20))), vec![]); + s.map_mut().add_dataset(ds); + } + s.run("layer.select", &json!({"layer": "Parcels"})).unwrap(); + let app = RustGisApp::new(s); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 1000.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.state_mut().ui.command_search = Some("layer.defineProjection".into()); + h.run_steps(3); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, Some("defineProjection")); + h +} + +/// Types through the labelled text widget after the dialog has settled, including replacing +/// its previous CRS. No form state is injected around the submission path. +fn enter_crs(h: &mut egui_kittest::Harness<'_, RustGisApp>, crs: &str) { + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Source coordinate system").focus(); + h.step(); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text(crs.into())); + h.run_steps(3); +} + +#[test] +fn dialog_applies_to_captured_layer_and_undo_restores_native_coordinates() { + let mut h = harness(); + h.state_mut().run("layer.select", json!({"layer": "Other"})).unwrap(); + enter_crs(&mut h, "EPSG:32631"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Apply").click(); + h.step(); + assert!(h.state().dialogs.open.is_none()); + let point = |app: &RustGisApp, name: &str| { + app.session.map().layer_by_name(name).unwrap().dataset.features[0].geometry.as_ref().unwrap().label_point().unwrap() + }; + assert!((point(h.state(), "Parcels").x - 2.335).abs() < 1e-6); + assert_eq!(point(h.state(), "Other").x, 451_219.33); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(point(h.state(), "Parcels").x, 451_219.33); +} + +#[test] +fn import_dialog_passes_crs_to_browser_upload_before_wrong_prj_is_read() { + use std::sync::{Arc, Mutex}; + let opened = Arc::new(Mutex::new(Vec::new())); + let captured = opened.clone(); + let mut h = harness(); + h.state_mut().services = + Services { open_async: Some(Box::new(move |kind| captured.lock().unwrap().push(kind.to_string()))), ..Services::default() }; + h.get_by_role_and_label(egui::accesskit::Role::Button, "Import Original File…").click(); + h.run_steps(3); + assert_eq!(h.state().dialogs.open, Some("addShapefile")); + enter_crs(&mut h, "EPSG:32631"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Choose File…").click(); + h.step(); + assert_eq!(*opened.lock().unwrap(), ["shapefile"], "dialog error: {:?}", h.state().dialogs.error); + let ds = h.state().session.map().layer_by_name("Parcels").unwrap().dataset.clone(); + let parts = rustgis_engine::data::io::shapefile_parts_in(&ds, rustgis_engine::data::raster::Crs::LonLat).unwrap().0; + let entries = parts.into_iter().map(|(ext, bytes)| (format!("parcels.{ext}"), bytes)).collect::>(); + let bytes = rustgis_engine::data::zip::write(&entries).unwrap(); + // A drop or another picker is unmarked and must not inherit this import's CRS. + let ordinary = h.state_mut().open_picked("ordinary.zip", &bytes).unwrap(); + let ordinary_id = ordinary["layer"].as_u64().unwrap() as u32; + assert_eq!( + h.state().session.map().layer(ordinary_id).unwrap().dataset.features[0].geometry.as_ref().unwrap().label_point().unwrap().x, + 451_219.33 + ); + let result = h.state_mut().open_picked(&format!("{}parcels.zip", rustgis_ui_egui::SHAPEFILE_PICK), &bytes).unwrap(); + let id = result["layer"].as_u64().unwrap() as u32; + let point = h.state().session.map().layer(id).unwrap().dataset.features[0].geometry.as_ref().unwrap().label_point().unwrap(); + assert!((point.x - 2.335).abs() < 1e-6, "{point:?}"); + // The browser can return several grouped Shapefiles from one pick. + let second = h.state_mut().open_picked(&format!("{}another.zip", rustgis_ui_egui::SHAPEFILE_PICK), &bytes).unwrap(); + let second_id = second["layer"].as_u64().unwrap() as u32; + assert_eq!(h.state().session.map().layer(second_id).unwrap().dataset.crs, h.state().session.map().layer(id).unwrap().dataset.crs); +} + +#[test] +fn dialog_rejects_a_different_map_with_the_same_layer_id() { + let mut h = harness(); + let original_id = h.state().session.map().layer_by_name("Parcels").unwrap().id; + h.state_mut().run("map.new", json!({"name": "Second Map"})).unwrap(); + let mut ds = Dataset::new("Unrelated", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(451_219.33, 5_411_550.20))), vec![]); + let id = h.state_mut().session.map_mut().add_dataset(ds); + assert_eq!(id, original_id, "the collision is the regression's precondition"); + h.state_mut().session.active_layer = Some(id); + h.run_steps(3); + enter_crs(&mut h, "EPSG:32631"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Apply").click(); + h.run_steps(3); + assert!(h.state().dialogs.error.as_deref().is_some_and(|e| e.contains("project or map changed")), "{:?}", h.state().dialogs.error); + let current = &h.state().session.map().layer(id).unwrap().dataset; + assert_eq!(current.features[0].geometry.as_ref().unwrap().label_point().unwrap().x, 451_219.33); + let original = &h.state().session.project.maps[0].layer(original_id).unwrap().dataset; + assert_eq!(original.features[0].geometry.as_ref().unwrap().label_point().unwrap().x, 451_219.33); +} + +#[test] +fn dialog_rejects_a_replacement_project_with_the_same_map_and_layer() { + let mut h = harness(); + let map = h.state().session.map().name.clone(); + let id = h.state().session.map().layer_by_name("Parcels").unwrap().id; + h.state_mut().run("project.new", json!({})).unwrap(); + let mut ds = Dataset::new("Unrelated", GeometryType::Point, vec![]); + ds.push(Some(Geometry::Point(Coord::new(451_219.33, 5_411_550.20))), vec![]); + assert_eq!(h.state_mut().session.map_mut().add_dataset(ds), id); + assert_eq!(h.state().session.map().name, map); + h.state_mut().session.active_layer = Some(id); + h.run_steps(3); + enter_crs(&mut h, "EPSG:32631"); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Apply").click(); + h.run_steps(3); + assert!(h.state().dialogs.error.as_deref().is_some_and(|e| e.contains("project or map changed")), "{:?}", h.state().dialogs.error); + let current = &h.state().session.map().layer(id).unwrap().dataset; + assert_eq!(current.features[0].geometry.as_ref().unwrap().label_point().unwrap().x, 451_219.33); +} + +#[test] +fn shapefile_picker_validation_errors_are_visible_after_inbox_processing() { + use std::sync::{Arc, Mutex}; + for (name, pending, message) in [ + ("orphan.prj", Some(json!({"crs": "EPSG:32631"})), "pick its .shp and .dbf together"), + ("parcels.zip", None, "Choose the source coordinate system before importing"), + ] { + let mut h = harness(); + let before = h.state().session.map().layers.len(); + h.state_mut().session.notices.clear(); + h.state_mut().ui.pending_shapefile = pending; + h.state_mut().services.inbox = Some(Arc::new(Mutex::new(vec![(format!("{}{name}", rustgis_ui_egui::SHAPEFILE_PICK), vec![])]))); + h.step(); + assert!(h.state().session.notices.iter().any(|n| n.error && n.text.contains(message)), "{:?}", h.state().session.notices); + assert_eq!(h.state().session.map().layers.len(), before); + } +} + +#[test] +fn downloaded_shapefile_keeps_the_requested_source_crs() { + use std::io::{Read, Write}; + let mut h = harness(); + h.state_mut().dialogs.open = None; + let ds = h.state().session.map().layer_by_name("Parcels").unwrap().dataset.clone(); + let entries = rustgis_engine::data::io::shapefile_parts_in(&ds, rustgis_engine::data::raster::Crs::LonLat) + .unwrap() + .0 + .into_iter() + .map(|(ext, bytes)| (format!("parcels.{ext}"), bytes)) + .collect::>(); + let bytes = rustgis_engine::data::zip::write(&entries).unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + stream.set_read_timeout(Some(std::time::Duration::from_secs(2))).unwrap(); + let mut request = [0; 2048]; + let _ = stream.read(&mut request); + write!(stream, "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", bytes.len()).unwrap(); + stream.write_all(&bytes).unwrap(); + } + }); + h.state_mut() + .run( + "ui.addUrl", + json!({"url": format!("http://{address}/parcels.zip"), "crs": "EPSG:32631", "name": "Downloaded", "layer": "parcels"}), + ) + .unwrap(); + for _ in 0..100 { + h.step(); + if h.state().session.map().layer_by_name("Downloaded").is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let layer = h.state().session.map().layer_by_name("Downloaded").unwrap(); + let point = layer.dataset.features[0].geometry.as_ref().unwrap().label_point().unwrap(); + assert!((point.x - 2.335).abs() < 1e-6 && (point.y - 48.855).abs() < 1e-6, "{point:?}"); + assert_eq!(layer.dataset.crs, Some(rustgis_engine::data::raster::Crs::parse("EPSG:32631").unwrap())); +} diff --git a/docs/user-guide/data-formats.md b/docs/user-guide/data-formats.md index 548b95ef8..5d18e1b54 100644 --- a/docs/user-guide/data-formats.md +++ b/docs/user-guide/data-formats.md @@ -293,6 +293,45 @@ need an EPSG code and otherwise default to WGS 84. A layer made in RustGIS defau another, such as `{"layer": "Parcels", "path": "parcels.gpkg", "crs": "EPSG:4647"}`. GeoJSON, KML, GPX, GeoParquet and CSV are always written in WGS 84. +## Define Projection + +When a Shapefile's `.prj` is missing or incorrect, right-click its layer and choose +**Define Projection…** (also in Command Search). Enter the **source** coordinate system, such +as `EPSG:2263` or a WKT/PROJ definition, then Apply. This reinterprets the native coordinate +numbers: it changes the layer's position, preserves attributes and selection, and can be undone +in one step. Use **Zoom To Layer** to see its corrected position. It does not modify the original +file. Saving the project keeps the definition. Shapefile exports use it by default; +GeoPackage and FlatGeobuf exports do so when the definition has an EPSG code. Custom +definitions without an EPSG code can be exported in their native coordinates as Shapefiles. + +This differs from changing the map's coordinate system, which only changes the display, or +exporting to another CRS, which converts coordinate numbers while preserving locations. +RustGIS stores geometry in longitude/latitude, so Define Projection first reconstructs native +numbers through the previous CRS; small floating-point rounding differences are possible. +Edits already made to the layer are reinterpreted too. If the wrong CRS already discarded or +clamped coordinates, import the original file again to recover them accurately. + +If the incorrect `.prj` prevented geometry from being read, Define Projection cannot restore +it. Choose **Import Original File…** in the dialog, or search for **Import Shapefile with +Projection…**. Enter the correct CRS before choosing the file. In the browser, pick the `.shp` +and `.dbf` together, with any sidecars, or choose a ZIP. The override replaces the `.prj` before +any conversion and applies to every Shapefile in a ZIP; other formats keep their own CRS. +This adds a new layer, so existing layer edits and styles are not transferred. + +A streamed layer cannot be changed by Define Projection. Use **Load All Features** first (up +to the read limit), or import again with the override; streamed Shapefiles remember that +override when the project reopens. Layers whose import lost geometry must be imported again. + +The same operations are available to scripts and the CLI: + +```json +{"cmd":"layer.defineProjection","params":{"layer":"Parcels","crs":"EPSG:2263"}} +{"cmd":"layer.addData","params":{"path":"parcels.shp","crs":"EPSG:2263"}} +``` + +`layer.addDataBytes`, `layer.addUrl` and `layer.addDownload` also accept `crs` for zipped +Shapefiles. The source files and their `.prj` sidecars are never rewritten by either operation. + ## RustGIS files | File | Extension | Notes | From ca0e79c65a077a07e5ec845097f596a7c6d1271a Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Fri, 23 Oct 2026 21:47:20 -0300 Subject: [PATCH 082/297] Web reads: a file's last bytes from a store that answers a suffix range with 501 (#751) * Web reads: the last bytes from a store that answers a suffix range with 501 GitHub's release files (Azure storage) serve 'bytes=a-b' but answer 'bytes=-n' with 501 Not Implemented, so a GeoParquet's footer couldn't be read and layer.addUrl failed with 'http status: 501' (geobr's data, for one). Asked for the last bytes and answered 501, the fetch now reads the file's length from a one-byte range's Content-Range and asks for the last bytes by their place, at the same address; a server giving no length goes on as one without ranges. Co-Authored-By: Claude Opus 5.5 * Web reads: the length probe redirected is followed, refused keeps its status The one-byte range asked for a file's length took any answer: a redirect or a refusal (4xx/5xx) was reported as a server without ranges, the status lost and the redirect never followed. Now only an answer below 300 settles the length; a redirect is followed and the range asked for again where it leads, and a refusal fails with its own status (#751 review). Tested for a redirected probe, a 404 and a whole-file 200. Co-Authored-By: Claude Opus 5.5 * Web reads: a Content-Range's length read by a helper, tested on its own content_range_total replaces the chain that read the file's length from the probe's Content-Range, and is tested for an unknown (*), zero, missing or negative total. The probe test also asks for more last bytes than the file holds and gets the whole file (#751 review). Co-Authored-By: Claude Opus 5.5 * Web reads: the length probe's byte read, so its connection is reused A 206 probe dropped unread kept ureq from pooling its connection, so the request for the last bytes opened another. The byte is read (at most 64, errors ignored) before the retry (#751 review). Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/tiles/src/cache.rs | 111 +++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/crates/tiles/src/cache.rs b/crates/tiles/src/cache.rs index 813f23fe6..c63b40bed 100644 --- a/crates/tiles/src/cache.rs +++ b/crates/tiles/src/cache.rs @@ -1060,6 +1060,13 @@ mod native { fetch_answer(agent, url, true).map_err(|f| Failure { url: url.to_string(), ..f }) } + /// A file's length from a `Content-Range` header (`bytes 0-0/1234`); none when the server + /// doesn't know it (`*`), the file is empty or the header can't be read. + pub(super) fn content_range_total(header: &str) -> Option { + let (_, len) = header.rsplit_once('/')?; + len.trim().parse::().ok().filter(|len| *len > 0) + } + /// The answer to `url`; a refusal keeps the server's answer only when `detail` asks for it, /// as tiles a server doesn't have are routine and only their status is read. fn fetch_answer(agent: &ureq::Agent, url: &str, detail: bool) -> Result, Failure> { @@ -1074,8 +1081,10 @@ mod native { } let (url, limit) = super::split_download_limit(url); let max = limit.unwrap_or(MAX_BYTES); - let (url, tail) = super::split_tail(url); - let (url, range) = super::split_range(url); + let (url, mut tail) = super::split_tail(url); + let (url, mut range) = super::split_range(url); + // The last bytes asked for, while the file's length is asked for instead (see below). + let mut measuring: Option = None; // An `s3://` address becomes its web address, and a request to an S3 connection's // endpoint is signed. let prepared = crate::s3::prepare(url)?; @@ -1112,6 +1121,29 @@ mod native { if status == 401 && crate::earthdata::is_data_host(&at) { return Err(Failure::without_answer("", crate::earthdata::refused_message(url), Reason::Status(status))); } + // A store that serves ranges but not the last bytes by their count (GitHub's release + // files, on Azure storage, answer `bytes=-n` with 501): the file's length from a + // one-byte range's `Content-Range`, then the last bytes by their place, at the same + // address. A server that gives no length goes on as one without ranges; the one-byte + // range redirected is asked for again where it leads, and refused fails as any refusal. + if let Some(n) = tail.filter(|_| status == 501 && range.is_none()) { + (measuring, tail, range) = (Some(n), None, Some((0, 0))); + continue; + } + if let Some(n) = measuring.filter(|_| status < 300) { + measuring = None; + let header = resp.headers().get("content-range").and_then(|v| v.to_str().ok()); + match header.filter(|_| status == 206).and_then(content_range_total) { + Some(len) => { + range = Some((len.saturating_sub(n), len - 1)); + // The probe's byte read, so its connection goes back to the pool for the + // next request; a body that won't read is left, the request goes on. + let _ = resp.body_mut().with_config().limit(64).read_to_vec(); + continue; + } + None => return Err(format!("{url}: {}", super::NO_RANGES).into()), + } + } if status >= 400 { return Err(if detail { refused(&mut resp) @@ -1380,6 +1412,81 @@ mod tests { format!("http://{addr}") } + /// The last bytes from a store that serves ranges but answers `bytes=-n` with 501 (GitHub's + /// release files, on Azure storage): the file's length from a one-byte range, then the last + /// bytes by their place. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_last_bytes_come_from_a_store_without_suffix_ranges() { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let asked = Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = asked.clone(); + std::thread::spawn(move || { + for stream in listener.incoming().take(3) { + let Ok(mut stream) = stream else { return }; + let mut buf = [0u8; 2048]; + let n = stream.read(&mut buf).unwrap_or(0); + let head = String::from_utf8_lossy(&buf[..n]).to_ascii_lowercase(); + let range = head.lines().find_map(|l| l.strip_prefix("range: ")).unwrap_or("").trim().to_string(); + seen.lock().unwrap().push(range.clone()); + let answer = match range.as_str() { + "bytes=-4" => "HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string(), + "bytes=0-0" => { + "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 0-0/10\r\nContent-Length: 1\r\nConnection: close\r\n\r\n0" + .to_string() + } + "bytes=6-9" => { + "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 6-9/10\r\nContent-Length: 4\r\nConnection: close\r\n\r\n6789" + .to_string() + } + _ => "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string(), + }; + let _ = stream.write_all(answer.as_bytes()); + } + }); + let agent = native::agent(); + let got = native::get_detailed(&agent, &format!("http://{addr}/f.parquet{TAIL}4")).unwrap(); + assert_eq!(got, b"6789"); + assert_eq!(*asked.lock().unwrap(), ["bytes=-4", "bytes=0-0", "bytes=6-9"]); + } + + /// The one-byte range asked for a file's length: redirected, it is asked for again where it + /// leads; refused, the refusal's status is kept; answered whole, the server has no ranges. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn the_length_asked_for_follows_redirects_and_keeps_refusals() { + let no_suffix = "HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string(); + let agent = native::agent(); + let base = serve(vec![ + no_suffix.clone(), + "HTTP/1.1 302 Found\r\nLocation: /g.parquet\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(), + "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 0-0/10\r\nContent-Length: 1\r\nConnection: close\r\n\r\n0".into(), + "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 6-9/10\r\nContent-Length: 4\r\nConnection: close\r\n\r\n6789".into(), + ]); + assert_eq!(native::get_detailed(&agent, &format!("{base}/f.parquet{TAIL}4")).unwrap(), b"6789"); + let base = serve(vec![no_suffix.clone(), "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into()]); + let f = native::get_detailed(&agent, &format!("{base}/f.parquet{TAIL}4")).unwrap_err(); + assert_eq!(f.status(), Some(404), "{}", f.message); + let base = serve(vec![no_suffix, "HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\n0123456789".into()]); + let f = native::get_detailed(&agent, &format!("{base}/f.parquet{TAIL}4")).unwrap_err(); + assert!(f.message.contains(super::NO_RANGES), "{}", f.message); + // More last bytes asked for than the file holds: the whole file. + let base = serve(vec![ + "HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(), + "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 0-0/3\r\nContent-Length: 1\r\nConnection: close\r\n\r\na".into(), + "HTTP/1.1 206 Partial Content\r\nContent-Range: bytes 0-2/3\r\nContent-Length: 3\r\nConnection: close\r\n\r\nabc".into(), + ]); + assert_eq!(native::get_detailed(&agent, &format!("{base}/f.parquet{TAIL}4")).unwrap(), b"abc"); + // The length as a Content-Range gives it, when it does. + assert_eq!(native::content_range_total("bytes 0-0/1234"), Some(1234)); + assert_eq!(native::content_range_total("bytes 0-0/ 10 "), Some(10)); + for unknown in ["bytes 0-0/*", "bytes 0-0/0", "bytes 0-0", "bytes 0-0/-5", ""] { + assert_eq!(native::content_range_total(unknown), None, "{unknown}"); + } + } + /// A refused request keeps who refused it (#509); the plain error reads as it always has. #[cfg(not(target_arch = "wasm32"))] #[test] From 0ecc0868cbfaeef6f1b4691831394c08a2f85c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Fri, 23 Oct 2026 20:16:33 -0500 Subject: [PATCH 083/297] feat(ui): offer layers and fields as values in the Script pane (#771) Co-authored-by: Qiusheng Wu --- crates/ui-egui/src/scriptcomplete.rs | 257 +++++++++++++++++++++++---- docs/user-guide/analysis.md | 6 +- 2 files changed, 223 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 7c9464371..d1cc0485f 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -4,8 +4,10 @@ //! box and tested on its own: command ids (a line's first word), a command's parameter keys in //! its `{…}` and in its second-level objects (`key_at`), and the ids some values take: tools for //! a `gp.run` line's `"tool"`, commands for a `task.addStep` line's `"command"` (`value_at`), -//! whose own keys then go in its `"params": {…}`. Typing aids beside it: a command's parameters -//! added on accept (`template`), and braces, brackets and quotes that close themselves (`brace`). +//! whose own keys then go in its `"params": {…}`, and what the project holds: layers for a +//! `"layer"`, the line's layer's fields for a `"field"`. Typing aids beside it: a command's +//! parameters added on accept (`template`), and braces, brackets and quotes that close themselves +//! (`brace`). //! Quoted text is read by one rule everywhere (`rustgis_engine::Quotes`), the one the colours use. use egui::text::{CCursor, CCursorRange}; @@ -35,7 +37,8 @@ pub struct Offer { /// its id), the keys of the second-level object of a command's entry `key`, a geoprocessing /// tool's parameter keys (by its id) in a `gp.run` line's `"params"`, or the ids a value takes: /// the tools for a `gp.run` line's `"tool"`, the commands for a `task.addStep` line's -/// `"command"`. +/// `"command"`; or what the project holds for a value: the map's layers for a `"layer"`, the +/// fields of a layer (by its id) for a `"field"`. #[derive(Clone, Debug, PartialEq)] pub enum Of { CommandKeys(String), @@ -43,6 +46,15 @@ pub enum Of { ToolKeys(String), ToolIds, CommandIds, + Layers, + Fields(u32), +} + +impl Of { + /// Whether its items are a value's (written as a JSON string's text), not keys. + fn is_value(&self) -> bool { + matches!(self, Of::ToolIds | Of::CommandIds | Of::Layers | Of::Fields(_)) + } } /// The ids that complete the command id being typed at char `at` of `script`: the cursor at the @@ -398,6 +410,19 @@ fn named_in(script: &str, h: &Hint) -> Option<(Named, String)> { Some((what, value_of(line_of(script, h)?, key)?)) } +/// The layer a line's object (`line`, from its `{`) acts on, as Run finds it: by its `"layer"` +/// (a name, or an id written as text), the active layer without one. None for a layer that +/// isn't there, or one given otherwise (a number, an escaped name): not the active layer then. +fn line_layer<'a>(session: &'a rustgis_engine::Session, line: &str) -> Option<&'a rustgis_engine::map::Layer> { + let given = rustgis_engine::param_keys(line).is_some_and(|keys| keys.contains(&"layer")); + let p = match value_of(line, "layer") { + Some(name) => serde_json::json!({ "layer": name }), + None if given => return None, + None => serde_json::json!({}), + }; + session.map().layer(session.layer_id(&p).ok()?) +} + /// What to offer at char `at` of `script`: the command ids for a line's first word, the keys of /// the line's command for a key being typed in its `{…}`, and in its second-level objects the /// keys their entries describe; in a `gp.run` line the tool ids for its `"tool"` and the tool's @@ -408,17 +433,35 @@ fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::S return complete(start, at, &word, ids); } if let Some(ValueAt { hint, start, word, key }) = value_at(script, at) { - // The values `named` lists; the project's layers and fields come later (#662's third step). - let (_, what) = named(&hint.id).filter(|(names, _)| key == *names)?; - return match what { - Named::Tool => value_ids(start, at, &word, rustgis_engine::analysis::tools().map(|t| t.id), Of::ToolIds), - // Those `task.addStep` takes, by its own rule (not a script's: it adds the step, it - // doesn't run it). - Named::Command => { - let steps = - session.registry.commands.iter().map(|c| c.id).filter(|id| rustgis_engine::cmd::items::can_be_step(session, id)); - value_ids(start, at, &word, steps, Of::CommandIds) + if let Some((_, what)) = named(&hint.id).filter(|(names, _)| key == *names) { + return match what { + Named::Tool => value_ids(start, at, &word, rustgis_engine::analysis::tools().map(|t| t.id), Of::ToolIds), + // Those `task.addStep` takes, by its own rule (not a script's: it adds the step, it + // doesn't run it). + Named::Command => { + let steps = + session.registry.commands.iter().map(|c| c.id).filter(|id| rustgis_engine::cmd::items::can_be_step(session, id)); + value_ids(start, at, &word, steps, Of::CommandIds) + } + }; + } + // Only where the command takes that key: a value for one Run turns down is no help. + if !takes(session, &hint.id, &key) { + return None; + } + return match key.as_str() { + "layer" => { + // Those Run finds by their name (`Map::layer_by_name`): of two named alike, case + // aside, the first. + let map = session.map(); + let names = map.layers.iter().filter(|l| map.layer_by_name(&l.name).is_some_and(|f| f.id == l.id)).map(|l| l.name.as_str()); + value_ids(start, at, &word, names, Of::Layers) + } + "field" => { + let l = line_layer(session, line_of(script, &hint)?)?; + value_ids(start, at, &word, l.dataset.fields.iter().map(|f| f.name.as_str()), Of::Fields(l.id)) } + _ => None, }; } let KeyAt { hint: h, start, word, nested } = key_at(script, at)?; @@ -478,10 +521,10 @@ fn sub_desc(desc: &str, key: &str) -> Option { } /// The ids of `ids` that complete `word`, typed from char `start` to `end`, case aside, each -/// once: the tools or commands a value takes (`of`), offered from its quote, so not cut to -/// `MAX_ITEMS` (the list scrolls). Commands sorted, as a line's first word offers them; tools -/// in their order, the built-in ones first (an external one with the same id isn't the one Run -/// finds). +/// once: the tools, commands, layers or fields a value takes (`of`), offered from its quote, so +/// not cut to `MAX_ITEMS` (the list scrolls). Commands sorted, as a line's first word offers +/// them; the rest in their order: tools with the built-in ones first (an external one with the +/// same id isn't the one Run finds), layers top first, fields as the layer's table has them. fn value_ids<'a>(start: usize, end: usize, word: &str, ids: impl Iterator, of: Of) -> Option { let mut seen = std::collections::HashSet::new(); let mut items: Vec = ids.filter(|id| starts_with_ci(id, word) && seen.insert(*id)).map(str::to_string).collect(); @@ -491,21 +534,66 @@ fn value_ids<'a>(start: usize, end: usize, word: &str, ids: impl Iterator Option<(String, Option)> { - let described = |desc: &str| entries(desc).into_iter().find(|(keys, _)| keys.iter().any(|k| k == key)).map(|(_, text)| (text, None)); +/// What the help beside a list says about item `item` of `of`: for a key, the line describing its +/// parameter (`"size"?: number`) and a longer help under it (a tool's, when it has one); for a +/// layer, what it holds; for a field, its type and its alias. +fn item_help(session: &rustgis_engine::Session, of: &Of, item: &str) -> Option<(String, Option)> { + let described = |desc: &str| entries(desc).into_iter().find(|(keys, _)| keys.iter().any(|k| k == item)).map(|(_, text)| (text, None)); match of { Of::CommandKeys(id) => described(¶ms_of(session, id)?), Of::SubKeys { id, key: entry } => described(&sub_desc(¶ms_of(session, id)?, entry)?), Of::ToolKeys(id) => { - let p = rustgis_engine::analysis::tool(id)?.params.iter().find(|p| p.name == key)?; + let p = rustgis_engine::analysis::tool(id)?.params.iter().find(|p| p.name == item)?; Some((param_line(p), (!p.help.is_empty()).then(|| tl!(p.help).to_string()))) } + Of::Layers => { + let l = session.map().layer_by_name(item)?; + if l.is_raster() { + return Some((tl!("Raster").to_string(), None)); + } + let text = crate::i18n::fmt( + "{type} · {n} features · {fields} fields", + &[ + ("type", tl_raw!(l.geometry_type().label())), + ("n", &l.dataset.features.len().to_string()), + ("fields", &l.dataset.fields.len().to_string()), + ], + ); + Some((text, None)) + } + Of::Fields(id) => { + let f = session.map().layer(*id)?.dataset.fields.iter().find(|f| f.name == item)?; + Some((tl!(f.ty.label()).to_string(), f.alias.clone())) + } Of::ToolIds | Of::CommandIds => None, } } +/// The icon beside item `item` of a list of `of`: a layer's kind, as the Layers pane shows it, or +/// a field's; none for ids and keys. +fn item_icon(session: &rustgis_engine::Session, of: Option<&Of>, item: &str) -> Option<&'static str> { + use rustgis_engine::data::GeometryType; + match of? { + Of::Layers => { + let l = session.map().layer_by_name(item)?; + Some(match l.geometry_type() { + _ if l.is_raster() => "raster_layer", + GeometryType::Point => "point_layer", + GeometryType::Polyline => "line_layer", + _ => "polygon_layer", + }) + } + Of::Fields(_) => Some("field"), + _ => None, + } +} + +/// `text` as it goes between a JSON string's quotes (`Mi "capa"` as `Mi \"capa\"`). +fn json_text(text: &str) -> String { + let quoted = serde_json::to_string(text).unwrap_or_default(); + quoted.get(1..quoted.len().saturating_sub(1)).unwrap_or_default().to_string() +} + /// A tool's parameter as the help shows it, as a command's parameters are described: its key /// (`?` when it may be left out), what it takes, its name and its default /// (`"units"?: "Kilometers" | "Miles" — Units, default "Kilometers"`). @@ -639,6 +727,11 @@ fn params_of(session: &rustgis_engine::Session, id: &str) -> Option { } } +/// Whether command `id`'s parameter description names top-level key `key`. +fn takes(session: &rustgis_engine::Session, id: &str, key: &str) -> bool { + params_of(session, id).is_some_and(|desc| rustgis_engine::param_keys(&desc).is_some_and(|keys| keys.contains(&key))) +} + /// What accepting a command whose parameters `desc` describes puts after its id, and where the /// cursor goes in it (chars): its required keys, each with its value to type, the cursor at the /// first (` {"lon": |, "lat": }`); empty braces when every key may be left out (` {|}`); nothing @@ -713,14 +806,15 @@ fn draw_help(ui: &mut Ui, help: &Help, width: f32) { #[derive(Clone, Default)] struct List { /// What was worked out to offer, and for which cursor, script (its hash: one replaced from - /// outside the box, as long as before, still tells) and plugins (their revision: a plugin - /// turned off mustn't stay on offer, to be accepted). While none changes (the cursor + /// outside the box, as long as before, still tells), plugins (their revision: a plugin + /// turned off mustn't stay on offer, to be accepted) and project (the session's revision and + /// active layer: the layers and fields offered). While none changes (the cursor /// blinking, the pointer moving) it isn't worked out again. Kept whether the list shows or not. /// The hash is taken each frame the box has focus: the length alone misses a script replaced by /// one as long, and a change made by code comes with no input event to wait for. It reads the /// text once, as egui's own layout already does each frame (the layouter's cache key, the /// galley cache), so it adds a constant, not a new order of cost. - worked: Option>>, + worked: Option), Option>>, /// The list shown, and its highlighted item: what the keys act on. open: Option<(Offer, usize)>, /// The offer Escape closed: not shown again until what's typed changes. @@ -741,8 +835,8 @@ struct List { } /// What was worked out at a cursor, and for which script (its hash) and `with` (what else it -/// depends on: the plugins' revision for the list; the language and the plugins' revision for -/// the help): worked out again only when one changes. +/// depends on: the plugins' revision and the project for the list; the language and the +/// plugins' revision for the help): worked out again only when one changes. #[derive(Clone)] struct Worked { at: Option, @@ -876,13 +970,14 @@ pub fn show( let offer = if out.response.response.has_focus() { // A cursor, not a selection: with text selected, Enter and Tab act on it as usual. let at = out.cursor_range.filter(|r| r.is_empty()).map(|r| r.primary.index.0); - let (text, plugins) = (egui::util::hash(script), rustgis_engine::plugins::revision()); - let kept = Worked::get(&list.worked, at, text, &plugins); + let text = egui::util::hash(script); + let with = (rustgis_engine::plugins::revision(), session.revision, session.active_layer); + let kept = Worked::get(&list.worked, at, text, &with); match kept { Some(offer) => offer, None => { let offer = at.and_then(|at| offer_at(script, at, &script_ids_cached(ui, session), session)); - list.worked = Some(Worked { at, text, with: plugins, value: offer.clone() }); + list.worked = Some(Worked { at, text, with, value: offer.clone() }); offer } } @@ -938,10 +1033,23 @@ pub fn show( ui.with_layout(layout, |ui| { // The help follows the item under the pointer, else the highlighted one. let mut hovered = None; + let t = crate::theme::Tokens::get(ui.ctx()); ui.vertical(|ui| { ui.set_min_width(MIN_WIDTH); for (i, item) in offer.items.iter().enumerate() { - let r = ui.selectable_label(i == selected, egui::RichText::new(item).monospace()); + let label = |ui: &mut Ui| ui.selectable_label(i == selected, egui::RichText::new(item).monospace()); + // A layer's kind or a field, before its name, as the Layers pane shows it. + let r = match item_icon(session, offer.of.as_ref(), item) { + Some(icon) => { + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), r, icon, t.icon, t.accent); + label(ui) + }) + .inner + } + None => label(ui), + }; // Taken as the button goes down, as in a code editor: the press already // takes the focus from the box, and a click (down, then up a frame or more // later) could find the list gone by then. A click still counts @@ -980,9 +1088,9 @@ pub fn show( draw_help(ui, &help, help_width); } } - // A key: the line describing its parameter. + // A key: the line describing its parameter; a layer or a field: what it holds. Some(of) => { - if let Some((text, about)) = item.and_then(|key| key_help(session, of, key)) { + if let Some((text, about)) = item.and_then(|it| item_help(session, of, it)) { ui.add_space(GAP); draw_key_help(ui, &text, about.as_deref(), help_width); } @@ -1063,9 +1171,12 @@ pub fn hint(ui: &Ui, box_id: Id, out: &TextEditOutput, visible: egui::Rect, scri pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &str, session: &rustgis_engine::Session) { let chars: Vec = script.chars().collect(); let (Some(before), Some(mut after)) = (chars.get(..offer.start), chars.get(offer.end..)) else { return }; + let value = offer.of.as_ref().is_some_and(Of::is_value); + // A value as a JSON string holds it (a layer named `Mi "capa"` as `Mi \"capa\"`). + let item = if value { json_text(item) } else { item.to_string() }; let (tail, at) = match &offer.of { - // An id for a value: closed, the cursor after its quote (one already there is kept). - Some(Of::ToolIds | Of::CommandIds) => (if after.first() == Some(&'"') { String::new() } else { "\"".to_string() }, 1), + // A value: closed, the cursor after its quote (one already there is kept). + Some(_) if value => (if after.first() == Some(&'"') { String::new() } else { "\"".to_string() }, 1), // A key: closed, its colon and a space after it, ready for the value; a closing quote // already there is taken in, and a key that has its colon keeps it. Some(_) => { @@ -1079,12 +1190,12 @@ pub fn accept(ui: &Ui, box_id: Id, script: &mut String, offer: &Offer, item: &st } // A command starting a line. None if after.iter().take_while(|c| **c != '\n').all(|c| c.is_whitespace()) => { - params_of(session, item).map(|d| template(&d)).unwrap_or_default() + params_of(session, &item).map(|d| template(&d)).unwrap_or_default() } None => Default::default(), }; let mut new: String = before.iter().collect(); - new.push_str(item); + new.push_str(&item); new.push_str(&tail); new.extend(after); let mut state = TextEditState::load(ui.ctx(), box_id).unwrap_or_default(); @@ -2136,10 +2247,78 @@ mod tests { let ids: std::collections::HashSet<&str> = rustgis_engine::analysis::tools().map(|t| t.id).collect(); assert_eq!(all.len(), ids.len(), "every tool, once"); assert!(offer(r#"gp.run {"tool": "zzz"#).is_none()); - assert!(offer(r#"labels.style {"layer": ""#).is_none(), "another key's value"); + assert!(offer(r#"labels.style {"where": ""#).is_none(), "another key's value"); assert!(offer(r#"task.addStep {"tool": ""#).is_none(), "another command"); } + /// A `"layer"` value offers the map's layers, top first, case aside; a `"field"` value the + /// fields of the line's layer (before or after it), or the active layer's without one, as Run + /// finds it. Only in a command that takes the key; nothing for a layer that isn't there or + /// isn't given by name, nor at the second level. + #[test] + fn layers_and_fields_are_offered_for_their_values() { + let mut app = crate::RustGisApp::with_sample(); + let offer = |app: &crate::RustGisApp, s: &str| super::offer_at(s, s.chars().count(), &[], &app.session); + let names: Vec = app.session.map().layers.iter().map(|l| l.name.clone()).collect(); + let o = offer(&app, r#"labels.style {"layer": ""#).unwrap(); + assert_eq!((o.items, o.of), (names, Some(super::Of::Layers))); + assert_eq!(offer(&app, r#"labels.field {"layer": "ci"#).unwrap().items, ["Cities"], "case aside"); + assert!(offer(&app, r#"map.goToXY {"layer": ""#).is_none(), "a command that doesn't take a layer"); + assert!(offer(&app, r#"labels.style {"layer": "Cities", "field": ""#).is_none(), "nor a field"); + assert!(offer(&app, r#"labels.field {"layer": "zz"#).is_none()); + let cities = app.session.map().layer_by_name("Cities").unwrap(); + let (id, fields): (u32, Vec) = (cities.id, cities.dataset.fields.iter().map(|f| f.name.clone()).collect()); + let o = offer(&app, r#"labels.field {"layer": "cities", "field": ""#).unwrap(); + assert_eq!((o.items, o.of), (fields.clone(), Some(super::Of::Fields(id)))); + let s = r#"labels.field {"field": ""#; + let o = super::offer_at(&format!(r#"{s}", "layer": "Cities"}}"#), s.chars().count(), &[], &app.session).unwrap(); + assert_eq!(o.items, fields, "the layer after the field"); + assert!(offer(&app, r#"labels.field {"layer": "Nowhere", "field": ""#).is_none(), "a layer that isn't there"); + assert!(offer(&app, r#"labels.field {"layer": 99, "field": ""#).is_none(), "a layer given as a number"); + assert!(offer(&app, r#"labels.field {"layer": "a\"b", "field": ""#).is_none(), "an escaped name"); + app.session.active_layer = Some(id); + assert_eq!(offer(&app, r#"labels.field {"field": ""#).unwrap().items, fields, "the active layer"); + app.session.active_layer = None; + assert!(offer(&app, r#"labels.field {"field": ""#).is_none(), "no active layer"); + assert!(offer(&app, r#"gp.run {"params": {"layer": ""#).is_none(), "a second-level value (#662's 3b)"); + // Two layers named alike but for case: the first, the one Run finds by either name. + let other = app.session.map().layers.iter().position(|l| l.name != "Cities").unwrap(); + app.session.map_mut().layers[other].name = "CITIES".into(); + let cities = app.session.map().layers.iter().filter(|l| l.name.eq_ignore_ascii_case("cities")).count(); + assert_eq!(cities, 2); + assert_eq!(offer(&app, r#"labels.style {"layer": "ci"#).unwrap().items.len(), 1, "once, case aside"); + } + + /// Beside a layer, what it holds and its kind's icon, as the Layers pane shows it; beside a + /// field, its type and the field icon; no icon for ids. + #[test] + fn layers_and_fields_have_help_and_icons() { + use super::{Of, item_help, item_icon}; + let app = crate::RustGisApp::with_sample(); + let s = &app.session; + let cities = s.map().layer_by_name("Cities").unwrap(); + let (text, _) = item_help(s, &Of::Layers, "Cities").unwrap(); + assert!(text.contains(&cities.dataset.features.len().to_string()), "{text}"); + assert_eq!(item_icon(s, Some(&Of::Layers), "Cities"), Some("point_layer")); + assert_eq!(item_icon(s, Some(&Of::Layers), "Rivers"), Some("line_layer")); + assert_eq!(item_icon(s, Some(&Of::Layers), "Countries"), Some("polygon_layer")); + let f = &cities.dataset.fields[0]; + assert_eq!(item_help(s, &Of::Fields(cities.id), &f.name).map(|h| h.0), Some(f.ty.label().to_string())); + assert_eq!(item_icon(s, Some(&Of::Fields(cities.id)), &f.name), Some("field")); + assert_eq!(item_icon(s, Some(&Of::ToolIds), "buffer"), None); + } + + /// In the pane: accepting a layer closes its name, written as a JSON string holds it. + #[test] + fn a_layer_is_accepted_as_json_writes_it() { + let mut h = pane(); + h.state_mut().session.map_mut().layers[0].name = r#"Big "lakes""#.into(); + type_in(&mut h, r#"labels.style {"layer": "Big"#); + h.key_press(egui::Key::Enter); + h.run(); + assert_eq!(h.state().workflow.script, r#"labels.style {"layer": "Big \"lakes\"""#); + } + /// In the pane: a quote after `"tool": ` in a `gp.run` line offers the tools with the /// highlighted one's help beside; accepting closes the id (a quote already there is kept), /// the cursor after it. @@ -2198,7 +2377,7 @@ mod tests { assert!(keys(r#"topology.rules {"nope": {""#).is_empty(), "no such entry"); let of = offer(r#"topology.rules {"add": {""#).and_then(|o| o.of).unwrap(); assert_eq!(of, super::Of::SubKeys { id: "topology.rules".into(), key: "add".into() }); - let (line, about) = super::key_help(&app.session, &of, "rule").unwrap(); + let (line, about) = super::item_help(&app.session, &of, "rule").unwrap(); assert!(line.starts_with(r#""rule": "mustNotOverlap""#), "{line}"); assert_eq!(about, None); } diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index c52991422..6aedeb739 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -290,7 +290,11 @@ quote after `"tool": ` opens the list of the tools, beside the highlighted tool' one closes it. In its `"params": {…}`, the keys offered are those of the tool its `"tool"` names (by id or name, before or after `params`), beside the highlighted key's kind, name, default and help; there the help by the cursor is the tool's. A `task.addStep` line works the same way: its -`"command": "` offers the commands, and its `"params"` that command's keys and help. A `{` or `[` +`"command": "` offers the commands, and its `"params"` that command's keys and help. For a +command that takes them, `"layer": "` offers the map's layers, top first, each with its kind's +icon and beside it what it holds; `"field": "` offers the fields of the layer the line names +(before or after it), or of the active layer when it names none, with each field's type beside it. Accepting one writes its name +as the line's JSON needs it (`"Big \"lakes\""`). A `{` or `[` typed closes itself, the cursor inside, unless text follows it or it's in a string or a comment; so does a `"` where a string can start (after a space, `{`, `[`, `,` or `:`). A `}` or `]` typed before the same one steps over it, as does a `"` typed before the quote that ends its string, and From 978f047d60e5a09df5730e70edac35f7796ae40a Mon Sep 17 00:00:00 2001 From: Li-Sheng Chou <142213184+choupeanut@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:36:18 +0800 Subject: [PATCH 084/297] feat(plugins): raise the manifest command limit to 256 (#780) * feat(plugins): raise the manifest command limit to 256 * docs(tests): describe plugin validation regression tests --- crates/plugins/src/manifest.rs | 2 +- crates/plugins/src/tests.rs | 59 ++++++++++++++++++++++++++++++++++ docs/plugins.md | 2 +- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index cf40f8acc..5afe43b5a 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; pub const ABI: u32 = 1; /// Limits on what a manifest may declare. -const MAX_COMMANDS: usize = 64; +const MAX_COMMANDS: usize = 256; const MAX_TOOLS: usize = 64; const MAX_PARAMS: usize = 32; const MAX_CHOICES: usize = 64; diff --git a/crates/plugins/src/tests.rs b/crates/plugins/src/tests.rs index 702b6bc84..aef4aede8 100644 --- a/crates/plugins/src/tests.rs +++ b/crates/plugins/src/tests.rs @@ -59,6 +59,7 @@ fn plugins_reach_the_host_through_requests() { crate::unload("unit-forward"); } +/// Skills packages carry validated skills without code; coded plugins may carry skills too. #[test] fn plugins_ship_skills_and_skills_packages_have_no_code() { // A skills package: no module and no commands; its skills come from the package. @@ -108,6 +109,7 @@ fn plugins_ship_skills_and_skills_packages_have_no_code() { } } +/// Fuel, memory and reply bounds stop hostile modules and return errors to the host. #[test] fn hostile_modules_are_stopped_not_crashes() { load("unit-hostile", &[]); @@ -120,6 +122,7 @@ fn hostile_modules_are_stopped_not_crashes() { crate::unload("unit-hostile"); } +/// Invalid manifests, archives and module exports are refused during package loading. #[test] fn bad_packages_are_refused() { let wasm = testing::wasm(); @@ -162,6 +165,55 @@ fn bad_packages_are_refused() { assert!(crate::load(Package::from_zip(&e).unwrap(), "t").unwrap_err().contains("doesn't provide")); } +/// Catalog plugins can cross the old limit, reach the new one and run their last command. +#[test] +fn plugins_accept_up_to_256_commands() { + let wasm = testing::wasm(); + let with_commands = |count: usize| { + json!({"id": "unit-command-limit", "name": "Catalog", "version": "1.0.0", + "commands": (0..count).map(|i| json!({"id": format!("h{i}"), "label": format!("Layer {i}")})).collect::>()}) + }; + for count in [64, 65, 256] { + let text = with_commands(count).to_string(); + let zip = testing::zip_of(&[("plugin.json", text.as_bytes()), ("plugin.wasm", &wasm)]); + let pkg = Package::from_zip(&zip).unwrap(); + assert_eq!(pkg.manifest.commands.len(), count); + if count == 256 { + crate::load(pkg, "test").unwrap(); + let cmd = crate::command("plugin:unit-command-limit/h255").unwrap(); + assert_eq!(crate::run_command(&cmd, &mut Recorder::default(), &json!({})).unwrap(), json!({"hello": "world"})); + assert!(crate::unload("unit-command-limit")); + } + } + let e = Manifest::parse(&with_commands(257).to_string()).unwrap_err(); + assert!(e.contains("at most 256 commands and 64 tools"), "{e}"); + + // Even a large catalog still needs unique command ids. + let mut repeated = with_commands(256); + repeated["commands"][255]["id"] = json!("h0"); + let e = Manifest::parse(&repeated.to_string()).unwrap_err(); + assert!(e.contains("command id 'h0' is invalid or repeated"), "{e}"); +} + +/// More commands do not raise the limits on tools or their parameters. +#[test] +fn plugin_tool_and_parameter_limits_stay_bounded() { + let mut m = json!({"id": "bounded", "name": "Bounded", "version": "1.0.0", + "commands": (0..256).map(|i| json!({"id": format!("c{i}"), "label": "Layer"})).collect::>(), + "tools": (0..64).map(|i| json!({"id": format!("t{i}"), "name": "Tool"})).collect::>()}); + m["tools"][0]["params"] = + json!((0..32).map(|i| json!({"name": format!("p{i}"), "label": "Parameter", "kind": "text"})).collect::>()); + assert!(Manifest::parse(&m.to_string()).is_ok()); + let mut too_many_tools = m.clone(); + too_many_tools["tools"].as_array_mut().unwrap().push(json!({"id": "t64", "name": "Tool"})); + let e = Manifest::parse(&too_many_tools.to_string()).unwrap_err(); + assert!(e.contains("at most 256 commands and 64 tools"), "{e}"); + m["tools"][0]["params"].as_array_mut().unwrap().push(json!({"name": "p32", "label": "Parameter", "kind": "text"})); + let e = Manifest::parse(&m.to_string()).unwrap_err(); + assert!(e.contains("more than 32 parameters"), "{e}"); +} + +/// Enabled plugin tools register with geoprocessing and return layers or selections. #[test] fn tools_join_the_geoprocessing_tools() { crate::tools::register(); @@ -198,6 +250,7 @@ fn tools_join_the_geoprocessing_tools() { crate::unload("unit-tools"); } +/// Registry and blocklist parsing validates entries and resolves package addresses safely. #[test] fn registries_and_blocklists_are_read_defensively() { let sha = "a".repeat(64); @@ -255,6 +308,7 @@ fn registries_and_blocklists_are_read_defensively() { assert!(registry::blocked(&list, "y", &"b".repeat(64)).is_none()); } +/// Blocklisting unloads affected plugins and prevents them from being loaded again. #[test] fn blocked_plugins_are_unloaded_and_refused() { load("unit-blocked", &[]); @@ -275,6 +329,7 @@ fn blocked_plugins_are_unloaded_and_refused() { assert!(crate::blocked("unit-blocked", "any").is_some(), "an empty list from another registry unblocks nothing"); } +/// Version comparisons and translation fallbacks preserve the plugin's declared behavior. #[test] fn versions_and_translations() { assert!(version_cmp("1.10.0", "1.9.3").is_gt()); @@ -296,6 +351,7 @@ fn versions_and_translations() { assert_eq!(crate::translate("de", "Not a plugin string"), None); } +/// Ribbon tabs accept declared entries and enforce reserved names, shapes and size limits. #[test] fn plugin_tabs_are_checked() { let with_tab = |tab: Value| { @@ -318,10 +374,13 @@ fn plugin_tabs_are_checked() { (json!({"label": "publish", "groups": [{"label": "G", "items": ["h"]}]}), "RustGIS's tabs"), (json!({"label": "", "groups": [{"label": "G", "items": ["h"]}]}), "label"), (json!({"label": "T", "groups": []}), "groups"), + (json!({"label": "T", "groups": vec![json!({"label": "G", "items": ["h"]}); 9]}), "1 to 8 groups"), (json!({"label": "T", "groups": [{"label": "G", "items": []}]}), "items"), + (json!({"label": "T", "groups": [{"label": "G", "items": vec!["h"; 17]}]}), "1 to 16 items"), (json!({"label": "T", "groups": [{"label": "G", "items": ["nope"]}]}), "isn't one of"), (json!({"label": "T", "groups": [{"label": "G", "items": ["tool:h"]}]}), "isn't one of"), (json!({"label": "T", "groups": [{"label": "G", "items": [{"menu": "M", "items": []}]}]}), "needs 1 to"), + (json!({"label": "T", "groups": [{"label": "G", "items": [{"menu": "M", "items": vec!["h"; 33]}]}]}), "1 to 32 items"), (json!({"label": "T", "groups": [{"label": "G", "items": [{"menu": "M", "items": ["zz"]}]}]}), "isn't one of"), (json!({"label": "T", "groups": [{"label": "G", "items": [7]}]}), "plugin.json"), ] { diff --git a/docs/plugins.md b/docs/plugins.md index 7ca22b95a..7c6d43fbb 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -185,7 +185,7 @@ cargo build --release --target wasm32-unknown-unknown - `skills` (optional) lists skills for the AI Assistant that the package carries. See [Skills](#skills). -A manifest can declare at most 64 commands, 64 tools and 32 parameters per tool. +A manifest can declare at most 256 commands, 64 tools and 32 parameters per tool. A package is a zip archive, conventionally named `-.rgplugin`. It holds `plugin.json` at its root or inside one top-level folder; `entry` is relative to that manifest. From 3085e570f5e6e97cc0b725d8857ae6b31b071bf4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:37:07 -0400 Subject: [PATCH 085/297] build(deps): bump anthropics/claude-code-action (#782) Bumps the actions-minor-patch group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action). Updates `anthropics/claude-code-action` from 1.0.250 to 1.0.257 - [Release notes](https://github.com/anthropics/claude-code-action/releases) - [Commits](https://github.com/anthropics/claude-code-action/compare/a26b07d8c778b0c43f64618e5fd29fd0f8af72e8...aa8a7738e48bf30a40ee499ce818bef081c5ab5e) --- updated-dependencies: - dependency-name: anthropics/claude-code-action dependency-version: 1.0.257 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/claude-code-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 51282609a..d713ca34a 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -360,7 +360,7 @@ jobs: # does not strand us on an old version. When it bumps, re-read the # action's release notes for changes to the scrub, MCP wiring, or the # `execution_file` contract that the summary step below parses. - uses: anthropics/claude-code-action@a26b07d8c778b0c43f64618e5fd29fd0f8af72e8 # v1 (Claude Code 2.1.220) + uses: anthropics/claude-code-action@aa8a7738e48bf30a40ee499ce818bef081c5ab5e # v1 (Claude Code 2.1.220) with: # Use the workflow's GITHUB_TOKEN for GitHub API calls instead of the # default OIDC -> Claude GitHub App token exchange. That exchange From 74c4ef53da78253896705910d22135caf16d4b29 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:37:32 -0400 Subject: [PATCH 086/297] build(deps): bump the cargo-minor-patch group with 4 updates (#784) Bumps the cargo-minor-patch group with 4 updates: [zstd](https://github.com/gyscos/zstd-rs), [rustls](https://github.com/rustls/rustls), [tokio](https://github.com/tokio-rs/tokio) and [webpki-roots](https://github.com/rustls/webpki-roots). Updates `zstd` from 0.14.2 to 0.14.3 - [Release notes](https://github.com/gyscos/zstd-rs/releases) - [Commits](https://github.com/gyscos/zstd-rs/compare/v0.14.2...v0.14.3) Updates `rustls` from 0.23.45 to 0.23.46 - [Release notes](https://github.com/rustls/rustls/releases) - [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md) - [Commits](https://github.com/rustls/rustls/compare/v/0.23.45...v/0.23.46) Updates `tokio` from 1.53.2 to 1.53.3 - [Release notes](https://github.com/tokio-rs/tokio/releases) - [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.53.2...tokio-1.53.3) Updates `webpki-roots` from 1.0.9 to 1.0.10 - [Release notes](https://github.com/rustls/webpki-roots/releases) - [Commits](https://github.com/rustls/webpki-roots/compare/v/1.0.9...v/1.0.10) --- updated-dependencies: - dependency-name: zstd dependency-version: 0.14.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch - dependency-name: rustls dependency-version: 0.23.46 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch - dependency-name: tokio dependency-version: 1.53.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch - dependency-name: webpki-roots dependency-version: 1.0.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ea63200c..7655b19ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1776,7 +1776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3852,7 +3852,7 @@ dependencies = [ "seq-macro", "snap", "twox-hash", - "zstd 0.14.2", + "zstd 0.14.3", ] [[package]] @@ -4666,7 +4666,7 @@ dependencies = [ "url", "webpki-roots", "weezl", - "zstd 0.14.2", + "zstd 0.14.3", ] [[package]] @@ -4865,14 +4865,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -5340,7 +5340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5506,10 +5506,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5740,9 +5740,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -5941,7 +5941,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -6499,9 +6499,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -6730,7 +6730,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7109,7 +7109,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7535,11 +7535,11 @@ dependencies = [ [[package]] name = "zstd" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ - "zstd-safe 8.1.0", + "zstd-safe 8.2.0", ] [[package]] @@ -7553,18 +7553,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "8.1.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.1+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", From 09e5dac27559616f90ee75ddab7a6c58578fcdbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:37:55 -0400 Subject: [PATCH 087/297] build(deps): bump pyo3-build-config from 0.29.3 to 0.30.1 (#787) Bumps [pyo3-build-config](https://github.com/pyo3/pyo3) from 0.29.3 to 0.30.1. - [Release notes](https://github.com/pyo3/pyo3/releases) - [Changelog](https://github.com/PyO3/pyo3/blob/v0.30.1/CHANGELOG.md) - [Commits](https://github.com/pyo3/pyo3/compare/v0.29.3...v0.30.1) --- updated-dependencies: - dependency-name: pyo3-build-config dependency-version: 0.30.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 15 ++++++++++++--- apps/rustgis-python/Cargo.toml | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7655b19ab..3e08361b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4180,7 +4180,7 @@ dependencies = [ "libc", "once_cell", "portable-atomic", - "pyo3-build-config", + "pyo3-build-config 0.29.3", "pyo3-ffi", "pyo3-macros", ] @@ -4194,6 +4194,15 @@ dependencies = [ "target-lexicon", ] +[[package]] +name = "pyo3-build-config" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70130e2b6dd1ee589a8788026a429561ca5c40fa7a2fa7914c2282eddae24700" +dependencies = [ + "target-lexicon", +] + [[package]] name = "pyo3-ffi" version = "0.29.3" @@ -4201,7 +4210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc0b8e19df29aad7086cf977bb0c2a2f143e30567eb113e9cf72b62ca698330" dependencies = [ "libc", - "pyo3-build-config", + "pyo3-build-config 0.29.3", ] [[package]] @@ -4752,7 +4761,7 @@ name = "rustgis-python" version = "0.3.0" dependencies = [ "pyo3", - "pyo3-build-config", + "pyo3-build-config 0.30.1", "rustgis-engine", "serde_json", ] diff --git a/apps/rustgis-python/Cargo.toml b/apps/rustgis-python/Cargo.toml index fffef14d4..265ee4b17 100644 --- a/apps/rustgis-python/Cargo.toml +++ b/apps/rustgis-python/Cargo.toml @@ -22,4 +22,4 @@ serde_json = { workspace = true } pyo3 = { version = "0.29", features = ["abi3-py312", "generate-import-lib"] } [build-dependencies] -pyo3-build-config = "0.29" +pyo3-build-config = "0.30" From ed232f76ae659bdcb8bd3ff9c05112a7c6bcde09 Mon Sep 17 00:00:00 2001 From: Mavrick Fitzgerald Date: Fri, 23 Oct 2026 21:48:49 -0500 Subject: [PATCH 088/297] test(ui): stop calling half-painted GPU frames settled (#791) --- crates/ui-egui/tests/gpu_fastpath.rs | 59 +++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/tests/gpu_fastpath.rs b/crates/ui-egui/tests/gpu_fastpath.rs index 5a95e911a..269370453 100644 --- a/crates/ui-egui/tests/gpu_fastpath.rs +++ b/crates/ui-egui/tests/gpu_fastpath.rs @@ -15,27 +15,25 @@ fn one_at_a_time() -> std::sync::MutexGuard<'static, ()> { LOCK.lock().unwrap_or_else(|e| e.into_inner()) } +/// Waits for worker results, downloaded tiles and the GPU callbacks that upload their pictures. +/// UI steps alone do not run paced uploads; every candidate settled frame must be painted. fn wait_for_frame(h: &mut egui_kittest::Harness<'_, RustGisApp>) { use rustgis_ui_egui::rustgis_engine::render::rustgis_tiles::cache; let end = std::time::Instant::now() + std::time::Duration::from_secs(10); - // Tiles still arriving would draw the map again: until none arrived since the last frames. loop { - assert!(std::time::Instant::now() < end, "tiles kept arriving"); + assert!(std::time::Instant::now() < end, "frame workers, tiles or GPU uploads did not settle"); let tiles = cache::generation(); for _ in 0..40 { h.step(); } - while h.state().map.drawing() || cache::in_flight() > 0 { - assert!(std::time::Instant::now() < end, "frame worker did not finish"); - std::thread::sleep(std::time::Duration::from_millis(2)); - h.step(); - } - // Uploading a finished scene changes whether tile generations participate in its key. - // Let an idle frame adopt that live-tile key before the test starts moving the camera. + h.render().expect("paint frame and advance tile uploads"); + // Painting can discover another upload backlog. Let the next UI frame adopt it + // before treating drawing=false as proof that the displayed scene is complete. h.step(); if !h.state().map.drawing() && cache::in_flight() == 0 && cache::generation() == tiles { break; } + std::thread::sleep(std::time::Duration::from_millis(2)); } } @@ -322,3 +320,46 @@ fn raster_tiles_move_with_the_camera_on_the_gpu() { assert!(h.state().map.moves_on_gpu()); assert_gpu_picture_matches_cpu(&mut h); } + +/// Waiting for a live scene must include paints: tile uploads are paced in the GPU callback, +/// so CPU composition and finished downloads alone do not make the displayed frame complete. +#[test] +fn waiting_for_live_tiles_paints_until_the_top_layer_is_uploaded() { + use rustgis_ui_egui::rustgis_engine::{ + map::{TileKind, TileLayer}, + render::rustgis_tiles, + }; + let _serial = one_at_a_time(); + let png = |color| { + let mut out = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(256, 256, image::Rgba(color)).write_to(&mut out, image::ImageFormat::Png).expect("tile"); + out.into_inner() + }; + let bottom = png([180, 40, 40, 255]); + let top = png([30, 40, 200, 255]); + rustgis_tiles::cache::set_fetcher(move |url, done| done(Ok(if url.contains("upload-top") { top.clone() } else { bottom.clone() }))); + let Some(mut h) = harness() else { return }; + h.state_mut().run("map.body", json!({"name": "moon", "basemap": false})).expect("Moon"); + h.state_mut().run("map.projection", json!({"type": "mercator"})).expect("projection"); + h.state_mut().run("map.setScale", json!({"scale": 10_000_000.0})).expect("tile scale"); + let m = h.state_mut().session.map_mut(); + m.layers.clear(); + m.graticule = false; + // More independent pictures than one upload frame accepts, even on a fast adapter. + for n in 0..31 { + m.tiles.push(TileLayer::new( + format!("Bottom {n}"), + TileKind::Raster, + format!("https://upload-bottom-{n}.test/{{z}}/{{x}}/{{y}}.png"), + )); + } + m.tiles.insert(0, TileLayer::new("Top", TileKind::Raster, "https://upload-top.test/{z}/{x}/{y}.png")); + h.state_mut().map.invalidate(); + wait_for_frame(&mut h); + let image = h.render().expect("settled tiles"); + let center = h.state().map.view_rect.expect("map").0.center(); + let p = image.get_pixel(center.x.round() as u32, center.y.round() as u32); + assert!(u16::from(p[2]) > u16::from(p[0]) * 2, "the waiter returned before the top live layer was uploaded: {p:?}"); + h.step(); + assert!(!h.state().map.drawing(), "painting the settled frame must not reveal an upload backlog"); +} From b2152f4cee55ec7a3722d557ab95a4a6640f45c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Fri, 23 Oct 2026 22:13:08 -0500 Subject: [PATCH 089/297] =?UTF-8?q?feat(ui):=20run=20the=20Script=20pane?= =?UTF-8?q?=20with=20Ctrl+Enter=20(=E2=8C=98+Enter=20on=20macOS)=20(#783)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): run the Script pane with Ctrl+Enter (⌘+Enter on macOS) * fix(ui): build the Script run time with i18n::fmt --- crates/ui-egui/src/analysispane.rs | 64 ++++++++++++++++++++-- crates/ui-egui/src/scriptcolor.rs | 5 +- crates/ui-egui/src/scriptcomplete.rs | 79 ++++++++++++++++++++++++++++ docs/user-guide/analysis.md | 7 ++- 4 files changed, 148 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index e20102eed..8247a203b 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -33,9 +33,20 @@ pub struct Workflow { pub error: Option, pub script: String, pub script_out: Vec<(String, bool)>, + /// The Script pane's last run, shown above its results. + pub script_last: Option, pub trend: TrendForm, } +/// A Script pane run, as the line above its results tells it: its number this session (so a run +/// made again by mistake shows), how long it took and when it started (seconds since 1970; 0 for +/// a clock that can't tell). +pub struct ScriptRun { + pub n: u32, + pub secs: f64, + pub at: u64, +} + /// The Trend Analysis section's inputs (#579), and a run under way. #[derive(Default)] pub struct TrendForm { @@ -1063,12 +1074,14 @@ fn script_rows(ui: &Ui) -> f32 { ui.text_style_height(&egui::TextStyle::Monospace) * SCRIPT_MIN_ROWS as f32 } -/// The height the Script pane needs below its box: the buttons, and a run's results (their own -/// scroll, at most SCRIPT_OUT_ROWS lines) when there are any. +/// The height the Script pane needs below its box: the buttons, the last run's line, and its +/// results (their own scroll, at most SCRIPT_OUT_ROWS lines) when there are any. fn script_below(app: &RustGisApp, ui: &Ui) -> f32 { let gap = ui.spacing().item_spacing.y; + // A row: as tall as a button, whatever its text. + let last = if app.workflow.script_last.is_none() { 0.0 } else { ui.spacing().interact_size.y + gap }; let results = if app.workflow.script_out.is_empty() { 0.0 } else { script_out_height(ui) + gap }; - ui.spacing().interact_size.y + gap * 3.0 + results + ui.spacing().interact_size.y + gap * 3.0 + last + results } /// The most height a run's results take. @@ -1093,6 +1106,26 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { // One id whether the pane scrolls as a whole or not, so resizing it across that keeps the // box's focus, cursor and selection. let box_id = egui::Id::new("script_text"); + // Ctrl+Enter (⌘+Enter) runs the script (#773), taken before the box sees it, so it runs rather + // than breaking the line; once a press, not again for each repeat of a key held down. The box + // keeps the focus, so the list closes, as Escape closes it. + let ctrl_enter = ui.memory(|m| m.has_focus(box_id)) + && ui.input_mut(|i| { + let mut run = false; + i.events.retain(|e| match e { + egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat, modifiers, .. } + if modifiers.matches_logically(egui::Modifiers::COMMAND) => + { + run |= !repeat; + false + } + _ => true, + }); + run + }); + if ctrl_enter { + crate::scriptcomplete::close(ui, box_id); + } // The suggestion list's keys (#662), taken before the box sees them. if let Some((offer, item)) = crate::scriptcomplete::keys(ui, box_id, &app.workflow.script) { crate::scriptcomplete::accept(ui, box_id, &mut app.workflow.script, &offer, &item, &app.session); @@ -1119,9 +1152,17 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { crate::scriptcomplete::braces(ui, before, &out.inner, &mut app.workflow.script); } ui.horizontal(|ui| { - if ui.button(tl!("Run")).clicked() { + let shortcut = if cfg!(target_os = "macos") { "⌘+Enter" } else { "Ctrl+Enter" }; + let run = ui.button(tl!("Run")).on_hover_text(crate::i18n::fmt("Run the script ({keys})", &[("keys", shortcut)])).clicked(); + if run || ctrl_enter { let script = app.workflow.script.clone(); + // When it started and how long it took: a tool run outside the app in the browser + // (`gp.finish`) ends later. + let at = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + let started = web_time::Instant::now(); let out = app.run("script.run", json!({"script": script})); + let n = app.workflow.script_last.as_ref().map_or(1, |r| r.n.saturating_add(1)); + app.workflow.script_last = Some(ScriptRun { n, secs: started.elapsed().as_secs_f64(), at }); app.workflow.script_out = match out { Ok(v) => v["ran"] .as_array() @@ -1135,6 +1176,21 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { let _ = app.run("ui.commandSearch", Value::Null); } }); + // Above the results' scroll, so it stays in view: its number and how long it took, and on + // the right when it started, as the Geoprocessing History tells it (UTC: the app has no time + // zone). + if let Some(r) = &app.workflow.script_last { + ui.horizontal(|ui| { + let text = crate::i18n::fmt("Run #{n} ({secs} s)", &[("n", &r.n.to_string()), ("secs", &format!("{:.2}", r.secs))]); + ui.label(egui::RichText::new(text).small().strong()); + if r.at > 0 { + let (_, time) = rustgis_engine::map::graphics::civil(r.at); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(crate::i18n::fmt("{time} UTC", &[("time", &time)])).small().weak()) + }); + } + }); + } // In a scroll of their own, so a long error (it wraps) can't push them out of the pane. egui::ScrollArea::vertical().id_salt("script_out").max_height(out_h).auto_shrink([false, true]).show(ui, |ui| { for (line, err) in &app.workflow.script_out { diff --git a/crates/ui-egui/src/scriptcolor.rs b/crates/ui-egui/src/scriptcolor.rs index 8e2918b6e..a1e898f10 100644 --- a/crates/ui-egui/src/scriptcolor.rs +++ b/crates/ui-egui/src/scriptcolor.rs @@ -522,13 +522,15 @@ mod tests { assert!(!underlined("map.zoomFull"), "a good id isn't"); } - /// A run's results, however long, stay in view under the buttons, in a scroll of their own. + /// A run's results, however long, stay in view under the buttons, in a scroll of their own. The + /// line above them, the run and when it started, too. #[test] fn long_results_stay_in_view() { use egui_kittest::kittest::Queryable; let mut app = crate::RustGisApp::with_sample(); app.workflow.script = "map.zoomFull\n".repeat(300); app.workflow.script_out = (0..12).map(|i| (format!("line {i}: {}", "a long error ".repeat(30)), true)).collect(); + app.workflow.script_last = Some(crate::analysispane::ScriptRun { n: 1, secs: 0.5, at: 1_800_000_000 }); let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, @@ -537,6 +539,7 @@ mod tests { assert!(h.get_by_label("Run").rect().max.y < 700.0); let first = h.get_by_label_contains("line 0:").rect(); assert!(first.min.y < 700.0, "the first result shows: {first:?}"); + assert!(h.get_by_label_contains("Run #1 (").rect().max.y < first.min.y, "the run above them"); } /// The kept unknown ids follow the script: a change is read again, the same script isn't. diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index d1cc0485f..82156b793 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -888,6 +888,15 @@ pub fn filter(ui: &Ui, box_id: Id) -> egui::EventFilter { } } +/// Closes the list as Escape does: not shown again until what's typed changes. +pub fn close(ui: &Ui, box_id: Id) { + let mut list = load(ui, box_id); + if let Some((offer, _)) = list.open.take() { + list.dismissed = Some(offer); + store(ui, box_id, list); + } +} + /// Before the box sees the keys: with the list open and the box focused, ↑/↓ move the highlight, /// Enter or Tab accept it (so Enter doesn't break the line, nor Tab indent), Escape closes the list /// (and the box keeps the focus). Returns the item to accept, if any. A list worked out for @@ -1461,6 +1470,76 @@ mod tests { h.get_by_label("labels.style"); } + /// Ctrl+Enter (⌘+Enter) runs the script (#773): no line break, and the box keeps the focus. + /// The line above the results tells the run's number, so a run made again shows, how long it + /// took and when it started. + #[test] + fn ctrl_enter_runs_the_script() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + type_in(&mut h, "map.zoomFull"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter); + h.run(); + let out = &h.state().workflow.script_out; + assert!(out.iter().any(|(line, err)| !err && line.starts_with("map.zoomFull")), "{out:?}"); + assert_eq!(h.state().workflow.script, "map.zoomFull", "no line break"); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("script_text")))); + h.get_by_label_contains("Run #1 ("); + h.get_by_label_contains(" UTC"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter); + h.run(); + h.get_by_label_contains("Run #2 ("); + } + + /// A key held down runs the script once: its repeats are taken (no line break), not run. + /// Presses with no release between them, as a held key sends: egui marks all but the first + /// as repeats itself, whatever the event says. + #[test] + fn a_held_ctrl_enter_runs_once() { + let mut h = pane(); + type_in(&mut h, "map.zoomFull"); + for _ in 0..3 { + h.event(egui::Event::Key { + key: egui::Key::Enter, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::COMMAND, + physical_key: None, + }); + h.run(); + } + assert_eq!(h.state().workflow.script_last.as_ref().map(|r| r.n), Some(1), "the repeats don't run it"); + assert_eq!(h.state().workflow.script, "map.zoomFull", "nor break the line"); + } + + /// With the list open, Ctrl+Enter runs the script and closes the list, as Escape does: it + /// accepts nothing, and the list isn't opened again for that word. + #[test] + fn ctrl_enter_closes_the_list() { + use egui_kittest::kittest::Queryable; + let mut h = pane(); + type_in(&mut h, "labels.sty"); + h.get_by_label("labels.style"); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter); + h.run(); + assert!(h.state().workflow.script_last.is_some(), "it ran"); + assert_eq!(h.state().workflow.script, "labels.sty", "nothing accepted"); + h.run(); + assert!(h.query_by_label("labels.style").is_none(), "closed"); + } + + /// Ctrl+Enter once the box has lost the focus runs nothing. + #[test] + fn ctrl_enter_elsewhere_runs_nothing() { + let mut h = pane(); + type_in(&mut h, "map.zoomFull"); + h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new("script_text"))); + h.run(); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter); + h.run(); + assert!(h.state().workflow.script_last.is_none()); + } + /// A click accepts an item, and the box keeps the focus. Pressed and let go in frames of their /// own, as a real click is (`click()` does both in one frame, which hid that the press takes /// the focus from the box first). diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 6aedeb739..052823219 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -270,8 +270,11 @@ the environment does not set their output grids. Raster Functions › Resample m raster's grid. The **Script** pane runs commands from text, one per line (`id {json}`); its box grows with the script down to the bottom of the pane, -short of the buttons, and then scrolls, so **Run** stays in view. It colours them as you type, in -both themes: `#` comments in green italics, the command id, and the JSON's strings, numbers and +short of the buttons, and then scrolls, so **Run** stays in view; Ctrl+Enter (⌘+Enter on macOS) +in the box runs the script too. Above a run's results, a line tells its number, how long it took +and when it started (`Run #2 (0.84 s)`, and `14:04 UTC` on the right), so a run made again by +mistake shows (Undo takes a whole run back). It colours the commands as you type, in both +themes: `#` comments in green italics, the command id, and the JSON's strings, numbers and `true`/`false`/`null`; an id no command has, or one that can't run in a script (`ui.*`, `script.*`, `notebook.run`), and parameters that aren't valid JSON are red and underlined. A script is read to its 10,000th line; any further are dimmed, as Run doesn't read them. Typing a line's first word From 5925147790608f63748ae5dfabe15b4a30979863 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 24 Oct 2026 00:01:18 -0400 Subject: [PATCH 090/297] test(gpu): paint until tile uploads finish in the GPU waiter (#793) The waiter added in #791 painted once per 40 UI steps, but tile uploads only advance during paints and are paced per paint. On the Windows runners' software adapter (WARP) the 32-layer regression needed more paints than fit in ten seconds, failing CI on main. Keep painting while the map is still filling in, and widen the hang guard to 60 seconds for slow adapters. --- crates/ui-egui/tests/gpu_fastpath.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/gpu_fastpath.rs b/crates/ui-egui/tests/gpu_fastpath.rs index 269370453..cd1bddc32 100644 --- a/crates/ui-egui/tests/gpu_fastpath.rs +++ b/crates/ui-egui/tests/gpu_fastpath.rs @@ -19,17 +19,24 @@ fn one_at_a_time() -> std::sync::MutexGuard<'static, ()> { /// UI steps alone do not run paced uploads; every candidate settled frame must be painted. fn wait_for_frame(h: &mut egui_kittest::Harness<'_, RustGisApp>) { use rustgis_ui_egui::rustgis_engine::render::rustgis_tiles::cache; - let end = std::time::Instant::now() + std::time::Duration::from_secs(10); + // A hang guard: a software adapter (WARP on the Windows runners) is many times slower. + let end = std::time::Instant::now() + std::time::Duration::from_secs(60); loop { assert!(std::time::Instant::now() < end, "frame workers, tiles or GPU uploads did not settle"); let tiles = cache::generation(); for _ in 0..40 { h.step(); } - h.render().expect("paint frame and advance tile uploads"); // Painting can discover another upload backlog. Let the next UI frame adopt it // before treating drawing=false as proof that the displayed scene is complete. - h.step(); + // Uploads are paced per paint (fewer a paint on a slow adapter): paint until done. + loop { + h.render().expect("paint frame and advance tile uploads"); + h.step(); + if !h.state().map.drawing() || std::time::Instant::now() >= end { + break; + } + } if !h.state().map.drawing() && cache::in_flight() == 0 && cache::generation() == tiles { break; } From e006e8d076167ae1be7a856c59a5b59be086d273 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:02:07 -0400 Subject: [PATCH 091/297] build(deps): bump eframe from 0.36.2 to 0.37.0 in the egui group (#781) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(deps): bump eframe from 0.36.2 to 0.37.0 in the egui group Bumps the egui group with 1 update: [eframe](https://github.com/emilk/egui). Updates `eframe` from 0.36.2 to 0.37.0 - [Release notes](https://github.com/emilk/egui/releases) - [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md) - [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0) --- updated-dependencies: - dependency-name: eframe dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: egui ... Signed-off-by: dependabot[bot] * build(deps): move the whole egui stack to 0.37 eframe 0.37 alone can't build against egui 0.36, so this bumps egui, egui-wgpu, egui_extras and egui_kittest with it (folding in #785 and #786) and ports the UI to the 0.37 API: - WidgetType is now accesskit's Role (Checkbox -> CheckBox, TextEdit -> TextInput); Id::new -> Id::unique (same hash); Ui::id -> Ui::scope_id; TextEdit::vertical_align -> align. - Window screenshots come back through a ScreenshotCallback, not an Event::Screenshot: the relay's snapshot files them by number. - ScrollArea's offset is private: the ribbon's scroll arrows set it through the builder on the next frame. - SubMenuButton::RIGHT_ARROW -> arrow_atom; wheel events name their source. * fix(ui): keep the UI tests and fonts working on egui 0.37 - egui 0.37 no longer bundles its fonts by default: rustgis-ui-egui asks for them (and the emoji faces 0.36 bundled), since the theme falls back to Hack and the apps alone enabled them before. - egui_kittest 0.37 fails a test on an unnamed input widget: the harnesses opt out until the widgets are named (follow-up issue). - Panels keep their state under an id scoped to their parent, so the pane-width tests read the panes' rects the app now records. - A combo box's list opens on the frame after the click. - The relay's snapshot keeps only the screenshot it waits for, so a late one isn't held after its snapshot ended. * test(ui): give egui 0.37's popups and windows their extra frame egui 0.37 shows a combo box's list, a menu or a new window on the frame after it opens, so the integration tests step once more before looking inside. The every-pane test draws every language's text in monospace, where no face has some letters (ə): it allows the boxes egui 0.36 drew there silently. * fix(ui): keep the relay's screenshot slot and the arrows' offset per app - The relay snapshot's screenshot lands in a slot the app owns, not a process-wide static, so two apps in one process can't drop each other's. - A ribbon arrow's offset stays asked for until the scroll area is there, so a frame egui discards doesn't lose the click. - The language-switch test allows the boxes a machine without a CJK face draws for Chinese. * fix(ui): ask for a ribbon arrow's offset once, on a frame egui keeps Waiting for the area to reach the offset could pin it there for good (the window widened past it, or the wheel moved it meanwhile). Also says why a dropped relay screenshot is safe. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: giswqs --- Cargo.lock | 372 +++++++++++----------- Cargo.toml | 10 +- THIRD-PARTY-LICENSES.txt | 71 +---- apps/rustgis/examples/navigate_bench.rs | 1 + crates/ui-egui/Cargo.toml | 4 +- crates/ui-egui/examples/planet_bench.rs | 2 + crates/ui-egui/examples/ui_shot.rs | 2 + crates/ui-egui/src/agentthreads.rs | 2 +- crates/ui-egui/src/analysispane.rs | 2 +- crates/ui-egui/src/assistantpane.rs | 67 ++-- crates/ui-egui/src/bookmarkpane.rs | 4 +- crates/ui-egui/src/chrome.rs | 12 +- crates/ui-egui/src/connectiontree.rs | 21 +- crates/ui-egui/src/dialogs.rs | 8 +- crates/ui-egui/src/geometrycalc.rs | 2 +- crates/ui-egui/src/graphicformat.rs | 2 +- crates/ui-egui/src/labelpane.rs | 16 +- crates/ui-egui/src/layoutview.rs | 42 +-- crates/ui-egui/src/lib.rs | 8 + crates/ui-egui/src/mapview.rs | 64 ++-- crates/ui-egui/src/modelbuilder.rs | 14 +- crates/ui-egui/src/panes.rs | 238 ++++++++------ crates/ui-egui/src/pluginpanel.rs | 4 +- crates/ui-egui/src/pluginsui.rs | 4 +- crates/ui-egui/src/relayshot.rs | 69 ++-- crates/ui-egui/src/ribbon.rs | 221 +++++++------ crates/ui-egui/src/scriptcolor.rs | 14 +- crates/ui-egui/src/scriptcomplete.rs | 30 +- crates/ui-egui/src/searchbox.rs | 12 +- crates/ui-egui/src/sqlbox.rs | 2 +- crates/ui-egui/src/sqlpane.rs | 2 +- crates/ui-egui/src/stacui.rs | 3 + crates/ui-egui/src/textform.rs | 10 +- crates/ui-egui/src/widgets.rs | 21 +- crates/ui-egui/src/workspacepanes.rs | 19 +- crates/ui-egui/tests/define_projection.rs | 20 +- crates/ui-egui/tests/gpu_fastpath.rs | 3 + crates/ui-egui/tests/measurement_units.rs | 30 +- crates/ui-egui/tests/smoke.rs | 152 ++++++--- 39 files changed, 868 insertions(+), 712 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e08361b9..5cf6769f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,66 +29,60 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.36.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.38.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -96,9 +90,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -114,13 +108,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -128,11 +122,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -287,6 +282,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", + "wl-clipboard-rs", "x11rb", ] @@ -870,42 +866,18 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "calloop" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" -dependencies = [ - "bitflags 2.13.2", - "polling", - "rustix 1.1.5", - "tracing", -] - [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop 0.13.0", + "calloop", "rustix 0.38.44", "wayland-backend", "wayland-client", ] -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.5", - "rustix 1.1.5", - "wayland-backend", - "wayland-client", -] - [[package]] name = "cbc" version = "0.2.1" @@ -1488,9 +1460,9 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" dependencies = [ "bytemuck", "emath", @@ -1498,9 +1470,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" dependencies = [ "ahash", "bytemuck", @@ -1522,6 +1494,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1533,9 +1506,9 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" dependencies = [ "accesskit", "ahash", @@ -1553,9 +1526,9 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" dependencies = [ "ahash", "bytemuck", @@ -1573,31 +1546,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", - "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +checksum = "def286cf47194f3fe45b8e37c503ada7a20a64b59a903b4bc704baec943b1827" dependencies = [ "ahash", "egui", @@ -1609,9 +1584,9 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" dependencies = [ "bytemuck", "egui", @@ -1623,9 +1598,9 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +checksum = "0979c2e37860fcf13a2bea77508607f067a9a6a84ba9bd47b5e298582fb59b86" dependencies = [ "dify", "eframe", @@ -1650,9 +1625,9 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" dependencies = [ "bytemuck", ] @@ -1693,22 +1668,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.7", ] [[package]] @@ -1734,15 +1709,15 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" dependencies = [ "ahash", "bytemuck", "ecolor", "emath", - "epaint_default_fonts", + "epaint_default_fonts 0.37.0", "font-types", "harfrust", "log", @@ -1753,6 +1728,7 @@ dependencies = [ "skrifa", "smallvec", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] @@ -1763,6 +1739,12 @@ version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +[[package]] +name = "epaint_default_fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" + [[package]] name = "equivalent" version = "1.0.2" @@ -1776,7 +1758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1881,6 +1863,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flagset" version = "0.4.7" @@ -2253,9 +2241,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash 0.2.0", @@ -2883,12 +2871,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3306,6 +3294,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num" version = "0.4.3" @@ -3788,6 +3785,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -3884,35 +3891,56 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.14.0", "serde", ] [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.14.0", ] [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3927,6 +3955,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -4773,7 +4810,7 @@ dependencies = [ "ab_glyph", "base64 0.23.1", "bytemuck", - "epaint_default_fonts", + "epaint_default_fonts 0.36.2", "image", "log", "lyon", @@ -4874,7 +4911,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5004,7 +5041,7 @@ dependencies = [ "ab_glyph", "log", "memmap2", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "tiny-skia 0.11.4", ] @@ -5071,6 +5108,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "seq-macro" version = "0.3.6" @@ -5271,8 +5314,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", + "calloop", + "calloop-wayland-source", "cursor-icon", "libc", "log", @@ -5289,44 +5332,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.2", - "calloop 0.14.5", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.5", - "thiserror 2.0.21", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.2.2" @@ -5349,7 +5354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5515,10 +5520,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5776,7 +5781,7 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf", + "phf 0.13.1", "pin-project-lite", "postgres-protocol", "postgres-types", @@ -5909,6 +5914,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -5950,7 +5966,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6076,9 +6092,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -6091,9 +6107,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -6385,32 +6401,6 @@ dependencies = [ "wayland-scanner", ] -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -6739,7 +6729,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7062,7 +7052,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "bytemuck", - "calloop 0.13.0", + "calloop", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -7084,7 +7074,7 @@ dependencies = [ "redox_syscall 0.4.1", "rustix 0.38.44", "sctk-adwaita", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "smol_str", "tracing", "unicode-segmentation", @@ -7118,7 +7108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7140,6 +7130,24 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index ec2de3f1b..6e41ce5a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,11 +95,11 @@ ruzstd = { version = "0.9", default-features = false, features = ["std"] } brotli-decompressor = { version = "~6.0.1", default-features = false, features = ["std"] } # ui -egui = "0.36" -egui-wgpu = "0.36" -egui_extras = { version = "0.36", default-features = false } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui-wgpu = "0.37" +egui_extras = { version = "0.37", default-features = false } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" pollster = "0.4" diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index 545e3cac7..3f9e5c3b5 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -9,7 +9,7 @@ by `cargo xtask notices`. Apache License 2.0 Used by: a5, ab_glyph, ab_glyph_rasterizer, accesskit, accesskit_atspi_common, accesskit_consumer, accesskit_macos, accesskit_unix, accesskit_windows, accesskit_winit, aes, ahash, allocator-api2, -approx, arboard, arc-swap, arrayvec, as-raw-xcb-connection, async-channel, async-executor, async-io, +approx, arc-swap, arrayvec, as-raw-xcb-connection, async-channel, async-executor, async-io, async-lock, async-process, async-recursion, async-signal, async-task, async-trait, atomic-waker, atspi, atspi-common, atspi-proxies, autocfg, aws-lc-sys, base64, bit-set, bit-vec, bitflags, block-buffer, block-padding, blocking, btoi, bufstream, bumpalo, bytemuck, bytemuck_derive, cbc, cc, @@ -38,11 +38,11 @@ pyo3-macros-backend, quote, rand, range-alloc, raw-window-handle, raw-window-met rayon-core, regex, regex-automata, regex-syntax, renderdoc-sys, ring, robust, roxmltree, rstar, rustc-hash, rustc_version, rustix, rustls, rustls-native-certs, rustls-pemfile, rustversion, ryu, scoped-tls, scopeguard, secrecy, secret-service, security-framework, security-framework-sys, -self_cell, semver, seq-macro, serde, serde_core, serde_derive, serde_json, serde_repr, sha1, sha2, -shlex, sif-itree, signal-hook-registry, simdutf8, siphasher, smallvec, smol_str, socket2, spade, -spirv, spki, stable_deref_trait, static_assertions, syn, sys-locale, tar, tempfile, thiserror, -thiserror-impl, tiberius-macros, time, time-core, tinyvec, tokio-postgres, ttf-parser, type-map, -typed-path, unicode-bidi, unicode-ident, unicode-normalization, unicode-properties, +self_cell, semver, send_wrapper, seq-macro, serde, serde_core, serde_derive, serde_json, serde_repr, +sha1, sha2, shlex, sif-itree, signal-hook-registry, simdutf8, siphasher, smallvec, smol_str, +socket2, spade, spirv, spki, stable_deref_trait, static_assertions, syn, sys-locale, tar, tempfile, +thiserror, thiserror-impl, tiberius-macros, time, time-core, tinyvec, tokio-postgres, ttf-parser, +type-map, typed-path, unicode-bidi, unicode-ident, unicode-normalization, unicode-properties, unicode-segmentation, unicode-width, ureq, ureq-proto, url, utf8-zero, utf8_iter, uuid, vcpkg, vello_common, vello_cpu, version_check, wasm-bindgen, wasm-bindgen-futures, wasm-bindgen-macro, wasm-bindgen-macro-support, wasm-bindgen-shared, wasm-encoder, wasmi, wasmi_collections, wasmi_core, @@ -8083,35 +8083,6 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -==================================================================================================== -Boost Software License 1.0 -Used by: clipboard-win, error-code -==================================================================================================== - -Boost Software License - Version 1.0 - August 17th, 2003 - -Permission is hereby granted, free of charge, to any person or organization -obtaining a copy of the software and accompanying documentation covered by -this license (the "Software") to use, reproduce, display, distribute, -execute, and transmit the Software, and to prepare derivative works of the -Software, and to permit third-parties to whom the Software is furnished to -do so, all subject to the following: - -The copyright notices in the Software and this entire statement, including -the above license grant, this restriction and the following disclaimer, -must be included in all copies of the Software, in whole or in part, and -all derivative works of the Software, unless such copies or derivative -works are solely in the form of machine-executable object code generated by -a source language processor. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT -SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE -FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. - ==================================================================================================== Community Data License Agreement Permissive 2.0 Used by: webpki-roots @@ -10021,31 +9992,6 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -==================================================================================================== -MIT License -Used by: smithay-clipboard -==================================================================================================== - -Copyright (c) 2018 Lucas Timmins & Victor Berger - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - ==================================================================================================== MIT License Used by: spin, tracing-core, zip @@ -10333,9 +10279,8 @@ SOFTWARE. ==================================================================================================== MIT License -Used by: wayland-backend, wayland-client, wayland-cursor, wayland-protocols, wayland-protocols-experimental, -wayland-protocols-misc, wayland-protocols-plasma, wayland-protocols-wlr, wayland-scanner, -wayland-sys +Used by: wayland-backend, wayland-client, wayland-cursor, wayland-protocols, wayland-protocols-plasma, +wayland-protocols-wlr, wayland-scanner, wayland-sys ==================================================================================================== Copyright (c) 2015 Elinor Berger diff --git a/apps/rustgis/examples/navigate_bench.rs b/apps/rustgis/examples/navigate_bench.rs index f49b0c0e3..2d519766f 100644 --- a/apps/rustgis/examples/navigate_bench.rs +++ b/apps/rustgis/examples/navigate_bench.rs @@ -84,6 +84,7 @@ impl eframe::App for Bench { delta: egui::vec2(0.0, (dir * 600.0 * dt) as f32), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Wheel, }); } } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5097a0b07..f2bf21192 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,7 +11,9 @@ workspace = true [dependencies] rustgis-engine = { workspace = true } rustgis-assistant = { workspace = true } -egui = { workspace = true } +# egui's own faces (Hack, the theme's fallback for Georgian) and its emoji, which egui 0.37 no longer +# bundles by default. +egui = { workspace = true, features = ["monochrome_emoji_fonts"] } # The GPU map backend draws inside egui's own wgpu render pass. egui-wgpu = { workspace = true } bytemuck = { workspace = true } diff --git a/crates/ui-egui/examples/planet_bench.rs b/crates/ui-egui/examples/planet_bench.rs index 8f2be82cf..0e9e35a28 100644 --- a/crates/ui-egui/examples/planet_bench.rs +++ b/crates/ui-egui/examples/planet_bench.rs @@ -47,6 +47,7 @@ fn main() -> Result<(), Box> { app.session.map_mut().camera.center = lonlat_to_merc(Coord::new(15.0, 25.0)); app.session.map_mut().camera.res = 20_000.0; let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) .with_size(egui::vec2(1480.0, 900.0)) .with_pixels_per_point(2.0) .with_step_dt(1.0 / 60.0) @@ -94,6 +95,7 @@ fn main() -> Result<(), Box> { delta: egui::vec2(0.0, 4.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Wheel, }); let start = std::time::Instant::now(); h.step(); diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index 3982de986..58fa91051 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -49,6 +49,7 @@ fn main() { }; app.set_render_state(Some(rs.clone())); let mut harness = egui_kittest::Harness::builder() + .with_accessibility_check(false) .with_size(egui::vec2(w, h)) .with_pixels_per_point(2.0) .with_max_steps(10_000) @@ -228,6 +229,7 @@ fn main() { delta: egui::vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }); harness.step(); } else if l.get("release").is_some() { diff --git a/crates/ui-egui/src/agentthreads.rs b/crates/ui-egui/src/agentthreads.rs index 13b924d34..66633ddc1 100644 --- a/crates/ui-egui/src/agentthreads.rs +++ b/crates/ui-egui/src/agentthreads.rs @@ -294,7 +294,7 @@ pub fn windows(app: &mut RustGisApp, ctx: &egui::Context) { let offset = 28.0 * i as f32; let mut open = true; egui::Window::new(format!("{} · {}", th.label, th.id)) - .id(egui::Id::new(("acp_thread", th.id.as_str()))) + .id(egui::Id::unique(("acp_thread", th.id.as_str()))) .open(&mut open) .collapsible(true) .resizable(true) diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 8247a203b..69ad2accb 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -1105,7 +1105,7 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { let out_h = script_out_height(ui); // One id whether the pane scrolls as a whole or not, so resizing it across that keeps the // box's focus, cursor and selection. - let box_id = egui::Id::new("script_text"); + let box_id = egui::Id::unique("script_text"); // Ctrl+Enter (⌘+Enter) runs the script (#773), taken before the box sees it, so it runs rather // than breaking the line; once a press, not again for each repeat of a key held down. The box // keeps the focus, so the list closes, as Escape closes it. diff --git a/crates/ui-egui/src/assistantpane.rs b/crates/ui-egui/src/assistantpane.rs index dc8d08f8b..71c218bb0 100644 --- a/crates/ui-egui/src/assistantpane.rs +++ b/crates/ui-egui/src/assistantpane.rs @@ -101,7 +101,7 @@ pub struct AssistantState { const FIELDS: [&str; 3] = ["assistant_model", "assistant_url", "assistant_key"]; fn typing(ctx: &egui::Context) -> bool { - ctx.memory(|m| FIELDS.iter().any(|f| m.has_focus(egui::Id::new(f)))) + ctx.memory(|m| FIELDS.iter().any(|f| m.has_focus(egui::Id::unique(f)))) } fn pref(app: &RustGisApp, key: &str) -> Option { @@ -692,7 +692,10 @@ fn provider_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Co return; } ui.add( - egui::TextEdit::singleline(&mut cfg.model).id(egui::Id::new(FIELDS[0])).desired_width(150.0).hint_text(tl!("model id")), + egui::TextEdit::singleline(&mut cfg.model) + .id(egui::Id::unique(FIELDS[0])) + .desired_width(150.0) + .hint_text(tl!("model id")), ); if !app.assistant.models.is_empty() { egui::ComboBox::from_id_salt("assistant_models").selected_text("").width(24.0).height(320.0).show_ui(ui, |ui| { @@ -724,14 +727,14 @@ fn provider_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Co let preset = provider::preset(&cfg.provider); if !cfg.login() { ui.label(tl!("Address")); - ui.add(egui::TextEdit::singleline(&mut cfg.base_url).id(egui::Id::new(FIELDS[1])).desired_width(200.0)); + ui.add(egui::TextEdit::singleline(&mut cfg.base_url).id(egui::Id::unique(FIELDS[1])).desired_width(200.0)); ui.end_row(); ui.label(tl!("API key")); ui.horizontal(|ui| { let hint = if preset.is_some_and(|p| p.keyless) { tl!("not needed") } else { tl!("paste your key") }; ui.add( egui::TextEdit::singleline(&mut cfg.key) - .id(egui::Id::new(FIELDS[2])) + .id(egui::Id::unique(FIELDS[2])) .password(!app.assistant.show_key) .desired_width(150.0) .hint_text(hint), @@ -1159,7 +1162,7 @@ fn composer(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, busy: bool, ctx: &egu if app.assistant.agent.as_ref().is_some_and(|a| a.config.login() && !a.config.key.is_empty()) { plan_line(ui, t); } - let id = egui::Id::new("assistant_input"); + let id = egui::Id::unique("assistant_input"); // Enter sends; Shift+Enter starts a new line. let focused = ui.memory(|m| m.has_focus(id)); let mut submit = false; @@ -1471,7 +1474,7 @@ mod tests { app.assistant.chunks = Some((generation, Chunks::default())); app.assistant.inflight = Some((generation, Slot::default())); // Render just the composer: no polling or network dispatch is needed to press Stop. - let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 160.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(600.0, 160.0)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); let busy = app.assistant.agent.as_ref().is_some_and(Agent::busy); @@ -1491,7 +1494,7 @@ mod tests { #[test] fn plan_settings_order_and_signed_out_model() { use egui_kittest::kittest::{NodeT, Queryable}; - let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(800.0, 600.0)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -1523,7 +1526,7 @@ mod tests { let saved = Arc::new(Mutex::new(Vec::<(String, String)>::new())); let log = saved.clone(); app.services.save_pref = Some(Box::new(move |k, v| log.lock().unwrap().push((k.to_string(), v.to_string())))); - let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(800.0, 600.0)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -1549,7 +1552,7 @@ mod tests { fn waiting_controls_reopen_copy_and_cancel() { use egui_kittest::kittest::Queryable; let url = "https://auth.openai.com/synthetic-sign-in"; - let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 220.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(260.0, 220.0)).build_ui_state( |ui, actions: &mut (bool, bool)| { let (reopen, cancel) = waiting_for_browser(ui, &Tokens::light(), url); actions.0 |= reopen; @@ -1589,7 +1592,7 @@ mod tests { app }; app.assistant.login_error = Some(rustgis_assistant::Error::new(rustgis_assistant::Recovery::Auth, "")); - let mut h = egui_kittest::Harness::builder().with_size(vec2(width, height)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(width, height)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -1675,7 +1678,7 @@ mod tests { // A fixed number of frames, not `run`: each catalog request ends on a thread that asks for a // repaint, which can land mid-run (it did on Windows) and run past `run`'s step limit. let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 600.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(400.0, 600.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -1769,14 +1772,18 @@ mod tests { let mut app = plan_app(); app.assistant.settings = false; app.assistant.agent.as_mut().unwrap().transcript.push(Entry::Error(error)); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(1400.0, 860.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.run(); assert!(h.query_by_label("Weekly usage").is_some()); h.get_by_label("Weekly usage").click(); @@ -1846,19 +1853,23 @@ mod tests { } fn right_pane(h: &egui_kittest::Harness<'_, RustGisApp>) -> egui::Rect { - egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("right_pane")).unwrap().outer_rect + h.state().ui.pane_rects[1].unwrap() } /// The window with the assistant open and `entries` in the conversation. fn chat(entries: Vec, model: &str) -> egui_kittest::Harness<'static, RustGisApp> { - let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - RustGisApp::with_sample(), - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(1400.0, 860.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + RustGisApp::with_sample(), + ); h.state_mut().run("ui.pane.assistant", serde_json::Value::Null).unwrap(); for _ in 0..4 { h.step(); diff --git a/crates/ui-egui/src/bookmarkpane.rs b/crates/ui-egui/src/bookmarkpane.rs index af08f35ce..bcbfce218 100644 --- a/crates/ui-egui/src/bookmarkpane.rs +++ b/crates/ui-egui/src/bookmarkpane.rs @@ -66,7 +66,7 @@ fn run(app: &mut RustGisApp, id: &str, p: Value) { fn act(ui: &mut Ui, icon: &str, tip: &str) -> egui::Response { let enabled = ui.is_enabled(); let r = widgets::icon_button(ui, icon, tip); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip)); r } @@ -181,7 +181,7 @@ fn row(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, map: &str, in_view: bool, ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click()); let tip = if open { tl!("Hide the description") } else { tl!("Show the description") }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); icons::paint(ui.painter(), r, if open { "chevron_down" } else { "chevron_right" }, t.text_dim, t.accent); if resp.on_hover_text(tip).clicked() { app.ui.bookmarks.open = if open { None } else { Some(key.clone()) }; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 301655e3a..cada206fe 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -99,7 +99,7 @@ pub fn header(app: &mut RustGisApp, ui: &mut Ui) { pos2(full.min.x - layout.left_pad, full.min.y), pos2(full.max.x + HEADER_PAD, full.max.y + layout.bottom_pad), ); - let drag = ui.interact(bar, egui::Id::new("header_drag"), Sense::click_and_drag()); + let drag = ui.interact(bar, egui::Id::unique("header_drag"), Sense::click_and_drag()); if drag.double_clicked() { let maximized = ui.ctx().input(|i| i.viewport().maximized).unwrap_or(false); let action = app.ui.titlebar.map_or(DoubleClick::Zoom, |m| m.double_click); @@ -174,7 +174,7 @@ pub fn header(app: &mut RustGisApp, ui: &mut Ui) { 26.0 }; let sr = Rect::from_min_max(pos2(right - sw, full.center().y - 12.0), pos2(right, full.center().y + 12.0)); - let resp = ui.interact(sr, egui::Id::new("command_search_box"), Sense::click()); + let resp = ui.interact(sr, egui::Id::unique("command_search_box"), Sense::click()); ui.painter().rect_filled(sr, CornerRadius::same(12), if resp.hovered() { t.hover } else { t.input }); ui.painter().rect_stroke(sr, CornerRadius::same(12), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let compact = sw < 100.0; @@ -816,7 +816,7 @@ pub fn command_search(app: &mut RustGisApp, ctx: &egui::Context) { let screen = ctx.content_rect(); let width = (screen.width() - 48.0).clamp(220.0, 520.0); let list_h = (screen.height() - 190.0).clamp(120.0, 360.0); - let modal = egui::Modal::new(egui::Id::new("command_search")).show(ctx, |ui| { + let modal = egui::Modal::new(egui::Id::unique("command_search")).show(ctx, |ui| { ui.set_width(width); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); @@ -1129,7 +1129,7 @@ pub fn notices(app: &mut RustGisApp, ctx: &egui::Context) { let mut y = screen.max.y - 40.0; for toast in app.map.toasts.iter_mut().rev().take(3) { // Keyed by the notice, not the slot, so a selection stays with its text as toasts come and go. - let shown = egui::Area::new(egui::Id::new(("toast", toast.id))) + let shown = egui::Area::new(egui::Id::unique(("toast", toast.id))) .fixed_pos(pos2(screen.max.x - 380.0, y - 44.0)) .order(egui::Order::Tooltip) .show(ctx, |ui| { @@ -1197,7 +1197,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = RustGisApp::with_sample(); app.run("labels.style", serde_json::json!({"layer": "Cities", "size": 20})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); shortcuts(app, &ctx); @@ -1216,7 +1216,7 @@ mod tests { h.run(); assert_eq!(h.state().session.undo_depth(), depth, "the project untouched"); assert_eq!(h.state().workflow.script, "", "the typing undone"); - h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new("script_text"))); + h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique("script_text"))); h.run(); h.event(undo); h.run(); diff --git a/crates/ui-egui/src/connectiontree.rs b/crates/ui-egui/src/connectiontree.rs index 36d8ded5b..7e2022535 100644 --- a/crates/ui-egui/src/connectiontree.rs +++ b/crates/ui-egui/src/connectiontree.rs @@ -610,14 +610,19 @@ mod tests { type Harness = egui_kittest::Harness<'static, RustGisApp>; fn harness() -> Harness { - egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).with_step_dt(0.05).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - RustGisApp::with_sample(), - ) + egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(egui::vec2(1400.0, 860.0)) + .with_max_steps(64) + .with_step_dt(0.05) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + RustGisApp::with_sample(), + ) } /// Answers every request on a local port with `answer(request head)`: status and body. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 71a0c9eaa..dbd831dc9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -193,7 +193,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { let mut close = false; // Opens centred and can be moved; each dialog remembers where it was put. egui::Window::new(tl!(title)) - .id(egui::Id::new(("dialog", which))) + .id(egui::Id::unique(("dialog", which))) .open(&mut open) .collapsible(false) .resizable(false) @@ -234,7 +234,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { ui.label(tl!("Choose the source coordinate system before importing. It replaces a missing or incorrect .prj without changing the original file.")); ui.label(tl!("Source coordinate system")); let response = ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:2263").desired_width(f32::INFINITY)); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Source coordinate system"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Source coordinate system"))); ui.label(tl!("In the browser, pick the .shp and .dbf together, with any sidecars, or pick a ZIP. The override applies to every Shapefile in the ZIP.")); close = buttons(app, ui, "Choose File…", |app| app.run("ui.importShapefile", json!({"crs": app.dialogs.text.trim()})).map(|_| ())); } @@ -243,7 +243,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { ui.label(tl!("Interpret the layer's native coordinates in this coordinate system. This changes where it draws; the source file is unchanged.")); ui.label(tl!("Source coordinate system")); let label = ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:2263").desired_width(f32::INFINITY)); - label.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Source coordinate system"))); + label.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Source coordinate system"))); ui.label(egui::RichText::new(tl!("Geometry lost during import cannot be recovered here. Use Import Original File to choose its coordinate system before reading it. Streamed layers must first be loaded into memory.")).weak()); if ui.button(tl!("Import Original File…")).clicked() { let crs = app.dialogs.text.clone(); @@ -1508,7 +1508,7 @@ mod tests { app.run("map.projection", json!({"type": "robinson"})).unwrap(); app.run("ui.dialog", json!({"name": "coordinateSystem"})).unwrap(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); diff --git a/crates/ui-egui/src/geometrycalc.rs b/crates/ui-egui/src/geometrycalc.rs index c3556f020..5d80fa8f2 100644 --- a/crates/ui-egui/src/geometrycalc.rs +++ b/crates/ui-egui/src/geometrycalc.rs @@ -78,7 +78,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { let mut calculate = false; let t = Tokens::get(ctx); egui::Window::new(tl!("Calculate Geometry")) - .id(egui::Id::new("geometry_calculator_compact")) + .id(egui::Id::unique("geometry_calculator_compact")) .open(&mut open) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/graphicformat.rs b/crates/ui-egui/src/graphicformat.rs index 647af2a8f..00e0257d3 100644 --- a/crates/ui-egui/src/graphicformat.rs +++ b/crates/ui-egui/src/graphicformat.rs @@ -192,7 +192,7 @@ mod tests { fn harness(app: RustGisApp) -> egui_kittest::Harness<'static, RustGisApp> { let mut fonts = false; - egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(1000.0, 800.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index da5a9f54d..53dbd33a5 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -77,7 +77,7 @@ fn class(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { ui.label(tl!("Class")); ui.label(RichText::new(name).color(t.text_dim)); }); - let key = egui::Id::new("label_pane_class_tab"); + let key = egui::Id::unique("label_pane_class_tab"); let mut tab = ui.data(|d| d.get_temp::(key)).unwrap_or(ClassTab::Field); ui.horizontal(|ui| { for (label, it) in [(tl!("Field"), ClassTab::Field), (tl!("SQL"), ClassTab::Sql)] { @@ -144,9 +144,9 @@ fn sql(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { Ok((n, of)) }; // A draft belongs to this layer of this project: another project may reuse the layer's id. - let owner = egui::Id::new((app.session.opened, l.id)); + let owner = egui::Id::unique((app.session.opened, l.id)); // A count holds for this project state at this scale (display filters follow the scale). - let version = egui::Id::new((app.session.revision, scale.to_bits())).value(); + let version = egui::Id::unique((app.session.revision, scale.to_bits())).value(); match crate::sqlbox::show(ui, "label_sql", owner, version, labels.default.where_.as_deref(), &l.dataset, count) { Some(crate::sqlbox::Apply::Clause(w)) => run(app, "labels.class.update", json!({"where": w})), Some(crate::sqlbox::Apply::Clear) => run(app, "labels.class.update", json!({"where": null})), @@ -302,7 +302,7 @@ struct ColorDrag { /// Previews a colour pick on its layer until `finish_color_drag` commits the completed gesture. pub(crate) fn color_drag(app: &mut RustGisApp, ui: &Ui, l: &Layer, color: Rgba) { - let key = egui::Id::new(COLOR_DRAG); + let key = egui::Id::unique(COLOR_DRAG); let mut pending = ui.data(|d| d.get_temp::(key)); // One left pending on another layer (the selection changed mid-pick, from the Assistant or a // shortcut) is finished first rather than dropped with its preview in place. @@ -329,7 +329,7 @@ pub(crate) fn finish_color_drag(app: &mut RustGisApp, ui: &Ui) { if ui.input(|i| i.pointer.primary_down() && i.focused) { return; } - let key = egui::Id::new(COLOR_DRAG); + let key = egui::Id::unique(COLOR_DRAG); let Some(drag) = ui.data_mut(|d| { let drag = d.get_temp::(key); d.remove::(key); @@ -379,7 +379,7 @@ mod tests { fn harness(app: RustGisApp) -> egui_kittest::Harness<'static, RustGisApp> { // The pane's fonts (its semibold heading) are installed on the first frame, as the app does. let mut fonts = false; - egui_kittest::Harness::builder().with_size(egui::vec2(360.0, 900.0)).build_ui_state( + egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(360.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -476,7 +476,7 @@ mod tests { let color = |app: &RustGisApp, id| app.session.map().layer(id).unwrap().labels.default.color; let (cities_before, countries_before) = (color(&app, cities), color(&app, countries)); let steps = app.session.undo_depth(); - let mut h = egui_kittest::Harness::builder().build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).build_ui_state( |ui, (app, pick): &mut (RustGisApp, Option)| { if let Some(c) = pick.take() { let l = app.session.map().layer(app.session.active_layer.unwrap()).unwrap().clone(); @@ -530,7 +530,7 @@ mod tests { let before = app.session.map().layer(id).unwrap().labels.default.color; let steps = app.session.undo_depth(); // The picker's colour each frame, if it reports one, as `text_symbol` hands it on. - let mut h = egui_kittest::Harness::builder().build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).build_ui_state( |ui, (app, pick): &mut (RustGisApp, Option)| { if let Some(c) = pick.take() { let l = app.session.map().layer(app.session.active_layer.unwrap()).unwrap().clone(); diff --git a/crates/ui-egui/src/layoutview.rs b/crates/ui-egui/src/layoutview.rs index 522607eed..acc0d6e8a 100644 --- a/crates/ui-egui/src/layoutview.rs +++ b/crates/ui-egui/src/layoutview.rs @@ -556,7 +556,7 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { // A stable id: dragging an element selects it and opens the Format pane beside the page, // and an automatic id would change with it, losing the drag before it is dropped (#325). ui.advance_cursor_after_rect(area); - let resp = ui.interact(area, ui.id().with("layout_page"), Sense::click_and_drag()); + let resp = ui.interact(area, ui.scope_id().with("layout_page"), Sense::click_and_drag()); let painter = ui.painter_at(area); painter.rect_filled(area, 0.0, if t.dark { Color32::from_gray(38) } else { Color32::from_rgb(0xD9, 0xDD, 0xE3) }); let (page, s) = page_rect(&app.layout, area, &layout); @@ -1177,7 +1177,7 @@ fn grid_properties(app: &mut RustGisApp, ui: &mut Ui, grid: Option<&Grid>, id: u } let Some(g) = grid else { return }; // Each number kept across a drag until it is let go (#607). - let key = |field: &str| egui::Id::new(("grid_drag", id, field)); + let key = |field: &str| egui::Id::unique(("grid_drag", id, field)); egui::Grid::new(("grid_props", id)).num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { ui.label(tl!("Interval")); let mut step = g.step; @@ -1320,7 +1320,7 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { let Some(e) = app.layout.selected.and_then(|id| layout.element(id)).cloned() else { return }; let id = e.id; // Each number kept across a drag until it is let go (#607): one key per layout, element and field. - let drag_key = |field: &str| egui::Id::new(("layout_drag", li, id, field)); + let drag_key = |field: &str| egui::Id::unique(("layout_drag", li, id, field)); let update = |app: &mut RustGisApp, p: Value| { let mut p = p; p["layout"] = json!(li); @@ -1793,7 +1793,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -1845,7 +1845,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -1906,7 +1906,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -1961,7 +1961,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2005,7 +2005,7 @@ mod tests { let shown = area.clone(); // Fonts take effect on the frame after they are installed. let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2125,7 +2125,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2169,7 +2169,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().clone(); app.layout.selected = Some(title.id); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2222,7 +2222,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().clone(); app.layout.selected = Some(title.id); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2273,7 +2273,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2328,7 +2328,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().id; app.layout.selected = Some(title); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2382,7 +2382,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().clone(); app.layout.selected = Some(title.id); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2429,7 +2429,7 @@ mod tests { app.run("layout.addElement", json!({"kind": "arrow", "angle": 0, "length": 60})).unwrap()["element"].as_u64().unwrap() as u32; app.layout.selected = Some(id); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2475,7 +2475,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().id; app.layout.selected = Some(title); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2522,7 +2522,7 @@ mod tests { app.run("layout.addElement", json!({"kind": "extentIndicator", "frame": inset, "of": main, "width": 1.5})).unwrap(); app.layout.selected = Some(inset); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2560,7 +2560,7 @@ mod tests { app.run("layout.update", json!({"element": legend, "exclude": ["Cities"]})).unwrap(); app.layout.selected = Some(legend); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2611,7 +2611,7 @@ mod tests { let ids: Vec = app.session.project.layouts[li].elements.iter().map(|e| e.id).collect(); app.run("ui.pane.bookmarks", json!({})).unwrap(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2697,7 +2697,7 @@ mod tests { let text = serde_json::to_string(&app.session.project).unwrap(); app.layout.selected = Some(title); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2775,7 +2775,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fdaa1f255..1a2771d74 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -488,6 +488,8 @@ pub struct UiState { /// The keyboard shortcuts. pub keymap: keymap::Keymap, pub tab_strip_rect: Option, + /// Where the left and right panes were drawn on the last frame, while they show. + pub pane_rects: [Option; 2], /// Tabs hidden from the ribbon (Settings › Customize › Ribbon). pub hidden_tabs: Vec, /// Extra Quick Access Toolbar commands. @@ -667,6 +669,7 @@ impl Default for UiState { shortcuts: false, keymap: keymap::Keymap::default(), tab_strip_rect: None, + pane_rects: [None; 2], copy_out: None, open_url: None, copy_image: None, @@ -771,6 +774,9 @@ pub struct RustGisApp { /// A screen snapshot an agent attached through the relay asked for, while it is captured. #[cfg(not(target_arch = "wasm32"))] relay_shot: Option, + /// Where the relay snapshot's window screenshot lands. + #[cfg(not(target_arch = "wasm32"))] + relay_arrived: relayshot::Arrived, /// The frame's `egui::Context`, kept so `ui.mcpRelay` can hand the relay its wake callback: /// a queued message asks for a repaint, or it waits for the user to move the mouse. #[cfg(not(target_arch = "wasm32"))] @@ -883,6 +889,8 @@ impl RustGisApp { #[cfg(not(target_arch = "wasm32"))] relay_shot: None, #[cfg(not(target_arch = "wasm32"))] + relay_arrived: relayshot::Arrived::default(), + #[cfg(not(target_arch = "wasm32"))] connect: Default::default(), #[cfg(not(target_arch = "wasm32"))] frame_ctx: None, diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 0e1707c28..e0be21403 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1565,7 +1565,7 @@ fn interact(app: &mut RustGisApp, ui: &mut Ui, resp: &egui::Response, rect: Rect if let (Some((at, job)), Some(end)) = (shown, end) { let t = Tokens::get(ui.ctx()); let painter = - ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.id().with("text_drag"))).with_clip_rect(rect); + ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.scope_id().with("text_drag"))).with_clip_rect(rect); // Laid out as the map draws it (its style too, #535), in the accent colour. let galley = painter.layout_job(job); let (pos, r) = place_galley(Align2::CENTER_CENTER, at + (end - origin), &galley); @@ -2165,7 +2165,7 @@ fn overlays(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t: // Facing north the corner stays the map's (identify, select and sketch clicks land there). if bearing != 0.0 || pitch != 0.0 { let hit = ui - .interact(north_arrow_rect(rect), egui::Id::new("map_north_arrow"), Sense::click()) + .interact(north_arrow_rect(rect), egui::Id::unique("map_north_arrow"), Sense::click()) .on_hover_text(tl!("Reset north")) .on_hover_cursor(egui::CursorIcon::PointingHand); if hit.clicked() { @@ -2188,7 +2188,7 @@ fn overlays(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t: [("zoom_in", "map.zoomIn", "Zoom in"), ("zoom_out", "map.zoomOut", "Zoom out"), ("zoom_full", "map.zoomFull", "Full extent")] { let r = Rect::from_min_size(pos2(rect.max.x - 44.0, y), vec2(32.0, 32.0)); - let resp = ui.interact(r, egui::Id::new(("nav", id)), Sense::click()); + let resp = ui.interact(r, egui::Id::unique(("nav", id)), Sense::click()); p.rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.ribbon }); p.rect_stroke(r, CornerRadius::same(8), Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); icons::paint(p, r.shrink(7.0), icon, t.icon, t.accent); @@ -2352,8 +2352,8 @@ fn popup(app: &mut RustGisApp, ui: &mut Ui, rect: Rect) { p.circle_stroke(anchor, 6.0, Stroke::new(2.0, Color32::from_rgb(0x00, 0xA8, 0xC0))); // The popup is as tall as its rows, up to a limit; the corner grip sets the width and that // limit, and both are remembered for the next popup. - let limit_id = egui::Id::new("identify_popup_limit"); - let shown_id = egui::Id::new("identify_popup_shown"); + let limit_id = egui::Id::unique("identify_popup_limit"); + let shown_id = egui::Id::unique("identify_popup_shown"); let space = vec2((rect.width() - 24.0).max(240.0), (rect.height() - 24.0).max(140.0)); let limit = ui.ctx().data(|d| d.get_temp::(limit_id)).unwrap_or(vec2(310.0, 360.0)).min(space); let last_h = ui.ctx().data(|d| d.get_temp::(shown_id)).unwrap_or(limit.y); @@ -2366,7 +2366,7 @@ fn popup(app: &mut RustGisApp, ui: &mut Ui, rect: Rect) { // Keyed by the feature: egui remembers an area's (and a scroll area's and grid's) size, and // the next popup mustn't start from the last one's. let key = (&layer, &title, attrs.len()); - egui::Area::new(egui::Id::new(("identify_popup", key))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique(("identify_popup", key))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { let frame = egui::Frame::NONE .fill(t.menu) .stroke(Stroke::new(1.0, t.border_strong)) @@ -2465,7 +2465,7 @@ fn popup(app: &mut RustGisApp, ui: &mut Ui, rect: Rect) { // The corner grip: dragging it sets the width and the height limit. let r = frame.response.rect; let grip = Rect::from_min_max(r.max - vec2(16.0, 16.0), r.max); - let resp = ui.interact(grip, egui::Id::new("identify_popup_grip"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); + let resp = ui.interact(grip, egui::Id::unique("identify_popup_grip"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeNwSe); let color = if resp.hovered() || resp.dragged() { t.text } else { t.text_dim }; for k in [4.0, 8.0, 12.0] { ui.painter() @@ -2571,7 +2571,7 @@ fn navigator(app: &mut RustGisApp, ui: &mut Ui, p: &egui::Painter, rect: Rect, t let (w, h) = (rect.width() as f64, rect.height() as f64); // Each button is an egui widget above the map, so its clicks are its own (not the map's // identify, select or sketch) and popups over it get theirs first. - let button = |ui: &mut Ui, r: Rect, id: &str| ui.interact(r, ui.id().with(("navigator", id)), Sense::click()).clicked(); + let button = |ui: &mut Ui, r: Rect, id: &str| ui.interact(r, ui.scope_id().with(("navigator", id)), Sense::click()).clicked(); for (dir, (dx, dy)) in [("n", (0.0, -1.0)), ("s", (0.0, 1.0)), ("e", (1.0, 0.0)), ("w", (-1.0, 0.0))] { let tip = c + vec2(dx, dy) * 28.0; let base = c + vec2(dx, dy) * 18.0; @@ -2609,9 +2609,9 @@ fn view_tabs(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { for (i, n) in names.iter().enumerate() { let w = crate::widgets::text_w(ui, n, regular(12.0)) + 46.0 + TAB_CLOSE_W; let tr = Rect::from_min_size(pos2(x, r.min.y + 4.0), vec2(w, r.height() - 4.0)); - let resp = ui.interact(tr, egui::Id::new(("maptab", i)), Sense::click()); + let resp = ui.interact(tr, egui::Id::unique(("maptab", i)), Sense::click()); // Painted, so named for screen readers (and tests). - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, n)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, n)); let is = i == active; // A project keeps at least one map (`map.remove` refuses the last). let last = names.len() < 2; @@ -2635,7 +2635,7 @@ fn view_tabs(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if is { t.text } else { t.text_dim }, ); // Over the tab's fill, so painted after it. - if tab_close(ui, t, tr, egui::Id::new(("maptab_close", i)), is, !last, tip) { + if tab_close(ui, t, tr, egui::Id::unique(("maptab_close", i)), is, !last, tip) { close = Some(Deletion::Map(n.clone())); } if resp.clicked() && !is { @@ -2656,8 +2656,8 @@ fn view_tabs(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { for (i, n) in layouts.iter().enumerate() { let w = crate::widgets::text_w(ui, n, regular(12.0)) + 46.0 + TAB_CLOSE_W; let tr = Rect::from_min_size(pos2(x, r.min.y + 4.0), vec2(w, r.height() - 4.0)); - let resp = ui.interact(tr, egui::Id::new(("layouttab", i)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, n)); + let resp = ui.interact(tr, egui::Id::unique(("layouttab", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, n)); let is = app.session.active_layout == Some(i) && !app.mb.shown(); if is { ui.painter().rect_filled(tr, CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, t.ribbon); @@ -2684,7 +2684,7 @@ fn view_tabs(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if is { t.text } else { t.text_dim }, ); // Over the tab's fill, so painted after it. - if tab_close(ui, t, tr, egui::Id::new(("layouttab_close", i)), is, true, tl!("Delete Layout")) { + if tab_close(ui, t, tr, egui::Id::unique(("layouttab_close", i)), is, true, tl!("Delete Layout")) { close = Some(Deletion::Layout(n.clone())); } if resp.clicked() && !is { @@ -2703,7 +2703,7 @@ fn view_tabs(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let n = if app.mb.name.trim().is_empty() { tl!("Untitled model").to_string() } else { app.mb.name.trim().to_string() }; let w = crate::widgets::text_w(ui, &n, regular(12.0)) + 46.0 + TAB_CLOSE_W; let tr = Rect::from_min_size(pos2(x, r.min.y + 4.0), vec2(w, r.height() - 4.0)); - let resp = ui.interact(tr, egui::Id::new("modeltab"), Sense::click()); + let resp = ui.interact(tr, egui::Id::unique("modeltab"), Sense::click()); let is = app.mb.shown(); if is { ui.painter().rect_filled(tr, CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, t.ribbon); @@ -2729,7 +2729,7 @@ fn view_tabs(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if is { semibold(12.0) } else { regular(12.0) }, if is { t.text } else { t.text_dim }, ); - if tab_close(ui, t, tr, egui::Id::new("modeltab_close"), is, true, tl!("Close ModelBuilder")) { + if tab_close(ui, t, tr, egui::Id::unique("modeltab_close"), is, true, tl!("Close ModelBuilder")) { let _ = app.run("ui.modelBuilder", json!({"close": true})); } if resp.clicked() && !is { @@ -2738,7 +2738,7 @@ fn view_tabs(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { x += w + 2.0; } let pr = Rect::from_min_size(pos2(x + 2.0, r.min.y + 6.0), vec2(22.0, 20.0)); - let resp = ui.interact(pr, egui::Id::new("maptab_new"), Sense::click()); + let resp = ui.interact(pr, egui::Id::unique("maptab_new"), Sense::click()); if resp.hovered() { ui.painter().rect_filled(pr, CornerRadius::same(4), t.hover); } @@ -2779,7 +2779,7 @@ pub fn confirm_delete(app: &mut RustGisApp, ctx: &egui::Context) { }; let undo = crate::widgets::shortcut_text(app, "edit.undo"); let mut decision = None; - egui::Modal::new(egui::Id::new("confirm_delete")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("confirm_delete")).show(ctx, |ui| { ui.set_width(360.0); ui.label(egui::RichText::new(title).font(semibold(15.0))); if !undo.is_empty() { @@ -2970,14 +2970,18 @@ mod tests { let mut app = RustGisApp::with_sample(); app.run("map.body", json!({"name": "moon", "basemap": false})).expect("Moon"); app.run("map.projection", json!({"type": "globe"})).expect("globe"); - let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 550.0)).with_max_steps(10_000).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(900.0, 550.0)) + .with_max_steps(10_000) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); let end = std::time::Instant::now() + std::time::Duration::from_secs(10); while h.state().map.key.is_none() || h.state().map.drawing() { assert!(std::time::Instant::now() < end, "exact globe frame did not arrive"); @@ -2995,8 +2999,12 @@ mod tests { /// the map is drawn once at it. #[test] fn a_pane_drag_draws_the_map_once_it_stops() { - let mut h = - egui_kittest::Harness::builder().with_size(vec2(1200.0, 700.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_ui_state( + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(1200.0, 700.0)) + .with_step_dt(1.0 / 60.0) + .with_max_steps(10_000) + .build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); diff --git a/crates/ui-egui/src/modelbuilder.rs b/crates/ui-egui/src/modelbuilder.rs index 09e7bd08b..dae8f48c3 100644 --- a/crates/ui-egui/src/modelbuilder.rs +++ b/crates/ui-egui/src/modelbuilder.rs @@ -730,7 +730,7 @@ fn palette_ui(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, canvas: Rect) { for tool in tools { let resp = { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(tool.name))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(tool.name))); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } @@ -776,7 +776,7 @@ fn drag_source(ui: &Ui, resp: &egui::Response, what: &Add, label: &str) { && let Some(p) = ui.ctx().pointer_latest_pos() { let t = Tokens::get(ui.ctx()); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("mb_drag"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("mb_drag"))); let galley = painter.layout_no_wrap(label.to_string(), semibold(12.0), t.text); let r = Rect::from_min_size(p + vec2(12.0, 8.0), galley.size() + vec2(16.0, 10.0)); painter.rect(r, CornerRadius::same(6), t.pane, Stroke::new(1.5, kind_color(ModelNodeKind::Tool, &t)), egui::StrokeKind::Inside); @@ -831,7 +831,7 @@ fn canvas_ui(app: &mut RustGisApp, ui: &mut Ui, rect: Rect, t: &Tokens, problems app.mb.fit = false; fit(&mut app.mb, rect); } - let bg = ui.interact(rect, egui::Id::new("mb_canvas"), Sense::click_and_drag()); + let bg = ui.interact(rect, egui::Id::unique("mb_canvas"), Sense::click_and_drag()); let painter = ui.painter_at(rect); painter.rect_filled(rect, 0.0, t.canvas); // Zoom (Ctrl+wheel, pinch) about the pointer; the wheel pans. @@ -930,7 +930,7 @@ fn canvas_ui(app: &mut RustGisApp, ui: &mut Ui, rect: Rect, t: &Tokens, problems if !r.intersects(rect.expand(50.0)) { continue; } - let resp = ui.interact(r.intersect(rect), egui::Id::new(("mb_node", &n.id)), Sense::click_and_drag()); + let resp = ui.interact(r.intersect(rect), egui::Id::unique(("mb_node", &n.id)), Sense::click_and_drag()); if resp.drag_started() { drag_started = true; clicked_node = Some(n.id.clone()); @@ -1020,7 +1020,7 @@ fn canvas_ui(app: &mut RustGisApp, ui: &mut Ui, rect: Rect, t: &Tokens, problems } let p = view.to_screen(out_port_pos(&n)); let pr = Rect::from_center_size(p, Vec2::splat(18.0 * z.max(0.7))); - let resp = ui.interact(pr, egui::Id::new(("mb_out", &n.id)), Sense::drag()).on_hover_text(tl!("Drag to an input to connect")); + let resp = ui.interact(pr, egui::Id::unique(("mb_out", &n.id)), Sense::drag()).on_hover_text(tl!("Drag to an input to connect")); if resp.drag_started() { app.mb.wiring = Some(n.id.clone()); } @@ -1628,7 +1628,7 @@ fn log_ui(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, problems: &[model::Prob fn confirm(app: &mut RustGisApp, ctx: &egui::Context) { let Some(p) = app.mb.confirm.clone() else { return }; let mut answer = None; - egui::Modal::new(egui::Id::new("mb_confirm")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("mb_confirm")).show(ctx, |ui| { ui.set_width(320.0); ui.label(egui::RichText::new(tl!("Discard unsaved changes to the current model?")).strong()); ui.add_space(8.0); @@ -1685,7 +1685,7 @@ mod tests { fn harness(app: RustGisApp) -> egui_kittest::Harness<'static, RustGisApp> { let mut fonts = false; - egui_kittest::Harness::builder().with_size(vec2(1300.0, 800.0)).build_ui_state( + egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1300.0, 800.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index f59568111..4a03c1a0a 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -25,22 +25,25 @@ use crate::{LayersView, LeftPane, RightPane, RustGisApp, icons}; pub fn show(app: &mut RustGisApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.pane).inner_margin(egui::Margin::ZERO); + app.ui.pane_rects = [None; 2]; if let Some(left) = app.ui.left { - egui::Panel::left("left_pane").resizable(true).default_size(270.0).size_range(200.0..=480.0).frame(frame).show(ui, |ui| { - left_header(app, ui, left, &t); - // The search box sits at the foot of every left pane, the app's lower-left corner. - crate::searchbox::show(app, ui, &t); - // As on the right: a row wider than the pane (a long folder path, a long favourite) is - // clipped at its edge, never reported to the panel, which would widen to fit it (#353). - let rect = ui.available_rect_before_wrap(); - let mut body = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(*ui.layout())); - match left { - LeftPane::Contents => contents(app, &mut body, &t), - LeftPane::Catalog => catalog(app, &mut body, &t), - LeftPane::Sources => crate::sourcespane::show(app, &mut body, &t), - } - ui.advance_cursor_after_rect(rect); - }); + let pane = + egui::Panel::left("left_pane").resizable(true).default_size(270.0).size_range(200.0..=480.0).frame(frame).show(ui, |ui| { + left_header(app, ui, left, &t); + // The search box sits at the foot of every left pane, the app's lower-left corner. + crate::searchbox::show(app, ui, &t); + // As on the right: a row wider than the pane (a long folder path, a long favourite) is + // clipped at its edge, never reported to the panel, which would widen to fit it (#353). + let rect = ui.available_rect_before_wrap(); + let mut body = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(*ui.layout())); + match left { + LeftPane::Contents => contents(app, &mut body, &t), + LeftPane::Catalog => catalog(app, &mut body, &t), + LeftPane::Sources => crate::sourcespane::show(app, &mut body, &t), + } + ui.advance_cursor_after_rect(rect); + }); + app.ui.pane_rects[0] = Some(pane.response.rect); } // A point picked for the Time Series pane is taken only while that pane shows: with it // closed or another in view, the pick ends rather than waiting with a stale click. @@ -68,7 +71,7 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { if let Some(right) = app.ui.right { // The Web Data pane's searches and lists want room: it keeps its own width. let (id, width) = if right == RightPane::WebData { ("right_pane_web", 440.0) } else { ("right_pane", 320.0) }; - egui::Panel::right(id).resizable(true).default_size(width).size_range(260.0..=640.0).frame(frame).show(ui, |ui| { + let pane = egui::Panel::right(id).resizable(true).default_size(width).size_range(260.0..=640.0).frame(frame).show(ui, |ui| { // The pane is as wide as the user made it. Content wider than that is clipped, never // reported to the panel: egui would keep the panel's edge where the content ends, off // the window, and give the map the room it no longer covers (#307). @@ -77,6 +80,7 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { right_pane(app, &mut body, right, &t); ui.advance_cursor_after_rect(rect); }); + app.ui.pane_rects[1] = Some(pane.response.rect); } // Here rather than in the Symbology and Labeling panes, so a drag still finishes if the pane // closes meanwhile; and after the panes, so a change the picker reports as the pointer is let @@ -291,8 +295,8 @@ fn right_header(app: &mut RustGisApp, ui: &mut Ui, right: RightPane, t: &Tokens) let (_, cmd) = right_title(pane); let on = pane == right; let tr = Rect::from_min_size(pos2(x, r.min.y), vec2(w, r.height())); - let resp = ui.interact(tr, egui::Id::new(("right_pane_tab", pane as u8)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, name)); + let resp = ui.interact(tr, egui::Id::unique(("right_pane_tab", pane as u8)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name)); let p = ui.painter(); if resp.hovered() && !on { p.rect_filled(tr.shrink2(vec2(1.0, 5.0)), CornerRadius::same(4), t.hover); @@ -304,9 +308,9 @@ fn right_header(app: &mut RustGisApp, ui: &mut Ui, right: RightPane, t: &Tokens) } // The tab's ✕ closes its pane, in view or not. let cr = Rect::from_center_size(pos2(tr.max.x - 5.0 - cross / 2.0, tr.center().y), vec2(cross, cross)); - let x_resp = ui.interact(cr, egui::Id::new(("right_pane_close", pane as u8)), Sense::click()); + let x_resp = ui.interact(cr, egui::Id::unique(("right_pane_close", pane as u8)), Sense::click()); let tip = crate::i18n::fmt("Close {name}", &[("name", name)]); - x_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + x_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); if x_resp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(3), t.hover); } @@ -335,8 +339,8 @@ fn right_header(app: &mut RustGisApp, ui: &mut Ui, right: RightPane, t: &Tokens) } // The ✕ at the end closes them all. let all = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(22.0, 22.0)); - let all_resp = ui.interact(all, egui::Id::new("right_pane_close_all"), Sense::click()); - all_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Close all panes"))); + let all_resp = ui.interact(all, egui::Id::unique("right_pane_close_all"), Sense::click()); + all_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Close all panes"))); if all_resp.hovered() { ui.painter().rect_filled(all, CornerRadius::same(4), t.hover); } @@ -348,9 +352,9 @@ fn right_header(app: &mut RustGisApp, ui: &mut Ui, right: RightPane, t: &Tokens) let hidden: Vec = (0..open.len()).filter(|i| !shown.contains(i)).filter_map(|i| open.get(i).copied()).collect(); if !hidden.is_empty() { let mr = Rect::from_center_size(pos2(r.max.x - all_w - more_w / 2.0, r.center().y), vec2(more_w - 4.0, 22.0)); - let more = ui.interact(mr, egui::Id::new("right_pane_more"), Sense::click()); + let more = ui.interact(mr, egui::Id::unique("right_pane_more"), Sense::click()); let tip = crate::i18n::fmt("{n} more panes", &[("n", &hidden.len().to_string())]); - more.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + more.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); if more.hovered() { ui.painter().rect_filled(mr, CornerRadius::same(4), t.hover); } @@ -386,7 +390,7 @@ fn header_strip(ui: &mut Ui, t: &Tokens) -> Rect { /// The ✕ at the right end of a pane header `r`, running `close`. fn close_button(app: &mut RustGisApp, ui: &mut Ui, r: Rect, close: &str, salt: &str, t: &Tokens) { let cr = Rect::from_center_size(pos2(r.max.x - 18.0, r.center().y), vec2(22.0, 22.0)); - let resp = ui.interact(cr, egui::Id::new(("pane_close", salt)), Sense::click()); + let resp = ui.interact(cr, egui::Id::unique(("pane_close", salt)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); } @@ -420,9 +424,9 @@ fn left_header(app: &mut RustGisApp, ui: &mut Ui, left: LeftPane, t: &Tokens) { let w = if icons_only { 32.0 } else { width }; let tr = Rect::from_min_size(pos2(x, r.min.y), vec2(w, r.height())); let on = *pane == left; - let mut resp = ui.interact(tr, egui::Id::new(("left_pane_tab", i)), Sense::click()); + let mut resp = ui.interact(tr, egui::Id::unique(("left_pane_tab", i)), Sense::click()); // Named and marked open for screen readers (and scripted clicks), icons or not. - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name)); let p = ui.painter(); if resp.hovered() && !on { p.rect_filled(tr.shrink2(vec2(1.0, 5.0)), CornerRadius::same(4), t.hover); @@ -628,7 +632,7 @@ fn contents(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { // The map's row takes copied layers (#491), at the top: there is one even with no layers. // Across the pane, like the layer rows, not just its name. let row = Rect::from_min_max(header.rect.min, pos2(ui.max_rect().max.x, header.rect.max.y)); - let header = ui.interact(row, egui::Id::new("layers_map_header"), Sense::click()); + let header = ui.interact(row, egui::Id::unique("layers_map_header"), Sense::click()); header.context_menu(|ui| { if paste_layers_button(app, ui) { ui.close(); @@ -818,7 +822,7 @@ const LAYERS_VIEWS: [(LayersView, &str, &str); 3] = [ /// A small icon button that picks a Layers view; `on` marks the view shown. fn view_button(ui: &mut Ui, icon: &str, hover: &str, on: bool, t: &Tokens) -> egui::Response { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, hover)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, hover)); if on { ui.painter().rect_filled(r, CornerRadius::same(5), t.selection); } else if resp.hovered() { @@ -1051,8 +1055,8 @@ fn selection_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { active_outline(ui, r, app.session.active_layer == Some(l.id), t); // Lined up with the other views' checkboxes and names. let cb = Rect::from_center_size(pos2(r.min.x + 33.0, cy), vec2(14.0, 14.0)); - let cb_resp = ui.interact(hit(cb), egui::Id::new(("layer_selectable", l.id)), Sense::click()); - cb_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, l.selectable, tl!("Selectable"))); + let cb_resp = ui.interact(hit(cb), egui::Id::unique(("layer_selectable", l.id)), Sense::click()); + cb_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, l.selectable, tl!("Selectable"))); visibility_box(ui, cb, l.selectable, t); // Only the map's interactive selection reads it: the tools that name a layer (Select By // Attributes, Select Layer By Location, the attribute table) select in any layer. @@ -1061,8 +1065,8 @@ fn selection_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { } if count > 0 { let cr = Rect::from_center_size(pos2(r.max.x - 8.0 - 11.0, cy), vec2(22.0, 22.0)); - let clear = ui.interact(cr, egui::Id::new(("layer_selection_clear", l.id)), Sense::click()); - clear.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Clear selection"))); + let clear = ui.interact(cr, egui::Id::unique(("layer_selection_clear", l.id)), Sense::click()); + clear.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear selection"))); if clear.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(5), t.hover); } @@ -1113,7 +1117,7 @@ fn service_group_row( // in the same places. let name = row_name(ui, group.name.clone(), semibold(12.5), ui.available_width() - NAME_X - 8.0); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), name.row_h), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &group.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &group.name)); if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(4.0, 1.0)), CornerRadius::same(5), t.hover); } @@ -1121,11 +1125,11 @@ fn service_group_row( let ar = Rect::from_center_size(pos2(r.min.x + 16.0, cy), vec2(14.0, 14.0)); icons::paint(ui.painter(), ar, if group.expanded { "chevron_down" } else { "chevron_right" }, t.text_dim, t.accent); // `interact` takes the id as it is (not under the `push_id`): each group's own. - let arrow = ui.interact(hit(ar), egui::Id::new(("group_expand", group.id)), Sense::click()); + let arrow = ui.interact(hit(ar), egui::Id::unique(("group_expand", group.id)), Sense::click()); let visible = !children.is_empty() && children.iter().all(|l| l.visible); let cb = Rect::from_center_size(pos2(r.min.x + 33.0, cy), vec2(14.0, 14.0)); - let cb_resp = ui.interact(hit(cb), egui::Id::new(("group_visible", group.id)), Sense::click()); - cb_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, visible, tl!("Show or hide"))); + let cb_resp = ui.interact(hit(cb), egui::Id::unique(("group_visible", group.id)), Sense::click()); + cb_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, visible, tl!("Show or hide"))); visibility_box(ui, cb, visible, t); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 57.0, cy), vec2(18.0, 16.0)), "map", t.icon, t.accent); let elided = name.elided; @@ -1184,7 +1188,7 @@ fn layer_group_row( let name = row_name(ui, group.name.clone(), semibold(12.5), ui.available_width() - NAME_X - 8.0); let sense = if reorder { Sense::click_and_drag() } else { Sense::click() }; let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), name.row_h), sense); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &group.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &group.name)); if reorder && resp.drag_started_by(egui::PointerButton::Primary) { egui::DragAndDrop::set_payload(ui.ctx(), DraggedLayer(group.id)); } @@ -1196,10 +1200,10 @@ fn layer_group_row( let cy = r.min.y + 13.0; let ar = Rect::from_center_size(pos2(r.min.x + 16.0, cy), vec2(14.0, 14.0)); icons::paint(ui.painter(), ar, if group.expanded { "chevron_down" } else { "chevron_right" }, t.text_dim, t.accent); - let arrow = ui.interact(hit(ar), egui::Id::new(("layer_group_expand", group.id)), Sense::click()); + let arrow = ui.interact(hit(ar), egui::Id::unique(("layer_group_expand", group.id)), Sense::click()); let cb = Rect::from_center_size(pos2(r.min.x + 33.0, cy), vec2(14.0, 14.0)); - let cb_resp = ui.interact(hit(cb), egui::Id::new(("layer_group_visible", group.id)), Sense::click()); - cb_resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, group.visible, tl!("Show or hide"))); + let cb_resp = ui.interact(hit(cb), egui::Id::unique(("layer_group_visible", group.id)), Sense::click()); + cb_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, group.visible, tl!("Show or hide"))); visibility_box(ui, cb, group.visible, t); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 57.0, cy), vec2(18.0, 16.0)), "contents", t.icon, t.accent); let elided = name.elided; @@ -1406,7 +1410,7 @@ fn tile_row(app: &mut RustGisApp, ui: &mut Ui, i: usize, count: usize, reorder: let cy = r.min.y + 13.0; let mut x = r.min.x + 26.0; let cb = Rect::from_center_size(pos2(x + 7.0, cy), vec2(14.0, 14.0)); - let cb_resp = ui.interact(hit(cb), egui::Id::new(("tile_vis", i)), Sense::click()).on_hover_text(tl!("Show or hide")); + let cb_resp = ui.interact(hit(cb), egui::Id::unique(("tile_vis", i)), Sense::click()).on_hover_text(tl!("Show or hide")); visibility_box(ui, cb, tile.visible, t); x += 22.0; icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 9.0, cy), vec2(18.0, 16.0)), "basemap", t.icon, t.accent); @@ -1536,7 +1540,7 @@ fn layer_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, scale: f64, reorder: let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), name.row_h), sense); // The icons and the badge line up with the name's first line. let cy = r.min.y + 13.0; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &l.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &l.name)); if reorder && resp.drag_started_by(egui::PointerButton::Primary) { egui::DragAndDrop::set_payload(ui.ctx(), DraggedLayer(l.id)); } @@ -1567,11 +1571,11 @@ fn layer_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, scale: f64, reorder: if has_legend { icons::paint(ui.painter(), ar, if l.expanded { "chevron_down" } else { "chevron_right" }, t.text_dim, t.accent); } - let ar_resp = ui.interact(hit(ar), egui::Id::new(("expand", l.id)), Sense::click()); + let ar_resp = ui.interact(hit(ar), egui::Id::unique(("expand", l.id)), Sense::click()); x += 16.0; // Visibility checkbox. let cb = Rect::from_center_size(pos2(x + 7.0, cy), vec2(14.0, 14.0)); - let cb_resp = ui.interact(hit(cb), egui::Id::new(("vis", l.id)), Sense::click()); + let cb_resp = ui.interact(hit(cb), egui::Id::unique(("vis", l.id)), Sense::click()); visibility_box(ui, cb, l.visible, t); x += 22.0; // Symbol patch (single symbol) or layer-type icon. @@ -1689,7 +1693,7 @@ fn layer_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, scale: f64, reorder: }); for e in vector_legend.iter().take(40) { let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &e.label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &e.label)); let pr = Rect::from_center_size(pos2(r.min.x + 84.0, r.center().y), vec2(18.0, 14.0)); patch(ui.painter(), pr, &e.symbol, l.geometry_type()); ui.painter().text(pos2(r.min.x + 100.0, r.center().y), Align2::LEFT_CENTER, &e.label, regular(11.5), t.text); @@ -2767,7 +2771,7 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }; let row = |app: &mut RustGisApp, ui: &mut Ui, tool: &'static rustgis_engine::analysis::ToolSpec| { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 42.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(tool.name))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(tool.name))); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); } @@ -3313,14 +3317,14 @@ struct PendingSymbology { } fn clear_pending_symbology(ctx: &egui::Context) { - ctx.data_mut(|data| data.remove::(egui::Id::new(PENDING_SYMBOLOGY))); + ctx.data_mut(|data| data.remove::(egui::Id::unique(PENDING_SYMBOLOGY))); } /// The pending retry, unless another project, map or layer is in view or the layer's renderer /// changed since the switch failed (an edit from the pane, the ribbon, an agent or undo /// supersedes it); a stale one is dropped. fn pending_symbology(app: &RustGisApp, ctx: &egui::Context) -> Option { - let pending = ctx.data(|data| data.get_temp::(egui::Id::new(PENDING_SYMBOLOGY)))?; + let pending = ctx.data(|data| data.get_temp::(egui::Id::unique(PENDING_SYMBOLOGY)))?; let current = pending.project == app.session.opened && pending.map == app.session.project.active_map && Some(pending.layer) == app.session.active_layer @@ -3366,7 +3370,7 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }; let sel = cur_kind == *label; let (r, resp) = ui.allocate_exact_size(vec2(68.0, 56.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, sel, tl!(*label))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, tl!(*label))); ui.painter().rect_filled( r, CornerRadius::same(6), @@ -3404,7 +3408,7 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { error: error.into(), renderer: std::sync::Arc::new(l.renderer.clone()), }; - ui.data_mut(|data| data.insert_temp(egui::Id::new(PENDING_SYMBOLOGY), pending)); + ui.data_mut(|data| data.insert_temp(egui::Id::unique(PENDING_SYMBOLOGY), pending)); } Ok(_) => clear_pending_symbology(ui.ctx()), } @@ -3433,7 +3437,7 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { Ok(_) => clear_pending_symbology(ui.ctx()), Err(error) => { let pending = PendingSymbology { error: error.into(), ..pending.clone() }; - ui.data_mut(|data| data.insert_temp(egui::Id::new(PENDING_SYMBOLOGY), pending)); + ui.data_mut(|data| data.insert_temp(egui::Id::unique(PENDING_SYMBOLOGY), pending)); } } } @@ -3662,7 +3666,7 @@ fn finish_class_color_drag(app: &mut RustGisApp, ui: &Ui) { if ui.input(|i| i.pointer.primary_down() && i.focused) { return; } - let key = egui::Id::new(CLASS_COLOR_DRAG); + let key = egui::Id::unique(CLASS_COLOR_DRAG); let Some(drag) = ui.data_mut(|d| { let drag = d.get_temp::(key); d.remove::(key); @@ -3777,7 +3781,7 @@ fn legend_list(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { if let Some(color) = picked { // Keep FF too: six-digit polygon colours deliberately preserve translucency. let params = with("color", json!(color.hex8())); - let key = egui::Id::new(CLASS_COLOR_DRAG); + let key = egui::Id::unique(CLASS_COLOR_DRAG); let mut dragging = ui.data(|d| d.get_temp::(key)); // One left pending on another layer (the pane switched layers mid-drag) is // finished first rather than dropped with its preview in place. @@ -3803,7 +3807,7 @@ fn legend_list(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { // The typing lives in the pane's state while the field has focus, under the // class it names (not the row, which another class can take meanwhile). Enter or // clicking away applies it; Escape drops it. - let id = egui::Id::new(("class_label", l.id, l.renderer.field().unwrap_or_default(), class.to_string())); + let id = egui::Id::unique(("class_label", l.id, l.renderer.field().unwrap_or_default(), class.to_string())); let typing = id.with("typing"); let focused = ui.memory(|m| m.has_focus(id)); let mut buf = match focused { @@ -3948,7 +3952,7 @@ fn stroke_classes(r: &Renderer) -> Vec<(ClassRef, String, Symbol)> { /// layer with classes can stack strokes for one class of its own, picked at the top. fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::map::Layer, t: &Tokens) { let classes = stroke_classes(&l.renderer); - let picked_id = egui::Id::new(("stroke_class", l.id)); + let picked_id = egui::Id::unique(("stroke_class", l.id)); // The class being edited, while the renderer still has it; `None` is the layer's own strokes. let picked: Option = ui.data(|d| d.get_temp::>(picked_id)).flatten().filter(|c| classes.iter().any(|(k, ..)| k == c)); @@ -4449,7 +4453,7 @@ fn raster_details(ui: &mut Ui, rl: &rustgis_engine::map::RasterLayer, t: &Tokens /// pair is valid, so the layer's values don't snap back. fn rgb_stretch_bounds(app: &mut RustGisApp, ui: &mut Ui, layer: impl std::hash::Hash + std::fmt::Debug, t: &Tokens, shown: [[f64; 2]; 3]) { type Bounds = [[f64; 2]; 3]; - let key = ui.id().with(("custom_stretch_rgb", layer)); + let key = ui.scope_id().with(("custom_stretch_rgb", layer)); let pending = ui.data(|d| d.get_temp::<(Bounds, Bounds)>(key)).filter(|(base, _)| *base == shown); let mut edit = pending.map_or(shown, |(_, e)| e); let (mut done, mut changed) = (false, false); @@ -4627,7 +4631,7 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { ui.label(tl!(label)); let mut x = v; // Kept across a drag until it is let go (#607). - let drag = Dragged::lend(ui, egui::Id::new(("relief_drag", l.id, which)), &mut x); + let drag = Dragged::lend(ui, egui::Id::unique(("relief_drag", l.id, which)), &mut x); let r = ui.add(egui::DragValue::new(&mut x).range(range).speed(speed).max_decimals(3)); if drag.settled(ui, &r, &mut x) && x != v { let (a, b, c) = match which { @@ -4674,11 +4678,11 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { let (mut lo, mut hi) = (low, high); // Each kept across a drag until it is let go (#607); both are sent together. ui.label(tl!("Min %")); - let drag = Dragged::lend(ui, egui::Id::new(("percent_clip_drag", l.id, "low")), &mut lo); + let drag = Dragged::lend(ui, egui::Id::unique(("percent_clip_drag", l.id, "low")), &mut lo); let a = ui.add(egui::DragValue::new(&mut lo).range(0.0..=49.0).speed(0.1).max_decimals(2)); let low_settled = drag.settled(ui, &a, &mut lo); ui.label(tl!("Max %")); - let drag = Dragged::lend(ui, egui::Id::new(("percent_clip_drag", l.id, "high")), &mut hi); + let drag = Dragged::lend(ui, egui::Id::unique(("percent_clip_drag", l.id, "high")), &mut hi); let b = ui.add(egui::DragValue::new(&mut hi).range(0.0..=49.0).speed(0.1).max_decimals(2)); let high_settled = drag.settled(ui, &b, &mut hi); if (low_settled || high_settled) && (lo != low || hi != high) { @@ -4701,7 +4705,7 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { Stretch::Custom { min, max, .. } => { // Edits are kept here until both bounds are valid, so the layer's values don't snap back. // They remember the layer's bounds they started from, and go stale when those change. - let key = ui.id().with(("custom_stretch", l.id)); + let key = ui.scope_id().with(("custom_stretch", l.id)); let pending = ui.data(|d| d.get_temp::<([f64; 2], [f64; 2])>(key)).filter(|(base, _)| *base == [min, max]); let [mut lo, mut hi] = pending.map_or([min, max], |(_, edit)| edit); let speed = ((max - min).abs() / 200.0).clamp(1e-6, 1e6); @@ -4727,7 +4731,7 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { let mut nn = n; ui.label(tl!("Deviations")); // Kept across a drag until it is let go (#607). - let drag = Dragged::lend(ui, egui::Id::new(("deviations_drag", l.id)), &mut nn); + let drag = Dragged::lend(ui, egui::Id::unique(("deviations_drag", l.id)), &mut nn); let a = ui.add(egui::DragValue::new(&mut nn).range(0.1..=10.0).speed(0.05).max_decimals(2)); if drag.settled(ui, &a, &mut nn) && nn != n { let _ = app.run("raster.stretch", json!({"type": "standardDeviation", "n": nn})); @@ -4787,7 +4791,7 @@ fn raster_masking(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, d: &rustgis_engi // The values typed are kept here until they are applied, remembering the layer's // values they started from (they go stale when those change). let text: String = mask.values.iter().map(|v| format!("{v}")).collect::>().join(", "); - let key = ui.id().with(("mask_values", l.id)); + let key = ui.scope_id().with(("mask_values", l.id)); let pending = ui.data(|d| d.get_temp::<(String, String, bool)>(key)).filter(|(base, _, _)| *base == text); let (mut edit, bad) = pending.map_or((text.clone(), false), |(_, e, bad)| (e, bad)); ui.label(tl!("Hide values (no data)")); @@ -4842,7 +4846,7 @@ fn raster_masking(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, d: &rustgis_engi if ranged { // As with a custom stretch: kept here until both bounds are valid. let (min, max) = (mask.min.unwrap_or(f64::NEG_INFINITY), mask.max.unwrap_or(f64::INFINITY)); - let key = ui.id().with(("mask_range", l.id)); + let key = ui.scope_id().with(("mask_range", l.id)); let pending = ui.data(|d| d.get_temp::<([f64; 2], [f64; 2])>(key)).filter(|(base, _)| *base == [min, max]); let [mut lo, mut hi] = pending.map_or([min, max], |(_, edit)| edit); let span = if min.is_finite() && max.is_finite() { (max - min).abs() } else { 100.0 }; @@ -5011,20 +5015,24 @@ mod tests { app.session.project.folders.push(long.clone()); let fav = rustgis_engine::sources::Source { kind: "file".into(), name: long.clone(), address: long, ..Default::default() }; app.session.sources.favourites.push(fav); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(1400.0, 860.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); let width = |h: &mut egui_kittest::Harness<'_, RustGisApp>, pane: LeftPane| { h.state_mut().ui.left = Some(pane); for _ in 0..6 { h.step(); } - egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new("left_pane")).map(|s| s.outer_rect.width()) + h.state().ui.pane_rects[0].map(|r| r.width()) }; let start = width(&mut h, LeftPane::Contents); assert!(start.is_some_and(|w| w < 300.0), "the Layers pane starts at its default width: {start:?}"); @@ -5092,14 +5100,18 @@ mod tests { .unwrap(); app.run("layer.definitionQuery", json!({"where": "c_valid < 30"})).unwrap(); app.ui.open_right(RightPane::Symbology); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(1400.0, 900.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.run_steps(6); h } @@ -5257,14 +5269,18 @@ mod tests { let id = layer(&app, "Countries"); app.session.active_layer = Some(id); app.ui.open_right(RightPane::Symbology); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 1400.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(1400.0, 1400.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.run_steps(6); let transparency = |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer(id).map(|l| l.transparency); let start = transparency(&h); @@ -5302,14 +5318,18 @@ mod tests { app.run("layer.symbology", json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 1})).unwrap(); app.run("layer.classStyle", json!({"layer": "Countries", "index": 0, "color": original.hex8()})).unwrap(); let id = layer(&app, "Countries"); - let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 600.0)).with_max_steps(64).build_ui_state( - move |ui, app: &mut RustGisApp| { - finish_class_color_drag(app, ui); - let layer = app.session.map().layer(id).unwrap().clone(); - legend_list(app, ui, &layer, &Tokens::light()); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(600.0, 600.0)) + .with_max_steps(64) + .build_ui_state( + move |ui, app: &mut RustGisApp| { + finish_class_color_drag(app, ui); + let layer = app.session.map().layer(id).unwrap().clone(); + legend_list(app, ui, &layer, &Tokens::light()); + }, + app, + ); h.run_steps(3); let fill = |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer(id).unwrap().renderer.legend()[0].symbol.fill; @@ -5355,14 +5375,18 @@ mod tests { app.run("layer.symbology", json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 1})).unwrap(); app.run("layer.classStyle", json!({"layer": "Countries", "index": 0, "color": "#FF000000"})).unwrap(); let id = layer(&app, "Countries"); - let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 600.0)).with_max_steps(64).build_ui_state( - move |ui, app: &mut RustGisApp| { - finish_class_color_drag(app, ui); - let layer = app.session.map().layer(id).unwrap().clone(); - legend_list(app, ui, &layer, &Tokens::light()); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(600.0, 600.0)) + .with_max_steps(64) + .build_ui_state( + move |ui, app: &mut RustGisApp| { + finish_class_color_drag(app, ui); + let layer = app.session.map().layer(id).unwrap().clone(); + legend_list(app, ui, &layer, &Tokens::light()); + }, + app, + ); h.run_steps(3); let fill = |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer(id).unwrap().renderer.legend()[0].symbol.fill; let inactive_theme = match h.ctx.theme() { @@ -5412,7 +5436,9 @@ mod tests { fn an_open_layer_menu_leaves_the_map_alone() { let app = RustGisApp::with_sample(); let layer = app.session.map().layers.iter().find(|l| l.name == "Cities").cloned().unwrap(); - let mut h = egui_kittest::Harness::builder().build_ui_state(|ui, app: &mut RustGisApp| layer_menu(app, ui, &layer, true), app); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .build_ui_state(|ui, app: &mut RustGisApp| layer_menu(app, ui, &layer, true), app); h.step(); assert_eq!(h.state().session.active_layer, Some(layer.id)); let rev = h.state().session.revision; diff --git a/crates/ui-egui/src/pluginpanel.rs b/crates/ui-egui/src/pluginpanel.rs index 506bf9ce1..6fce70020 100644 --- a/crates/ui-egui/src/pluginpanel.rs +++ b/crates/ui-egui/src/pluginpanel.rs @@ -195,7 +195,7 @@ fn button( /// A 26 px button with a chevron pointing left or right, found by `tip`. fn step_button(ui: &mut Ui, on: bool, left: bool, tip: &str, t: &Tokens) -> egui::Response { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), if on { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tip)); if on && resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } @@ -236,7 +236,7 @@ fn slider(app: &mut RustGisApp, ui: &mut Ui, pid: &str, item: &PanelItemDecl, st ui.spacing_mut().slider_width = (room - buttons - 4.0).max(60.0); let resp = ui.add_enabled(on, egui::Slider::new(&mut v, min..=max).show_value(false)); let resp = if item.help.is_empty() { resp } else { resp.on_hover_text(tr(&item.help)) }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, on, label_of(item, state, tr))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, on, label_of(item, state, tr))); if item.steps && step_button(ui, on && v < max, false, tl!("Step forward"), t).clicked() { stepped = Some(v + 1); } diff --git a/crates/ui-egui/src/pluginsui.rs b/crates/ui-egui/src/pluginsui.rs index 52a67e2fc..e9f66e385 100644 --- a/crates/ui-egui/src/pluginsui.rs +++ b/crates/ui-egui/src/pluginsui.rs @@ -896,7 +896,7 @@ fn confirm(app: &mut RustGisApp, ctx: &egui::Context, t: &Tokens) { let Some(c) = &app.plugins.confirm else { return }; let m = c.manifest.clone(); let mut decision = None; - egui::Modal::new(egui::Id::new("plugin_confirm")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("plugin_confirm")).show(ctx, |ui| { ui.set_width(420.0); ui.label( egui::RichText::new(crate::i18n::fmt("Install {name} {version}?", &[("name", &m.name), ("version", &m.version)])) @@ -993,7 +993,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { let mut open = true; let screen = ctx.content_rect(); egui::Window::new(tl!("Plugin Manager")) - .id(egui::Id::new("plugin_manager")) + .id(egui::Id::unique("plugin_manager")) .open(&mut open) .collapsible(false) .resizable(true) diff --git a/crates/ui-egui/src/relayshot.rs b/crates/ui-egui/src/relayshot.rs index 6da04ee14..d80c06ca1 100644 --- a/crates/ui-egui/src/relayshot.rs +++ b/crates/ui-egui/src/relayshot.rs @@ -3,6 +3,8 @@ //! this waits for the map to finish drawing (at most the request's wait), asks egui for a //! screenshot of the window, crops it to the map view and hands the PNG back. +use std::sync::{Arc, Mutex}; + use egui::{Color32, ColorImage, Rect}; use crate::RustGisApp; @@ -10,14 +12,41 @@ use crate::RustGisApp; /// A screenshot not back this long after the wait is given up, and the map drawn offscreen. const SCREENSHOT_GRACE: f64 = 10.0; -/// The tag on the screenshot this asks egui for, numbered, so only that snapshot's screenshot is -/// taken for it (not a late one of an earlier snapshot that timed out). -#[derive(Debug, PartialEq)] -struct RelayShotTag(u64); - -/// The next screenshot's number. +/// The next screenshot's number. Each screenshot this asks egui for is numbered, so only that +/// snapshot's screenshot is taken for it (not a late one of an earlier snapshot that timed out). static NEXT_TAG: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +/// An app's slot for the screenshot it waits for: that screenshot's number (0 for none) and the +/// screenshot once egui hands it back. Only that one is kept: one coming back after its snapshot +/// ended is dropped. +#[derive(Debug, Clone, Default)] +pub struct Arrived(Arc>)>>); + +impl Arrived { + /// Waits for the screenshot numbered `tag` (0: for none), dropping any other. + fn wait_for(&self, tag: u64) { + if let Ok(mut arrived) = self.0.lock() { + *arrived = (tag, None); + } + } + + /// The screenshot numbered `tag`, if it is back. + fn take(&self, tag: u64) -> Option> { + let mut arrived = self.0.lock().ok()?; + if arrived.0 == tag { arrived.1.take() } else { None } + } + + /// Keeps `image` if it is the screenshot numbered `tag` waited for. One dropped (a late one, or + /// a poisoned lock) is safe: the snapshot waits out its grace and draws the map offscreen. + fn arrive(&self, tag: u64, image: Arc) { + if let Ok(mut arrived) = self.0.lock() + && arrived.0 == tag + { + arrived.1 = Some(image); + } + } +} + /// A screen snapshot under way. #[derive(Debug, Clone, Copy)] pub struct Shot { @@ -52,12 +81,16 @@ fn settled(app: &RustGisApp) -> bool { /// ran its messages. pub fn drive(app: &mut RustGisApp, ctx: &egui::Context) { let Some(relay) = app.relay.clone() else { - app.relay_shot = None; + if app.relay_shot.take().is_some() { + app.relay_arrived.wait_for(0); + } app.map.hide_overlays = false; return; }; let Some(request) = relay.screen_request() else { - app.relay_shot = None; + if app.relay_shot.take().is_some() { + app.relay_arrived.wait_for(0); + } app.map.hide_overlays = false; return; }; @@ -66,22 +99,13 @@ pub fn drive(app: &mut RustGisApp, ctx: &egui::Context) { let rect = view_rect(app, ctx); let finish = |app: &mut RustGisApp, png: Result, String>, settled: bool| { app.relay_shot = None; + app.relay_arrived.wait_for(0); app.map.hide_overlays = false; relay.finish_screen(&mut app.session, png, settled); }; match shot.asked { Some((was_settled, at, ppp)) => { - let image = ctx.input(|i| { - i.events.iter().find_map(|e| match e { - egui::Event::Screenshot { image, user_data, .. } - if user_data.data.as_ref().and_then(|d| d.downcast_ref::()) == Some(&RelayShotTag(shot.tag)) => - { - Some(image.clone()) - } - _ => None, - }) - }); - if let Some(image) = image { + if let Some(image) = app.relay_arrived.take(shot.tag) { let png = crop_png(&image, at, ppp) .ok_or_else(|| "the window's screenshot didn't hold the map, so it was drawn offscreen".to_string()); return finish(app, png, was_settled); @@ -108,7 +132,12 @@ pub fn drive(app: &mut RustGisApp, ctx: &egui::Context) { // Overlays leave only the frame being captured (this one draws after this), and // come back with the screenshot. app.map.hide_overlays = !request.overlays; - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(RelayShotTag(tag)))); + app.relay_arrived.wait_for(tag); + let (arrived, repaint) = (app.relay_arrived.clone(), ctx.clone()); + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::ScreenshotCallback::new(move |image| { + arrived.arrive(tag, image); + repaint.request_repaint(); + }))); } } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 6552b2ed0..e7543fde6 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -95,27 +95,26 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui) { let r = ui.max_rect(); let full = Rect::from_min_max(pos2(r.min.x - 8.0, r.max.y + 3.0), pos2(r.max.x + 8.0, r.max.y + 4.0)); ui.painter().rect_filled(full, 0.0, t.border); - let scroll = - egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 0.0); - let tab = app.ui.tab.clone(); - match tab.as_str() { - t if t.starts_with("plugin:") => plugin_tab(app, ui, t.trim_start_matches("plugin:")), - "Data" => data_tab(app, ui), - "Style" => style_tab(app, ui), - "Edit" => edit_tab(app, ui), - "Analyze" => analyze_tab(app, ui), - "View" => view_tab(app, ui), - "Publish" => publish_tab(app, ui), - "Plugins" => plugins_tab(app, ui), - "Settings" => settings_tab(app, ui), - "Help" => help_tab(app, ui), - _ => home_tab(app, ui), - } - }); + let tips = [tl!("Scroll the ribbon left"), tl!("Scroll the ribbon right")]; + arrow_scroll_area(ui, &t, "ribbon_groups", egui::ScrollArea::horizontal(), tips, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 0.0); + let tab = app.ui.tab.clone(); + match tab.as_str() { + t if t.starts_with("plugin:") => plugin_tab(app, ui, t.trim_start_matches("plugin:")), + "Data" => data_tab(app, ui), + "Style" => style_tab(app, ui), + "Edit" => edit_tab(app, ui), + "Analyze" => analyze_tab(app, ui), + "View" => view_tab(app, ui), + "Publish" => publish_tab(app, ui), + "Plugins" => plugins_tab(app, ui), + "Settings" => settings_tab(app, ui), + "Help" => help_tab(app, ui), + _ => home_tab(app, ui), + } }); - scroll_buttons(ui, &t, &scroll, [tl!("Scroll the ribbon left"), tl!("Scroll the ribbon right")]); + }); }); if peek_started { let pos = ui.input(|i| i.pointer.interact_pos()); @@ -131,16 +130,17 @@ const SCROLL_BUTTON_W: f32 = 18.0; /// Arrow buttons at the edges of a horizontal scroll area (the ribbon's groups, the tab strip) /// whose content is wider than the window: the scroll bar is hidden (it would cover the group /// labels and tabs) and the wheel alone is undiscoverable. -fn scroll_buttons(ui: &mut Ui, t: &Tokens, scroll: &egui::scroll_area::ScrollAreaOutput, tips: [&str; 2]) { +/// +/// The offset a click asked for, if one did. +fn scroll_buttons(ui: &mut Ui, t: &Tokens, scroll: &egui::scroll_area::ScrollAreaOutput, tips: [&str; 2]) -> Option { let view = scroll.inner_rect; let max = (scroll.content_size.x - view.width()).max(0.0); if max < 1.0 { - return; + return None; } - let mut state = scroll.state; - let offset = state.offset.x; + let offset = scroll.state.clamped_offset().x; let step = (view.width() * 0.6).max(120.0); - let mut moved = false; + let mut moved = None; for (left, show) in [(true, offset > 0.5), (false, offset < max - 0.5)] { if !show { continue; @@ -155,14 +155,32 @@ fn scroll_buttons(ui: &mut Ui, t: &Tokens, scroll: &egui::scroll_area::Scroll let ir = Rect::from_center_size(r.center(), vec2(16.0, 16.0)); icons::paint(ui.painter(), ir, icon, t.text_dim, t.accent); let tip = if left { tips[0] } else { tips[1] }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); if resp.on_hover_text(tip).clicked() { - state.offset.x = if left { (offset - step).max(0.0) } else { (offset + step).min(max) }; - moved = true; + moved = Some(if left { (offset - step).max(0.0) } else { (offset + step).min(max) }); } } - if moved { - state.store(ui.ctx(), scroll.id); + moved +} + +/// A horizontal scroll area (`area`, named `name`) without a scroll bar, with [`scroll_buttons`] +/// at its edges. +fn arrow_scroll_area(ui: &mut Ui, t: &Tokens, name: &str, area: egui::ScrollArea, tips: [&str; 2], add: impl FnOnce(&mut Ui) -> R) { + // egui sets a scroll area's offset only as it is built, so a click moves it on the next frame. + // It is asked for once, on a frame egui keeps: a discarded one doesn't move the area, and asking + // on every frame would pin the area there against the wheel. + let key = ui.make_persistent_id(("arrow_scroll_offset", name)); + let mut area = area.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); + let pending = ui.data(|d| d.get_temp::(key)); + if let Some(x) = pending { + area = area.horizontal_scroll_offset(x); + } + let scroll = area.show(ui, add); + if pending.is_some() && !ui.ctx().will_discard() { + ui.data_mut(|d| d.remove_temp::(key)); + } + if let Some(x) = scroll_buttons(ui, t, &scroll, tips) { + ui.data_mut(|d| d.insert_temp(key, x)); ui.ctx().request_repaint(); } } @@ -209,70 +227,67 @@ fn tab_strip(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let pad = ((room - text) / tabs.len().max(1) as f32).clamp(8.0, 22.0); // The active tab scrolls into view for a few frames (a pass that egui discards drops its // scroll request) after it changes, or the strip's room or tab widths do. - let active_id = egui::Id::new("tab_strip_active"); + let active_id = egui::Id::unique("tab_strip_active"); let key = (app.ui.tab.clone(), room.round() as i32, text.round() as i32); let (shown, scrolls): ((String, i32, i32), u8) = ui.data(|d| d.get_temp(active_id)).unwrap_or_default(); let scrolls = if shown == key { scrolls } else { 0 }; if scrolls < 3 { ui.data_mut(|d| d.insert_temp(active_id, (key, scrolls + 1))); } - let scroll = egui::ScrollArea::horizontal() - .id_salt("tab_strip") - .max_width(room) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - ui.horizontal_centered(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 0.0); - for (tab, caption) in &tabs { - let tab = tab.as_str(); - let active = app.ui.tab == tab; - let font = if active { semibold(12.5) } else { regular(12.5) }; - let shown = caption.as_str(); - let w = text_w(ui, shown, font.clone()) + pad; - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, shown)); - let body = Rect::from_min_max(pos2(r.min.x, r.min.y + 3.0), r.max); - if active { - ui.painter().rect_filled(body, CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, t.ribbon); - ui.painter().rect_stroke( - body, - CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, - Stroke::new(1.0, t.border), - egui::StrokeKind::Inside, - ); - ui.painter().rect_filled( - Rect::from_min_max(pos2(r.min.x + 1.0, r.max.y - 1.5), pos2(r.max.x - 1.0, r.max.y + 1.0)), - 0.0, - t.ribbon, - ); - let bar = t.accent; - ui.painter().rect_filled( - Rect::from_min_max(pos2(r.min.x + 8.0, r.max.y - 3.0), pos2(r.max.x - 8.0, r.max.y - 1.0)), - CornerRadius::same(1), - bar, - ); - } else if resp.hovered() { - ui.painter().rect_filled(body.shrink2(vec2(1.0, 2.0)), CornerRadius::same(5), t.hover); - } - let color = if active { t.text } else { t.text_dim }; - ui.painter().text(pos2(r.center().x, r.center().y + 1.5), Align2::CENTER_CENTER, shown, font, color); - if resp.double_clicked() { - let _ = app.run("ui.ribbon", Value::Null); - } else if resp.clicked() { - if app.ui.ribbon_minimized { - app.ui.ribbon_peek = !(app.ui.ribbon_peek && app.ui.tab == tab); - } - app.ui.tab = tab.to_string(); - } - // A tab that became active (clicked, or opened by a command) scrolls - // into view, clear of the arrow buttons. - if active && scrolls < 3 { - ui.scroll_to_rect_animation(r.expand2(vec2(SCROLL_BUTTON_W, 0.0)), None, egui::style::ScrollAnimation::none()); + let area = egui::ScrollArea::horizontal().id_salt("tab_strip").max_width(room); + let tips = [tl!("Scroll the tabs left"), tl!("Scroll the tabs right")]; + arrow_scroll_area(ui, t, "tab_strip", area, tips, |ui| { + ui.horizontal_centered(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 0.0); + for (tab, caption) in &tabs { + let tab = tab.as_str(); + let active = app.ui.tab == tab; + let font = if active { semibold(12.5) } else { regular(12.5) }; + let shown = caption.as_str(); + let w = text_w(ui, shown, font.clone()) + pad; + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown)); + let body = Rect::from_min_max(pos2(r.min.x, r.min.y + 3.0), r.max); + if active { + ui.painter().rect_filled(body, CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, t.ribbon); + ui.painter().rect_stroke( + body, + CornerRadius { nw: 6, ne: 6, sw: 0, se: 0 }, + Stroke::new(1.0, t.border), + egui::StrokeKind::Inside, + ); + ui.painter().rect_filled( + Rect::from_min_max(pos2(r.min.x + 1.0, r.max.y - 1.5), pos2(r.max.x - 1.0, r.max.y + 1.0)), + 0.0, + t.ribbon, + ); + let bar = t.accent; + ui.painter().rect_filled( + Rect::from_min_max(pos2(r.min.x + 8.0, r.max.y - 3.0), pos2(r.max.x - 8.0, r.max.y - 1.0)), + CornerRadius::same(1), + bar, + ); + } else if resp.hovered() { + ui.painter().rect_filled(body.shrink2(vec2(1.0, 2.0)), CornerRadius::same(5), t.hover); + } + let color = if active { t.text } else { t.text_dim }; + ui.painter().text(pos2(r.center().x, r.center().y + 1.5), Align2::CENTER_CENTER, shown, font, color); + if resp.double_clicked() { + let _ = app.run("ui.ribbon", Value::Null); + } else if resp.clicked() { + if app.ui.ribbon_minimized { + app.ui.ribbon_peek = !(app.ui.ribbon_peek && app.ui.tab == tab); } + app.ui.tab = tab.to_string(); } - }); + // A tab that became active (clicked, or opened by a command) scrolls + // into view, clear of the arrow buttons. + if active && scrolls < 3 { + ui.scroll_to_rect_animation(r.expand2(vec2(SCROLL_BUTTON_W, 0.0)), None, egui::style::ScrollAnimation::none()); + } + } }); - scroll_buttons(ui, t, &scroll, [tl!("Scroll the tabs left"), tl!("Scroll the tabs right")]); + }); app.ui.tab_strip_rect = Some(ui.max_rect()); // Right side: collapse/expand the ribbon, then the quick tool indicator. ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -347,7 +362,7 @@ fn home_tab(app: &mut RustGisApp, ui: &mut Ui) { |ui| { let tip = crate::i18n::fmt("Remove {name}", &[("name", &n)]); let r = crate::widgets::icon_button(ui, "delete", &tip); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &tip)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip)); r.clicked() }, ); @@ -603,7 +618,7 @@ pub(crate) fn basemap_gallery(ui: &mut Ui, app: &mut RustGisApp) { let (r, resp) = ui.allocate_exact_size(vec2(92.0, 74.0), Sense::click()); // Web basemaps cover the built-in one. let sel = app.session.map().basemap == b && app.session.map().tiles.iter().all(|t| t.overlay); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, sel, tl!(b.label()))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, tl!(b.label()))); let thumb = Rect::from_min_size(r.min + vec2(4.0, 4.0), vec2(84.0, 48.0)); ui.painter().rect_filled(thumb, CornerRadius::same(4), crate::theme::c32(water)); // A little continent. @@ -714,7 +729,7 @@ fn web_basemaps(ui: &mut Ui, app: &mut RustGisApp, t: &Tokens) { ui.add_space(2.0); // Stacked: a web basemap goes over the others (to blend with them in the Layers pane) // rather than replacing them. - let stack_id = egui::Id::new("web-basemaps-stack"); + let stack_id = egui::Id::unique("web-basemaps-stack"); let mut stack = ui.data_mut(|d| *d.get_persisted_mut_or_default::(stack_id)); let resp = ui.checkbox(&mut stack, tl!("Stack on the current basemaps")); let tip = @@ -3442,7 +3457,7 @@ fn effects_groups(app: &mut RustGisApp, ui: &mut Ui, layer: &rustgis_engine::map ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Factor")).size(11.5)); // Kept across a drag until it is let go (#607). - let drag = Dragged::lend(ui, egui::Id::new(("extrusion_factor", layer.id)), &mut k); + let drag = Dragged::lend(ui, egui::Id::unique(("extrusion_factor", layer.id)), &mut k); let r = ui.add(egui::DragValue::new(&mut k).range(0.0..=1e6).speed(xx.factor.max(0.0001) * 0.02)); if drag.settled(ui, &r, &mut k) && k != xx.factor { let t = if xx.field.is_some() { "field" } else { "height" }; @@ -3894,7 +3909,7 @@ fn raster_style_groups(app: &mut RustGisApp, ui: &mut Ui) { let mut a = style.rotation; ui.horizontal(|ui| { // Kept across a drag until it is let go (#607). - let drag = Dragged::lend(ui, egui::Id::new(("raster_rotation", layer.id)), &mut a); + let drag = Dragged::lend(ui, egui::Id::unique(("raster_rotation", layer.id)), &mut a); let r = ui.add(egui::DragValue::new(&mut a).range(0.0..=359.0).speed(1.0).suffix("°")); if drag.settled(ui, &r, &mut a) && a != style.rotation { let _ = app.run("raster.rotation", json!({"angle": a})); @@ -4100,7 +4115,7 @@ struct LabelSizeDrag { /// Retains the starting size and layer while applying each frame's preview. fn label_size_drag(app: &mut RustGisApp, ui: &Ui, layer: &rustgis_engine::map::Layer, size: f64) { - let key = egui::Id::new("ribbon_label_size_drag"); + let key = egui::Id::unique("ribbon_label_size_drag"); let pending = ui.data_mut(|d| d.remove_temp::(key)); let before = match pending { Some(drag) if drag.layer == layer.id => drag.before, @@ -4122,7 +4137,7 @@ pub(crate) fn finish_label_size_drag(app: &mut RustGisApp, ui: &Ui) { if ui.input(|i| i.pointer.primary_down() && i.focused) { return; } - if let Some(drag) = ui.data_mut(|d| d.remove_temp::(egui::Id::new("ribbon_label_size_drag"))) { + if let Some(drag) = ui.data_mut(|d| d.remove_temp::(egui::Id::unique("ribbon_label_size_drag"))) { apply_label_size_drag(app, drag); } } @@ -4453,7 +4468,7 @@ mod tests { app.session.run("layer.select", &json!({"layer": "Cities"})).unwrap(); app.ws.recording = Some(Vec::new()); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1600.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1600.0, 700.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -4551,14 +4566,18 @@ mod tests { let mut app = RustGisApp::with_sample(); app.session.run("layer.select", &json!({"layer": "Cities"})).unwrap(); app.ui.tab = "Style".into(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(vec2(1200.0, 800.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.run(); for _ in 0..2 { h.get_by_label("Scroll the ribbon right").click(); diff --git a/crates/ui-egui/src/scriptcolor.rs b/crates/ui-egui/src/scriptcolor.rs index a1e898f10..22e89d83e 100644 --- a/crates/ui-egui/src/scriptcolor.rs +++ b/crates/ui-egui/src/scriptcolor.rs @@ -255,7 +255,7 @@ pub type Cache = egui::cache::FrameCache; /// commands don't change while the app runs. pub fn unknown_ids_cached(ui: &egui::Ui, session: &rustgis_engine::Session, script: &str) -> std::sync::Arc> { type Kept = (u64, u64, std::sync::Arc>); - let key = egui::Id::new("script_unknown_ids"); + let key = egui::Id::unique("script_unknown_ids"); let (text, plugins) = (egui::util::hash(script), rustgis_engine::plugins::revision()); if let Some((t, p, ids)) = ui.data(|d| d.get_temp::(key)) && (t, p) == (text, plugins) @@ -429,7 +429,7 @@ mod tests { let mut app = crate::RustGisApp::with_sample(); let script = "# zoom\nnope.cmd {\"a\": \"é\"}\nlabels.style {\"size\": }\n"; app.workflow.script = script.into(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 400.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 400.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -480,7 +480,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = crate::RustGisApp::with_sample(); app.workflow.script = "map.zoomFull\n".repeat(300); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -496,7 +496,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = crate::RustGisApp::with_sample(); app.workflow.script = "map.zoomFull".into(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -531,7 +531,7 @@ mod tests { app.workflow.script = "map.zoomFull\n".repeat(300); app.workflow.script_out = (0..12).map(|i| (format!("line {i}: {}", "a long error ".repeat(30)), true)).collect(); app.workflow.script_last = Some(crate::analysispane::ScriptRun { n: 1, secs: 0.5, at: 1_800_000_000 }); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -565,7 +565,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = crate::RustGisApp::with_sample(); app.workflow.script = "map.zoomFull\n".repeat(300); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 150.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 150.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -595,7 +595,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = crate::RustGisApp::with_sample(); app.workflow.script = "map.zoomFull\n".repeat(30); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 82156b793..cac082fc5 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -658,7 +658,7 @@ fn help_at(session: &rustgis_engine::Session, script: &str, at: usize, h: &Hint) /// commands don't while the app runs; their count tells another session's registry apart). fn script_ids_cached(ui: &Ui, session: &rustgis_engine::Session) -> std::sync::Arc> { type Kept = ((u64, usize), std::sync::Arc>); - let key = Id::new("script_complete_ids"); + let key = Id::unique("script_complete_ids"); let now = (rustgis_engine::plugins::revision(), session.registry.commands.len()); if let Some((was, ids)) = ui.data(|d| d.get_temp::(key)) && was == now @@ -1410,10 +1410,15 @@ mod tests { /// The pane with an empty script, its box focused. fn pane() -> egui_kittest::Harness<'static, crate::RustGisApp> { use egui_kittest::kittest::Queryable; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( - |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), - crate::RustGisApp::with_sample(), - ); + // Without the interface fonts (`theme::install_fonts`) egui's faces have no "→". + let mut h = egui_kittest::Harness::builder() + .allow_missing_glyphs() + .with_accessibility_check(false) + .with_size(egui::vec2(500.0, 700.0)) + .build_ui_state( + |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), + crate::RustGisApp::with_sample(), + ); h.run(); h.get_by_role(egui::accesskit::Role::MultilineTextInput).focus(); h.run(); @@ -1483,7 +1488,7 @@ mod tests { let out = &h.state().workflow.script_out; assert!(out.iter().any(|(line, err)| !err && line.starts_with("map.zoomFull")), "{out:?}"); assert_eq!(h.state().workflow.script, "map.zoomFull", "no line break"); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("script_text")))); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("script_text")))); h.get_by_label_contains("Run #1 ("); h.get_by_label_contains(" UTC"); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter); @@ -1533,7 +1538,7 @@ mod tests { fn ctrl_enter_elsewhere_runs_nothing() { let mut h = pane(); type_in(&mut h, "map.zoomFull"); - h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new("script_text"))); + h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique("script_text"))); h.run(); h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Enter); h.run(); @@ -1573,7 +1578,7 @@ mod tests { type_in(&mut h, "labels.sty"); h.key_press(egui::Key::Enter); h.run(); - let state = egui::widgets::text_edit::TextEditState::load(&h.ctx, egui::Id::new("script_text")).unwrap(); + let state = egui::widgets::text_edit::TextEditState::load(&h.ctx, egui::Id::unique("script_text")).unwrap(); let now = (state.cursor.char_range().unwrap(), h.state().workflow.script.clone()); let mut undoer = state.undoer(); let back = undoer.undo(&now).map(|(_, text)| text.clone()); @@ -1668,6 +1673,7 @@ mod tests { delta: egui::vec2(0.0, 100_000.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, }); // egui eases a wheel's scroll over frames, asking for a repaint until it's done. h.run_steps(60); @@ -1818,7 +1824,7 @@ mod tests { fn a_narrow_window_puts_the_help_under_the_list() { use egui_kittest::kittest::Queryable; let width = 380.0; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(width, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), crate::RustGisApp::with_sample(), ); @@ -1840,7 +1846,7 @@ mod tests { fn a_short_window_scrolls_the_list() { use egui_kittest::kittest::Queryable; let (width, height) = (380.0, 300.0); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, height)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(width, height)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), crate::RustGisApp::with_sample(), ); @@ -1872,7 +1878,7 @@ mod tests { } let items = super::offer("labels.", 7, &super::script_ids(&h.state().session)).unwrap().items; assert!(items.len() > 3, "a longer list: {items:?}"); - let list = h.ctx.data(|d| d.get_temp::(egui::Id::new("script_text").with("complete"))).unwrap(); + let list = h.ctx.data(|d| d.get_temp::(egui::Id::unique("script_text").with("complete"))).unwrap(); let (shown, _) = list.drawn.unwrap(); let last = h.get_by_role_and_label(egui::accesskit::Role::Button, items.last().unwrap()).rect(); assert!(last.max.y <= shown.max.y, "every item in view, no scroll: {last:?} in {shown:?}"); @@ -1994,7 +2000,7 @@ mod tests { h.run(); assert_eq!(h.state().workflow.script, "labels.style "); type_in(&mut h, "{"); - let state = egui::widgets::text_edit::TextEditState::load(&h.ctx, egui::Id::new("script_text")).unwrap(); + let state = egui::widgets::text_edit::TextEditState::load(&h.ctx, egui::Id::unique("script_text")).unwrap(); let now = (state.cursor.char_range().unwrap(), h.state().workflow.script.clone()); let back = state.undoer().undo(&now).map(|(_, text)| text.clone()); assert!(back.is_some_and(|t| !t.contains('{')), "no lone brace left by undo"); diff --git a/crates/ui-egui/src/searchbox.rs b/crates/ui-egui/src/searchbox.rs index 084b2b5f1..184da673f 100644 --- a/crates/ui-egui/src/searchbox.rs +++ b/crates/ui-egui/src/searchbox.rs @@ -121,7 +121,7 @@ fn boxed(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let width = ui.available_width(); let (frame_r, _) = ui.allocate_exact_size(vec2(width, 28.0), Sense::hover()); let edit_r = Rect::from_min_max(pos2(frame_r.min.x + 26.0, frame_r.min.y + 2.0), pos2(frame_r.max.x - 24.0, frame_r.max.y - 2.0)); - let id = egui::Id::new("map_search_box"); + let id = egui::Id::unique("map_search_box"); let focused = ui.memory(|m| m.has_focus(id)); // The arrows are the list's, taken before the text box sees them. let (down, up) = if focused { @@ -155,10 +155,10 @@ fn boxed(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { .hint_text(tl!("Search layers, places, lat/lon…")) .font(regular(12.5)) .desired_width(edit_r.width()) - .vertical_align(egui::Align::Center), + .align(egui::Align2::LEFT_CENTER), ); // Named for screen readers (and scripted clicks): the hint text isn't the box's name. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Search the map"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Search the map"))); let resp = resp.on_hover_text(tl!( "A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place" )); @@ -180,7 +180,7 @@ fn boxed(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if !st.query.is_empty() || st.marker.is_some() { let x = Rect::from_center_size(pos2(frame_r.max.x - 13.0, frame_r.center().y), vec2(18.0, 18.0)); let xr = ui.interact(x, id.with("clear"), Sense::click()); - xr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Clear search"))); + xr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear search"))); if xr.hovered() { p.rect_filled(x, CornerRadius::same(4), t.hover); } @@ -244,7 +244,7 @@ fn list_area(app: &mut RustGisApp, ui: &Ui, frame_r: Rect, list: &[Row], t: &Tok } let width = frame_r.width().max(260.0); let mut picked = None; - let area = egui::Area::new(egui::Id::new("map_search_list")) + let area = egui::Area::new(egui::Id::unique("map_search_list")) .order(egui::Order::Foreground) .pivot(Align2::LEFT_BOTTOM) .fixed_pos(pos2(frame_r.min.x, frame_r.min.y - 4.0)) @@ -316,7 +316,7 @@ fn list_area(app: &mut RustGisApp, ui: &Ui, frame_r: Rect, list: &[Row], t: &Tok app.ui.search.scroll = false; // Clicking the list mustn't count as clicking away from the box. if area.response.contains_pointer() && ui.input(|i| i.pointer.any_pressed()) { - ui.memory_mut(|m| m.request_focus(egui::Id::new("map_search_box"))); + ui.memory_mut(|m| m.request_focus(egui::Id::unique("map_search_box"))); } list.get(picked?).copied() } diff --git a/crates/ui-egui/src/sqlbox.rs b/crates/ui-egui/src/sqlbox.rs index c028b4ef3..d96a31402 100644 --- a/crates/ui-egui/src/sqlbox.rs +++ b/crates/ui-egui/src/sqlbox.rs @@ -51,7 +51,7 @@ pub fn show( ds: &Dataset, count: impl FnOnce(&str) -> Result<(usize, usize), String>, ) -> Option { - let key = egui::Id::new(("sqlbox_draft", slot)); + let key = egui::Id::unique(("sqlbox_draft", slot)); let t = &Tokens::get(ui.ctx()); let stored = stored.map(str::trim).filter(|w| !w.is_empty()); let fresh = || Draft { diff --git a/crates/ui-egui/src/sqlpane.rs b/crates/ui-egui/src/sqlpane.rs index 9b1a60da2..e9a523d50 100644 --- a/crates/ui-egui/src/sqlpane.rs +++ b/crates/ui-egui/src/sqlpane.rs @@ -329,7 +329,7 @@ fn body(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.end_row(); } }); - let id = egui::Id::new("sql_editor"); + let id = egui::Id::unique("sql_editor"); let out = egui::TextEdit::multiline(&mut app.ui.sql.text) .id(id) .code_editor() diff --git a/crates/ui-egui/src/stacui.rs b/crates/ui-egui/src/stacui.rs index fbdf8c537..19c64af7c 100644 --- a/crates/ui-egui/src/stacui.rs +++ b/crates/ui-egui/src/stacui.rs @@ -1205,11 +1205,14 @@ mod tests { app.ui.stac.api = "https://one.example/stac".into(); app.ui.stac.waiting = Some((Asked::Collections("https://one.example/stac".into()), Slot::default())); let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) .with_size(egui::vec2(900.0, 700.0)) .build_ui_state(|ui, app: &mut RustGisApp| dialog(app, ui), app); h.step(); h.step(); h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Other")).unwrap().click(); + // The list opens on the frame after the click. + h.step(); h.step(); h.get_by_label("Local catalog").click(); h.step(); diff --git a/crates/ui-egui/src/textform.rs b/crates/ui-egui/src/textform.rs index 36f7e7f27..b6ebf22b0 100644 --- a/crates/ui-egui/src/textform.rs +++ b/crates/ui-egui/src/textform.rs @@ -59,7 +59,7 @@ pub const LABEL_W: f32 = 96.0; /// The form, its fields told apart by `id` (an element, a dialog). `tokens` are the dynamic text /// lists the text expands where it is shown, each a section of the Dynamic Text menu. pub fn show(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, tokens: &[&[(&str, &str)]], f: Fields<'_>) -> Settled { - let id = egui::Id::new(id); + let id = egui::Id::unique(id); let mut out = Settled::default(); let hint = tl!("Shift+Enter starts a new line."); let mut edit = @@ -113,7 +113,7 @@ pub fn show(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, tokens: &[& ui.label(tl!("Bold")); let r = ui.checkbox(b, ""); let on = *b; - r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, tl!("Bold"))); + r.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, on, tl!("Bold"))); out.bold = r.changed(); ui.end_row(); } @@ -122,7 +122,7 @@ pub fn show(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, tokens: &[& // Named for assistive technology, as its label sits in the other column. let r = ui.checkbox(it, ""); let on = *it; - r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, tl!("Italic"))); + r.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, on, tl!("Italic"))); out.italic = r.changed(); ui.end_row(); } @@ -164,7 +164,7 @@ pub fn show(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, tokens: &[& } }); // Named for assistive technology, as its label sits in the other column. - list.response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, tl!("Font"))); + list.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, tl!("Font"))); ui.end_row(); // A serif asked for where none is installed is drawn in the sans: said here, so the // page never differs from what was asked unsaid. @@ -199,7 +199,7 @@ mod tests { } fn harness(form: Form) -> egui_kittest::Harness<'static, Form> { - egui_kittest::Harness::builder().build_ui_state( + egui_kittest::Harness::builder().with_accessibility_check(false).build_ui_state( |ui, f: &mut Form| { // The numbers are lent saved and kept only once settled, as the pane's callers do. let (mut size, mut spacing) = (f.size, f.letter_spacing); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ac9a25f37..c16059b9e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -129,7 +129,7 @@ pub fn big( let w = (lw + 14.0).max(50.0); let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H - 2.0), Sense::click()); let on = enabled(app, id) || menu; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, label.replace('\n', " "))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, label.replace('\n', " "))); bg(ui, r, &resp, checked, &t); let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + 21.0), vec2(32.0, 32.0)); let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; @@ -163,7 +163,7 @@ pub fn small(ui: &mut Ui, app: &mut RustGisApp, icon: &str, label: &str, desc: & let tw = if label.is_empty() { 0.0 } else { text_w(ui, label, regular(11.5)) + 8.0 }; let (r, resp) = ui.allocate_exact_size(vec2(26.0 + tw, 22.0), Sense::click()); let on = enabled(app, id); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, if label.is_empty() { desc } else { label })); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, if label.is_empty() { desc } else { label })); bg(ui, r, &resp, checked, &t); let ic = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(18.0, 18.0)); let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; @@ -236,7 +236,7 @@ fn small_menu_with( let t = Tokens::get(ui.ctx()); let tw = text_w(ui, label, regular(11.5)) + 20.0; let (r, resp) = ui.allocate_exact_size(vec2(26.0 + tw, 22.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), if label.is_empty() { desc } else { label })); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), if label.is_empty() { desc } else { label })); bg(ui, r, &resp, false, &t); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(18.0, 18.0)), icon, t.icon, t.accent); ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.5), t.text); @@ -291,7 +291,7 @@ pub fn menu_row(ui: &mut Ui, icon: &str, label: &str, shortcut: &str, on: bool) pub fn submenu_row(ui: &mut Ui, icon: &str, label: &str, add: impl FnOnce(&mut Ui)) { let button = egui::Button::new(menu_text(ui, icon, label)) .min_size(vec2(0.0, 24.0)) - .right_text(egui::containers::menu::SubMenuButton::RIGHT_ARROW); + .right_text(egui::containers::menu::SubMenuButton::arrow_atom(None)); let (resp, _) = egui::containers::menu::SubMenuButton::from_button(button).ui(ui, add); menu_icon(ui, &resp, icon); } @@ -391,7 +391,7 @@ pub fn primary_button(ui: &mut Ui, label: &str, enabled: bool, min_size: egui::V let size = vec2(min_size.x.max(text_w(ui, label, font.clone()) + 28.0), min_size.y.max(28.0)); let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() }); // Screen readers (and UI tests) find it by its label. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); let fill = if !enabled { t.green.gamma_multiply(0.45) } else if resp.is_pointer_button_down_on() { @@ -412,7 +412,7 @@ pub fn dialog_button(ui: &mut Ui, label: &str, fill: Option, min_ let font = if fill.is_some() { semibold(12.5) } else { regular(12.5) }; let size = vec2(min_size.x.max(text_w(ui, label, font.clone()) + 24.0), min_size.y); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let v = *ui.style().interact(&resp); let (bg, text) = match fill { Some(c) if resp.is_pointer_button_down_on() => (c.gamma_multiply(0.8), egui::Color32::WHITE), @@ -480,7 +480,7 @@ pub fn search_field(ui: &mut Ui, text: &mut String, hint: &str) -> Response { } if clear { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Clear"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear"))); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } @@ -510,6 +510,7 @@ mod tests { /// Opens a colour button's picker and says whether it offers additive blending. fn offers_additive(button: fn(&mut egui::Ui, &mut egui::Color32)) -> bool { let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) .build_ui_state(|ui, c: &mut egui::Color32| button(ui, c), egui::Color32::from_rgb(224, 105, 59)); h.get_by_role(egui::accesskit::Role::ColorWell).click(); h.run(); @@ -539,17 +540,17 @@ mod tests { whole: u64, settled: Vec<&'static str>, } - let mut h = egui_kittest::Harness::builder().build_ui_state( + let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).build_ui_state( |ui, s: &mut Saved| { let mut real = s.real; - let drag = super::Dragged::lend(ui, egui::Id::new("real"), &mut real); + let drag = super::Dragged::lend(ui, egui::Id::unique("real"), &mut real); let r = ui.add(egui::DragValue::new(&mut real).speed(0.1)); if drag.settled(ui, &r, &mut real) && real != s.real { s.real = real; s.settled.push("real"); } let mut whole = s.whole; - let drag = super::Dragged::lend(ui, egui::Id::new("whole"), &mut whole); + let drag = super::Dragged::lend(ui, egui::Id::unique("whole"), &mut whole); let r = ui.add(egui::DragValue::new(&mut whole).speed(0.1)); if drag.settled(ui, &r, &mut whole) && whole != s.whole { s.whole = whole; diff --git a/crates/ui-egui/src/workspacepanes.rs b/crates/ui-egui/src/workspacepanes.rs index 4b784a1bb..bb0dcb889 100644 --- a/crates/ui-egui/src/workspacepanes.rs +++ b/crates/ui-egui/src/workspacepanes.rs @@ -1052,13 +1052,18 @@ mod tests { // A point picked at 1°E 30°N. app.workflow.pick = Some(crate::analysispane::Pick { purpose: "ts_point", needed: 1, points: vec![rustgis_engine::geom::Coord::new(1.0, 30.0)] }); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(420.0, 600.0)).build_ui_state( - |ui, app: &mut RustGisApp| { - let t = crate::theme::Tokens::get(ui.ctx()); - time_series(app, ui, &t); - }, - app, - ); + // Without the interface fonts (`theme::install_fonts`) egui's faces have no "●". + let mut h = egui_kittest::Harness::builder() + .allow_missing_glyphs() + .with_accessibility_check(false) + .with_size(egui::vec2(420.0, 600.0)) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let t = crate::theme::Tokens::get(ui.ctx()); + time_series(app, ui, &t); + }, + app, + ); h.run_steps(3); assert_eq!(h.state().ws.series_layer, "air"); assert_eq!(h.state().ws.series.len(), 1); diff --git a/crates/ui-egui/tests/define_projection.rs b/crates/ui-egui/tests/define_projection.rs index 7391882d8..a4cb02dae 100644 --- a/crates/ui-egui/tests/define_projection.rs +++ b/crates/ui-egui/tests/define_projection.rs @@ -17,14 +17,18 @@ fn harness() -> egui_kittest::Harness<'static, RustGisApp> { } s.run("layer.select", &json!({"layer": "Parcels"})).unwrap(); let app = RustGisApp::new(s); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 1000.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(egui::vec2(1400.0, 1000.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.state_mut().ui.command_search = Some("layer.defineProjection".into()); h.run_steps(3); h.key_press(egui::Key::Enter); diff --git a/crates/ui-egui/tests/gpu_fastpath.rs b/crates/ui-egui/tests/gpu_fastpath.rs index cd1bddc32..6130625fb 100644 --- a/crates/ui-egui/tests/gpu_fastpath.rs +++ b/crates/ui-egui/tests/gpu_fastpath.rs @@ -70,6 +70,7 @@ fn harness() -> Option> { app.run("ui.map.gpu", json!({"on": true})).expect("gpu on"); Some( egui_kittest::Harness::builder() + .with_accessibility_check(false) .with_size(egui::vec2(900.0, 550.0)) .with_pixels_per_point(1.0) .with_max_steps(10_000) @@ -168,6 +169,7 @@ fn zooming_a_planet_with_raster_tiles_reuses_the_gpu_frame() { delta: egui::vec2(0.0, 2.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Wheel, }); h.step(); } @@ -303,6 +305,7 @@ fn raster_tiles_move_with_the_camera_on_the_gpu() { delta: egui::vec2(0.0, 0.5), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Wheel, }); h.step(); } diff --git a/crates/ui-egui/tests/measurement_units.rs b/crates/ui-egui/tests/measurement_units.rs index f86d21efc..20c91c638 100644 --- a/crates/ui-egui/tests/measurement_units.rs +++ b/crates/ui-egui/tests/measurement_units.rs @@ -13,14 +13,18 @@ fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, R app.run("layer.template", json!({"template": "lines", "name": "Paths"})).unwrap(); app.run("edit.addFeature", json!({"geometry": {"type": "LineString", "coordinates": [[0, 0], [0, 0.001]]}})).unwrap(); app.run("select.rows", json!({"layer": "Paths", "rows": [0]})).unwrap(); - egui_kittest::Harness::builder().with_size(egui::vec2(1800.0, 1000.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ) + egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(egui::vec2(1800.0, 1000.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ) } fn first_x_metres(h: &egui_kittest::Harness<'_, RustGisApp>) -> f64 { @@ -33,6 +37,8 @@ fn first_x_metres(h: &egui_kittest::Harness<'_, RustGisApp>) -> f64 { fn choose_editing_units(h: &mut egui_kittest::Harness<'_, RustGisApp>, unit: &str) { h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Distance units").click(); + // The list opens on the frame after the click. + h.step(); h.step(); h.get_by_role_and_label(egui::accesskit::Role::Button, unit).click(); h.step(); @@ -123,6 +129,8 @@ fn editing_units_are_remembered_and_applied_explicitly() { } h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Distance units").click(); h.step(); + // What it opened shows on the next frame. + h.step(); let metres = h.get_by_role_and_label(egui::accesskit::Role::Button, "Meters"); assert_eq!(metres.accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "default metres option is not selected"); metres.click(); @@ -241,6 +249,8 @@ fn distance_controls_follow_display_units_and_keep_metre_command_values() { h.step(); } h.get_by_role_and_label(egui::accesskit::Role::Button, "Closest").click(); + // The list opens on the frame after the click. + h.step(); h.step(); h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "6.2 mi").click(); h.step(); @@ -252,6 +262,8 @@ fn distance_controls_follow_display_units_and_keep_metre_command_values() { } h.get_by_role_and_label(egui::accesskit::Role::Button, "View Clipping").click(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "62.1 mi").click(); h.step(); assert_eq!(h.state().session.map().view.clip_distance, Some(100000.0)); @@ -260,6 +272,8 @@ fn distance_controls_follow_display_units_and_keep_metre_command_values() { h.step(); h.get_by_role_and_label(egui::accesskit::Role::Button, "View Clipping").click(); h.step(); + // What it opened shows on the next frame. + h.step(); assert!(h.query_by_role_and_label(egui::accesskit::Role::RadioButton, "100 km").is_some()); assert_eq!(h.state().session.map().view.clip_distance, Some(100000.0)); } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 645315738..cd4356c9c 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -6,7 +6,7 @@ use serde_json::json; /// Builds a headless World Atlas UI for interaction tests. fn harness() -> egui_kittest::Harness<'static, RustGisApp> { - egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( + egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -101,6 +101,7 @@ fn command_search_basemap_gallery_selects_and_can_open_tile_dialog() { delta: egui::vec2(0.0, -800.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }); for _ in 0..6 { h.step(); @@ -254,14 +255,18 @@ fn mcp_relay_early_start_wakes_queued_request_and_updates_ui() { writeln!(client, "{}", json!({"jsonrpc":"2.0","id":1,"method":"ping"})).expect("queue ping"); client.flush().expect("flush request"); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(egui::vec2(1400.0, 860.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); let mut input = BufReader::new(client.try_clone().expect("clone stream")); let deadline = Instant::now() + Duration::from_secs(3); let mut reply = String::new(); @@ -428,6 +433,8 @@ fn nested_ogc_children_sort_on_import_rename_refresh_and_reopen() { #[test] fn every_tab_pane_and_dialog_renders() { let mut h = harness(); + // Every language is shown: no face in the monospace chain has some of their letters (ə). + h.ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Tofu); for _ in 0..4 { h.step(); } @@ -677,6 +684,8 @@ fn browse_lists_thousands_of_plugins_quickly() { h.state_mut().plugins.set_registry(Some(Ok(entries))); h.state_mut().run("ui.plugins", json!({"open": true, "tab": "browse"})).unwrap(); h.step(); + // What it opened shows on the next frame. + h.step(); let _ = h.get_by_label("5000 plugins"); // Only the visible rows are laid out, so a frame stays cheap with thousands of plugins // (about 1 ms in a release build; it was over 100 ms when every card was drawn). The bound @@ -725,6 +734,8 @@ fn browse_lists_thousands_of_plugins_quickly() { #[test] fn language_switch_waits_for_its_font() { let mut h = harness(); + // A machine without a CJK face draws Chinese as boxes, whenever it switches. + h.ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Tofu); h.state_mut().run("ui.language", json!({"code": "en"})).unwrap(); for _ in 0..4 { h.step(); @@ -1261,14 +1272,18 @@ fn delimited_text_options_go_with_the_picked_file() { fn delimited_text_dialog_lists_the_picked_files_columns() { let services = rustgis_ui_egui::Services { open_async: Some(Box::new(|_: &str| {})), ..Default::default() }; let app = RustGisApp::with_sample().with_services(services); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(egui::vec2(1400.0, 860.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.state_mut().run("ui.addDelimited", json!(null)).unwrap(); let picked = format!("{}parks.csv", rustgis_ui_egui::DELIMITED_PICK); h.state_mut().open_picked(&picked, b"name;east;north\nCentral Park;992000;220000\n").unwrap(); @@ -1655,6 +1670,8 @@ fn add_service_keeps_and_picks_connections() { h.step(); h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Pick one…")).unwrap().click(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("Other user").click(); h.step(); assert_eq!(h.state().dialogs.connection.as_deref(), Some(other_id.as_str())); @@ -1683,6 +1700,8 @@ fn add_database_picks_and_keeps_connections() { h.step(); h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Pick one…")).unwrap().click(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("Picked DB").click(); h.step(); assert_eq!(h.state().dialogs.a, address); @@ -1854,6 +1873,8 @@ fn stac_dialog_keeps_and_picks_connections() { h.step(); h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("Other")).unwrap().click(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("Picked STAC").click(); h.step(); assert_eq!(h.state().ui.stac.api, api); @@ -2012,14 +2033,19 @@ fn connections_tree_browses_and_adds() { } }); // Frames 0.05 s apart, so two clicks fall within egui's double-click delay. - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).with_step_dt(0.05).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - RustGisApp::with_sample(), - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(egui::vec2(1400.0, 860.0)) + .with_max_steps(64) + .with_step_dt(0.05) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + RustGisApp::with_sample(), + ); h.state_mut().run("connection.new", json!({"kind":"wms","address":format!("http://{addr}/wms"),"name":"Local"})).unwrap(); h.state_mut().run("ui.pane.sources", json!({})).unwrap(); h.step(); @@ -2070,6 +2096,8 @@ fn connections_tree_browses_and_adds() { // Recent now lists the added layer under "Local" too, above the connection's own row. h.query_all_by_label("Local").last().unwrap().click_secondary(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("Refresh").click(); h.step(); h.step(); @@ -2087,14 +2115,18 @@ fn web_save_asks_for_the_file_name() { ..Default::default() }; let app = RustGisApp::with_sample().with_services(services); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(egui::vec2(1400.0, 860.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); let names = |got: &std::sync::Mutex)>>| got.lock().unwrap().iter().map(|(n, _)| n.clone()).collect::>(); // A project never saved: Save asks first, with the project's name. h.state_mut().run("layer.color", json!({"layer": "Countries", "color": "#336699"})).unwrap(); @@ -2265,14 +2297,18 @@ fn letting_agents_control_rustgis_starts_the_relay_and_shows_how_to_connect() { app.run("ui.pane.assistant", json!({})).expect("opens the assistant"); // The connection instructions expand below the checkbox: keep the settings above the // bottom composer so both pointer clicks reach the checkbox. - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 2000.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(egui::vec2(1400.0, 2000.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); // The first frame loads the assistant's settings and closes the form when they're complete (a // key in the environment): open it after that. h.step(); @@ -2325,14 +2361,18 @@ fn agents_on_this_computer_switches_tick_and_stick() { } })); app.run("ui.pane.assistant", json!({})).expect("opens the assistant"); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 2000.0)).with_max_steps(64).build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder() + .with_accessibility_check(false) + .with_size(egui::vec2(1400.0, 2000.0)) + .with_max_steps(64) + .build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); // The first frame loads the assistant's settings and closes the form when they're complete (a // key in the environment): open it after that. h.step(); @@ -2506,6 +2546,8 @@ fn ogc_group_context_menus_refresh_all_or_one_child() { let before = server.requests().len(); h.get_by_label("Places").click_secondary(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("Refresh").click(); wait_for_ogc(&mut h, |app| app.ui.service_import.is_none()); assert!(serde_json::to_string(&h.state().session.map().layer(point).unwrap().dataset).unwrap().contains("New Place")); @@ -2516,6 +2558,8 @@ fn ogc_group_context_menus_refresh_all_or_one_child() { let before = server.requests().len(); h.get_by_label("My OGC Layer").click_secondary(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("Refresh").click(); wait_for_ogc(&mut h, |app| app.ui.service_import.is_none()); assert_eq!(server.requests()[before..].iter().map(|r| r.path.as_str()).collect::>(), [ROOT, CATALOG, ITEMS, LINE_ITEMS]); @@ -2686,6 +2730,8 @@ fn a_run_opens_again_in_its_tool_from_the_history() { assert!(h.query_all_by_label_contains(" UTC").next().is_some(), "when it ran"); h.get_by_role_and_label(egui::accesskit::Role::Button, "⋯").click_accesskit(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("Open in Tool").click_accesskit(); h.step(); let app = h.state(); @@ -2774,6 +2820,8 @@ fn dynamic_text_is_a_menu_of_values_that_starts_the_text() { h.step(); h.get_by_label("Dynamic Text").click_accesskit(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("Map scale {scale}").click_accesskit(); h.step(); assert_eq!(h.state().session.tool.code(), "graphicText"); @@ -2795,6 +2843,8 @@ fn dynamic_text_is_a_menu_of_values_that_starts_the_text() { // Another tool forgets the value. h.get_by_label("Dynamic Text").click_accesskit(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("Current date {date}").click_accesskit(); h.step(); assert_eq!(h.state().ui.dynamic_text, Some("{date}")); @@ -2904,6 +2954,8 @@ fn help_shortcuts_lists_the_keymap() { h.step(); h.state_mut().run("ui.shortcuts", json!(null)).unwrap(); h.step(); + // What it opened shows on the next frame. + h.step(); let undo = if cfg!(target_os = "macos") { "⌘Z" } else { "Ctrl+Z" }; assert!(h.query_by_label(undo).is_some(), "Help › Shortcuts lists Undo's key"); assert!(h.query_by_label("Map Tool: Explore").is_some(), "and what Esc does"); @@ -3006,6 +3058,8 @@ fn line_strokes_editor_edits_one_class() { // The layer's strokes first; class 1 is marked as having its own. h.query_all_by_role(egui::accesskit::Role::ComboBox).find(|n| n.value().as_deref() == Some("All classes")).unwrap().click(); h.step(); + // What it opened shows on the next frame. + h.step(); h.get_by_label("1 •").click_accesskit(); h.step(); h.step(); From 015a046a0d13ef1fbb87c89399206c33c1ffe5ff Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 24 Oct 2026 01:23:41 -0400 Subject: [PATCH 092/297] =?UTF-8?q?fix(ui):=20fall=20back=20to=20an=20inst?= =?UTF-8?q?alled=20symbol=20face=20for=20marks=20like=20=E2=9C=93=20(#796)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): fall back to an installed symbol face for marks like ✓ Segoe UI has no check mark, so on Windows the Assistant pane's tool calls and the attribute rule list drew "✓"/"✗" as boxes. egui 0.37's test harness panics on such glyphs, which failed the Windows tests on main after #781. Add an installed symbol face (Segoe UI Symbol, Apple Symbols, DejaVu Sans) as a last-resort fallback, as the CJK face is. * fix(ui): skip Arial Unicode, add Noto Sans Symbols 2 as symbol faces Apple Symbols ships with every macOS, so the 23 MB Arial Unicode is never needed; Noto-only Linux installs have no DejaVu. --- crates/render/src/fonts.rs | 39 ++++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 15 +++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/crates/render/src/fonts.rs b/crates/render/src/fonts.rs index f12728c48..095ed543a 100644 --- a/crates/render/src/fonts.rs +++ b/crates/render/src/fonts.rs @@ -339,6 +339,32 @@ pub fn script_font(script: ScriptFont) -> Option<&'static (Vec, u32)> { } } +/// Installed faces with the symbols the interface marks things with (checked by +/// [`covers_symbols`]): the interface faces lack some, Segoe UI the check mark among them. +const SYMBOL_CANDIDATES: &[&str] = &[ + "C:\\Windows\\Fonts\\seguisym.ttf", + "/System/Library/Fonts/Apple Symbols.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", + "/usr/share/fonts/noto/NotoSansSymbols2-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf", + "/usr/share/fonts/google-noto/NotoSansSymbols2-Regular.ttf", +]; + +/// Whether a face draws the check and cross marks. +fn covers_symbols(bytes: &[u8], index: u32) -> bool { + use ab_glyph::Font; + ab_glyph::FontRef::try_from_slice_and_index(bytes, index).is_ok_and(|f| ['\u{2713}', '\u{2717}'].iter().all(|c| f.glyph_id(*c).0 != 0)) +} + +/// An installed face for symbols the interface faces may lack (bytes and face index), found +/// once. `None` on the web or when nothing suitable is installed. +pub fn symbol_font() -> Option<&'static (Vec, u32)> { + static FOUND: OnceLock, u32)>> = OnceLock::new(); + FOUND.get_or_init(|| SYMBOL_CANDIDATES.iter().find_map(|p| read(p).filter(|b| covers_symbols(b, 0)).map(|b| (b, 0)))).as_ref() +} + /// Where the web app downloads a face for `script` from (SIL Open Font License): Noto Sans for /// CJK, Vazirmatn for Arabic script. pub fn script_font_url(script: ScriptFont) -> &'static str { @@ -371,6 +397,19 @@ mod tests { assert!(super::variable_noto_serif(&["/no/such/folder"]).is_none()); } + #[test] + fn a_symbol_face_needs_the_check_and_cross_marks() { + // Ubuntu Light has neither; junk is no font at all. + assert!(!super::covers_symbols(epaint_default_fonts::UBUNTU_LIGHT, 0)); + assert!(!super::covers_symbols(b"not a font", 0)); + // DejaVu Sans has both, where it is installed. + for p in super::SYMBOL_CANDIDATES.iter().filter(|p| p.contains("DejaVu")) { + if let Ok(b) = std::fs::read(p) { + assert!(super::covers_symbols(&b, 0), "{p}"); + } + } + } + #[test] fn an_arabic_face_needs_persian_and_latin_letters() { // Ubuntu Light has Latin but no Arabic; junk is no font at all. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1a2771d74..9a0911461 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1015,6 +1015,20 @@ impl RustGisApp { } } + /// On the desktop, an installed symbol face as a last-resort fallback, so marks the interface + /// faces lack (Segoe UI has no "✓") don't draw as boxes. The bytes are borrowed from the font + /// cache, not copied. + fn add_symbol_fallback(&mut self) { + if self.extra_fonts.iter().any(|(n, _, _)| n == "symbol-fallback") { + return; + } + if let Some((bytes, index)) = rustgis_engine::render::fonts::symbol_font() { + let mut face = egui::FontData::from_static(bytes); + face.index = *index; + self.extra_fonts.push(("symbol-fallback".into(), std::sync::Arc::new(face), None)); + } + } + /// The script face the current language needs, if any. fn current_region(&self) -> Option { Self::script_region(self.chosen_lang().script()) @@ -3400,6 +3414,7 @@ impl RustGisApp { } if self.fonts_frames == 0 { self.add_cjk_fallback(); + self.add_symbol_fallback(); self.installed_region = self.current_region(); theme::install_fonts(ctx, &self.extra_fonts, self.installed_region); self.fonts_pending = true; From 42859ba8471a6b6fada091fbb0976de455fba6bb Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:16:58 +0200 Subject: [PATCH 093/297] feat(connections): remember database passwords in the OS secret store (#777) * feat(connections): remember database passwords in the OS secret store A named PostGIS, MySQL/MariaDB or SQL Server connection can keep its password in the operating system's password store, behind an unchecked "Remember on this computer" opt-in in the new, edit and login forms. Nothing is saved by default, Test never persists, and the browser and CLI stay session-only. The stored value is an envelope bound to the connection's id, kind and cleaned address, so a reused id, an interrupted address edit or an old preference never sends an old password. Rename and group edits keep it; an address or username edit, a removal and Forget delete it first and leave the definition unchanged if that fails. Projects stay address-only: opening one reconnects only a unique exact-address remembered match, offers a chooser when several match, and never newly authorizes ambient credentials. A refused saved password is suppressed for the session without touching the store. connection.forgetPassword forgets a stored password; it is reserved to the user, so plugins cannot run it and the assistant asks each time. Driver errors are redacted of the active password at connect, query and reconnect boundaries, keeping DataError::Login. Closes #755. * Fix remembered database credential review issues * Resolve only the databases an incremental change names * Keep a refused login from taking the session and the address * Attempt a refused login once per database * Restore active credentials after refused typed login * Evict cached session credentials on authentication refusal * Suppress equivalent saved copies of refused session passwords --- crates/assistant/src/tests.rs | 40 + crates/data/src/lib.rs | 77 ++ crates/data/src/source/duckdb.rs | 111 ++- crates/data/src/source/mysql.rs | 6 +- crates/data/src/source/postgis.rs | 16 +- crates/data/src/source/sqlserver.rs | 4 +- crates/engine/src/cmd/connection_browse.rs | 54 +- crates/engine/src/cmd/connection_logins.rs | 130 ++++ crates/engine/src/cmd/connection_use.rs | 126 +++ crates/engine/src/cmd/connections.rs | 341 ++++++-- crates/engine/src/cmd/items.rs | 34 +- crates/engine/src/cmd/layer.rs | 25 +- crates/engine/src/cmd/plugins.rs | 1 + crates/engine/src/cmd/project.rs | 49 +- crates/engine/src/connections.rs | 375 ++++++++- crates/engine/src/lib.rs | 5 + crates/engine/src/tests.rs | 20 +- .../engine/src/tests/remembered_database.rs | 130 ++++ .../src/tests/remembered_database_packets.rs | 731 ++++++++++++++++++ crates/ui-egui/src/connectionui.rs | 208 ++++- crates/ui-egui/src/dialogs.rs | 5 +- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panes.rs | 44 +- crates/ui-egui/src/sourcespane.rs | 22 +- .../ui-egui/tests/remembered_database/mod.rs | 244 ++++++ crates/ui-egui/tests/smoke.rs | 3 + docs/user-guide/data.md | 132 +++- 27 files changed, 2724 insertions(+), 212 deletions(-) create mode 100644 crates/engine/src/cmd/connection_logins.rs create mode 100644 crates/engine/src/cmd/connection_use.rs create mode 100644 crates/engine/src/tests/remembered_database.rs create mode 100644 crates/engine/src/tests/remembered_database_packets.rs create mode 100644 crates/ui-egui/tests/remembered_database/mod.rs diff --git a/crates/assistant/src/tests.rs b/crates/assistant/src/tests.rs index fd268ee1c..345df398c 100644 --- a/crates/assistant/src/tests.rs +++ b/crates/assistant/src/tests.rs @@ -216,6 +216,46 @@ fn connection_commands_require_fresh_approval_before_login_or_scripts() { } } +#[test] +fn remembered_database_forget_requires_fresh_direct_and_script_approval() { + use rustgis_engine::data::secrets::{MemorySecrets, SecretStore}; + use std::sync::Arc; + for scripted in [false, true] { + let store = Arc::new(MemorySecrets::new()); + let mut s = Session::new(Project::default()); + s.database_logins.set_store(Some(store.clone())); + s.run( + "connection.new", + &json!({"kind":"postgres","name":"Private database","address":"postgres:host=127.0.0.1 port=1 user=approval"}), + ) + .unwrap(); + s.run("connection.login", &json!({"id":"c1","password":"forgotten-sentinel","remember":true})).unwrap(); + let args = if scripted { + json!({"script":"map.rename {\"name\":\"Approved map\"}\nconnection.forgetPassword {\"id\":\"c1\"}"}) + } else { + json!({"command":"connection.forgetPassword","params":{"id":"c1"}}) + }; + let tool = if scripted { "run" } else { "run_command" }; + let mut a = Agent::new(cfg("openai"), &s); + assert!(matches!(a.ask("forget this database's saved password", &s), Step::Send(_))); + let name = s.map().name.clone(); + assert_eq!(a.receive(a.generation(), ok(call("forget", tool, args.clone())), &mut s), Step::Confirm); + assert!(!a.pending(&s).unwrap().2); + assert!(store.load("connection/c1").unwrap().is_some()); + assert_eq!(s.map().name, name); + a.decide(false, false, &mut s); + assert!(store.load("connection/c1").unwrap().is_some()); + assert_eq!(a.receive(a.generation(), ok(call("allow", tool, args.clone())), &mut s), Step::Confirm); + let sent = a.decide(true, true, &mut s); + assert!(matches!(sent, Step::Send(_))); + assert!(!format!("{sent:?}").contains("forgotten-sentinel")); + assert_eq!(store.load("connection/c1").unwrap(), None); + assert!(!s.connections.find("c1").unwrap().remembered); + assert!(s.database_logins.has_session_password("c1")); + assert_eq!(a.receive(a.generation(), ok(call("again", tool, args)), &mut s), Step::Confirm); + } +} + #[test] fn gate_rules() { let s = sample(); diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 6390df580..a19e6693b 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -60,6 +60,83 @@ pub enum DataError { pub type Result = std::result::Result; +/// Replaces `secret` in `message` with asterisks; the text itself when it holds none. +fn without_secret(message: String, secret: &str) -> String { + if message.contains(secret) { message.replace(secret, "********") } else { message } +} + +/// Whether an I/O error's message holds `secret`. Only one carrying text from elsewhere (a +/// driver's, a server's) can: the system's own errors are fixed text and are not even formatted, +/// so the common case allocates nothing. +fn io_mentions(e: &std::io::Error, secret: &str) -> bool { + e.get_ref().is_some() && e.to_string().contains(secret) +} + +impl DataError { + /// The error with `secret` (a password the driver sent) taken out of its message, whatever a + /// server or driver echoes, keeping the kind: a refused login stays [`DataError::Login`]. + /// An empty secret leaves the error as it is, and so does text without it (nothing is copied). + pub fn redact_secret(self, secret: &str) -> Self { + if secret.is_empty() { + return self; + } + match self { + DataError::Format(m) => DataError::Format(without_secret(m, secret)), + DataError::Login(m) => DataError::Login(without_secret(m, secret)), + DataError::Unsupported(m) => DataError::Unsupported(without_secret(m, secret)), + DataError::NoChunks(m) => DataError::NoChunks(without_secret(m, secret)), + DataError::Io(e) if io_mentions(&e, secret) => { + DataError::Io(std::io::Error::new(e.kind(), without_secret(e.to_string(), secret))) + } + DataError::Io(e) => DataError::Io(e), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod secret_redaction_tests { + use super::*; + + #[test] + fn remembered_database_redaction_preserves_classification_and_owned_text() { + let message = String::from("no credential here"); + let pointer = message.as_ptr(); + let DataError::Login(unchanged) = DataError::Login(message).redact_secret("sentinel") else { panic!("lost Login") }; + assert_eq!(unchanged.as_ptr(), pointer); + let e = DataError::Login("server echoed sentinel twice sentinel".into()).redact_secret("sentinel"); + assert!(matches!(e, DataError::Login(_))); + assert_eq!(e.to_string(), "server echoed ******** twice ********"); + let e = DataError::Format("sentinel".into()).redact_secret("sentinel"); + assert!(matches!(e, DataError::Format(_))); + assert_eq!(DataError::Login("unchanged".into()).redact_secret("").to_string(), "unchanged"); + } + + #[derive(Debug)] + struct Pieces; + impl std::fmt::Display for Pieces { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("error: se")?; + f.write_str("nti")?; + f.write_str("nel echoed") + } + } + impl std::error::Error for Pieces {} + + #[test] + fn remembered_database_redaction_handles_fragmented_io_and_preserves_kind() { + let e = DataError::Io(std::io::Error::new(std::io::ErrorKind::PermissionDenied, Pieces)).redact_secret("sentinel"); + let DataError::Io(e) = e else { panic!("lost Io") }; + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied); + assert_eq!(e.to_string(), "error: ******** echoed"); + let long = "x".repeat(300); + let e = DataError::Io(std::io::Error::other(format!("before {long} after"))).redact_secret(&long); + assert_eq!(e.to_string(), "i/o: before ******** after"); + let e = DataError::Io(std::io::Error::other("unchanged")).redact_secret(&long); + assert_eq!(e.to_string(), "i/o: unchanged"); + } +} + /// One row: an optional geometry and one value per field. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Feature { diff --git a/crates/data/src/source/duckdb.rs b/crates/data/src/source/duckdb.rs index 7812c43d9..5f7337ae7 100644 --- a/crates/data/src/source/duckdb.rs +++ b/crates/data/src/source/duckdb.rs @@ -15,6 +15,7 @@ use super::Family; use super::external::{self, TempFile, fnv1a, output, stamp, touch}; +use crate::secrets::Secret; use crate::{DataError, Result}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -128,25 +129,91 @@ fn split_password(dsn: &str) -> (String, Option) { pub use super::pgconn::public_label; -fn passwords() -> &'static std::sync::Mutex> { - static P: std::sync::OnceLock>> = std::sync::OnceLock::new(); - P.get_or_init(Default::default) +/// A password remembered for a connection's address. `connection_id` names the saved connection +/// it was activated for (see [`set_connection_password`]); `None` is one typed for the address. +struct Entry { + password: Secret, + connection_id: Option, } -/// Remembers a connection's password for this session (never saved: projects name connections -/// without them, and libpq's `PGPASSWORD` or `~/.pgpass` serve when it isn't remembered). +type Passwords = std::collections::HashMap; + +static PASSWORDS: std::sync::LazyLock> = std::sync::LazyLock::new(Default::default); + +fn lock() -> std::sync::MutexGuard<'static, Passwords> { + PASSWORDS.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Remembers a connection's password for this session, as typed for its address (the saved +/// connections' own are activated by [`set_connection_password`]): projects name connections +/// without them, and libpq's `PGPASSWORD` or `~/.pgpass` serve when it isn't remembered. pub fn remember_password(dsn: &str, password: &str) { - passwords().lock().unwrap_or_else(|e| e.into_inner()).insert(dsn.to_string(), password.to_string()); + lock().insert(dsn.to_string(), Entry { password: Secret::new(password), connection_id: None }); +} + +/// Makes saved connection `id`'s password the one connections to `dsn` use (`Some`), replacing +/// whatever was remembered for the address; or takes away a password a saved connection +/// activated there, whichever one (`None`), leaving one typed for the address. +pub fn set_connection_password(dsn: &str, id: &str, password: Option<&str>) { + let mut all = lock(); + match password { + Some(p) => { + all.insert(dsn.to_string(), Entry { password: Secret::new(p), connection_id: Some(id.to_string()) }); + } + None => { + if all.get(dsn).is_some_and(|e| e.connection_id.is_some()) { + all.remove(dsn); + } + } + } +} + +/// Whether this driver's exact address slot is currently owned by saved connection `id`. +/// Unlike runtime selection, this also accounts for a later explicitly typed address password. +pub fn has_connection_password(dsn: &str, id: &str) -> bool { + lock().get(dsn).is_some_and(|entry| entry.connection_id.as_deref() == Some(id)) +} + +/// Tries an explicitly supplied password, restoring the previous address slot and its ownership +/// when `refused` classifies the outcome as an authentication refusal. Other failures retain +/// the new password. Never holds the cache lock during network I/O. +pub fn try_connection_password(dsn: &str, id: &str, password: &str, run: impl FnOnce() -> T, refused: impl FnOnce(&T) -> bool) -> T { + let previous = lock().insert(dsn.to_string(), Entry { password: Secret::new(password), connection_id: Some(id.to_string()) }); + let result = run(); + if refused(&result) { + let mut all = lock(); + if let Some(previous) = previous { + all.insert(dsn.to_string(), previous); + } else { + all.remove(dsn); + } + } + result +} + +/// Takes away every password saved connection `id` activated. +pub fn forget_connection_password(id: &str) { + lock().retain(|_, e| e.connection_id.as_deref() != Some(id)); +} + +/// Takes away every password a saved connection activated, leaving the typed ones. +pub fn forget_connection_passwords() { + lock().retain(|_, e| e.connection_id.is_none()); } /// The password remembered for a connection this session, if any. pub(crate) fn remembered_password(dsn: &str) -> Option { - passwords().lock().unwrap_or_else(|e| e.into_inner()).get(dsn).cloned() + lock().get(dsn).map(|e| e.password.expose().to_string()) } -/// Whether a password is remembered for a connection this session. +/// Whether a password is remembered for a connection this session, typed or activated. pub fn has_password(dsn: &str) -> bool { - passwords().lock().unwrap_or_else(|e| e.into_inner()).contains_key(dsn) + lock().contains_key(dsn) +} + +/// Whether a password typed for this address (not one a saved connection activated) is remembered. +pub fn has_explicit_password(dsn: &str) -> bool { + lock().get(dsn).is_some_and(|e| e.connection_id.is_none()) } /// The remembered password of a query's database, handed to DuckDB's PostgreSQL client in its @@ -154,7 +221,7 @@ pub fn has_password(dsn: &str) -> bool { /// would show it. fn password(database: Option<&Database>) -> Option { match database? { - Database::Postgres(dsn) => passwords().lock().unwrap_or_else(|e| e.into_inner()).get(dsn).cloned(), + Database::Postgres(dsn) => remembered_password(dsn), Database::File(_) | Database::Mysql(_) | Database::Sqlserver(_) => None, } } @@ -798,6 +865,30 @@ fn ours(name: &str) -> bool { mod tests { use super::*; + #[test] + fn remembered_database_cache_ownership_and_explicit_preservation() { + let a = "host=remembered_cache_a user=one"; + let b = "mysql://remembered_cache_b/db"; + set_connection_password(a, "cache-c1", Some("one")); + assert!(!has_explicit_password(a)); + assert_eq!(remembered_password(a).as_deref(), Some("one")); + set_connection_password(a, "cache-c2", Some("two")); + forget_connection_password("cache-c1"); + assert_eq!(remembered_password(a).as_deref(), Some("two")); + set_connection_password(a, "cache-c1", None); + assert!(!has_password(a)); + remember_password(a, "explicit"); + set_connection_password(a, "cache-c2", None); + assert!(has_explicit_password(a)); + assert_eq!(remembered_password(a).as_deref(), Some("explicit")); + set_connection_password(b, "cache-c2", Some("owned")); + forget_connection_passwords(); + assert!(!has_password(b)); + assert_eq!(remembered_password(a).as_deref(), Some("explicit")); + assert_eq!(password(Some(&Database::Postgres(a.into()))).as_deref(), Some("explicit")); + lock().remove(a); + } + #[test] fn databases_by_name() { // Quoted passwords with spaces, spaced `=`, escapes; an `@` past a URI's authority. diff --git a/crates/data/src/source/mysql.rs b/crates/data/src/source/mysql.rs index 128cb4492..d9755ae01 100644 --- a/crates/data/src/source/mysql.rs +++ b/crates/data/src/source/mysql.rs @@ -270,7 +270,7 @@ impl Conn { if broken(&e) { *guard = None; } - return Err(err(e)); + return Err(err(e).redact_secret(self.password.as_deref().unwrap_or(""))); } } } @@ -282,6 +282,10 @@ impl Conn { /// offers none, said in the second value), and sets the session read only with a statement /// timeout. fn open_client(config: &Config, password: Option<&str>) -> Result<((mysql::Conn, Server), bool)> { + connect_client(config, password).map_err(|e| e.redact_secret(password.unwrap_or(""))) +} + +fn connect_client(config: &Config, password: Option<&str>) -> Result<((mysql::Conn, Server), bool)> { super::tls_provider(); let base = mysql::OptsBuilder::new() .ip_or_hostname(Some(config.host.clone())) diff --git a/crates/data/src/source/postgis.rs b/crates/data/src/source/postgis.rs index 3e4da55ea..bd20c0784 100644 --- a/crates/data/src/source/postgis.rs +++ b/crates/data/src/source/postgis.rs @@ -168,11 +168,11 @@ impl Conn { let client = guard.as_mut().ok_or_else(|| DataError::Format("not connected".into()))?; let r = f(client); if !client.is_closed() { - return r.map_err(err); + return r.map_err(|e| redacted(&self.config, err(e))); } *guard = None; if attempt == 1 || r.is_ok() { - return r.map_err(err); + return r.map_err(|e| redacted(&self.config, err(e))); } } Err(DataError::Format("not connected".into())) @@ -197,7 +197,19 @@ impl Conn { } } +/// Connects, with the password taken out of any error: a server or the client may echo what it was given. fn open_client(config: &postgres::Config, tls: &Tls) -> Result { + connect_client(config, tls).map_err(|e| redacted(config, e)) +} + +fn redacted(config: &postgres::Config, error: DataError) -> DataError { + match config.get_password().and_then(|p| std::str::from_utf8(p).ok()) { + Some(password) => error.redact_secret(password), + None => error, + } +} + +fn connect_client(config: &postgres::Config, tls: &Tls) -> Result { let mut client = match tls { Tls::Disable => config.connect(postgres::NoTls), Tls::Unverified | Tls::Verified(_) => config.connect(tokio_postgres_rustls::MakeRustlsConnect::new(rustls_config(tls)?)), diff --git a/crates/data/src/source/sqlserver.rs b/crates/data/src/source/sqlserver.rs index 5fa4bc3fb..4a427ef32 100644 --- a/crates/data/src/source/sqlserver.rs +++ b/crates/data/src/source/sqlserver.rs @@ -260,7 +260,7 @@ fn open_on(rt: &tokio::runtime::Runtime, config: &Config, password: Option<&str> .await }) .map_err(|_| DataError::Format(format!("couldn't connect within {} s", config.connect_timeout.as_secs())))? - .map_err(err) + .map_err(|e| err(e).redact_secret(password.unwrap_or(""))) } fn connect_blocking(config: &Config, password: Option<&str>) -> Result<(tokio::runtime::Runtime, Client, u32, String, u32)> { @@ -300,7 +300,7 @@ impl Conn { if broken(&e) { *guard = None; } - return Err(err(e)); + return Err(err(e).redact_secret(self.password.as_deref().unwrap_or(""))); } } } diff --git a/crates/engine/src/cmd/connection_browse.rs b/crates/engine/src/cmd/connection_browse.rs index 9d49dc822..5eff2b52c 100644 --- a/crates/engine/src/cmd/connection_browse.rs +++ b/crates/engine/src/cmd/connection_browse.rs @@ -191,7 +191,7 @@ pub(super) fn browse(s: &mut Session, p: &Value) -> CmdResult { return Ok(out); } let found = match &level { - Level::Database => database(&c)?, + Level::Database => database(s, &c)?, Level::Service { url, kind } => service(s, p, &mut out, url, kind.as_deref())?, Level::S3Root if url.is_none() => { buckets(c.options.get("buckets").map(String::as_str).unwrap_or("").split(',').map(str::to_string)) @@ -227,38 +227,30 @@ pub(super) fn browse(s: &mut Session, p: &Value) -> CmdResult { } /// A live database's geometry tables, under their schemas. -fn database(c: &Connection) -> Result { - #[cfg(target_arch = "wasm32")] - { - let _ = c; - Err(DESKTOP_ONLY.into()) - } - #[cfg(not(target_arch = "wasm32"))] - { - use std::collections::BTreeMap; +fn database(s: &mut Session, c: &Connection) -> Result { + use std::collections::BTreeMap; - let tables = super::super::layer::database_table_details(&json!({"database": c.address}))?; - let text = |t: &Value, k: &str| t.get(k).and_then(Value::as_str).unwrap_or("").to_string(); - let mut schemas: BTreeMap> = BTreeMap::new(); - let truncated = tables.len() > MAX_CHILDREN; - for t in tables.iter().take(MAX_CHILDREN) { - let (label, name) = (text(t, "label"), text(t, "name")); - let shown = if label.contains(':') { format!("{name}:{}", text(t, "column")) } else { name }; - schemas.entry(text(t, "schema")).or_default().push(json!({ - "key": label, - "name": shown, - "what": "table", - "detail": text(t, "kind"), - "add": {"command": "layer.addDatabase", "params": {"database": c.address, "layer": label}}, - "options": true, - })); - } - let children = schemas - .into_iter() - .map(|(s, tables)| json!({"key": format!("schema:{s}"), "name": s, "what": "schema", "children": tables})) - .collect(); - Ok(Found { children, truncated, matched: None }) + let tables = super::connection_use::tables(s, c, None)?; + let text = |t: &Value, k: &str| t.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let mut schemas: BTreeMap> = BTreeMap::new(); + let truncated = tables.len() > MAX_CHILDREN; + for t in tables.iter().take(MAX_CHILDREN) { + let (label, name) = (text(t, "label"), text(t, "name")); + let shown = if label.contains(':') { format!("{name}:{}", text(t, "column")) } else { name }; + schemas.entry(text(t, "schema")).or_default().push(json!({ + "key": label, + "name": shown, + "what": "table", + "detail": text(t, "kind"), + "add": {"command": "layer.addDatabase", "params": {"database": c.address, "layer": label}}, + "options": true, + })); } + let children = schemas + .into_iter() + .map(|(s, tables)| json!({"key": format!("schema:{s}"), "name": s, "what": "schema", "children": tables})) + .collect(); + Ok(Found { children, truncated, matched: None }) } /// A service's layers (capabilities read here, or the app's `answer` and `documents`). diff --git a/crates/engine/src/cmd/connection_logins.rs b/crates/engine/src/cmd/connection_logins.rs new file mode 100644 index 000000000..b69012f2e --- /dev/null +++ b/crates/engine/src/cmd/connection_logins.rs @@ -0,0 +1,130 @@ +//! Resolves address-only project origins against trusted saved definitions, never project IDs. +use crate::Session; +#[cfg(not(target_arch = "wasm32"))] +use crate::connections::{Connection, LoginReason, LoginRequest, database_key, layer_database}; + +pub(crate) fn clear_active() { + #[cfg(not(target_arch = "wasm32"))] + rustgis_data::source::duckdb::forget_connection_passwords(); +} + +pub(crate) fn restore_active(s: &mut Session) { + #[cfg(not(target_arch = "wasm32"))] + for (key, id) in s.database_logins.activations() { + if let Some(pw) = s.database_logins.known_password(&id) { + rustgis_data::source::duckdb::set_connection_password(&key, &id, Some(pw.expose())); + } + } + #[cfg(target_arch = "wasm32")] + let _ = s; +} + +#[cfg(not(target_arch = "wasm32"))] +fn groups(s: &Session) -> std::collections::BTreeMap<(&'static str, String), Vec> { + let mut groups = std::collections::BTreeMap::<_, Vec<_>>::new(); + for l in s.project.maps.iter().flat_map(|m| &m.layers) { + let Some(src) = l.source.as_ref().filter(|src| !src.is_open()) else { continue }; + let Some((_, db)) = layer_database(src) else { continue }; + let Some(key) = database_key(&crate::sources::redact(db)) else { continue }; + groups.entry(key).or_default().push(l.id); + } + groups +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn request_password(s: &mut Session, c: &Connection) { + if let Some((kind, key)) = database_key(&c.address) { + let layers = groups(s).remove(&(kind, key.clone())).unwrap_or_default(); + s.database_logins.request(LoginRequest { + reason: LoginReason::Password, + kind, + key, + connections: vec![c.id.clone()], + layers, + opened: s.opened, + prompted: false, + }); + } +} + +/// Resolves every unopened database of the project: a project was opened or imported. +pub(crate) fn resolve_project(s: &mut Session) { + #[cfg(target_arch = "wasm32")] + let _ = s; + #[cfg(not(target_arch = "wasm32"))] + resolve(s, None); +} + +/// Resolves only the databases `keys` names: a saved definition or a single layer change can only +/// affect those, so an unrelated database that is down is not connected to again on every edit. +pub(crate) fn resolve_keys(s: &mut Session, keys: &[(&'static str, String)]) { + #[cfg(target_arch = "wasm32")] + let _ = (s, keys); + #[cfg(not(target_arch = "wasm32"))] + resolve(s, Some(keys)); +} + +#[cfg(not(target_arch = "wasm32"))] +fn resolve(s: &mut Session, only: Option<&[(&'static str, String)]>) { + for ((kind, key), layers) in groups(s) { + if let Some(only) = only + && !only.iter().any(|(k, database)| *k == kind && *database == key) + { + continue; + } + use rustgis_data::source::duckdb; + if duckdb::has_explicit_password(&key) { + s.database_logins.clear_requests_for(kind, &key); + continue; + } + let selected = s + .database_logins + .selection(kind, &key) + .and_then(|id| s.connections.find(id)) + .filter(|c| database_key(&c.address).is_some_and(|k| k == (kind, key.clone()))) + .cloned(); + let matches: Vec<_> = s.connections.matching_key(kind, &key).into_iter().cloned().collect(); + let candidates: Vec<_> = matches.iter().filter(|c| c.remembered && !s.database_logins.is_rejected(&c.id)).cloned().collect(); + let chosen = if let Some(c) = selected { + Some(c) + } else if candidates.len() == 1 { + candidates.first().cloned() + } else { + duckdb::set_connection_password(&key, "", None); + s.database_logins.drop_activation(&key); + if candidates.len() > 1 { + let ids = candidates.iter().map(|c| c.id.clone()).collect(); + s.database_logins.request(LoginRequest { + reason: LoginReason::Choose, + kind, + key: key.clone(), + connections: ids, + layers: layers.clone(), + opened: s.opened, + prompted: false, + }); + } else if let Some(c) = matches.iter().find(|c| s.database_logins.is_rejected(&c.id)) { + request_password(s, c); + } + None + }; + let Some(c) = chosen else { continue }; + match super::credential(s, &c) { + Ok(Some((pw, saved))) => { + super::activate(s, &c, pw.expose()); + super::reopen(s, &c, saved, !saved, true); + } + Ok(None) => { + duckdb::set_connection_password(&key, &c.id, None); + s.database_logins.drop_activation(&key); + request_password(s, &c); + } + Err(e) => { + duckdb::set_connection_password(&key, &c.id, None); + s.database_logins.drop_activation(&key); + s.notify(e, true); + request_password(s, &c); + } + } + } +} diff --git a/crates/engine/src/cmd/connection_use.rs b/crates/engine/src/cmd/connection_use.rs new file mode 100644 index 000000000..7cde29cc5 --- /dev/null +++ b/crates/engine/src/cmd/connection_use.rs @@ -0,0 +1,126 @@ +//! User-initiated database reads use the requested saved ID, never a same-address row's login. + +use serde_json::Value; +#[cfg(not(target_arch = "wasm32"))] +use serde_json::json; + +use crate::Session; +use crate::connections::Connection; + +/// Geometry-table details shared by saved-ID Browse and Test. An explicitly typed Test password +/// replaces session state only if authentication wasn't refused; a later listing failure keeps it. +/// Call only after the probe return: a probe must neither read secrets nor connect. +pub(super) fn tables(s: &mut Session, c: &Connection, typed: Option<&str>) -> Result, String> { + #[cfg(target_arch = "wasm32")] + { + let _ = (s, c, typed); + Err(crate::connections::DESKTOP_ONLY.into()) + } + #[cfg(not(target_arch = "wasm32"))] + { + use rustgis_data::DataError; + use rustgis_data::source::duckdb::{self, Database}; + + let database = Database::parse(&c.address); + let (kind, key) = match &database { + Database::Postgres(key) => ("postgres", key), + Database::Mysql(key) => ("mysql", key), + Database::Sqlserver(key) => ("sqlserver", key), + Database::File(_) => return Err("not a database server".into()), + }; + let credential = if typed.is_some() { + // Keep session, selection and activation bookkeeping unchanged during the attempt. + None + } else { + // Another saved ID's activation is not authority to read this connection. + // Passwords explicitly typed for the address remain intact. + duckdb::set_connection_password(key, &c.id, None); + s.database_logins.drop_activation(key); + if !s.database_logins.has_session_password(&c.id) && duckdb::has_explicit_password(key) { + None + } else { + super::credential(s, c)? + } + }; + if typed.is_none() && credential.is_none() && s.database_logins.is_rejected(&c.id) && !duckdb::has_explicit_password(key) { + super::request_password(s, c); + return Err("password is required".into()); + } + let saved = credential.as_ref().is_some_and(|(_, saved)| *saved); + let session = credential.as_ref().is_some_and(|(_, saved)| !*saved); + if typed.is_none() + && let Some((password, _)) = &credential + { + super::activate(s, c, password.expose()); + s.database_logins.select(kind, key, &c.id); + } + let result = if let Some(pw) = typed { + duckdb::try_connection_password(key, &c.id, pw, || read_tables(&database), |result| matches!(result, Err(DataError::Login(_)))) + } else { + read_tables(&database) + }; + if let Some(pw) = typed + && !matches!(&result, Err(DataError::Login(_))) + { + s.database_logins.set_session_password(&c.id, pw); + s.database_logins.select(kind, key, &c.id); + s.database_logins.note_activation(key, &c.id); + } + + match result { + Ok(tables) => { + s.database_logins.clear_requests_for(kind, key); + Ok(tables) + } + Err(error) => { + let password = typed.or_else(|| credential.as_ref().map(|(pw, _)| pw.expose())).unwrap_or(""); + let error = error.redact_secret(password); + if matches!(&error, DataError::Login(_)) { + if saved { + s.database_logins.reject(&c.id); + } else if session { + s.database_logins.reject_session_password(c); + } + if saved || session { + duckdb::forget_connection_password(&c.id); + s.database_logins.drop_activation(key); + } + super::request_password(s, c); + } + Err(format!("{}: {error}", database.short())) + } + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_tables(database: &rustgis_data::source::duckdb::Database) -> rustgis_data::Result> { + use rustgis_data::source::{duckdb::Database, mysql, postgis, sqlserver}; + + // Keep DataError intact through both connect and table listing so only an authentication + // refusal suppresses a saved credential. A transport or query error must not do that. + match database { + Database::Postgres(address) => { + let connection = postgis::connect(address, true)?; + Ok(postgis::tables(&connection)? + .into_iter() + .map(|t| json!({"label": t.label(), "schema": t.schema, "name": t.name, "column": t.column, "kind": t.kind})) + .collect()) + } + Database::Mysql(address) => { + let connection = mysql::connect(address, true)?; + Ok(mysql::tables(&connection)? + .into_iter() + .map(|t| json!({"label": t.label(), "schema": t.schema, "name": t.name, "column": t.column, "kind": t.kind})) + .collect()) + } + Database::Sqlserver(address) => { + let connection = sqlserver::connect(address, true)?; + Ok(sqlserver::tables(&connection)? + .into_iter() + .map(|t| json!({"label": t.label(), "schema": t.schema, "name": t.name, "column": t.column, "kind": t.kind})) + .collect()) + } + Database::File(_) => Err(rustgis_data::DataError::Format("not a database server".into())), + } +} diff --git a/crates/engine/src/cmd/connections.rs b/crates/engine/src/cmd/connections.rs index 904d1da86..0c312711d 100644 --- a/crates/engine/src/cmd/connections.rs +++ b/crates/engine/src/cmd/connections.rs @@ -13,6 +13,14 @@ use crate::{CmdResult, CommandSpec as C, Session}; #[path = "connection_browse.rs"] mod browse; +#[path = "connection_logins.rs"] +mod logins; +#[cfg(not(target_arch = "wasm32"))] +use logins::request_password; +pub(crate) use logins::{clear_active, resolve_keys, resolve_project, restore_active}; +#[path = "connection_use.rs"] +mod connection_use; + pub fn commands() -> Vec { vec![ C::new("connection.list", "List Connections", "Home › Add › Data Sources", list).view().params( @@ -27,10 +35,11 @@ pub fn commands() -> Vec { C::new("connection.duplicate", "Duplicate Connection", "Home › Add › Data Sources", duplicate).view().params(r#"{"id": "a connection's id or name", "name"?: text}"#), C::new("connection.remove", "Remove Connection", "Home › Add › Data Sources", remove).view().params(r#"{"id": "a connection's id or name"}"#), C::new("connection.login", "Log In to Connection", "Home › Add › Data Sources", login_command).view().params( - r#"{"id": "a connection's id or name", "password"?: "a database connection's, kept for this session only; the project's layers that read its database and couldn't be read open again", "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "an S3 connection's keys (credentials: keys), kept for this session only"}"#, + r#"{"id": "a connection's id or name; without password explicitly selects its existing session or remembered database password", "password"?: "database password, session-only by default", "remember"?: "boolean, default false: desktop OS-store opt-in for this password; S3 keys remain session-only", "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: text}"#, ), + C::new("connection.forgetPassword", "Forget Saved Password", "Home › Add › Data Sources", forget_password).view().params(r#"{"id": "a database connection's id or name"}"#), C::new("connection.test", "Test Connection", "Home › Add › Data Sources", test).view().params( - r#"{"id"?: "a connection's id or name", "kind"?: "postgres"|"mysql"|"sqlserver"|"service"|"wms"|"wmts"|"wfs"|"wcs"|"ogcapi"|"arcgis"|"csw"|"stac"|"s3", "name"?: text, "group"?: text, "address"?: text, "options"?: "an unsaved connection, as connection.new (when no id)", "password"?: text, "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "as connection.login (logs in first)", "probe"?: "true: only the address the test reads, {fetch} (null for databases, which their driver tests)", "answer"?: "non-database kinds only: what that address returned, when the app fetched it (no network request then); databases ignore answer and test through their driver"}"#, + r#"{"id"?: "a connection's id or name", "kind"?: "postgres"|"mysql"|"sqlserver"|"service"|"wms"|"wmts"|"wfs"|"wcs"|"ogcapi"|"arcgis"|"csw"|"stac"|"s3", "name"?: text, "group"?: text, "address"?: text, "options"?: "an unsaved connection, as connection.new (when no id)", "password"?: "for a named database, authentication refusal restores prior session credentials; a later listing failure keeps the new password; never persisted", "accessKeyId"?: text, "secretAccessKey"?: text, "sessionToken"?: "as connection.login (logs in first)", "probe"?: "true: only the address the test reads, {fetch} (null for databases, which their driver tests)", "answer"?: "non-database kinds only: what that address returned, when the app fetched it (no network request then); databases ignore answer and test through their driver"}"#, ), C::new("connection.browse", "Browse Connection", "Home › Add › Data Sources", browse::browse).view().params( r#"{"id": "a connection's id or name", "node"?: "a child's node, as a listing gives it (the connection's top level when absent)", "probe"?: "true: only the address this level reads, {fetch} (null: read without a fetch, or by the database driver); an S3 connection logs in first, as connection.test does", "answer"?: "what that address returned, when the app fetched it", "documents"?: [{"url": "a further document a listing asked for (next)", "text": "its answer"}]} → {connection, node, children: [{key, name, what, detail?, node? (browse it), children? (listed already), add? {command, params}: the command that adds it}], truncated?, matched?, notes?, service? {url, type}, next? (fetch it, then ask again with answer and documents)}"#, @@ -38,27 +47,11 @@ pub fn commands() -> Vec { ] } -fn shown(c: &Connection) -> Value { +fn shown(s: &Session, c: &Connection) -> Value { let database = DATABASE_KINDS.contains(&c.kind.as_str()); let available = !database || !cfg!(target_arch = "wasm32"); - let logged_in = if c.kind == "s3" { - s3::connection(&c.name).is_some() - } else if database { - #[cfg(not(target_arch = "wasm32"))] - { - use rustgis_data::source::duckdb::{self, Database}; - match Database::parse(&c.address) { - Database::Postgres(d) | Database::Mysql(d) | Database::Sqlserver(d) => duckdb::has_password(&d), - Database::File(_) => false, - } - } - #[cfg(target_arch = "wasm32")] - { - false - } - } else { - false - }; + let logged_in = + if c.kind == "s3" { s3::connection(&c.name).is_some() } else { database && s.database_logins.known_password(&c.id).is_some() }; let add_with = "connection.browse {id}, then {id, node} for a child's node; run a dataset's add {command, params}"; let mut out = serde_json::to_value(c).unwrap_or(Value::Null); out["available"] = json!(available); @@ -66,6 +59,7 @@ fn shown(c: &Connection) -> Value { out["why"] = json!(DESKTOP_ONLY); } out["loggedIn"] = json!(logged_in); + out["remembered"] = json!(c.remembered); out["addWith"] = json!(add_with); out } @@ -91,7 +85,7 @@ pub(crate) fn waiting(s: &Session) -> Vec<(Connection, usize)> { for layer in s.project.maps.iter().flat_map(|m| &m.layers) { let Some(src) = layer.source.as_ref().filter(|src| src.login) else { continue }; let Some((kind, database)) = crate::connections::layer_database(src) else { continue }; - if let Some(c) = s.connections.for_database(kind, database) { + if let [c] = s.connections.matching_database(kind, database).as_slice() { *counts.entry(c.id.clone()).or_default() += 1; } } @@ -109,6 +103,22 @@ pub(crate) fn note_waiting(s: &mut Session) { for (c, n) in waiting(s) { s.notify(format!("{n} layer(s) read the database of the connection '{}' and need its password: right-click the connection in Data Sources › Connections, or one of the layers, and choose Log In…", c.name), false); } + let mut ambiguous = BTreeMap::, usize>::new(); + for l in s.project.maps.iter().flat_map(|m| &m.layers) { + let Some(src) = l.source.as_ref().filter(|src| src.login) else { continue }; + let Some((kind, database)) = crate::connections::layer_database(src) else { continue }; + let candidates = s.connections.matching_database(kind, database); + if candidates.len() > 1 { + let labels = candidates.iter().map(|c| format!("{} ({})", c.name, c.id)).collect(); + *ambiguous.entry(labels).or_default() += 1; + } + } + for (candidates, count) in ambiguous { + s.notify( + format!("{count} layer(s) need an explicit connection choice: {}. Use connection.login {{id}}.", candidates.join(", ")), + false, + ); + } } fn list(s: &mut Session, p: &Value) -> CmdResult { @@ -123,12 +133,16 @@ fn list(s: &mut Session, p: &Value) -> CmdResult { Ok(json!({"connections": s.connections.connections.iter() .filter(|c| kind.is_none_or(|k| c.kind == k) && group.is_none_or(|g| c.group.chars().flat_map(char::to_lowercase).eq(g.chars().flat_map(char::to_lowercase)))) .map(|c| { - let mut out = shown(c); + let mut out = shown(s, c); if let Some(&n) = waiting.get(&c.id) { out["waiting"] = json!(n); } out - }).collect::>() })) + }).collect::>(), + "loginRequests": s.database_logins.requests(s.opened).map(|r| json!({ + "reason":r.reason.name(), "database":crate::connections::database_label(r.kind, &r.key), + "connections":r.connections, "layers":r.layers.len() + })).collect::>() })) } fn new(s: &mut Session, p: &Value) -> CmdResult { @@ -142,11 +156,12 @@ fn new(s: &mut Session, p: &Value) -> CmdResult { }; let explicit = !c.name.trim().is_empty(); let (c, stripped) = s.connections.add(c, explicit)?; - Ok(shown_stripped(&c, stripped)) + resolve_keys(s, &address_keys(&c.address)); + Ok(shown_stripped(s, &c, stripped)) } -fn shown_stripped(c: &Connection, stripped: bool) -> Value { - let mut out = shown(c); +fn shown_stripped(s: &Session, c: &Connection, stripped: bool) -> Value { + let mut out = shown(s, c); if stripped { out["stripped"] = json!(true); } @@ -169,14 +184,27 @@ fn edit(s: &mut Session, p: &Value) -> CmdResult { if let Some(v) = p.get("options") { c.options = options(v)?; } - let (c, stripped) = s.connections.update(&old.id, c)?; + let mut proposed = s.connections.clone(); + let (c, stripped) = proposed.update(&old.id, c)?; + if DATABASE_KINDS.contains(&old.kind.as_str()) && old.address != c.address { + delete_account(s, &old)?; + clear_identity(s, &old.id); + } + s.connections = proposed; + if old.address != c.address { + // Only the two databases this definition names can change: another database's layers + // waiting for a login keep their request, and an unrelated down server is not retried. + let mut keys = address_keys(&old.address); + keys.extend(address_keys(&c.address)); + resolve_keys(s, &keys); + } if c.kind == "s3" && (old.name != c.name || old.address != c.address || old.options != c.options) { s3::disconnect(&old.name); if old.name != c.name { s3::disconnect(&c.name); } } - Ok(shown_stripped(&c, stripped)) + Ok(shown_stripped(s, &c, stripped)) } fn duplicate(s: &mut Session, p: &Value) -> CmdResult { @@ -187,47 +215,162 @@ fn duplicate(s: &mut Session, p: &Value) -> CmdResult { c.name = name.into(); } let (c, _) = s.connections.add(c, explicit)?; - Ok(shown(&c)) + resolve_keys(s, &address_keys(&c.address)); + Ok(shown(s, &c)) } fn remove(s: &mut Session, p: &Value) -> CmdResult { let c = found(s, p)?.clone(); - s.connections.remove(&c.id); + let mut proposed = s.connections.clone(); + proposed.remove(&c.id); + if DATABASE_KINDS.contains(&c.kind.as_str()) { + delete_account(s, &c)?; + clear_identity(s, &c.id); + } + s.connections = proposed; + // The removed definition named one database: only its layers can change. The requests naming + // it went with `clear_identity`. + resolve_keys(s, &address_keys(&c.address)); if c.kind == "s3" { s3::disconnect(&c.name); } Ok(json!({"removed": c.id})) } +fn delete_account(s: &mut Session, c: &Connection) -> Result<(), String> { + if s.database_logins.has_store() { + s.database_logins + .delete(&c.id) + .map_err(|_| "The saved password couldn't be deleted. The connection was left unchanged.".to_string()) + } else if c.remembered { + Err("The saved password couldn't be deleted. The connection was left unchanged.".into()) + } else { + Ok(()) + } +} + +fn clear_identity(s: &mut Session, id: &str) { + s.database_logins.forget_connection(id); + #[cfg(not(target_arch = "wasm32"))] + rustgis_data::source::duckdb::forget_connection_password(id); +} + +/// The databases an address names (empty for kinds that read no database). +fn address_keys(address: &str) -> Vec<(&'static str, String)> { + crate::connections::database_key(address).into_iter().collect() +} + +/// The database a layer reads, as its driver names it (the same key `resolve_project` groups by). +pub(crate) fn layer_keys(src: &rustgis_map::source::SourceLayer) -> Vec<(&'static str, String)> { + let Some((_, database)) = crate::connections::layer_database(src) else { return Vec::new() }; + crate::connections::database_key(&crate::sources::redact(database)).into_iter().collect() +} + +fn forget_password(s: &mut Session, p: &Value) -> CmdResult { + let c = found(s, p)?.clone(); + if !DATABASE_KINDS.contains(&c.kind.as_str()) { + return Err("only database connections have saved passwords".into()); + } + delete_account(s, &c)?; + s.connections.set_remembered(&c.id, false); + s.database_logins.forget_saved(&c.id); + #[cfg(not(target_arch = "wasm32"))] + { + use rustgis_data::source::duckdb; + let key = crate::connections::database_key(&c.address).map(|(_, key)| key); + let was_active = key.as_ref().is_some_and(|key| duckdb::has_connection_password(key, &c.id)); + duckdb::forget_connection_password(&c.id); + if was_active + && let Some(key) = key + && !duckdb::has_explicit_password(&key) + && let Some(pw) = s.database_logins.session_password(&c.id) + { + // Restore this ID's explicit session value only if it owned the active slot. + // An inactive ID must not replace another ID or a typed address password. + duckdb::set_connection_password(&key, &c.id, Some(pw.expose())); + s.database_logins.note_activation(&key, &c.id); + } + } + let mut c = c; + c.remembered = false; + Ok(shown(s, &c)) +} + #[derive(Default)] struct Reopened { opened: usize, failed: usize, + /// The server refused this attempt's password: desktop database reopen only. + #[cfg(not(target_arch = "wasm32"))] + refused: bool, + password_saved: Option, + /// A store save was asked for and failed: the fixed warning belongs to this case only. + save_failed: bool, } #[cfg(not(target_arch = "wasm32"))] -fn reopen(s: &mut Session, c: &Connection, password: &str) -> Reopened { +fn reopen(s: &mut Session, c: &Connection, saved: bool, session: bool, all_unopened: bool) -> Reopened { let Some(key) = crate::connections::database_key(&c.address) else { return Reopened::default() }; let mut result = Reopened::default(); let mut first_error = None; for layer in s.project.maps.iter_mut().flat_map(|m| m.layers.iter_mut()) { - let Some(src) = layer.source.as_mut().filter(|src| src.login) else { continue }; + let Some(src) = layer.source.as_mut().filter(|src| src.login || all_unopened && !src.is_open()) else { continue }; let Some((kind, database)) = crate::connections::layer_database(src) else { continue }; if kind != key.0 || crate::connections::database_key(&crate::sources::redact(database)).as_ref() != Some(&key) { continue; } - if let Some(password_key) = crate::connections::database_key(database) { - rustgis_data::source::duckdb::remember_password(&password_key.1, password); + if result.refused { + // The server has already refused this attempt's password: the group's other layers + // must not send it again (one attempt per database, typed or saved). + src.login = true; + src.error = first_error.clone(); + result.failed += 1; + continue; } src.error = None; match src.open() { Ok(()) => result.opened += 1, Err(e) => { result.failed += 1; + result.refused |= src.login; first_error.get_or_insert(e); } } } + if result.refused { + use crate::connections::{LoginReason, LoginRequest}; + if saved { + s.database_logins.reject(&c.id); + } else if session { + s.database_logins.reject_session_password(c); + } + if saved || session { + rustgis_data::source::duckdb::forget_connection_password(&c.id); + s.database_logins.drop_activation(&key.1); + } + let layers = s + .project + .maps + .iter() + .flat_map(|m| &m.layers) + .filter_map(|l| { + let src = l.source.as_ref().filter(|src| src.login)?; + let (_, db) = crate::connections::layer_database(src)?; + (crate::connections::database_key(&crate::sources::redact(db)).as_ref() == Some(&key)).then_some(l.id) + }) + .collect(); + s.database_logins.request(LoginRequest { + reason: LoginReason::Password, + kind: key.0, + key: key.1.clone(), + connections: vec![c.id.clone()], + layers, + opened: s.opened, + prompted: false, + }); + } else { + s.database_logins.clear_requests_for(key.0, &key.1); + } if result.opened > 0 { s.notify(format!("{} layer(s) from the connection '{}' opened again", result.opened, c.name), false); } @@ -246,6 +389,10 @@ fn reopen(s: &mut Session, c: &Connection, password: &str) -> Reopened { } fn login(s: &mut Session, c: &Connection, p: &Value) -> Result { + let remember = p.get("remember").map(|v| v.as_bool().ok_or("remember is a boolean")).transpose()?.unwrap_or(false); + if remember && !DATABASE_KINDS.contains(&c.kind.as_str()) { + return Err("only database connections can remember a password".into()); + } if DATABASE_KINDS.contains(&c.kind.as_str()) { #[cfg(target_arch = "wasm32")] { @@ -253,9 +400,63 @@ fn login(s: &mut Session, c: &Connection, p: &Value) -> Result } #[cfg(not(target_arch = "wasm32"))] { - let pw = ps(p, "password").filter(|t| !t.is_empty()).ok_or("password is required")?; - super::layer::database_param(&json!({"database": c.address, "password": pw})); - return Ok(reopen(s, c, pw)); + let key = crate::connections::database_key(&c.address).ok_or("not a database connection")?; + let typed = ps(p, "password").filter(|t| !t.is_empty()); + if typed.is_none() { + s.database_logins.retry_saved(&c.id); + } + // A password typed for this address wins over any saved one (explicit input first). + let address_typed = typed.is_none() && rustgis_data::source::duckdb::has_explicit_password(&key.1); + let (password, saved) = match typed { + Some(_) => (None, false), + None if address_typed => (None, false), + None => { + // Resolved before this connection takes the address over: a login that turns + // out to have no password leaves another connection's activation alone. + let (pw, saved) = credential(s, c)?.ok_or("password is required")?; + (Some(pw), saved) + } + }; + let all_unopened = s.database_logins.requests(s.opened).any(|r| r.kind == key.0 && r.key == key.1); + let mut result = if let Some(pw) = typed { + // Keep selection and activation unchanged until this attempt is accepted. + rustgis_data::source::duckdb::try_connection_password( + &key.1, + &c.id, + pw, + || reopen(s, c, false, false, all_unopened), + |result| result.refused, + ) + } else { + // Another saved ID's activation is not authority to read this connection; + // a password typed for the address itself stays. + rustgis_data::source::duckdb::set_connection_password(&key.1, &c.id, None); + s.database_logins.drop_activation(&key.1); + s.database_logins.select(key.0, &key.1, &c.id); + if let Some(password) = &password { + activate(s, c, password.expose()); + } + let session = password.is_some() && !saved; + reopen(s, c, saved, session, all_unopened) + }; + if let Some(pw) = &typed + && !result.refused + { + // A refused password is not this session's credential: it was sent, and the user + // is asked for another one. + s.database_logins.set_session_password(&c.id, pw); + s.database_logins.select(key.0, &key.1, &c.id); + s.database_logins.note_activation(&key.1, &c.id); + } + if remember { + let stored = !result.refused && typed.is_some_and(|pw| s.database_logins.save(c, pw)); + if stored { + s.connections.set_remembered(&c.id, true); + } + result.save_failed = !stored && typed.is_some() && !result.refused; + result.password_saved = Some(stored); + } + return Ok(result); } } if c.kind != "s3" { @@ -292,7 +493,17 @@ fn login(s: &mut Session, c: &Connection, p: &Value) -> Result fn login_command(s: &mut Session, p: &Value) -> CmdResult { let c = found(s, p)?.clone(); let result = login(s, &c, p)?; - let mut out = shown(&c); + let mut c = c; + c.remembered = s.connections.find(&c.id).is_some_and(|c| c.remembered); + let mut out = shown(s, &c); + if let Some(saved) = result.password_saved { + out["passwordSaved"] = json!(saved); + // A refused password, or one nothing was typed for, isn't "available for this session": + // the fixed warning belongs to a store that couldn't keep a usable password. + if result.save_failed { + out["warning"] = json!("The password is available for this session, but wasn't remembered on this computer."); + } + } if result.opened + result.failed > 0 { out["reopened"] = json!(result.opened); out["failed"] = json!(result.failed); @@ -300,6 +511,33 @@ fn login_command(s: &mut Session, p: &Value) -> CmdResult { Ok(out) } +#[cfg(not(target_arch = "wasm32"))] +fn activate(s: &mut Session, c: &Connection, password: &str) { + if let Some((_, key)) = crate::connections::database_key(&c.address) { + rustgis_data::source::duckdb::set_connection_password(&key, &c.id, Some(password)); + s.database_logins.note_activation(&key, &c.id); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn credential(s: &mut Session, c: &Connection) -> Result, String> { + if let Some(pw) = s.database_logins.session_password(&c.id) { + return Ok(Some((pw.clone(), false))); + } + if !c.remembered || s.database_logins.is_rejected(&c.id) { + return Ok(None); + } + match s.database_logins.load(c) { + crate::connections::SavedPassword::Found(pw) => Ok(Some((pw, true))), + crate::connections::SavedPassword::Absent => { + s.connections.set_remembered(&c.id, false); + s.notify("The saved password is missing or doesn't match this connection.", true); + Ok(None) + } + crate::connections::SavedPassword::Unusable => Err("The saved password couldn't be used on this computer.".into()), + } +} + fn tested(s: &Session, p: &Value) -> Result { if ps(p, "id").is_some() { return Ok(found(s, p)?.clone()); @@ -367,9 +605,16 @@ fn answered(s: &mut Session, c: &Connection, text: &str) -> CmdResult { fn test(s: &mut Session, p: &Value) -> CmdResult { let c = tested(s, p)?; + if DATABASE_KINDS.contains(&c.kind.as_str()) && b(p, "probe").unwrap_or(false) { + return Ok(json!({"connection":c.id, "fetch":null})); + } let secrets = ["password", "accessKeyId", "secretAccessKey", "sessionToken"].iter().any(|k| ps(p, k).is_some_and(|v| !v.is_empty())); - if secrets { - login(s, &c, p)?; + if !DATABASE_KINDS.contains(&c.kind.as_str()) && secrets { + let mut input = p.clone(); + if let Some(o) = input.as_object_mut() { + o.remove("remember"); + } + login(s, &c, &input)?; } if c.kind == "s3" && !secrets && c.options.get("credentials").is_none_or(|v| v != "keys") { login(s, &c, &Value::Null)?; @@ -390,10 +635,18 @@ fn test(s: &mut Session, p: &Value) -> CmdResult { } let result = match c.kind.as_str() { "postgres" | "mysql" | "sqlserver" => { - if cfg!(target_arch = "wasm32") { - return Err(DESKTOP_ONLY.into()); + #[cfg(target_arch = "wasm32")] + return Err(DESKTOP_ONLY.into()); + #[cfg(not(target_arch = "wasm32"))] + { + if c.id.is_empty() { + super::layer::database_tables(s, &json!({"database":c.address,"password":ps(p,"password")}))? + } else { + let tables = connection_use::tables(s, &c, ps(p, "password").filter(|pw| !pw.is_empty()))?; + let layers: Vec<_> = tables.iter().filter_map(|v| v["label"].as_str()).collect(); + json!({"count":layers.len(),"layers":layers}) + } } - super::layer::database_tables(s, &json!({"database": c.address}))? } "service" => super::service::layers(s, &json!({"url": c.address}))?, "wms" | "wmts" | "wfs" | "wcs" | "ogcapi" | "arcgis" => super::service::layers(s, &json!({"url": c.address, "type": c.kind}))?, diff --git a/crates/engine/src/cmd/items.rs b/crates/engine/src/cmd/items.rs index 4865117d6..a6e0b9c8e 100644 --- a/crates/engine/src/cmd/items.rs +++ b/crates/engine/src/cmd/items.rs @@ -177,10 +177,19 @@ pub fn commands() -> Vec { .params(r#"{"path": "*.rgistemplate", "name"?: template name}"#), C::new("project.fromTemplate", "Open Custom Template", "Home › Project", |s, p| { let text = read_text(p)?; - let mut proj = Project::from_import_json(&text).map_err(|e| e.replace("RustGIS project", "RustGIS template"))?; + super::connections::clear_active(); + let mut proj = match Project::from_import_json(&text) { + Ok(proj) => proj, + Err(error) => { + super::connections::restore_active(s); + return Err(error.replace("RustGIS project", "RustGIS template")); + } + }; proj.name = ps(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Untitled").chars().take(200).collect(); super::project::reset_to(s, proj); s.path = None; + super::connections::resolve_project(s); + super::connections::note_waiting(s); Ok(Value::Null) }) .view() @@ -419,9 +428,16 @@ fn embedded_map(m: &Map, budget: &mut u64) -> Map { /// Adds maps from a map file, project or package. fn import_maps(s: &mut Session, p: &Value) -> CmdResult { let text = read_text(p)?; + super::connections::clear_active(); let maps: Vec = match serde_json::from_str::(&text) { Ok(m) => vec![m], - Err(_) => Project::from_import_json(&text).map_err(|_| "not a RustGIS map file, project or package".to_string())?.maps, + Err(_) => match Project::from_import_json(&text) { + Ok(project) => project.maps, + Err(_) => { + super::connections::restore_active(s); + return Err("not a RustGIS map file, project or package".into()); + } + }, }; let mut names = Vec::new(); for mut m in maps.into_iter().take(100) { @@ -436,6 +452,9 @@ fn import_maps(s: &mut Session, p: &Value) -> CmdResult { } // Repair what was read (rasters reopen, cameras clamp). s.project.normalize(); + super::connections::restore_active(s); + super::connections::resolve_project(s); + super::connections::note_waiting(s); if let Some(n) = names.last() && let Some(i) = s.project.maps.iter().position(|m| &m.name == n) { @@ -462,12 +481,11 @@ fn import_styles(s: &mut Session, p: &Value) -> CmdResult { } else if let Ok(m) = serde_json::from_str::(&text) { m.layers } else { - Project::from_import_json(&text) - .map_err(|_| "not a RustGIS layer file, map file or project".to_string())? - .maps - .into_iter() - .flat_map(|m| m.layers) - .collect() + // A project parse opens sources even though this import only takes their styles. + super::connections::clear_active(); + let project = Project::from_import_json(&text); + super::connections::restore_active(s); + project.map_err(|_| "not a RustGIS layer file, map file or project".to_string())?.maps.into_iter().flat_map(|m| m.layers).collect() }; let mut n = 0; for l in layers.into_iter().filter(|l| !l.is_raster() && l.geometry_type() != GeometryType::None).take(1000) { diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 3b2e032fb..5100e9784 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -1406,15 +1406,6 @@ pub(super) fn database_tables(_s: &mut Session, p: &Value) -> CmdResult { } } -/// The geometry tables of a live database server, with the schema, name, column and type of -/// each (`connection.browse` groups them by schema). -#[cfg(not(target_arch = "wasm32"))] -pub(super) fn database_table_details(p: &Value) -> Result, String> { - let db = database_param(p).ok_or("database is required")?; - let live = LiveDb::connect(&db).ok_or("not a database server")?.map_err(|e| format!("{}: {e}", db.short()))?; - Ok(live.tables().map_err(|e| format!("{}: {e}", db.short()))?.iter().map(LiveTable::detail).collect()) -} - /// Adds the tables of a database (a PostgreSQL connection or a DuckDB file) that have a geometry /// column, or the one `layer` names. fn add_database(s: &mut Session, p: &Value) -> CmdResult { @@ -1534,16 +1525,6 @@ impl LiveTable { LiveTable::Sqlserver(t) => t.label(), } } - - /// The table as a browser lists it: its label for `layer.addDatabase`, and what it is made of. - fn detail(&self) -> Value { - let (schema, name, column, kind) = match self { - LiveTable::Postgis(t) => (&t.schema, &t.name, &t.column, &t.kind), - LiveTable::Mysql(t) => (&t.schema, &t.name, &t.column, &t.kind), - LiveTable::Sqlserver(t) => (&t.schema, &t.name, &t.column, &t.kind), - }; - json!({"label": self.label(), "schema": schema, "name": name, "column": column, "kind": kind}) - } } #[cfg(not(target_arch = "wasm32"))] @@ -1961,7 +1942,13 @@ pub(crate) fn add_layer_text(s: &mut Session, text: &str) -> CmdResult { layer.name = format!("{base} ({i})"); } let id = s.map_mut().add_layer(layer); + super::connections::clear_active(); s.project.normalize(); + super::connections::restore_active(s); + // Only the database the added layer reads can change here. + let keys: Vec<_> = s.map().layer(id).and_then(|l| l.source.as_ref()).map(super::connections::layer_keys).unwrap_or_default(); + super::connections::resolve_keys(s, &keys); + super::connections::note_waiting(s); s.active_layer = Some(id); Ok(json!({"layer": id})) } diff --git a/crates/engine/src/cmd/plugins.rs b/crates/engine/src/cmd/plugins.rs index 26a7c0829..8e36722e9 100644 --- a/crates/engine/src/cmd/plugins.rs +++ b/crates/engine/src/cmd/plugins.rs @@ -275,6 +275,7 @@ const NEVER: &[&str] = &[ "connection.duplicate", "connection.remove", "connection.login", + "connection.forgetPassword", "connection.test", "connection.browse", "s3.connect", diff --git a/crates/engine/src/cmd/project.rs b/crates/engine/src/cmd/project.rs index 7af12e005..84a0898a0 100644 --- a/crates/engine/src/cmd/project.rs +++ b/crates/engine/src/cmd/project.rs @@ -40,6 +40,9 @@ pub(crate) fn reset_to(s: &mut Session, p: Project) { } fn reset(s: &mut Session, p: Project) { + super::connections::clear_active(); + s.database_logins.clear_activations(); + s.database_logins.clear_requests(); // Kept until the command ends: a failure gives the project back with its history. s.put_aside(); s.project = p; @@ -74,19 +77,29 @@ const MAX_PACKAGE: u64 = 1 << 30; fn open(s: &mut Session, p: &Value) -> CmdResult { let path = PathBuf::from(super::ps(p, "path").ok_or("path is required")?); let is_package = path.extension().is_some_and(|e| e.eq_ignore_ascii_case("rgispkg")); - let proj = if is_package { - // Read at most a byte past the limit, so a huge file is refused rather than loaded. - use std::io::Read; - let mut bytes = Vec::new(); - std::fs::File::open(&path) - .and_then(|f| f.take(MAX_PACKAGE + 1).read_to_end(&mut bytes)) - .map_err(|e| format!("{}: {e}", path.display()))?; - if bytes.len() as u64 > MAX_PACKAGE { - return Err("the package is too large".into()); + super::connections::clear_active(); + let proj = (|| { + if is_package { + // Read at most a byte past the limit, so a huge file is refused rather than loaded. + use std::io::Read; + let mut bytes = Vec::new(); + std::fs::File::open(&path) + .and_then(|f| f.take(MAX_PACKAGE + 1).read_to_end(&mut bytes)) + .map_err(|e| format!("{}: {e}", path.display()))?; + if bytes.len() as u64 > MAX_PACKAGE { + return Err("the package is too large".into()); + } + Project::from_import_json(&super::items::package_text(&bytes)?) + } else { + Project::load(&path) + } + })(); + let proj = match proj { + Ok(proj) => proj, + Err(error) => { + super::connections::restore_active(s); + return Err(error); } - Project::from_import_json(&super::items::package_text(&bytes)?)? - } else { - Project::load(&path)? }; reset(s, proj); // A package opens as a new, unsaved project (saving writes a plain project file). @@ -95,13 +108,22 @@ fn open(s: &mut Session, p: &Value) -> CmdResult { } s.path = if is_package { None } else { Some(path) }; s.notify(format!("Opened {}", s.project.name), false); + super::connections::resolve_project(s); super::connections::note_waiting(s); Ok(Value::Null) } fn open_text(s: &mut Session, p: &Value) -> CmdResult { let text = super::ps(p, "text").ok_or("text is required")?; - let proj = if super::b(p, "package").unwrap_or(false) { Project::from_import_json(text)? } else { Project::from_json(text)? }; + super::connections::clear_active(); + let proj = if super::b(p, "package").unwrap_or(false) { Project::from_import_json(text) } else { Project::from_json(text) }; + let proj = match proj { + Ok(proj) => proj, + Err(error) => { + super::connections::restore_active(s); + return Err(error); + } + }; reset(s, proj); // The browser opens files by name and contents: the name still names the project. if let Some(file) = super::ps(p, "fileName") { @@ -109,6 +131,7 @@ fn open_text(s: &mut Session, p: &Value) -> CmdResult { } s.path = None; s.notify(format!("Opened {}", s.project.name), false); + super::connections::resolve_project(s); super::connections::note_waiting(s); Ok(Value::Null) } diff --git a/crates/engine/src/connections.rs b/crates/engine/src/connections.rs index 4791fdb6c..9c0ce48c7 100644 --- a/crates/engine/src/connections.rs +++ b/crates/engine/src/connections.rs @@ -2,7 +2,9 @@ //! secret: addresses are stripped of passwords, tokens and signatures before they are kept. use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use rustgis_data::secrets::{Secret, SecretError, SecretStore}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -28,6 +30,11 @@ pub struct Connection { pub address: String, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub options: BTreeMap, + /// A database connection's password was saved in the system's secret store (never the + /// password itself, which is `DatabaseLogins`' to read). Set only by a login that asked to + /// remember it, and cleared when the saved password is forgotten or no longer fits. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub remembered: bool, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -84,6 +91,11 @@ pub fn layer_database(src: &rustgis_map::source::SourceLayer) -> Option<(&'stati } } +/// The password-free label for a driver's exact database key. +pub fn database_label(kind: &str, key: &str) -> String { + if kind == "postgres" && !key.contains("://") { format!("postgres:{key}") } else { key.to_string() } +} + fn same_name(a: &str, b: &str) -> bool { a.chars().flat_map(char::to_lowercase).eq(b.chars().flat_map(char::to_lowercase)) } @@ -121,6 +133,8 @@ pub fn clean(mut c: Connection) -> Result<(Connection, bool), String> { c.name = if c.kind == "s3" && c.address.is_empty() { "AWS S3".into() } else { sources::default_name(&c.address) }; } c.group = name_text(&c.group); + // Only a database connection has a password to remember. + c.remembered &= DATABASE_KINDS.contains(&c.kind.as_str()); if c.kind != "s3" && !c.options.is_empty() { return Err(format!("{} connections take no options", c.kind)); } @@ -157,13 +171,35 @@ pub fn clean(mut c: Connection) -> Result<(Connection, bool), String> { } impl Connections { - /// The first saved connection, in list order, of a database's kind whose address names it. + /// Every saved connection of a database's kind whose address names the database, in list order. + /// Only the wrapper, surrounding whitespace and password the address may carry are set aside: + /// the driver addresses are compared as text, so another form of the same server (a URI for a + /// connection string, reordered options, another name for the host) is another database. + pub fn matching_database(&self, kind: &str, database: &str) -> Vec<&Connection> { + match database_key(&sources::redact(database)) { + Some((k, key)) if k == kind => self.matching_key(kind, &key), + _ => Vec::new(), + } + } + + /// Every saved connection of a kind with this driver address (see [`database_key`]). + pub fn matching_key(&self, kind: &str, key: &str) -> Vec<&Connection> { + self.connections + .iter() + .filter(|c| c.kind == kind && database_key(&c.address).is_some_and(|(k, address)| k == kind && address == key)) + .collect() + } + + /// The saved connection a database comes from, when exactly one names it. Two that do leave + /// it to the user's choice (see [`Connections::matching_database`]): none is first. pub fn for_database(&self, kind: &str, database: &str) -> Option<&Connection> { - let key = database_key(&sources::redact(database)).filter(|k| k.0 == kind)?; - self.connections.iter().find(|c| c.kind == kind && database_key(&c.address).as_ref() == Some(&key)) + match self.matching_database(kind, database).as_slice() { + [only] => Some(*only), + _ => None, + } } - /// The saved connection a layer that reads a database comes from. + /// The saved connection a layer that reads a database comes from, when exactly one does. pub fn for_layer(&self, l: &rustgis_map::Layer) -> Option<&Connection> { let (kind, database) = layer_database(l.source.as_ref()?)?; self.for_database(kind, database) @@ -305,6 +341,8 @@ impl Connections { } self.next = next.checked_add(1).ok_or("connection ids exhausted")?; c.id = format!("c{next}"); + // A new connection, a copy or an import has no saved password: it is saved by a login. + c.remembered = false; self.connections.push(c.clone()); self.version = 1; self.generation = self.generation.wrapping_add(1); @@ -328,6 +366,9 @@ impl Connections { c.name = name; let slot = self.connections.iter_mut().find(|c| c.id == id).ok_or_else(|| format!("no connection '{id}'"))?; c.id = id.into(); + // The saved password belongs to the saved definition: an edit of the server or the user + // leaves it behind (the command deletes it first), and no edit claims one. + c.remembered = slot.remembered && slot.kind == c.kind && slot.address == c.address; *slot = c.clone(); self.generation = self.generation.wrapping_add(1); Ok((c, stripped)) @@ -339,6 +380,332 @@ impl Connections { self.generation = self.generation.wrapping_add(1); Some(c) } + + /// Says whether a database connection's password is saved; whether that changed the row. + pub fn set_remembered(&mut self, id: &str, on: bool) -> bool { + let Some(c) = self.connections.iter_mut().find(|c| c.id == id && DATABASE_KINDS.contains(&c.kind.as_str())) else { + return false; + }; + if c.remembered == on { + return false; + } + c.remembered = on; + self.generation = self.generation.wrapping_add(1); + true + } +} + +/// What a saved database password's lookup found. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SavedPassword { + /// The password saved for this definition. + Found(Secret), + /// None: no account, or one saved for another server or user (or damaged). The connection's + /// marker should be cleared. + Absent, + /// The store couldn't be asked (none here, or locked, denied, unavailable): the marker says + /// what it said. + Unusable, +} + +/// Why a database's layers wait for the user. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LoginReason { + /// More than one saved connection with a saved password names the database: the user picks + /// which one this session uses. + Choose, + /// No password is usable (the saved one was refused): the user types one. + Password, +} + +impl LoginReason { + pub fn name(self) -> &'static str { + match self { + LoginReason::Choose => "choose", + LoginReason::Password => "password", + } + } +} + +/// A database's layers waiting for a login the app should ask the user for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LoginRequest { + pub reason: LoginReason, + pub kind: &'static str, + /// The database as its driver names it, without a password. + pub key: String, + /// The saved connections it may be, by id. + pub connections: Vec, + /// The layers that wait, by id. + pub layers: Vec, + /// [`crate::Session::opened`] when it was made: a project opened since has other layers. + pub opened: u64, + /// The app has asked the user. + pub prompted: bool, +} + +/// The account a saved connection's password lives under in the system's secret store. +pub fn account(id: &str) -> String { + format!("connection/{id}") +} + +/// What the store keeps for a saved password: the definition it was saved for beside it, so an id +/// reused by another server (ids are sequential) never gets this one's password. +fn envelope(c: &Connection, password: &str) -> Secret { + Secret::new(json!({"kind": c.kind, "address": c.address, "password": password}).to_string()) +} + +/// The password in a stored envelope, if it was saved for exactly this definition. +fn password_in(c: &Connection, secret: &Secret) -> Option { + let v: Value = serde_json::from_str(secret.expose()).ok()?; + let o = v.as_object()?; + if o.len() != 3 || o.get("kind")?.as_str()? != c.kind || o.get("address")?.as_str()? != c.address { + return None; + } + o.get("password")?.as_str().filter(|p| !p.is_empty()).map(Secret::new) +} + +/// The database logins of this run: the system's secret store, the passwords given or loaded, which +/// connection was chosen for each database, and the logins the app should ask for. Never saved +/// with the project or the preferences, and never shown (its `Debug` counts, it doesn't print). +#[derive(Default)] +pub struct DatabaseLogins { + store: Option>, + /// Passwords given to `connection.login`, by connection id, for this session. + session: BTreeMap, + /// Saved passwords read from the store, by connection id. + saved: BTreeMap, + unavailable: BTreeSet, + /// The connection chosen for each database (kind and driver address) by a login. + selected: BTreeMap<(&'static str, String), String>, + /// Connections whose saved password the server refused this session. + rejected: BTreeSet, + /// The connection whose password the driver uses for each database address (driver key), + /// as set by a login or the project's resolution: what is set again when the driver's + /// passwords were cleared around reading a project's text. + activations: BTreeMap, + requests: Vec, +} + +impl std::fmt::Debug for DatabaseLogins { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DatabaseLogins") + .field("store", &self.store.is_some()) + .field("session", &self.session.len()) + .field("saved", &self.saved.len()) + .field("selected", &self.selected.len()) + .field("rejected", &self.rejected.len()) + .field("requests", &self.requests.len()) + .finish() + } +} + +impl DatabaseLogins { + /// Gives the system's secret store to use (the desktop app's; `None` in the browser, on the + /// command line and in scripts, where a password lasts for the session). + pub fn set_store(&mut self, store: Option>) { + self.store = store; + self.saved.clear(); + self.unavailable.clear(); + } + + pub fn has_store(&self) -> bool { + self.store.is_some() + } + + /// Whether `id` has a password given this session. + pub fn has_session_password(&self, id: &str) -> bool { + self.session.contains_key(id) + } + + pub fn session_password(&self, id: &str) -> Option<&Secret> { + self.session.get(id) + } + + pub fn set_session_password(&mut self, id: &str, password: &str) { + self.session.insert(id.to_string(), Secret::new(password)); + } + + /// Drops a refused session credential and suppresses an equivalent saved value for this run. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn reject_session_password(&mut self, c: &Connection) { + let Some(refused) = self.session.remove(&c.id) else { return }; + if !c.remembered || self.is_rejected(&c.id) { + return; + } + let same = match self.saved.get(&c.id) { + Some(saved) => saved == &refused, + // Explicit login/Refresh may have invalidated the lazy cache. Validate the bound + // account through the existing lookup before deciding whether it is the same value. + None => matches!(self.load(c), SavedPassword::Found(saved) if saved == refused), + }; + if same { + self.reject(&c.id); + } + } + + /// Reads the password saved for a definition (once: later calls use what was read). + pub fn load(&mut self, c: &Connection) -> SavedPassword { + if let Some(p) = self.saved.get(&c.id) { + return SavedPassword::Found(p.clone()); + } + if self.unavailable.contains(&c.id) { + return SavedPassword::Unusable; + } + let Some(store) = &self.store else { + self.unavailable.insert(c.id.clone()); + return SavedPassword::Unusable; + }; + match store.load(&account(&c.id)) { + Ok(Some(secret)) => match password_in(c, &secret) { + Some(p) => { + self.saved.insert(c.id.clone(), p.clone()); + SavedPassword::Found(p) + } + None => SavedPassword::Absent, + }, + Ok(None) => SavedPassword::Absent, + Err(_) => { + self.unavailable.insert(c.id.clone()); + SavedPassword::Unusable + } + } + } + + /// Saves a definition's password; whether it was saved. A saved password replaces an older + /// one and is trusted again if the server had refused the older one this session. + pub fn save(&mut self, c: &Connection, password: &str) -> bool { + let Some(store) = &self.store else { return false }; + if store.save(&account(&c.id), &envelope(c, password)).is_err() { + return false; + } + self.saved.insert(c.id.clone(), Secret::new(password)); + self.unavailable.remove(&c.id); + self.rejected.remove(&c.id); + true + } + + /// Deletes the account of a saved password (none to delete is success). Without a store the + /// error says so. + pub fn delete(&mut self, id: &str) -> Result<(), SecretError> { + let store = self.store.as_ref().ok_or_else(|| SecretError::Unavailable("no secret store".into()))?; + store.delete(&account(id))?; + self.saved.remove(id); + self.unavailable.remove(id); + self.rejected.remove(id); + Ok(()) + } + + /// Says the server refused the saved password of `id`: it is dropped from memory (the store + /// keeps it) and not tried again this session. + pub fn reject(&mut self, id: &str) { + self.saved.remove(id); + self.rejected.insert(id.to_string()); + } + + pub fn is_rejected(&self, id: &str) -> bool { + self.rejected.contains(id) + } + + /// The connection a login chose for a database, if any. + pub fn selection(&self, kind: &str, key: &str) -> Option<&str> { + self.selected.iter().find(|((k, d), _)| *k == kind && d == key).map(|(_, id)| id.as_str()) + } + + pub fn select(&mut self, kind: &'static str, key: &str, id: &str) { + self.selected.insert((kind, key.to_string()), id.to_string()); + } + + /// Forgets everything held for a connection that is going away or changing server: the + /// passwords given and read, the choice, the refusal, and the requests naming it. + pub fn forget_connection(&mut self, id: &str) { + self.session.remove(id); + self.saved.remove(id); + self.unavailable.remove(id); + self.rejected.remove(id); + self.selected.retain(|_, chosen| chosen != id); + self.activations.retain(|_, active| active != id); + self.requests.retain(|r| !r.connections.iter().any(|c| c == id)); + } + + /// Forgets the choice made for a database. + pub fn drop_selection(&mut self, kind: &str, key: &str) { + self.selected.retain(|(k, d), _| !(*k == kind && d == key)); + } + + /// The password a connection logs in with where it is known here: the one given this session, + /// else the saved one read (never reading the store). + pub fn known_password(&self, id: &str) -> Option<&Secret> { + self.session.get(id).or_else(|| self.saved.get(id)) + } + + /// Retries a lazy lookup on explicit login or Refresh, without retrying a refused password. + pub fn retry_saved(&mut self, id: &str) { + self.saved.remove(id); + self.unavailable.remove(id); + } + + /// Notes that `id`'s password is the one in use for a database address. + pub fn note_activation(&mut self, key: &str, id: &str) { + self.activations.insert(key.to_string(), id.to_string()); + } + + /// Notes that no connection's password is in use for a database address. + pub fn drop_activation(&mut self, key: &str) { + self.activations.remove(key); + } + + /// The database addresses with the connection whose password is in use for each. + pub fn activations(&self) -> Vec<(String, String)> { + self.activations.iter().map(|(k, id)| (k.clone(), id.clone())).collect() + } + + pub fn clear_activations(&mut self) { + self.activations.clear(); + } + + /// Forgets only the saved password of a connection, as the user asked to (a password given this + /// session stays). + pub fn forget_saved(&mut self, id: &str) { + self.saved.remove(id); + self.rejected.remove(id); + if !self.session.contains_key(id) { + self.activations.retain(|_, active| active != id); + } + } + + /// Adds a request, replacing the one for the same database. The app is asked again only when + /// what it would ask has changed, within the same project (another project has other layers). + pub fn request(&mut self, mut request: LoginRequest) { + if let Some(old) = self.requests.iter().find(|r| (r.kind, &r.key) == (request.kind, &request.key)) { + request.prompted = + old.opened == request.opened && old.prompted && old.reason == request.reason && old.connections == request.connections; + } + self.requests.retain(|r| (r.kind, &r.key) != (request.kind, &request.key)); + self.requests.push(request); + } + + /// Drops the requests for a database (it logged in, or its layers went). + pub fn clear_requests_for(&mut self, kind: &str, key: &str) { + self.requests.retain(|r| (r.kind, r.key.as_str()) != (kind, key)); + } + + /// Drops every request: the project they came from is gone, or the saved connections changed. + pub fn clear_requests(&mut self) { + self.requests.clear(); + } + + /// The requests of the project opened as `opened`. + pub fn requests(&self, opened: u64) -> impl Iterator { + self.requests.iter().filter(move |r| r.opened == opened) + } + + /// The next request the app hasn't asked the user about, marked as asked. + pub fn next_unprompted(&mut self, opened: u64) -> Option { + let r = self.requests.iter_mut().find(|r| r.opened == opened && !r.prompted)?; + r.prompted = true; + Some(r.clone()) + } } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 352b490b1..c8acff3ac 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -465,6 +465,10 @@ pub struct Session { pub sources: sources::Sources, /// Saved connections (`connection.*`): kept with the app's preferences, not the project; never a secret. pub connections: connections::Connections, + /// The database logins of this run: the system's secret store (desktop app only), passwords + /// given and read, which saved connection each database uses, and the logins to ask for. Never + /// saved; see [`connections::DatabaseLogins`]. + pub database_logins: connections::DatabaseLogins, /// How rasters added from now on are drawn (`raster.setDefaultStyle`; kept with the app's /// preferences, not the project). pub raster_defaults: rustgis_map::RasterDefaults, @@ -671,6 +675,7 @@ impl Session { dirty: false, sources: sources::Sources::default(), connections: connections::Connections::default(), + database_logins: connections::DatabaseLogins::default(), raster_defaults: rustgis_map::RasterDefaults::default(), registry: Registry { commands: cmd::all() }, revision: 1, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a3cea6946..0f35303fc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2,6 +2,14 @@ use serde_json::{Value, json}; use crate::{Project, Session, sample}; +#[cfg(not(target_arch = "wasm32"))] +#[path = "tests/remembered_database.rs"] +mod remembered_database; + +#[cfg(not(target_arch = "wasm32"))] +#[path = "tests/remembered_database_packets.rs"] +mod remembered_database_packets; + /// Keeps tests away from the user's installed plugins. fn isolate_plugins() { static ONCE: std::sync::Once = std::sync::Once::new(); @@ -1330,9 +1338,10 @@ fn hostile_params_never_panic() { json!({"transform": "affine", "crs": 32613, "pointsCrs": 4326, "cellSize": 1e-300, "path": "/nonexistent/x.tif", "points": [[0, 0, 1, 1], [1, 0, 2, 1], [0, 1, 1, 2]]}), json!({"points": [[0, 0, f64::MAX, f64::MAX], [1, 0, -f64::MAX, f64::MAX], [0, 1, f64::MAX, -f64::MAX]], "compare": true}), // Connections: junk kinds, options and secrets; no connection exists in a fresh session. - json!({"id": "c1", "kind": "s3", "address": "http://127.0.0.1:9", "name": "\u{7}", "group": 5, "options": {"credentials": "nope", "buckets": "NO_", "region": [1]}, "password": 7, "accessKeyId": "A", "secretAccessKey": 9}), + json!({"id": "c1", "kind": "s3", "address": "http://127.0.0.1:9", "name": "\u{7}", "group": 5, "options": {"credentials": "nope", "buckets": "NO_", "region": [1]}, "password": 7, "remember": [true], "accessKeyId": "A", "secretAccessKey": 9}), json!({"id": "", "kind": "postgres", "address": "postgres:host=127.0.0.1 port=9 password=x", "options": "x"}), json!({"kind": "wms", "address": "http://127.0.0.1:9/wms", "probe": "x", "answer": 7}), + json!({"id": {"unexpected":"object"}, "remember": "true", "password": []}), json!({"kind": "stac", "address": "http://127.0.0.1:9/stac", "answer": "\u{0}{"}), json!({"id": "c1", "node": "s3:/\u{7}", "probe": true, "answer": 7, "documents": "x"}), ]; @@ -10041,9 +10050,9 @@ fn database_layers_wait_for_their_connection_login() { assert_eq!(result["failed"], 2); assert!(t.map().layers.iter().take(2).all(|l| l.source.as_ref().unwrap().login)); assert_eq!(t.run("connection.list", &Value::Null).unwrap()["connections"][0]["waiting"], 2); - // Duplicate saved connections use list order for reporting, but either can log in. + // Duplicate addresses have no inferred connection identity; either can be selected explicitly. t.run("connection.new", &json!({"kind":"postgres","name":"Same database","address":dsn})).unwrap(); - assert_eq!(t.connections.for_layer(&t.map().layers[0]).unwrap().name, "Production GIS"); + assert!(t.connections.for_layer(&t.map().layers[0]).is_none()); let result = t.run("connection.login", &json!({"id":"Same database","password":"wrong"})).unwrap(); assert_eq!(result["failed"], 2); } @@ -10150,7 +10159,7 @@ fn connections_commands() { assert_eq!(list["connections"][0]["loggedIn"], true); assert!(!s.connections.to_json().contains("pw")); let copy = s.run("connection.duplicate", &json!({"id":db["id"]})).unwrap(); - assert_eq!(copy["loggedIn"], true, "the existing password store is shared by public DSN"); + assert_eq!(copy["loggedIn"], false, "a duplicate must not inherit another saved ID's session login"); let edited = s .run("connection.edit", &json!({"id":db["id"],"address":"postgres:host=127.0.0.1 port=9 dbname=connections_other user=u"})) .unwrap(); @@ -10158,7 +10167,7 @@ fn connections_commands() { s.run("connection.remove", &json!({"id":db["id"]})).unwrap(); let list = s.run("connection.list", &json!({"kind":"postgres"})).unwrap(); assert_eq!(list["connections"][0]["id"], copy["id"]); - assert_eq!(list["connections"][0]["loggedIn"], true, "removing a shortcut must not log out another connection using that DSN"); + assert_eq!(list["connections"][0]["loggedIn"], false); } s.run("connection.new", &json!({"kind":"s3","name":"conn-test-s3","options":{"credentials":"keys","buckets":"b11"}})).unwrap(); assert!(s.run("connection.test", &json!({"id":"conn-test-s3"})).unwrap_err().contains("log in first")); @@ -10182,6 +10191,7 @@ fn connections_commands() { "connection.duplicate", "connection.remove", "connection.login", + "connection.forgetPassword", "connection.test", "connection.browse", ] { diff --git a/crates/engine/src/tests/remembered_database.rs b/crates/engine/src/tests/remembered_database.rs new file mode 100644 index 000000000..ee3709ad3 --- /dev/null +++ b/crates/engine/src/tests/remembered_database.rs @@ -0,0 +1,130 @@ +use super::*; +use rustgis_data::secrets::{MemorySecrets, Secret, SecretError, SecretStore}; +use std::sync::Arc; + +fn session(store: Option>) -> Session { + let mut s = Session::new(Project::default()); + s.database_logins.set_store(store); + s +} +fn add(s: &mut Session, name: &str) -> String { + s.run( + "connection.new", + &json!({"kind":"postgres","name":name,"address":"postgres:host=127.0.0.1 port=1 user=remembered_lifecycle dbname=gis"}), + ) + .unwrap()["id"] + .as_str() + .unwrap() + .to_string() +} + +#[test] +fn remembered_database_opt_in_lifetime_and_identity() { + let store = Arc::new(MemorySecrets::new()); + let mut s = session(Some(store.clone())); + let id = add(&mut s, "First"); + s.run("connection.login", &json!({"id":id,"password":"sentinel-one"})).unwrap(); + assert_eq!(store.load(&format!("connection/{id}")).unwrap(), None); + let v = s.run("connection.login", &json!({"id":id,"password":"sentinel-one","remember":true})).unwrap(); + assert_eq!(v["passwordSaved"], true); + let envelope: Value = serde_json::from_str(store.load(&format!("connection/{id}")).unwrap().unwrap().expose()).unwrap(); + assert_eq!(envelope, json!({"kind":"postgres","address":s.connections.find(&id).unwrap().address,"password":"sentinel-one"})); + let other = add(&mut s, "Second"); + assert_eq!(s.run("connection.list", &Value::Null).unwrap()["connections"][1]["loggedIn"], false); + s.run("connection.login", &json!({"id":other,"password":"sentinel-two","remember":true})).unwrap(); + s.run("connection.edit", &json!({"id":id,"name":"Renamed","group":"Work"})).unwrap(); + assert!(s.connections.find(&id).unwrap().remembered); + let duplicate = s.run("connection.duplicate", &json!({"id":id})).unwrap(); + assert_eq!(duplicate["remembered"], false); + assert_eq!(duplicate["loggedIn"], false); + assert!(!s.connections.to_json().contains("sentinel")); + assert!(!s.project.saved_text().unwrap().0.contains("sentinel")); + s.run("connection.forgetPassword", &json!({"id":id})).unwrap(); + assert_eq!(store.load(&format!("connection/{id}")).unwrap(), None); + assert!(s.database_logins.has_session_password(&id)); + s.run("connection.forgetPassword", &json!({"id":id})).unwrap(); + s.run("connection.edit", &json!({"id":other,"address":"postgres:host=127.0.0.1 port=1 user=another dbname=gis"})).unwrap(); + assert!(!s.connections.find(&other).unwrap().remembered); + assert!(!s.database_logins.has_session_password(&other)); + assert_eq!(store.load(&format!("connection/{other}")).unwrap(), None); +} + +#[test] +fn remembered_database_store_failures_preserve_session() { + for store in [ + None, + Some(Arc::new(MemorySecrets::failing(SecretError::Denied)) as Arc), + Some(Arc::new(MemorySecrets::failing(SecretError::Unavailable("private backend payload".into()))) as Arc), + ] { + let mut s = session(store); + let id = add(&mut s, "Database"); + let v = s.run("connection.login", &json!({"id":id,"password":"session-sentinel","remember":true})).unwrap(); + assert_eq!(v["passwordSaved"], false); + assert_eq!(v["remembered"], false); + assert_eq!(v["loggedIn"], true); + assert_eq!(v["warning"], json!("The password is available for this session, but wasn't remembered on this computer.")); + assert!(!v.to_string().contains("private backend payload")); + s.run("connection.login", &json!({"id":id})).unwrap(); + } +} + +#[test] +fn remembered_database_invalid_envelope_and_reused_id() { + let store = Arc::new(MemorySecrets::new()); + let mut s = session(Some(store.clone())); + let id = add(&mut s, "Database"); + for raw in [ + "broken-secret-sentinel".to_string(), + json!({"kind":"postgres","address":"postgres:host=elsewhere","password":"stale-sentinel"}).to_string(), + json!({"kind":"postgres","address":s.connections.find(&id).unwrap().address,"password":"old-sentinel","extra":true}).to_string(), + ] { + store.save(&format!("connection/{id}"), &Secret::new(raw)).unwrap(); + s.connections.set_remembered(&id, true); + assert!(s.run("connection.login", &json!({"id":id})).is_err()); + assert!(!s.connections.find(&id).unwrap().remembered); + assert!(!s.notices.iter().any(|n| n.text.contains("sentinel"))); + } + for remember in [json!("true"), json!(1), Value::Null, json!({})] { + assert!(s.run("connection.login", &json!({"id":id,"password":"typed","remember":remember})).is_err()); + } +} + +#[test] +fn remembered_database_delete_failure_is_transactional() { + let store = Arc::new(MemorySecrets::new()); + let mut s = session(Some(store.clone())); + let id = add(&mut s, "Database"); + s.run("connection.login", &json!({"id":id,"password":"original","remember":true})).unwrap(); + let old = s.connections.to_json(); + s.database_logins.set_store(Some(Arc::new(MemorySecrets::failing(SecretError::Denied)))); + assert!(s.run("connection.edit", &json!({"id":id,"address":"postgres:host=changed user=other"})).is_err()); + assert_eq!(s.connections.to_json(), old); + assert!(s.run("connection.remove", &json!({"id":id})).is_err()); + assert_eq!(s.connections.to_json(), old); + assert!(s.run("connection.forgetPassword", &json!({"id":id})).is_err()); + assert_eq!(s.connections.to_json(), old); + s.database_logins.set_store(Some(store.clone())); + let second = add(&mut s, "Occupied"); + assert!(s.run("connection.edit", &json!({"id":id,"name":"Occupied","address":"postgres:host=changed"})).is_err()); + assert!(store.load(&format!("connection/{id}")).unwrap().is_some()); + s.run("connection.remove", &json!({"id":id})).unwrap(); + assert_eq!(store.load(&format!("connection/{id}")).unwrap(), None); + assert!(s.connections.find(&second).is_some()); +} + +#[test] +fn remembered_database_passwordless_login_reports_cached_credentials_per_id() { + let store = Arc::new(MemorySecrets::new()); + let mut seed = session(Some(store.clone())); + let id = add(&mut seed, "Remembered"); + let other = add(&mut seed, "Other"); + seed.run("connection.login", &json!({"id":id,"password":"status-sentinel","remember":true})).unwrap(); + let mut fresh = session(Some(store)); + fresh.connections = crate::connections::Connections::from_json(&seed.connections.to_json()); + assert_eq!(fresh.run("connection.list", &Value::Null).unwrap()["connections"][0]["loggedIn"], false); + assert_eq!(fresh.run("connection.login", &json!({"id":id})).unwrap()["loggedIn"], true); + let listing = fresh.run("connection.list", &Value::Null).unwrap(); + assert_eq!(listing["connections"][0]["loggedIn"], true); + assert_eq!(listing["connections"][1]["id"], other); + assert_eq!(listing["connections"][1]["loggedIn"], false); +} diff --git a/crates/engine/src/tests/remembered_database_packets.rs b/crates/engine/src/tests/remembered_database_packets.rs new file mode 100644 index 000000000..5c0ee3ebe --- /dev/null +++ b/crates/engine/src/tests/remembered_database_packets.rs @@ -0,0 +1,731 @@ +use super::*; +use rustgis_data::secrets::{MemorySecrets, SecretStore}; +use std::io::{Read, Write}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, +}; + +struct Server { + address: String, + packets: mpsc::Receiver>, + stop: Arc, + worker: Option>, +} +impl Server { + fn new() -> Self { + Self::start(false) + } + + /// When authentication succeeds, fail the first catalogue query instead of the password. + fn start(authenticate: bool) -> Self { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let stopped = stop.clone(); + let (send, packets) = mpsc::channel(); + let worker = std::thread::spawn(move || { + while !stopped.load(Ordering::Relaxed) { + let (mut stream, _) = match listener.accept() { + Ok(pair) => pair, + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(2)); + continue; + } + Err(_) => break, + }; + // macOS accepts can inherit the listener's nonblocking flag. + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(3))).unwrap(); + let mut size = [0; 4]; + if stream.read_exact(&mut size).is_err() { + continue; + } + let size = u32::from_be_bytes(size); + if !(4..=65536).contains(&size) { + continue; + } + if stream.read_exact(&mut vec![0; (size - 4) as usize]).is_err() { + continue; + } + if stream.write_all(b"R\0\0\0\x08\0\0\0\x03").is_err() { + continue; + } + let mut tag = [0]; + let mut size = [0; 4]; + if stream.read_exact(&mut tag).is_err() || tag[0] != b'p' || stream.read_exact(&mut size).is_err() { + continue; + } + let size = u32::from_be_bytes(size); + if !(4..=65536).contains(&size) { + continue; + } + let mut password = vec![0; (size - 4) as usize]; + if stream.read_exact(&mut password).is_err() { + continue; + } + let echoed = String::from_utf8_lossy(&password).trim_end_matches('\0').to_string(); + send.send(password).unwrap(); + if authenticate { + // AuthenticationOk and ReadyForQuery: the client's session setup is a + // simple Query containing three SET statements. + if stream.write_all(b"R\0\0\0\x08\0\0\0\0Z\0\0\0\x05I").is_err() { + continue; + } + let mut tag = [0]; + let mut size = [0; 4]; + if stream.read_exact(&mut tag).is_err() || tag[0] != b'Q' || stream.read_exact(&mut size).is_err() { + continue; + } + let size = u32::from_be_bytes(size); + if !(4..=65536).contains(&size) || stream.read_exact(&mut vec![0; (size - 4) as usize]).is_err() { + continue; + } + for _ in 0..3 { + stream.write_all(b"C\0\0\0\x08SET\0").unwrap(); + } + stream.write_all(b"Z\0\0\0\x05I").unwrap(); + // The catalogue lookup uses an extended query. Consume through Sync so + // the server's non-login error occurs after successful authentication. + loop { + let mut size = [0; 4]; + if stream.read_exact(&mut tag).is_err() || stream.read_exact(&mut size).is_err() { + break; + } + let size = u32::from_be_bytes(size); + if !(4..=65536).contains(&size) || stream.read_exact(&mut vec![0; (size - 4) as usize]).is_err() { + break; + } + if tag[0] == b'S' { + break; + } + } + } + let body = if authenticate { + format!("SERROR\0VERROR\0C42P01\0Mfixture listing failed {echoed}\0\0") + } else { + format!("SFATAL\0VFATAL\0C28P01\0Mrefused {echoed}\0\0") + }; + let _ = stream.write_all(b"E"); + let _ = stream.write_all(&((body.len() + 4) as u32).to_be_bytes()); + let _ = stream.write_all(body.as_bytes()); + if authenticate { + let _ = stream.write_all(b"Z\0\0\0\x05I"); + // A catalogue error is nonfatal: keep the connection alive until the + // client closes it, rather than racing its error response with TCP EOF. + let mut terminate = [0; 5]; + let _ = stream.read_exact(&mut terminate); + } + } + }); + Self { + address: format!("postgres:host=127.0.0.1 port={port} user=packet_user dbname=gis sslmode=disable"), + packets, + stop, + worker: Some(worker), + } + } + + #[track_caller] + fn take(&self) -> Vec { + self.packets.recv_timeout(std::time::Duration::from_secs(3)).unwrap() + } + fn empty(&self) { + assert!(self.packets.try_recv().is_err(), "unexpected password packet"); + } +} +impl Drop for Server { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + worker.join().unwrap(); + } + } +} +fn app(store: Arc, prefs: &str) -> Session { + let mut s = Session::new(Project::default()); + s.database_logins.set_store(Some(store)); + s.connections = crate::connections::Connections::from_json(prefs); + s +} +fn project(address: &str) -> String { + project_layers(address, 2) +} + +/// A project whose first `n` layers read `address`. +fn project_layers(address: &str, n: usize) -> String { + let mut p = world().project; + for layer in p.map_mut().layers.iter_mut().take(n) { + layer.source = Some(serde_json::from_value(json!({"postgis":{"database":address,"table":"roads","family":"Lines"}})).unwrap()); + } + serde_json::to_string(&p).unwrap() +} +fn remember(s: &mut Session, name: &str, address: &str, password: &str) -> String { + let id = + s.run("connection.new", &json!({"kind":"postgres","name":name,"address":address})).unwrap()["id"].as_str().unwrap().to_string(); + assert_eq!(s.run("connection.login", &json!({"id":id,"password":password,"remember":true})).unwrap()["passwordSaved"], true); + id +} + +// Driver passwords are process-global. Isolate packet scenarios from unrelated full-suite +// project resets (and ambient-password precedence tests) without mutating the parent's environment. +fn isolated(name: &str) -> bool { + isolated_with(name, &[]) +} + +/// The same isolation, with extra environment for the child (an ambient password, say). +fn isolated_with(name: &str, extra: &[(&str, &str)]) -> bool { + if std::env::var("RUSTGIS_PACKET_SCENARIO").as_deref() == Ok(name) { + return false; + } + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", &format!("tests::remembered_database_packets::{name}"), "--test-threads=1"]) + .env("RUSTGIS_PACKET_SCENARIO", name) + .env_remove("PGPASSWORD") + .env_remove("PGPASSFILE"); + for (key, value) in extra { + command.env(key, value); + } + let output = command.output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let mut summaries = stdout.lines().filter(|line| line.starts_with("test result:")); + let ran_one = summaries.next().is_some_and(|line| line.starts_with("test result: ok. 1 passed; 0 failed; 0 ignored;")) + && summaries.next().is_none(); + assert!( + output.status.success() && ran_one, + "isolated database scenario failed or did not run exactly one test:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + true +} +#[test] +fn remembered_database_unique_packet_refusal_and_replacement() { + if isolated("remembered_database_unique_packet_refusal_and_replacement") { + return; + } + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut first = app(store.clone(), "{}"); + let id = remember(&mut first, "One", &server.address, "saved-packet-sentinel"); + let prefs = first.connections.to_json(); + let mut s = app(store.clone(), &prefs); + let text = project(&server.address); + s.run("project.openText", &json!({"text":text})).unwrap(); + assert!(s.map().layers.iter().take(2).all(|l| l.source.as_ref().is_some_and(|src| src.login)), "{:?}", s.notices); + assert_eq!(server.take(), b"saved-packet-sentinel\0"); + server.empty(); + assert!(s.map().layers.iter().take(2).all(|l| l.source.as_ref().unwrap().login)); + assert_eq!(s.run("connection.list", &Value::Null).unwrap()["loginRequests"][0]["reason"], "password"); + assert!(!s.notices.iter().any(|n| n.text.contains("saved-packet-sentinel"))); + assert!(s.map().layers.iter().take(2).all(|l| !l.source.as_ref().unwrap().error.as_ref().unwrap().contains("saved-packet-sentinel"))); + let saved = store.load(&format!("connection/{id}")).unwrap(); + s.run("project.openText", &json!({"text":text})).unwrap(); + server.empty(); + s.run("connection.login", &json!({"id":id,"password":"unchecked-new"})).unwrap(); + // One attempt per database: the refused password is not sent to the group's other layers. + assert_eq!(server.take(), b"unchecked-new\0"); + server.empty(); + assert_eq!(store.load(&format!("connection/{id}")).unwrap(), saved); + assert!(s.database_logins.is_rejected(&id)); + // A refused password is not "available for this session", and the store keeps the old value. + let refused = s.run("connection.login", &json!({"id":id,"password":"refused-with-remember","remember":true})).unwrap(); + assert_eq!(refused["passwordSaved"], false, "{refused}"); + assert!(refused.get("warning").is_none(), "a refused password must not be called available: {refused}"); + assert_eq!(server.take(), b"refused-with-remember\0"); + server.empty(); + assert_eq!(store.load(&format!("connection/{id}")).unwrap(), saved); + assert!(s.database_logins.is_rejected(&id)); + s.run("project.new", &Value::Null).unwrap(); + assert_eq!(s.run("connection.login", &json!({"id":id,"password":"checked-new","remember":true})).unwrap()["passwordSaved"], true); + assert!(!s.database_logins.is_rejected(&id)); +} +#[test] +fn remembered_database_ambiguity_requires_exact_id_packet() { + if isolated("remembered_database_ambiguity_requires_exact_id_packet") { + return; + } + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut first = app(store.clone(), "{}"); + let one = remember(&mut first, "One", &server.address, "first-sentinel"); + let two = remember(&mut first, "Two", &server.address, "second-sentinel"); + let mut s = app(store.clone(), &first.connections.to_json()); + let text = project(&server.address); + s.run("project.openText", &json!({"text":text})).unwrap(); + server.empty(); + let listing = s.run("connection.list", &Value::Null).unwrap(); + assert_eq!(listing["loginRequests"][0]["reason"], "choose"); + assert_eq!(listing["loginRequests"][0]["connections"], json!([one, two])); + assert!(s.connections.for_layer(&s.map().layers[0]).is_none()); + s.run("connection.login", &json!({"id":two})).unwrap(); + assert_eq!(server.take(), b"second-sentinel\0"); + server.empty(); + // Fresh sessions still see ambiguity; no prior automatic activation authorizes a choice. + let mut fresh = app(store.clone(), &first.connections.to_json()); + fresh.run("project.openText", &json!({"text":text})).unwrap(); + server.empty(); + assert_eq!(fresh.run("connection.list", &Value::Null).unwrap()["loginRequests"][0]["reason"], "choose"); + for id in [&one, &two] { + fresh.run("connection.forgetPassword", &json!({"id":id})).unwrap(); + } + let mut forgotten = app(store, &fresh.connections.to_json()); + forgotten.run("project.openText", &json!({"text":text})).unwrap(); + server.empty(); +} + +#[test] +fn remembered_database_exact_address_and_explicit_precedence_packets() { + if isolated("remembered_database_exact_address_and_explicit_precedence_packets") { + return; + } + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut seed = app(store.clone(), "{}"); + let id = remember(&mut seed, "Exact", &server.address, "exact-saved-sentinel"); + let prefs = seed.connections.to_json(); + let port = server.address.split_whitespace().find_map(|s| s.strip_prefix("port=")).unwrap(); + for address in [ + format!("postgresql://packet_user@127.0.0.1:{port}/gis?sslmode=disable"), + server.address.replace("user=packet_user dbname=gis", "dbname=gis user=packet_user"), + server.address.replace("user=packet_user", "user=other"), + ] { + let mut s = app(store.clone(), &prefs); + s.run("project.openText", &json!({"text":project(&address)})).unwrap(); + server.empty(); + } + let mut explicit = app(store, &prefs); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + rustgis_data::source::duckdb::remember_password(&key, "explicit-sentinel"); + explicit.run("project.openText", &json!({"text":project(&server.address)})).unwrap(); + assert_eq!(server.take(), b"explicit-sentinel\0"); + assert_eq!(server.take(), b"explicit-sentinel\0"); + server.empty(); + // An explicit login without a password must not replace the address's own password with the + // saved one either: the address's wins, as it does when the project opens. + explicit.run("connection.login", &json!({"id":id})).unwrap(); + assert_eq!(server.take(), b"explicit-sentinel\0"); + server.empty(); +} + +/// An explicitly initiated connection prefers the saved password to an ambient one (`PGPASSWORD`). +/// The ambient value is set in a child process: it is process-global, and the rest of the suite +/// must not see it. +#[test] +fn remembered_database_saved_password_wins_over_ambient_environment() { + if isolated_with("remembered_database_saved_password_wins_over_ambient_environment", &[("PGPASSWORD", "ambient-wrong-sentinel")]) { + return; + } + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut seed = app(store.clone(), "{}"); + let id = remember(&mut seed, "Ambient", &server.address, "ambient-saved-sentinel"); + let mut s = app(store, &seed.connections.to_json()); + // `connection.test` is explicitly initiated, so the driver may consult the ambient password: + // the saved one must be sent instead. + assert!(s.run("connection.test", &json!({"id":id})).is_err(), "the refusing server must fail the listing"); + assert_eq!(server.take(), b"ambient-saved-sentinel\0"); + server.empty(); +} + +#[test] +fn remembered_database_forget_preserves_active_owner_and_explicit_input() { + if isolated("remembered_database_forget_preserves_active_owner_and_explicit_input") { + return; + } + use rustgis_data::source::{duckdb, postgis}; + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut s = app(store.clone(), "{}"); + let a = remember(&mut s, "A", &server.address, "owner-a"); + let b = remember(&mut s, "B", &server.address, "owner-b"); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + s.run("connection.forgetPassword", &json!({"id":a})).unwrap(); + assert_eq!(s.database_logins.selection("postgres", &key), Some(b.as_str())); + assert!(postgis::connect(&key, false).is_err()); + assert_eq!(server.take(), b"owner-b\0"); + assert_eq!(s.database_logins.session_password(&a).unwrap().expose(), "owner-a"); + assert!(store.load(&format!("connection/{a}")).unwrap().is_none()); + + duckdb::remember_password(&key, "address-typed"); + s.run("connection.forgetPassword", &json!({"id":a})).unwrap(); + assert!(duckdb::has_explicit_password(&key)); + assert!(postgis::connect(&key, false).is_err()); + assert_eq!(server.take(), b"address-typed\0"); + + s.run("connection.login", &json!({"id":b,"password":"owner-b"})).unwrap(); + // A loaded/active cache value can differ from the ID's latest explicit session value. + // Forget must restore the session value, not preserve the old owned slot by accident. + s.database_logins.set_session_password(&b, "session-b-new"); + s.run("connection.forgetPassword", &json!({"id":b})).unwrap(); + assert!(postgis::connect(&key, false).is_err()); + assert_eq!(server.take(), b"session-b-new\0"); + assert_eq!(s.database_logins.selection("postgres", &key), Some(b.as_str())); + server.empty(); +} + +#[test] +fn remembered_database_forget_saved_only_clears_its_activation() { + if isolated("remembered_database_forget_saved_only_clears_its_activation") { + return; + } + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut seed = app(store.clone(), "{}"); + let id = remember(&mut seed, "Saved", &server.address, "saved-only"); + let mut s = app(store, &seed.connections.to_json()); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + s.run("connection.login", &json!({"id":id})).unwrap(); + assert!(!s.database_logins.has_session_password(&id)); + s.run("connection.forgetPassword", &json!({"id":id})).unwrap(); + assert!(!rustgis_data::source::duckdb::has_password(&key)); + assert!(!s.database_logins.activations().iter().any(|(_, active)| active == &id)); + server.empty(); +} + +#[test] +fn remembered_database_test_refusal_restores_prior_session_and_active_password() { + if isolated("remembered_database_test_refusal_restores_prior_session_and_active_password") { + return; + } + use rustgis_data::source::{duckdb, postgis}; + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut s = app(store.clone(), "{}"); + let a = remember(&mut s, "A", &server.address, "prior-a"); + let b = remember(&mut s, "B", &server.address, "prior-b"); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + let saved = store.load(&format!("connection/{a}")).unwrap(); + for (active, expected) in [(Some(b.as_str()), "prior-b"), (Some(a.as_str()), "prior-a"), (None, "prior-address")] { + if let Some(id) = active { + s.run("connection.login", &json!({"id":id,"password":expected})).unwrap(); + } else { + duckdb::remember_password(&key, expected); + } + let selection = s.database_logins.selection("postgres", &key).map(str::to_string); + let activations = s.database_logins.activations(); + let error = s.run("connection.test", &json!({"id":a,"password":"test-refused","remember":true})).unwrap_err(); + assert!(!error.contains("test-refused")); + assert_eq!(server.take(), b"test-refused\0"); + assert_eq!(s.database_logins.session_password(&a).unwrap().expose(), "prior-a"); + assert_eq!(s.database_logins.selection("postgres", &key), selection.as_deref()); + assert_eq!(s.database_logins.activations(), activations); + assert_eq!(duckdb::has_explicit_password(&key), active.is_none()); + assert!(postgis::connect(&key, false).is_err()); + let mut packet = expected.as_bytes().to_vec(); + packet.push(0); + assert_eq!(server.take(), packet); + assert_eq!(store.load(&format!("connection/{a}")).unwrap(), saved); + assert!(!s.database_logins.is_rejected(&a)); + } + duckdb::set_connection_password(&key, &a, Some("cleanup-owned")); + duckdb::forget_connection_password(&a); + let mut fresh = app(Arc::new(MemorySecrets::new()), "{}"); + fresh.run("connection.new", &json!({"kind":"postgres","name":"Empty","address":server.address})).unwrap(); + assert!(fresh.run("connection.test", &json!({"id":"c1","password":"test-refused"})).is_err()); + assert_eq!(server.take(), b"test-refused\0"); + assert!(!fresh.database_logins.has_session_password("c1")); + assert!(fresh.database_logins.selection("postgres", &key).is_none()); + assert!(!duckdb::has_password(&key)); + server.empty(); +} + +#[test] +fn remembered_database_test_listing_failure_keeps_authenticated_session() { + if isolated("remembered_database_test_listing_failure_keeps_authenticated_session") { + return; + } + let server = Server::start(true); + let store = Arc::new(MemorySecrets::new()); + let mut s = app(store.clone(), "{}"); + let a = remember(&mut s, "A", &server.address, "prior-a"); + let b = remember(&mut s, "B", &server.address, "prior-b"); + let error = s.run("connection.test", &json!({"id":a,"password":"accepted-new","remember":true})).unwrap_err(); + assert!(error.contains("fixture listing failed"), "{error}"); + assert!(!error.contains("accepted-new")); + assert_eq!(server.take(), b"accepted-new\0"); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + assert_eq!(s.database_logins.session_password(&a).unwrap().expose(), "accepted-new"); + assert_eq!(s.database_logins.selection("postgres", &key), Some(a.as_str())); + assert!(s.database_logins.activations().contains(&(key.clone(), a.clone()))); + assert!(s.database_logins.requests(s.opened).next().is_none()); + let envelope: Value = serde_json::from_str(store.load(&format!("connection/{a}")).unwrap().unwrap().expose()).unwrap(); + assert_eq!(envelope["password"], "prior-a"); + assert_eq!(s.database_logins.session_password(&b).unwrap().expose(), "prior-b"); + server.empty(); +} + +#[test] +fn remembered_database_incremental_change_resolves_only_its_database() { + if isolated("remembered_database_incremental_change_resolves_only_its_database") { + return; + } + let server = Server::start(true); + let store = Arc::new(MemorySecrets::new()); + let mut s = app(store, "{}"); + let id = remember(&mut s, "Waiting", &server.address, "waiting-sentinel"); + // One layer waits for this database. Opening the project connects once, and the catalogue + // read fails (not a refusal), so the group stays unopened for a later resolution. + s.run("project.openText", &json!({"text":project_layers(&server.address, 1)})).unwrap(); + assert_eq!(server.take(), b"waiting-sentinel\0"); + // A change to another connection must not connect to this database again. + s.run( + "connection.new", + &json!({"kind":"postgres","name":"Far","address":"postgres:host=127.0.0.1 port=1 user=far dbname=gis sslmode=disable"}), + ) + .unwrap(); + server.empty(); + // A change to this database's own definition still resolves its layers. + let same = s.run("connection.new", &json!({"kind":"postgres","name":"Same","address":server.address})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + assert_eq!(server.take(), b"waiting-sentinel\0"); + s.run("connection.remove", &json!({"id":same})).unwrap(); + assert_eq!(server.take(), b"waiting-sentinel\0"); + server.empty(); + assert!(s.connections.find(&id).is_some()); +} + +#[test] +fn remembered_database_refused_login_does_not_keep_the_typed_password() { + if isolated("remembered_database_refused_login_does_not_keep_the_typed_password") { + return; + } + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut seed = app(store.clone(), "{}"); + let id = remember(&mut seed, "Refused", &server.address, "saved-a"); + let mut s = app(store.clone(), &seed.connections.to_json()); + // The saved password is refused, so the layer waits and the login below reaches the server. + s.run("project.openText", &json!({"text":project_layers(&server.address, 1)})).unwrap(); + assert_eq!(server.take(), b"saved-a\0"); + assert!(s.database_logins.is_rejected(&id)); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + let selection = s.database_logins.selection("postgres", &key).map(str::to_string); + let activations = s.database_logins.activations(); + // A password typed for the login is sent, but a refusal does not make it this session's. + let result = s.run("connection.login", &json!({"id":id,"password":"typed-wrong"})).unwrap(); + assert_eq!(result["failed"], 1, "{result}"); + assert_eq!(server.take(), b"typed-wrong\0"); + assert!(!s.database_logins.has_session_password(&id)); + assert!(!rustgis_data::source::duckdb::has_password(&key)); + assert_eq!(s.database_logins.selection("postgres", &key), selection.as_deref()); + assert_eq!(s.database_logins.activations(), activations); + assert!(store.load(&format!("connection/{id}")).unwrap().is_some()); + server.empty(); +} + +#[test] +fn remembered_database_passwordless_login_keeps_another_connection_active() { + if isolated("remembered_database_passwordless_login_keeps_another_connection_active") { + return; + } + use rustgis_data::source::duckdb; + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut s = app(store, "{}"); + let a = remember(&mut s, "A", &server.address, "owner-a"); + let b = s.run("connection.new", &json!({"kind":"postgres","name":"B","address":server.address})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + s.run("connection.login", &json!({"id":a})).unwrap(); + assert!(duckdb::has_connection_password(&key, &a)); + // A login for a connection with no password of its own fails without taking the address's + // activation away from the connection that is using it. + let error = s.run("connection.login", &json!({"id":b})).unwrap_err(); + assert!(error.contains("password is required"), "{error}"); + assert!(duckdb::has_connection_password(&key, &a)); + assert!(s.database_logins.activations().contains(&(key.clone(), a.clone()))); + server.empty(); +} + +#[test] +fn remembered_database_refused_login_restores_previous_driver_owner() { + if isolated("remembered_database_refused_login_restores_previous_driver_owner") { + return; + } + use rustgis_data::source::{duckdb, postgis}; + let server = Server::new(); + let store = Arc::new(MemorySecrets::new()); + let mut s = app(store.clone(), "{}"); + let a = remember(&mut s, "A", &server.address, "prior-a"); + let b = remember(&mut s, "B", &server.address, "prior-b"); + // Stage two waiting layers without letting a project open change the active credential. + s.project = serde_json::from_str(&project(&server.address)).unwrap(); + for layer in s.map_mut().layers.iter_mut().take(2) { + layer.source.as_mut().unwrap().login = true; + } + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + let saved = store.load(&format!("connection/{a}")).unwrap(); + for (owner, expected) in [(Some(b.as_str()), "prior-b"), (Some(a.as_str()), "prior-a"), (None, "prior-address")] { + if let Some(owner) = owner { + duckdb::set_connection_password(&key, owner, Some(expected)); + s.database_logins.select("postgres", &key, owner); + s.database_logins.note_activation(&key, owner); + } else { + duckdb::remember_password(&key, expected); + } + let selection = s.database_logins.selection("postgres", &key).map(str::to_string); + let activations = s.database_logins.activations(); + let result = s.run("connection.login", &json!({"id":a,"password":"login-refused","remember":true})).unwrap(); + assert_eq!(result["failed"], 2); + assert_eq!(result["passwordSaved"], false); + assert_eq!(server.take(), b"login-refused\0"); + server.empty(); + assert_eq!(s.database_logins.session_password(&a).unwrap().expose(), "prior-a"); + assert_eq!(s.database_logins.selection("postgres", &key), selection.as_deref()); + assert_eq!(s.database_logins.activations(), activations); + assert_eq!(duckdb::has_explicit_password(&key), owner.is_none()); + if let Some(owner) = owner { + assert!(duckdb::has_connection_password(&key, owner)); + } + let listing = s.run("connection.list", &Value::Null).unwrap(); + assert_eq!(listing["loginRequests"][0]["reason"], "password"); + assert_eq!(listing["loginRequests"][0]["connections"], json!([a])); + assert!(postgis::connect(&key, false).is_err()); + let mut packet = expected.as_bytes().to_vec(); + packet.push(0); + assert_eq!(server.take(), packet); + assert_eq!(store.load(&format!("connection/{a}")).unwrap(), saved); + } + server.empty(); +} + +#[test] +fn remembered_database_cached_session_refusal_evicts_reused_password() { + if isolated("remembered_database_cached_session_refusal_evicts_reused_password") { + return; + } + use rustgis_data::source::duckdb; + let server = Server::new(); + for command in ["connection.login", "connection.test", "connection.browse", "project.openText"] { + let store = Arc::new(MemorySecrets::new()); + let mut s = app(store.clone(), "{}"); + let id = s.run("connection.new", &json!({"kind":"postgres","name":"Session","address":server.address})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + s.run("connection.login", &json!({"id":id,"password":"cached-refused"})).unwrap(); + let text = project(&server.address); + s.project = serde_json::from_str(&text).unwrap(); + for layer in s.map_mut().layers.iter_mut().take(2) { + layer.source.as_mut().unwrap().login = true; + } + let params = if command == "project.openText" { json!({"text":text}) } else { json!({"id":id}) }; + let result = s.run(command, ¶ms); + match command { + "connection.login" => assert_eq!(result.unwrap()["failed"], 2), + "project.openText" => assert!(result.is_ok()), + _ => assert!(!result.unwrap_err().contains("cached-refused")), + } + assert_eq!(server.take(), b"cached-refused\0", "{command}"); + server.empty(); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + assert!(!s.database_logins.has_session_password(&id), "{command}"); + assert!(!duckdb::has_password(&key), "{command}"); + assert!(!s.database_logins.activations().iter().any(|(active, _)| active == &key), "{command}"); + assert!(!s.database_logins.is_rejected(&id), "a session refusal must not reject the saved credential"); + assert!(store.load(&format!("connection/{id}")).unwrap().is_none()); + let listing = s.run("connection.list", &Value::Null).unwrap(); + assert_eq!(listing["connections"][0]["loggedIn"], false); + assert_eq!(listing["loginRequests"][0]["reason"], "password"); + assert_eq!(listing["loginRequests"][0]["connections"], json!([id])); + // A second password-free operation must not put the refused password on the wire again. + let _ = s.run(command, ¶ms); + server.empty(); + } +} + +#[test] +fn remembered_database_cached_session_listing_failure_keeps_password() { + if isolated("remembered_database_cached_session_listing_failure_keeps_password") { + return; + } + use rustgis_data::source::duckdb; + let server = Server::start(true); + for command in ["connection.login", "connection.test", "connection.browse", "project.openText"] { + let mut s = app(Arc::new(MemorySecrets::new()), "{}"); + let id = s.run("connection.new", &json!({"kind":"postgres","name":"Session","address":server.address})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + s.run("connection.login", &json!({"id":id,"password":"cached-accepted"})).unwrap(); + let text = project_layers(&server.address, 1); + s.project = serde_json::from_str(&text).unwrap(); + s.map_mut().layers[0].source.as_mut().unwrap().login = true; + let params = if command == "project.openText" { json!({"text":text}) } else { json!({"id":id}) }; + let result = s.run(command, ¶ms); + match command { + "connection.login" => assert_eq!(result.unwrap()["failed"], 1), + "project.openText" => assert!(result.is_ok()), + _ => assert!(result.unwrap_err().contains("fixture listing failed")), + } + assert_eq!(server.take(), b"cached-accepted\0", "{command}"); + server.empty(); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + assert_eq!(s.database_logins.session_password(&id).unwrap().expose(), "cached-accepted", "{command}"); + assert!(duckdb::has_connection_password(&key, &id), "{command}"); + assert!(s.database_logins.activations().contains(&(key, id.clone())), "{command}"); + assert!(!s.database_logins.is_rejected(&id)); + assert!(s.database_logins.requests(s.opened).next().is_none(), "{command}"); + } +} + +#[test] +fn remembered_database_session_refusal_suppresses_only_matching_saved_value() { + if isolated("remembered_database_session_refusal_suppresses_only_matching_saved_value") { + return; + } + use rustgis_data::source::duckdb; + let server = Server::new(); + for same in [true, false] { + for command in ["connection.login", "connection.test", "connection.browse", "project.openText"] { + let store = Arc::new(MemorySecrets::new()); + let mut s = app(store.clone(), "{}"); + let id = remember(&mut s, "Remembered", &server.address, "stored-value"); + let attempted = if same { "stored-value" } else { "session-other" }; + if !same { + s.run("connection.login", &json!({"id":id,"password":attempted})).unwrap(); + } + let saved = store.load(&format!("connection/{id}")).unwrap(); + let text = project(&server.address); + s.project = serde_json::from_str(&text).unwrap(); + for layer in s.map_mut().layers.iter_mut().take(2) { + layer.source.as_mut().unwrap().login = true; + } + // Passwordless login invalidates the saved cache before using the session value; + // the other entry points exercise the already-cached saved copy. + let params = if command == "project.openText" { json!({"text":text}) } else { json!({"id":id}) }; + let result = s.run(command, ¶ms); + match command { + "connection.login" => assert_eq!(result.unwrap()["failed"], 2), + "project.openText" => assert!(result.is_ok()), + _ => assert!(!result.unwrap_err().contains(attempted)), + } + let mut packet = attempted.as_bytes().to_vec(); + packet.push(0); + assert_eq!(server.take(), packet, "{command}, same={same}"); + server.empty(); + let (_, key) = crate::connections::database_key(&server.address).unwrap(); + assert!(!s.database_logins.has_session_password(&id)); + assert!(!duckdb::has_password(&key)); + assert_eq!(s.database_logins.is_rejected(&id), same, "{command}"); + assert!(s.connections.find(&id).unwrap().remembered); + assert_eq!(store.load(&format!("connection/{id}")).unwrap(), saved); + // Automatic resolution must not retry the same refused bytes via the saved copy. + s.run("project.openText", &json!({"text":text})).unwrap(); + if !same { + // A different saved value was not refused by the first attempt and remains eligible. + assert_eq!(server.take(), b"stored-value\0", "{command}"); + } + server.empty(); + assert_eq!(s.run("connection.list", &Value::Null).unwrap()["loginRequests"][0]["reason"], "password"); + assert_eq!(store.load(&format!("connection/{id}")).unwrap(), saved); + } + } +} diff --git a/crates/ui-egui/src/connectionui.rs b/crates/ui-egui/src/connectionui.rs index a1a2ec8f1..43e5d2fc5 100644 --- a/crates/ui-egui/src/connectionui.rs +++ b/crates/ui-egui/src/connectionui.rs @@ -1,7 +1,7 @@ -//! Named endpoint definitions and session-only logins, with web probes fetched off the frame. +//! Named endpoint definitions, optional remembered database passwords, and off-frame web probes. use egui::vec2; -use rustgis_engine::connections::{Connection, DATABASE_KINDS}; +use rustgis_engine::connections::{Connection, DATABASE_KINDS, LoginReason, LoginRequest, database_key, database_label}; use rustgis_engine::render::rustgis_tiles::{cache, s3}; use rustgis_engine::sources::Source; use serde_json::{Value, json}; @@ -20,6 +20,10 @@ pub struct State { pub credentials: String, pub profile: String, pub password: String, + pub remember: bool, + /// The current project's login prompt, never a password. + pub request: Option, + pub selected: Option, pub access_key: String, pub secret_key: String, pub session_token: String, @@ -108,7 +112,7 @@ pub(crate) fn keep_button(ui: &mut egui::Ui) -> bool { fn stripped_notice(app: &mut RustGisApp, v: &Value) { if v["stripped"] == true { - app.session.notify(tl!("A password or token in the address was left out: connections never keep secrets.").to_string(), false); + app.session.notify(tl!("A password or token in the address was left out of the saved connection definition.").to_string(), false); } } @@ -149,7 +153,13 @@ pub fn open_edit(app: &mut RustGisApp, id: &str) -> Result<(), String> { pub fn log_in(app: &mut RustGisApp, id: &str) { match app.run("ui.connection", json!({"id": id})) { - Ok(_) => app.dialogs.info = Some(tl!("Type the password or keys to log in for this session, then Save.").to_string()), + Ok(_) => { + app.dialogs.info = Some(if DATABASE_KINDS.contains(&app.ui.connection.kind.as_str()) { + tl!("Type the password, then Save. Remember on this computer is optional.").to_string() + } else { + tl!("Type the keys to log in for this session, then Save.").to_string() + }); + } Err(e) => app.session.notify(e, true), } } @@ -218,7 +228,126 @@ pub fn start_test(app: &mut RustGisApp) { app.dialogs.info = Some(tl!("Testing…").into()); } +/// A request remains authorized only for its original project and exact saved definitions. +fn candidates(app: &RustGisApp, request: &LoginRequest) -> Vec { + if request.opened != app.session.opened { + return Vec::new(); + } + request + .connections + .iter() + .filter_map(|id| { + let c = app.session.connections.find(id)?; + let (kind, key) = database_key(&c.address)?; + (kind == request.kind && key == request.key).then(|| c.clone()) + }) + .collect() +} + +fn password_request(app: &RustGisApp, id: &str) -> Option { + app.session + .database_logins + .requests(app.session.opened) + .find(|r| r.reason == LoginReason::Password && r.connections.iter().any(|c| c == id)) + .cloned() +} + +fn password_prompt(app: &mut RustGisApp, id: &str, request: Option, error: Option) { + match open_edit(app, id) { + Ok(()) => { + app.ui.connection.request = request; + app.dialogs.info = Some(tl!("The saved password could not be used. Type a password to try again; check Remember on this computer to replace the saved password.").into()); + app.dialogs.error = error; + } + Err(e) => app.session.notify(e, true), + } +} + +pub(crate) fn choose(app: &mut RustGisApp, request: LoginRequest) { + if candidates(app, &request).is_empty() { + return; + } + app.ui.connection = State { request: Some(request), ..Default::default() }; + app.dialogs = crate::dialogs::Dialogs { open: Some("chooseConnection"), ..Default::default() }; +} + +pub(crate) fn chooser(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { + let Some(request) = app.ui.connection.request.clone() else { return true }; + let list = candidates(app, &request); + if list.is_empty() { + return true; + } + ui.label(tl!("Choose which saved connection to use for this database. This choice lasts for this session.")); + ui.label(database_label(request.kind, &request.key)); + egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { + for c in &list { + ui.group(|ui| { + ui.selectable_value(&mut app.ui.connection.selected, Some(c.id.clone()), &c.name); + if !c.group.is_empty() { + ui.label(egui::RichText::new(&c.group).weak()); + } + ui.label(egui::RichText::new(&c.address).small().weak()); + }); + } + }); + let selected = app.ui.connection.selected.clone().filter(|id| list.iter().any(|c| &c.id == id)); + ui.horizontal(|ui| { + if ui.add_enabled(selected.is_some(), egui::Button::new(tl!("Use Connection"))).clicked() + && let Some(id) = selected + { + let result = app.run("connection.login", json!({"id":id})); + crate::connectiontree::refresh(app, Some(&id)); + let refused = password_request(app, &id); + if result.is_err() || refused.is_some() { + password_prompt(app, &id, refused.or(Some(request)), result.err()); + return false; + } + return true; + } + ui.button(tl!("Cancel")).clicked() + }) + .inner +} + +fn poll_requests(app: &mut RustGisApp) { + if let Some(request) = &app.ui.connection.request + && matches!(app.dialogs.open, Some("chooseConnection" | "editConnection")) + && (request.opened != app.session.opened || candidates(app, request).is_empty()) + { + app.dialogs.open = None; + app.ui.connection = State::default(); + } + // A pending request never displaces an unrelated dialog, including a form being edited. + if app.dialogs.open.is_some() { + return; + } + while let Some(request) = app.session.database_logins.next_unprompted(app.session.opened) { + if app.ui.connection.request.as_ref().is_some_and(|shown| { + shown.opened == request.opened + && shown.kind == request.kind + && shown.key == request.key + && shown.reason == request.reason + && shown.connections == request.connections + }) { + // A layer-menu prompt may have shown this request before automatic polling reached + // it. In particular, cancelling it must not immediately open it again. + continue; + } + let list = candidates(app, &request); + if list.is_empty() { + continue; + } + if request.reason == LoginReason::Choose || list.len() > 1 { + choose(app, request); + } else { + password_prompt(app, &list[0].id, Some(request), None); + } + break; + } +} + pub fn poll(app: &mut RustGisApp, ctx: &egui::Context) { + poll_requests(app); if !matches!(app.dialogs.open, Some("newConnection" | "editConnection")) { app.ui.connection.test = None; return; @@ -298,15 +427,34 @@ fn save(app: &mut RustGisApp) -> Result<(), String> { stripped_notice(app, &v); let mut login = json!({"id": v["id"]}); secrets(&app.ui.connection, &mut login); - if login.as_object().is_some_and(|p| p.len() > 1) - && let Err(e) = app.run("connection.login", login) - { - app.ui.connection.id = v["id"].as_str().map(str::to_owned); - app.dialogs.open = Some("editConnection"); - return Err(e); + if login.as_object().is_some_and(|p| p.len() > 1) { + if DATABASE_KINDS.contains(&app.ui.connection.kind.as_str()) { + login["remember"] = json!(app.ui.connection.remember); + } + let id = v["id"].as_str().unwrap_or("").to_owned(); + let result = app.run("connection.login", login); + crate::connectiontree::refresh(app, Some(&id)); + let refused = password_request(app, &id); + if result.is_err() || refused.is_some() { + app.ui.connection.id = Some(id); + app.ui.connection.password.clear(); + app.ui.connection.remember = false; + app.ui.connection.request = refused; + app.dialogs.open = Some("editConnection"); + return Err(result.err().unwrap_or_else(|| tl!("The password was refused. Type a password to try again.").into())); + } + // The command says why a checked Remember didn't keep the password; nothing is claimed + // when it did. + if let Some(warning) = result.as_ref().ok().and_then(|v| v["warning"].as_str()) { + app.session.notify(warning.to_string(), true); + } + } + if let Some(id) = v["id"].as_str() { + crate::connectiontree::refresh(app, Some(id)); } let st = &mut app.ui.connection; st.password.clear(); + st.remember = false; st.access_key.clear(); st.secret_key.clear(); st.session_token.clear(); @@ -314,9 +462,14 @@ fn save(app: &mut RustGisApp) -> Result<(), String> { } pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { - ui.label( - egui::RichText::new(tl!("A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved.")).weak(), - ); + let help = if DATABASE_KINDS.contains(&app.ui.connection.kind.as_str()) { + tl!( + "A named endpoint you can reuse. Passwords last for this session unless you choose to remember them in this computer's password store." + ) + } else { + tl!("A named endpoint you can reuse. Keys and tokens are kept until RustGIS closes, never saved.") + }; + ui.label(egui::RichText::new(help).weak()); let st = &mut app.ui.connection; let database = DATABASE_KINDS.contains(&st.kind.as_str()); let mut changed = false; @@ -341,14 +494,12 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ui.end_row(); ui.label(tl!("Password")); changed |= ui - .add( - egui::TextEdit::singleline(&mut st.password) - .password(true) - .hint_text(tl!("This session only (optional)")) - .desired_width(360.0), - ) + .add(egui::TextEdit::singleline(&mut st.password).password(true).hint_text(tl!("Optional")).desired_width(360.0)) .changed(); ui.end_row(); + ui.label(""); + changed |= ui.checkbox(&mut st.remember, tl!("Remember on this computer")).changed(); + ui.end_row(); } else if st.kind == "s3" { for (label, field, hint) in [ (tl!("Endpoint"), &mut st.address, tl!("Blank for Amazon S3")), @@ -434,6 +585,25 @@ pub fn open_connection(app: &mut RustGisApp, c: &Connection) -> Result<(), Strin if cfg!(target_arch = "wasm32") { return Err(tl!("Database connections are used by the desktop app").into()); } + if app.session.database_logins.has_session_password(&c.id) || c.remembered { + let result = app.run("connection.login", json!({"id":c.id})); + crate::connectiontree::refresh(app, Some(&c.id)); + let refused = password_request(app, &c.id); + if result.is_err() || refused.is_some() { + password_prompt(app, &c.id, refused, result.err()); + return Ok(()); + } + } + #[cfg(not(target_arch = "wasm32"))] + if !app.session.database_logins.has_session_password(&c.id) + && !c.remembered + && let Some((_, key)) = database_key(&c.address) + { + // Picking an ID without credentials must not inherit another ID's activation. + // An unowned, explicitly typed address password remains highest priority. + rustgis_engine::data::source::duckdb::set_connection_password(&key, &c.id, None); + app.session.database_logins.drop_activation(&key); + } ("database", Value::Null) } "service" => ("service", json!({"type":"auto"})), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index dbd831dc9..124ad1f3d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -80,6 +80,7 @@ impl Dialogs { "addUrl" => "addUrl", "addService" => "addService", "addDatabase" => "addDatabase", + "chooseConnection" => "chooseConnection", "s3" => "s3", "zarr" => "zarr", "exportLayer" => "exportLayer", @@ -178,6 +179,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "addDatabase" => "Add From Database", "newConnection" => "New Connection", "editConnection" => "Edit Connection", + "chooseConnection" => "Choose Connection", "s3" => "S3 Buckets", "zarr" => "Add Zarr Variable", "exportLayer" => "Export Features", @@ -203,7 +205,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { ui.set_width(match which { "coordinateSystem" | "zarr" => 660.0, "addTiles" | "addUrl" | "addService" | "s3" => 560.0, - "newConnection" | "editConnection" => 520.0, + "newConnection" | "editConnection" | "chooseConnection" => 520.0, _ => 440.0, }); ui.spacing_mut().item_spacing = vec2(8.0, 8.0); @@ -301,6 +303,7 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "addService" => close = add_service(app, ui), "addDatabase" => close = add_database(app, ui), "newConnection" | "editConnection" => close = crate::connectionui::dialog(app, ui), + "chooseConnection" => close = crate::connectionui::chooser(app, ui), "s3" => crate::s3ui::dialog(app, ui), "zarr" => close = crate::zarrui::dialog(app, ui), "exportLayer" => close = export_layer(app, ui), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a0911461..ee0760af3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -910,6 +910,9 @@ impl RustGisApp { pub fn with_services(mut self, services: Services) -> Self { self.services = services; sourcespane::load_connections(&mut self); + // Saved database passwords live in the system's store, which the engine reaches through + // the session so the assistant and scripts see the same logins as the interface. + self.session.database_logins.set_store(self.services.secrets.clone()); if let Some(lang) = self.services.load_pref.as_ref().and_then(|load| load("language")) { self.ui.language = lang; } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 4a03c1a0a..8a5831884 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1806,18 +1806,38 @@ fn layer_menu(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, reorder: bool) { let _ = app.run("ui.refreshService", json!({"layer":l.id})); ui.close(); } - let login = l - .source - .as_ref() - .is_some_and(|s| s.login) - .then(|| app.session.connections.for_layer(l)) - .flatten() - .map(|c| (c.id.clone(), c.name.clone())); - if let Some((id, name)) = login - && ui.button(crate::i18n::fmt("Log In to {name}…", &[("name", &name)])).clicked() - { - crate::connectionui::log_in(app, &id); - ui.close(); + let login = l.source.as_ref().and_then(|s| { + let (kind, database) = rustgis_engine::connections::layer_database(s)?; + let connections: Vec<_> = app.session.connections.matching_database(kind, database).into_iter().cloned().collect(); + let (_, key) = rustgis_engine::connections::database_key(database)?; + if !s.login && !app.session.database_logins.requests(app.session.opened).any(|r| r.kind == kind && r.key == key) { + return None; + } + Some((kind, key, connections)) + }); + if let Some((kind, key, connections)) = login { + if connections.len() == 1 { + let c = &connections[0]; + if ui.button(crate::i18n::fmt("Log In to {name}…", &[("name", &c.name)])).clicked() { + crate::connectionui::log_in(app, &c.id); + ui.close(); + } + } else if connections.len() > 1 && ui.button(tl!("Choose Connection…")).clicked() { + let opened = app.session.opened; + crate::connectionui::choose( + app, + rustgis_engine::connections::LoginRequest { + reason: rustgis_engine::connections::LoginReason::Choose, + kind, + key, + connections: connections.iter().map(|c| c.id.clone()).collect(), + layers: vec![l.id], + opened, + prompted: true, + }, + ); + ui.close(); + } } let items: &[(&str, &str)] = if l.is_raster() { &[("Zoom To Layer", "layer.zoomTo"), ("Symbology", "ui.pane.symbology")] diff --git a/crates/ui-egui/src/sourcespane.rs b/crates/ui-egui/src/sourcespane.rs index bcf88484e..d9887f488 100644 --- a/crates/ui-egui/src/sourcespane.rs +++ b/crates/ui-egui/src/sourcespane.rs @@ -252,6 +252,9 @@ fn connections(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, list: &[Connection } }); if ui.small_button("⟳").on_hover_text(tl!("Refresh every connection's lists")).clicked() { + for c in list.iter().filter(|c| DATABASE_KINDS.contains(&c.kind.as_str())) { + app.session.database_logins.retry_saved(&c.id); + } crate::connectiontree::refresh(app, None); } }); @@ -312,6 +315,10 @@ fn connection_header(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connecti } else { ui.link(&c.name).on_hover_text(if c.kind == "s3" && c.address.is_empty() { tl!("Amazon S3") } else { &c.address }) }; + // The status precedes the kind: the narrow pane clips trailing row text. + if DATABASE_KINDS.contains(&c.kind.as_str()) && c.remembered { + ui.label(RichText::new(tl!("Saved password")).small().weak()); + } ui.label(RichText::new(tl!(crate::connectionui::label(&c.kind))).small().weak()); response.context_menu(|ui| { let mut items = vec![ @@ -336,7 +343,17 @@ fn connection_header(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connecti crate::connectionui::log_in(app, &c.id); ui.close(); } + if DATABASE_KINDS.contains(&c.kind.as_str()) && c.remembered && ui.button(tl!("Forget Saved Password")).clicked() { + match app.run("connection.forgetPassword", json!({"id":c.id})) { + Ok(_) => crate::connectiontree::refresh(app, Some(&c.id)), + Err(e) => app.session.notify(e, true), + } + ui.close(); + } if ui.button(tl!("Refresh")).clicked() { + if DATABASE_KINDS.contains(&c.kind.as_str()) { + app.session.database_logins.retry_saved(&c.id); + } crate::connectiontree::refresh(app, Some(&c.id)); ui.close(); } @@ -353,8 +370,9 @@ fn connection_header(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, c: &Connecti ui.close(); } if ui.button(tl!("Remove")).clicked() { - if let Err(e) = app.run("connection.remove", json!({"id":c.id})) { - app.session.notify(e, true); + match app.run("connection.remove", json!({"id":c.id})) { + Ok(_) => crate::connectiontree::refresh(app, Some(&c.id)), + Err(e) => app.session.notify(e, true), } ui.close(); } diff --git a/crates/ui-egui/tests/remembered_database/mod.rs b/crates/ui-egui/tests/remembered_database/mod.rs new file mode 100644 index 000000000..41c95ba41 --- /dev/null +++ b/crates/ui-egui/tests/remembered_database/mod.rs @@ -0,0 +1,244 @@ +use super::*; +use egui::accesskit::Role; +use rustgis_engine::data::secrets::{MemorySecrets, SecretError, SecretStore}; +use rustgis_ui_egui::Services; +use std::sync::Arc; + +fn with_store(store: Arc) -> egui_kittest::Harness<'static, RustGisApp> { + let app = RustGisApp::with_sample().with_services(Services { secrets: Some(store), ..Default::default() }); + egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ) +} +fn settle(h: &mut egui_kittest::Harness<'_, RustGisApp>) { + for _ in 0..4 { + h.step(); + } +} +const ADDRESS: &str = "postgres:host=127.0.0.1 port=1 dbname=ui_remembered user=smoke sslmode=disable"; + +#[test] +fn remembered_database_checkbox_test_save_forget_and_reset() { + let store = Arc::new(MemorySecrets::new()); + let mut h = with_store(store.clone()); + h.state_mut().run("ui.connection", json!({"kind":"postgres"})).unwrap(); + h.state_mut().ui.connection.name = "Remembered UI".into(); + h.state_mut().ui.connection.address = ADDRESS.into(); + h.state_mut().ui.connection.password = "ui-password-sentinel".into(); + settle(&mut h); + assert!(!h.state().ui.connection.remember); + h.get_by_label("Remember on this computer").click(); + settle(&mut h); + assert!(h.state().ui.connection.remember); + h.get_by_role_and_label(Role::Button, "Test").click(); + settle(&mut h); + assert!(h.state().session.connections.connections.is_empty()); + assert_eq!(store.load("connection/c1").unwrap(), None); + h.get_by_role_and_label(Role::Button, "Save").click(); + settle(&mut h); + assert_eq!(h.state().dialogs.open, None); + let id = h.state().session.connections.find("Remembered UI").unwrap().id.clone(); + assert!(h.state().session.connections.find(&id).unwrap().remembered); + assert!(store.load(&format!("connection/{id}")).unwrap().is_some()); + h.state_mut().run("ui.connection", json!({"id":id})).unwrap(); + settle(&mut h); + assert!(!h.state().ui.connection.remember); + assert!(h.state().ui.connection.password.is_empty()); + h.get_by_role_and_label(Role::Button, "Cancel").click(); + h.state_mut().run("ui.pane.sources", json!({})).unwrap(); + settle(&mut h); + h.get_by_label("Remembered UI").click_secondary(); + settle(&mut h); + h.get_by_label("Forget Saved Password").click(); + settle(&mut h); + assert_eq!(store.load(&format!("connection/{id}")).unwrap(), None); + assert!(!h.state().session.connections.find(&id).unwrap().remembered); + assert!(h.state().session.database_logins.has_session_password(&id)); +} + +#[test] +fn remembered_database_failed_store_has_visible_notice() { + let mut h = with_store(Arc::new(MemorySecrets::failing(SecretError::Denied))); + h.state_mut().run("ui.connection", json!({"kind":"postgres"})).unwrap(); + h.state_mut().ui.connection.name = "Session only UI".into(); + h.state_mut().ui.connection.address = ADDRESS.into(); + h.state_mut().ui.connection.password = "session-only-sentinel".into(); + settle(&mut h); + h.get_by_label("Remember on this computer").click(); + settle(&mut h); + h.get_by_role_and_label(Role::Button, "Save").click(); + settle(&mut h); + assert_eq!(h.state().dialogs.open, None); + let c = h.state().session.connections.find("Session only UI").unwrap(); + assert!(!c.remembered); + assert!(h.state().session.database_logins.has_session_password(&c.id)); + assert!(h.state().session.notices.iter().any(|n| n.error)); + assert!(!h.state().session.notices.iter().any(|n| n.text.contains("session-only-sentinel"))); +} + +#[test] +fn remembered_database_chooser_cancel_selection_and_project_scope() { + let store = Arc::new(MemorySecrets::new()); + let mut seed = with_store(store.clone()); + for name in ["First database", "Second database"] { + let id = seed.state_mut().run("connection.new", json!({"kind":"postgres","name":name,"address":ADDRESS})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + seed.state_mut().run("connection.login", json!({"id":id,"password":"chooser-ui-sentinel","remember":true})).unwrap(); + } + let prefs = seed.state().session.connections.to_json(); + let mut project = seed.state().session.project.clone(); + project.map_mut().layers[0].source = + Some(serde_json::from_value(json!({"postgis":{"database":ADDRESS,"table":"roads","family":"Lines"}})).unwrap()); + let text = serde_json::to_string(&project).unwrap(); + let mut h = with_store(store); + h.state_mut().session.connections = rustgis_engine::connections::Connections::from_json(&prefs); + h.state_mut().run("project.openText", json!({"text":text})).unwrap(); + settle(&mut h); + assert_eq!(h.state().dialogs.open, Some("chooseConnection")); + assert!( + h.state().session.database_logins.selection("postgres", &rustgis_engine::connections::database_key(ADDRESS).unwrap().1).is_none() + ); + h.get_by_role_and_label(Role::Button, "Cancel").click(); + settle(&mut h); + assert_eq!(h.state().dialogs.open, None); + assert!(!h.state().session.map().layers[0].source.as_ref().unwrap().is_open()); + h.state_mut().run("project.openText", json!({"text":text})).unwrap(); + settle(&mut h); + h.query_all_by_label("Second database").next().unwrap().click(); + settle(&mut h); + h.get_by_role_and_label(Role::Button, "Use Connection").click(); + settle(&mut h); + let key = rustgis_engine::connections::database_key(ADDRESS).unwrap().1; + assert_eq!(h.state().session.database_logins.selection("postgres", &key), Some("c2")); + h.state_mut().run("project.new", json!({})).unwrap(); + settle(&mut h); + assert_ne!(h.state().dialogs.open, Some("chooseConnection")); + assert!(h.state().session.database_logins.requests(h.state().session.opened).next().is_none()); +} + +#[test] +fn remembered_database_refused_saved_login_opens_empty_unchecked_form() { + if std::env::var_os("RUSTGIS_UI_REFUSAL_CHILD").is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "remembered_database::remembered_database_refused_saved_login_opens_empty_unchecked_form", + "--test-threads=1", + ]) + .env("RUSTGIS_UI_REFUSAL_CHILD", "1") + .env_remove("PGPASSWORD") + .env_remove("PGPASSFILE") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let mut summaries = stdout.lines().filter(|line| line.starts_with("test result:")); + let ran_one = summaries.next().is_some_and(|line| line.starts_with("test result: ok. 1 passed; 0 failed; 0 ignored;")) + && summaries.next().is_none(); + assert!( + output.status.success() && ran_one, + "isolated UI refusal failed or did not run exactly one test:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr) + ); + return; + } + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let worker = std::thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(std::time::Duration::from_secs(3))).unwrap(); + let mut size = [0; 4]; + if stream.read_exact(&mut size).is_err() { + continue; + } + let size = u32::from_be_bytes(size); + assert!((4..=65536).contains(&size)); + if stream.read_exact(&mut vec![0; (size - 4) as usize]).is_err() { + continue; + } + stream.write_all(b"R\0\0\0\x08\0\0\0\x03").unwrap(); + let mut tag = [0]; + if stream.read_exact(&mut tag).is_err() { + continue; + } + let mut size = [0; 4]; + if tag[0] != b'p' || stream.read_exact(&mut size).is_err() { + continue; + } + let size = u32::from_be_bytes(size); + assert!((4..=65536).contains(&size)); + let mut password = vec![0; (size - 4) as usize]; + stream.read_exact(&mut password).unwrap(); + assert_eq!(password, b"refused-ui-sentinel\0"); + let body = b"SFATAL\0VFATAL\0C28P01\0Mrefused refused-ui-sentinel\0\0"; + stream.write_all(b"E").unwrap(); + stream.write_all(&((body.len() + 4) as u32).to_be_bytes()).unwrap(); + stream.write_all(body).unwrap(); + } + }); + let store = Arc::new(MemorySecrets::new()); + let address = format!("postgres:host=127.0.0.1 port={port} dbname=gis user=refused_ui sslmode=disable"); + let mut seed = with_store(store.clone()); + seed.state_mut().run("connection.new", json!({"kind":"postgres","name":"Refused UI","address":address})).unwrap(); + seed.state_mut().run("connection.login", json!({"id":"c1","password":"refused-ui-sentinel","remember":true})).unwrap(); + let prefs = seed.state().session.connections.to_json(); + let mut project = seed.state().session.project.clone(); + project.map_mut().layers[0].source = + Some(serde_json::from_value(json!({"postgis":{"database":address,"table":"roads","family":"Lines"}})).unwrap()); + let mut h = with_store(store); + h.state_mut().session.connections = rustgis_engine::connections::Connections::from_json(&prefs); + h.state_mut().run("project.openText", json!({"text":serde_json::to_string(&project).unwrap()})).unwrap(); + worker.join().unwrap(); + settle(&mut h); + assert_eq!(h.state().dialogs.open, Some("editConnection")); + assert!(h.state().ui.connection.password.is_empty()); + assert!(!h.state().ui.connection.remember); + assert!(h.state().dialogs.info.is_some()); + assert!(h.state().session.database_logins.is_rejected("c1")); + assert!(!h.state().session.notices.iter().any(|n| n.text.contains("refused-ui-sentinel"))); +} + +#[test] +fn remembered_database_recording_history_recent_and_preferences_never_get_sentinel() { + use std::collections::HashMap; + use std::sync::Mutex; + let store = Arc::new(MemorySecrets::new()); + let prefs = Arc::new(Mutex::new(HashMap::::new())); + let (load, save) = (prefs.clone(), prefs.clone()); + let mut app = RustGisApp::with_sample().with_services(Services { + secrets: Some(store.clone()), + load_pref: Some(Box::new(move |k| load.lock().unwrap().get(k).cloned())), + save_pref: Some(Box::new(move |k, v| { + save.lock().unwrap().insert(k.into(), v.into()); + })), + ..Default::default() + }); + app.run("ui.taskRecord", json!({})).unwrap(); + app.run("connection.new", json!({"kind":"postgres","name":"No leak","address":ADDRESS})).unwrap(); + app.run("connection.login", json!({"id":"No leak","password":"boundary-sentinel","remember":true})).unwrap(); + let mut project = app.session.project.clone(); + let (text, _) = project.saved_text().unwrap(); + let outputs = [ + serde_json::to_string(&app.ws.recording).unwrap(), + app.run("sources.list", json!({})).unwrap().to_string(), + app.run("connection.list", json!({})).unwrap().to_string(), + app.run("gp.historyScript", json!({})).unwrap_or_default().to_string(), + serde_json::to_string(&*prefs.lock().unwrap()).unwrap(), + text, + format!("{:?}", app.session.database_logins), + format!("{:?}", app.session.notices), + ]; + for output in outputs { + assert!(!output.contains("boundary-sentinel"), "{output}"); + assert!(!output.contains("\"password\":\"boundary-sentinel\""), "{output}"); + } + assert!(store.load("connection/c1").unwrap().is_some()); +} diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index cd4356c9c..0bcbddad7 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -4,6 +4,9 @@ use egui_kittest::kittest::Queryable; use rustgis_ui_egui::RustGisApp; use serde_json::json; +#[cfg(not(target_arch = "wasm32"))] +mod remembered_database; + /// Builds a headless World Atlas UI for interaction tests. fn harness() -> egui_kittest::Harness<'static, RustGisApp> { egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( diff --git a/docs/user-guide/data.md b/docs/user-guide/data.md index 62ecccf22..e85d75e71 100644 --- a/docs/user-guide/data.md +++ b/docs/user-guide/data.md @@ -163,17 +163,17 @@ type, and empty tables (such as the TIGER geocoder's) are left out. Features kee views: the table's integer primary key, else the row's place in the table (the rows of a view or a partitioned table without a key are numbered as read, so theirs can change between views). -The password is kept until RustGIS closes and never written to the project, which names the -connection without it. Without one, `PGPASSWORD` or libpq's password file (`~/.pgpass`, or -`%APPDATA%\postgresql\pgpass.conf`) serve when you add a table. A project keeps a streamed -table's connection and reads it again when it opens, sending only a password given in this -session: a project file could name any host, and must never get your own credentials. When the -server wants a password and the layer's database matches a saved connection, the project says -which connection is waiting. Right-click that connection in **Data Sources › Connections**, or a -waiting layer, and choose **Log In…**. Saving the password reopens every layer from that database. -Without a matching connection, connect again with **Add Data › Database…** (the layer reads again -when the project is next opened). A small table's features are kept in the project as they were -when added. +Passwords are never written to the project. By default, an entered password lasts until +RustGIS closes. For a named desktop connection, **Remember on this computer** is an unchecked +opt-in to the operating system's password store; see [Named connections](#named-connections) +for reconnect, selection and forgetting rules. Opening a project can use a remembered password +only for a unique, exact saved-address match, or an explicit session selection. Otherwise a +waiting layer offers **Log In…**, or a chooser when several remembered connections match. +Without a matching connection, connect again with **Add Data › Database…**. +`PGPASSWORD` or libpq's password file (`~/.pgpass`, or +`%APPDATA%\postgresql\pgpass.conf`) can serve when you explicitly add a table without a password; +opening a project never newly authorizes these ambient credentials. A small table's features +are kept in the project as they were when added. `layer.addSql` takes a connection as its `"database"` too, through DuckDB's `postgres` extension (installed the first time, like DuckDB Spatial), so a query names its tables plainly; the password @@ -231,10 +231,11 @@ the column's SRID (MySQL's per-column SRID, MariaDB's `REF_SYSTEM_ID`) or else i becomes a layer per type. Features keep their ids between views from the table's integer primary key; a table without one has its rows numbered as read, so theirs can change between views. -The password is kept until RustGIS closes and never written to the project; `MYSQL_PWD` serves -when you add a table without one, and a project opening sends only a password given in this -session, as for PostGIS. SQL queries (`layer.addSql`, the SQL pane) don't run on MySQL or MariaDB -databases. +Passwords are never written to the project and last for the session unless you opt in with +**Remember on this computer** for a named desktop connection. The same exact-address reconnect, +chooser and Forget rules in [Named connections](#named-connections) apply. `MYSQL_PWD` can serve when you explicitly +add a table without a password, but opening a project never newly authorizes it. +SQL queries (`layer.addSql`, the SQL pane) don't run on MySQL or MariaDB databases. ## SQL Server and Azure SQL @@ -278,13 +279,15 @@ read or sample locally instead of sending a viewport polygon to the server. A sm therefore miss features in a large sampled table. `geometry` layers and higher compatibility levels retain server-side viewport filtering. -The provider only reads data; edits are not written back to SQL Server. The password is kept -until RustGIS closes, never in the project. Secret-named query values such as `pwd` and -`access_token` are removed from saved connection addresses. `SQLCMDPASSWORD` serves only when adding a database, -not when reopening a project. A streamed layer reopening sends only credentials given this -session; Windows integrated sign-in also requires that same address to have been added this -session. Reconnect with **Add Data › Database…** when needed. SQL queries (`layer.addSql`, the SQL -pane) do not run on SQL Server connections. +The provider only reads data; edits are not written back to SQL Server. Passwords are never +written to the project and last for the session unless **Remember on this computer** is +checked for a named desktop connection. The [named-connection rules](#named-connections) govern +remembered reconnects and explicit selection. Secret-named query values such as `pwd` and +`access_token` are removed from saved connection addresses. `SQLCMDPASSWORD` serves only when +explicitly adding a database, not when opening a project. Windows integrated sign-in still +requires that same address to have been added this session: opening a project does not +authorize Windows identity. Reconnect with **Add Data › Database…** when needed. SQL queries +(`layer.addSql`, the SQL pane) do not run on SQL Server connections. ## Web Data @@ -457,29 +460,80 @@ names the command and parameters that add it (`service.add`, `layer.addDatabase` it left some out. Like `connection.test`, it reads the address itself on the desktop and the command line; with `probe: true` it returns the address to fetch, and the app passes what it fetched back as `answer` (and further `documents` when a service asks for them, as `next`). -Browsing never changes the project or the saved connections. - -Addresses are saved without passwords or tokens. `connection.login` remembers a database -`password`, or S3 `accessKeyId`, `secretAccessKey` and optional `sessionToken`, for this session -only, using the same credential stores as the existing add commands. With login parameters, -`connection.test` logs in first; a failed listing does not undo that session login. A database -login also reopens project layers that read the same password-free address and were refused for a -missing or wrong password. `connection.list` gives such waiting layers as `waiting`, and -`connection.login` gives `reopened` and `failed` when it tries any of them. A server that is down -or a missing table is not reported as waiting for a login. +Browsing never changes the project. When a saved password turns out to be missing, or to have been +saved for another address, the connection loses its **Saved password** marker; the stored entry +itself is left for **Forget Saved Password**, an address edit or a removal to delete. + +Addresses are saved without passwords or tokens. Database passwords entered through +`connection.login` are kept separately by connection ID for this session. +On desktop, **Remember on this computer** in the new, edit and login forms is always unchecked +initially. Checking it opts that submitted password into the operating system's password store; +**Test** never saves it. The command equivalent is +`connection.login {"id":"c1","password":"…","remember":true}`; `remember` defaults to false. +If the store is missing, unavailable or denies access, the login remains usable for the +session and a visible warning says it was not remembered (`passwordSaved:false` in the result). +The browser and command line have no OS-store hook and remain session-only. + +The stored envelope is bound to the connection's ID, kind and cleaned address, including its +username. Renaming or regrouping preserves it. Changing the address or username, or removing +the connection, deletes the old stored password before committing the change; if deletion +fails, the definition remains unchanged. Invalid edits do not delete a password. +Duplicating a connection copies neither its stored password nor its session login. +**Saved password** marks a remembered connection without reading the store just to display it. +**Forget Saved Password** (`connection.forgetPassword {"id":"c1"}`) deletes the stored value, +but leaves an explicitly entered session password and already-open layers alone. Forgetting an +inactive connection never selects it or replaces another connection's active password. + +Projects remain address-only: they contain neither passwords nor saved connection IDs. +On opening a project, one exact matching remembered connection may reconnect automatically. +Matching strips passwords and outer wrappers/whitespace, but does not infer that URI and +libpq forms, reordered parameters, host aliases or different usernames mean the same server. +Several eligible remembered matches open a chooser with no default; cancel leaves the layers +unavailable. Choose a connection explicitly, or use `connection.login {"id":"c2"}` to select +its existing session or remembered password without exposing it. Selection lasts for this +session, not in the project. `connection.list` reports secret-free `loginRequests` with the +reason, database label, candidate IDs and affected-layer count; ambiguous layers are not +attributed to the first saved row. `loggedIn` means this ID has a session password or an already +loaded remembered password available; it does not certify successful server authentication. + +Explicit session input or selection takes precedence over a saved password; a saved password +takes precedence over existing ambient fallbacks on explicitly initiated connections. +Opening a project never newly authorizes environment/password-file credentials or Windows +identity. A positively refused saved password is not retried for the rest of the session, +and no other saved password is tried automatically. Its old OS-store value is retained. +The fresh login form has an empty password and Remember unchecked: an unchecked replacement is +session-only and does not overwrite or delete the old value; checking Remember and successfully +saving replaces it. A password the server refuses is not kept as this session's, and is not sent +to the database's other layers. A refused typed login restores the previous active password, +selection and activation; with no previous password, the attempted value is removed. +If a password reused from this ID's session cache is refused, that cached value, its owned +driver entry and activation are cleared, and a password is requested. An identical saved value +is also suppressed for this session; a different saved value is not marked refused by this +attempt. The OS-store entry is retained in either case. +A transport error or missing table is not a wrong-password prompt. Use Forget to delete a +previously saved password. + +A database login reopens waiting layers at the same password-free address; +`connection.list` reports them as `waiting`, and `connection.login` returns `reopened` and +`failed` counts. Adding, editing or removing a saved connection, or adding a layer, resolves +only the databases that change: an unrelated database that is down is not connected to again. +`connection.test` never persists a password. For a named connection, if the +server positively refuses a password typed for that Test, the previous per-ID session credential, +selection and active address password are restored (or the attempted credential is removed if +none existed). +A failure after authentication, such as a table-listing error, keeps the new session login. +S3 `accessKeyId`, `secretAccessKey` and optional `sessionToken` remain session-only. S3 profiles and environment credentials remain subject to the existing endpoint restrictions; editing an S3 name, endpoint or options disconnects its session login. S3 shares the name-based session registry used by the S3 dialog and `s3.connect`, so editing or removing a saved connection also affects a session entry of the same name created there. -Renaming clears the runtime entries under both the old and new names, so the renamed +Renaming clears runtime entries under both the old and new names, so the renamed connection cannot inherit an unrelated login at its destination name. Web-service logins are not supported. Plugins may list connections but cannot manage, log in -to, test or browse them. The in-app assistant asks for approval each time for those operations; -external assistants using MCP cannot run them. Even `probe: true` needs approval: connection -operations cannot be approved for the whole conversation. -Database passwords are shared by public DSN for the session, including existing layers and -other named connections using that DSN. Editing or removing a named connection does not revoke -that shared login; changing its address does not transfer the old password to the new address. +to, forget passwords for, test or browse them. The in-app assistant asks for fresh approval +each time for those operations, including Forget and scripted calls; external assistants +using MCP cannot run them. Even `probe: true` needs approval: connection operations cannot be +approved for the whole conversation. Desktop and web apps save connections with their preferences (`connections` in desktop `prefs.json`, `rustgis.connections` in web localStorage). The first load imports saved services, From bb961a970fc8b1bd611af6c379e99a7af0c0c36e Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sat, 24 Oct 2026 13:31:20 +0200 Subject: [PATCH 094/297] fix(map): Load All Features waits for a web source still opening (#775) * fix(map): Load All Features waits for a web source still opening * fix(map): Load All Features names a source still opening after the wait; the test holds the server until the load waits --- crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ crates/map/src/lib.rs | 9 +++++++++ 2 files changed, 41 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0f35303fc..40c25058b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9709,6 +9709,38 @@ fn streamed_layer_outside_its_zoom_range_reads_nothing() { assert!(sent.load(std::sync::atomic::Ordering::Relaxed) > before); } +/// Load All Features on a layer whose web source is still opening waits for it to open, rather +/// than fail with "the layer's source isn't open" (the server here holds its answers a while). +#[test] +fn load_all_features_waits_for_a_source_still_opening() { + use rustgis_data::{Dataset, Feature, Geometry, GeometryType}; + let mut ds = Dataset::new("pts", GeometryType::Point, vec![]); + for i in 0..100u32 { + let c = rustgis_geom::Coord::new(f64::from(i) * 0.1, 1.0); + ds.features.push(Feature { id: u64::from(i) + 1, geometry: Some(Geometry::Point(c)), attrs: vec![] }); + } + let fgb = rustgis_data::source::fgb::write(&ds).unwrap(); + let (base, _) = serve_ranges(fgb.clone()); + let mut s = Session::new(Project::default()); + s.run("layer.addUrl", &json!({"url": format!("{base}/pts.fgb")})).unwrap(); + let text = serde_json::to_string(&s.project).unwrap(); + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (slow, _) = serve_gated(fgb, true, Some(gate.clone())); + let mut t = Session::new(Project::default()); + t.run("project.openText", &json!({"text": text.replace(&base, &slow)})).unwrap(); + let layer = t.map().layers.iter().find(|l| l.is_streamed()).unwrap().clone(); + // Started as the view starts it, and held by the server: still opening when the load begins. + let mut started = layer.source.clone().unwrap(); + started.open().unwrap(); + assert!(!started.is_open() && started.is_pending()); + let load = std::thread::spawn(move || layer.load_all()); + // Still waiting while the server holds its answers, rather than failed at once. + std::thread::sleep(std::time::Duration::from_millis(300)); + assert!(!load.is_finished(), "the load didn't wait for the source: {:?}", load.join().unwrap().err()); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + assert_eq!(load.join().unwrap().unwrap().features.len(), 100); +} + /// A FlatGeobuf and a GeoParquet by their address are read in place, by range: a view fetches a /// small part of the file; the project keeps the address and reads it again; asked to, a file is /// downloaded whole instead. diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index 9bb76f2e6..a95ac42b4 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -392,6 +392,15 @@ impl Layer { let mut copy = src.clone(); if !copy.is_open() { copy.open()?; + // A source by its web address may still be opening in the background (the view or the + // project started it): wait for it, as an export does, rather than read nothing. + copy.wait_open(std::time::Duration::from_secs(30)); + if !copy.is_open() { + return Err(copy + .error + .clone() + .unwrap_or_else(|| "the layer's source is still opening: try again once it has opened".into())); + } } let (mut ds, _) = copy.read_all()?; // A sample isn't all of them: refuse rather than run a tool on part of the data. From 5b892876f4a36fe384af15594c9daeab32637a85 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Sat, 24 Oct 2026 08:32:43 -0300 Subject: [PATCH 095/297] Analysis: a trend over a state in one call from `rustgis-cli run`, with progress for trends and summaries (#579) (#792) * cube.trend and cube.reduce: one call takes a state where only a shell or a script waits (rustgis-cli run, Python), their progress on stderr; a STAC series' search takes 10,000 items Run from the command line or Python, cube.trend took the Script pane's tenth of the panes' bound on pairs (2 billion) and a STAC search 2,000 items, so a state at ~1 km over 25 years (4.4 billion pairs, four MODIS tiles: 2,304 items) had to be cut into bands by hand. Session::long_runs, set by `rustgis-cli run` and Python, gives a direct trend the panes' bound (20 billion) and runs cube.trend and cube.reduce on a thread that tells their progress (`cube.trend: 42%` on stderr, ending at 100% on success; a line a failure cut short is ended before the error). `ask` and `mcp` keep the waited bound, since an assistant or an MCP client waits on them. A trend's or a summary's search takes MAX_SERIES_ITEMS (10,000) while the time slider's cube keeps MAX_CUBE_ITEMS. Co-Authored-By: Claude Opus 5.5 * Long runs: progress stops at 99% until the run has succeeded; the result comes back over a channel, not polled; the guide says the 10,000-item search holds in the Script pane too Co-Authored-By: Claude Opus 5.5 * Long runs: the STAC search's 10,000 items follow long_runs like the pairs; 100% told once the layers are added; one run_here for both commands; a line a tenth in a log; Python's docs say what a long command holds Co-Authored-By: Claude Opus 5.5 * Long runs: Python keeps the Script pane's bounds (it holds the GIL and Ctrl+C waits for the end); only rustgis-cli run takes the panes' Co-Authored-By: Claude Opus 5.5 * Long runs: a worker's panic is said with its cause; the test's doc rewrapped; the CLI's progress state noted as one command's at a time Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- apps/rustgis-cli/src/main.rs | 37 +++++++++- crates/engine/src/cmd/cube.rs | 123 +++++++++++++++++++++++++++++++--- crates/engine/src/cmd/stac.rs | 21 ++++-- crates/engine/src/lib.rs | 7 ++ crates/engine/src/tests.rs | 99 +++++++++++++++++++++++++++ docs/cli.md | 8 +++ docs/python.md | 6 ++ docs/user-guide/analysis.md | 14 +++- 8 files changed, 294 insertions(+), 21 deletions(-) diff --git a/apps/rustgis-cli/src/main.rs b/apps/rustgis-cli/src/main.rs index 1d4622d35..ce0ec841b 100644 --- a/apps/rustgis-cli/src/main.rs +++ b/apps/rustgis-cli/src/main.rs @@ -26,8 +26,9 @@ //! be run with `--cmd`. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use std::io::Write; +use std::io::{IsTerminal, Write}; use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; /// `println!` that ignores a closed pipe (`rustgis-cli commands | head`). macro_rules! out { @@ -54,10 +55,41 @@ fn print(v: &Value) { out!("{}", serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())); } +// The progress line's state, for the one command `run` runs at a time (and resets after it). + +/// Whether a progress line is left without its end (a run that failed partway). +static PROGRESS_OPEN: AtomicBool = AtomicBool::new(false); +/// The tenth of a run last told where standard error isn't a terminal (a log, CI). +static TOLD_TENTH: AtomicUsize = AtomicUsize::new(usize::MAX); + +/// A long run's progress on standard error, one line rewritten (`cube.trend: 42%`); in a log, a +/// line every tenth and at the end. +fn progress(what: &str, share: f64) { + let percent = (share * 100.0).clamp(0.0, 100.0); + let ended = share >= 1.0; + if !std::io::stderr().is_terminal() { + let tenth = (percent / 10.0) as usize; + if TOLD_TENTH.swap(if ended { usize::MAX } else { tenth }, Ordering::Relaxed) != tenth || ended { + eprintln!("{what}: {percent:.0}%"); + } + return; + } + eprint!("\r{what}: {percent:.0}%"); + if ended { + eprintln!(); + } + PROGRESS_OPEN.store(!ended, Ordering::Relaxed); +} + /// Runs a command; what it said beside its result (the app's status bar) goes to standard error. fn run(s: &mut Session, id: &str, p: Value) -> Result { let mark = s.notice_mark(); let r = s.run(id, &p); + // A progress line a failure cut short ends here, so what follows starts a line of its own. + if PROGRESS_OPEN.swap(false, Ordering::Relaxed) { + eprintln!(); + } + TOLD_TENTH.store(usize::MAX, Ordering::Relaxed); // A failure is noted too; it's printed once, by the caller. for n in s.notices_since(mark).iter().filter(|n| r.as_ref().err() != Some(&n.text)) { eprintln!("rustgis-cli: {}{}", if n.error { "error: " } else { "" }, n.text); @@ -701,6 +733,9 @@ fn main() -> ExitCode { mcp(&mut s, &args) } "run" => { + // Nothing waits on `run` but the shell (an assistant or an MCP client does, and has a + // timeout): long runs take the app's bounds, their progress on stderr. + s.long_runs = Some(progress); open_inputs(&mut s, &args)?; for (i, a) in args.iter().enumerate() { if a == "--cmd" diff --git a/crates/engine/src/cmd/cube.rs b/crates/engine/src/cmd/cube.rs index 7895df4bf..206e12d8a 100644 --- a/crates/engine/src/cmd/cube.rs +++ b/crates/engine/src/cmd/cube.rs @@ -40,11 +40,13 @@ pub const MAX_VALUES: u64 = if cfg!(target_arch = "wasm32") { 16_000_000 } else /// The most pairs of dates a trend compares in all (cells × dates² ⁄ 2), which bounds the time /// Sen's slope takes: far fewer in the browser, which works them out on one thread. At the cap a /// run can take minutes on the desktop, which the app runs on threads with progress and Cancel; -/// `cube.trend` called directly (a script, the CLI) takes [`MAX_WAITED_PAIRS`]. +/// `cube.trend` called directly (a script, the assistant, an MCP client) takes +/// [`MAX_WAITED_PAIRS`], but where only a shell waits on it (`rustgis-cli run`: +/// [`Session::long_runs`](crate::Session::long_runs)) it takes this. pub const MAX_PAIRS: u64 = if cfg!(target_arch = "wasm32") { 50_000_000 } else { 20_000_000_000 }; /// The most pairs a trend worked out where it's called compares (`cube.trend` without `ran`: a -/// script, the CLI), which waits for it with no Cancel: a tenth of the app's on the desktop. The -/// browser works every trend out where it's called, within its own lower cap. +/// script, the assistant, an MCP client), which waits for it with no Cancel: a tenth of the app's +/// on the desktop. The browser works every trend out where it's called, within its own lower cap. pub const MAX_WAITED_PAIRS: u64 = if cfg!(target_arch = "wasm32") { MAX_PAIRS } else { MAX_PAIRS / 10 }; /// The most dates a cube is read at. pub const MAX_DATES: usize = 2_000; @@ -930,11 +932,94 @@ fn trend_cmd(s: &mut Session, p: &Value) -> CmdResult { let (_, rasters, dates) = s.trend_results.remove(at); return add_outputs(s, rasters, dates); } - // Worked out here, waited for with no Cancel (a script, the CLI): a run of seconds, not minutes. + // Worked out here, waited for with no Cancel: in the app (a script, the assistant) and Python a + // run of seconds, not minutes; where only a shell waits on it (`rustgis-cli run`), the panes' + // bound, its progress told. let mut run = trend_start(s, p)?; - run.max_pairs = MAX_WAITED_PAIRS; - let rasters = run.run(&AtomicBool::new(false), &AtomicUsize::new(0))?; - add_outputs(s, rasters, run.steps.len()) + run.max_pairs = if s.long_runs.is_some() { MAX_PAIRS } else { MAX_WAITED_PAIRS }; + let dates = run.steps.len(); + let rasters = run_here(s.long_runs, "cube.trend", run.total(), |cancel, done| run.run(cancel, done))?; + let added = add_outputs(s, rasters, dates); + told_end(s.long_runs, "cube.trend", added) +} + +/// A run worked out where it's called: on a thread of its own telling its progress where only a +/// shell waits on it ([`Session::long_runs`](crate::Session::long_runs)), here +/// otherwise (and always in the browser, which has no threads). Its end is told by +/// [`told_end`], once the command has added what it made. +fn run_here( + long_runs: Option, + what: &str, + total: usize, + work: impl FnOnce(&AtomicBool, &AtomicUsize) -> Result + Send, +) -> Result { + #[cfg(not(target_arch = "wasm32"))] + if let Some(tell) = long_runs { + return run_telling(what, total, tell, work); + } + #[cfg(target_arch = "wasm32")] + let _ = (long_runs, what, total); + work(&AtomicBool::new(false), &AtomicUsize::new(0)) +} + +/// A long command's result, its end (a share of 1) told first when it succeeded: after its layers +/// were added, so a failure to add them isn't told as done. +fn told_end(long_runs: Option, what: &str, r: CmdResult) -> CmdResult { + if r.is_ok() + && let Some(tell) = long_runs + { + tell(what, 1.0); + } + r +} + +/// A long run on a thread of its own, its progress told every half second when it has moved by a +/// percent, up to 99% (its outputs are still being made, and added, when every slice is read). +#[cfg(not(target_arch = "wasm32"))] +fn run_telling( + what: &str, + total: usize, + tell: fn(&str, f64), + work: impl FnOnce(&AtomicBool, &AtomicUsize) -> Result + Send, +) -> Result { + use std::sync::mpsc::{RecvTimeoutError, channel}; + let (cancel, done) = (AtomicBool::new(false), AtomicUsize::new(0)); + let total = total.max(1); + std::thread::scope(|scope| { + let (sent, ended) = channel(); + let (c, d) = (&cancel, &done); + let worker = scope.spawn(move || { + let _ = sent.send(work(c, d)); + }); + let mut told = usize::MAX; + // Waited for, not polled, so a short run returns as soon as it ends. + let r = loop { + match ended.recv_timeout(std::time::Duration::from_millis(500)) { + Ok(r) => break Some(r), + Err(RecvTimeoutError::Timeout) => { + let percent = (done.load(Ordering::Relaxed).min(total) * 100 / total).min(99); + if percent != told { + told = percent; + tell(what, percent as f64 / 100.0); + } + } + // The worker panicked before it sent anything. + Err(RecvTimeoutError::Disconnected) => break None, + } + }; + // Joined here, so its panic is taken and said, not raised again when the scope ends. + match (r, worker.join()) { + (Some(r), _) => r, + (None, Err(panic)) => { + let why = panic.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| panic.downcast_ref::().cloned()); + Err(match why { + Some(why) => format!("{what} failed unexpectedly: {why}"), + None => format!("{what} failed unexpectedly"), + }) + } + (None, Ok(())) => Err(format!("{what} failed unexpectedly")), + } + }) } /// Adds a trend's (or a summary's) layers, each as a GeoTIFF the project keeps. @@ -1304,12 +1389,13 @@ pub fn reduce_start(s: &Session, p: &Value) -> Result { Ok(ReduceRun { cube, steps, stat, min_share, group_of, groups }) } -/// `cube.reduce`: the summary computed here (blocking), its layers added, each kept in the project -/// as a GeoTIFF. +/// `cube.reduce`: the summary computed here (blocking), its progress told where only a shell waits +/// on it (`rustgis-cli run`), its layers added, each kept in the project as a GeoTIFF. fn reduce_cmd(s: &mut Session, p: &Value) -> CmdResult { let run = reduce_start(s, p)?; - let rasters = run.run(&AtomicBool::new(false), &AtomicUsize::new(0))?; - add_outputs(s, rasters, run.dates()) + let rasters = run_here(s.long_runs, "cube.reduce", run.total(), |cancel, done| run.run(cancel, done))?; + let added = add_outputs(s, rasters, run.dates()); + told_end(s.long_runs, "cube.reduce", added) } #[cfg(test)] @@ -1336,6 +1422,21 @@ mod tests { assert_eq!(super::date_in_text("MOD13Q1.A2024153"), day("2024-06-01")); } + /// A long run's thread that panics gives an error naming the command and the cause, and its + /// end is never told. + #[cfg(not(target_arch = "wasm32"))] + #[test] + #[allow(clippy::panic)] + fn a_long_run_that_panics_says_why() { + fn tell(_: &str, share: f64) { + assert!(share < 1.0, "a run that panicked is told as done"); + } + let e = super::run_telling::<()>("cube.trend", 10, tell, |_, _| panic!("a band out of range")).unwrap_err(); + assert_eq!(e, "cube.trend failed unexpectedly: a band out of range"); + let e = super::run_telling::<()>("cube.reduce", 10, tell, |_, _| std::panic::panic_any(7u8)).unwrap_err(); + assert_eq!(e, "cube.reduce failed unexpectedly"); + } + /// Slices an ulp apart are one grid; half a pixel apart, another. #[test] fn grids_within_a_hair_are_one() { diff --git a/crates/engine/src/cmd/stac.rs b/crates/engine/src/cmd/stac.rs index 6ffe3cc3c..f085d51f9 100644 --- a/crates/engine/src/cmd/stac.rs +++ b/crates/engine/src/cmd/stac.rs @@ -247,8 +247,14 @@ fn add_asset(s: &mut Session, p: &Value) -> CmdResult { super::layer::add_url(s, &json!({"url": href, "name": name})) } -/// The most items a data cube takes (its search is paged up to this). +/// The most items a data cube on the time slider takes (its search is paged up to this). pub const MAX_CUBE_ITEMS: usize = 2000; +/// The most items a trend or a summary over a STAC collection takes (`cube.trend`, `cube.reduce`) +/// where only a shell waits on it ([`Session::long_runs`]): their reads are bounded by their +/// cells and dates (pieces, pairs), not their files, so a state's four MODIS tiles over 25 years +/// (2,304) fit. In the app, which waits for the search with no Cancel, and in Python, they take +/// [`MAX_CUBE_ITEMS`]. +pub const MAX_SERIES_ITEMS: usize = 10_000; /// The most pages of a search a data cube reads, whatever they hold. pub const MAX_CUBE_PAGES: usize = 100; @@ -296,11 +302,11 @@ pub(crate) fn period_after(v: &Value) -> Option { next.and_then(|n| rustgis_data::time::parse_text(&n)) } -/// Searches a collection over an area and period, every page up to [`MAX_CUBE_ITEMS`], and +/// Searches a collection over an area and period, every page up to `max_items`, and /// groups the items' asset by tile and day: `{"url"?, "collection", "asset", "bbox"? | "view", /// "start", "end", "maxCloud"?}`. #[cfg(not(target_arch = "wasm32"))] -pub fn cube_files(s: &Session, p: &Value) -> Result { +pub fn cube_files(s: &Session, p: &Value, max_items: usize) -> Result { let api = stac::api(ps(p, "url"))?; if stac::catalog::is_static(&api) { return Err("a data cube needs a STAC API's search; a static catalog can't be searched".into()); @@ -365,8 +371,8 @@ pub fn cube_files(s: &Session, p: &Value) -> Result { break; } items.extend(page); - if items.len() > MAX_CUBE_ITEMS { - return Err(format!("more than {MAX_CUBE_ITEMS} items: take a shorter period or a smaller area")); + if items.len() > max_items { + return Err(format!("more than {max_items} items: take a shorter period or a smaller area")); } next = more; } @@ -587,7 +593,8 @@ pub fn stac_cube(s: &Session, p: &Value) -> Result { if let Some(o) = searched.as_object_mut() { o.insert("bbox".into(), json!(bbox)); } - let CubeFiles { tiles, scales, nodata, collection, asset, .. } = cube_files(s, &searched)?; + let CubeFiles { tiles, scales, nodata, collection, asset, .. } = + cube_files(s, &searched, if s.long_runs.is_some() { MAX_SERIES_ITEMS } else { MAX_CUBE_ITEMS })?; // Every tile's file for a date, by day. let mut by_day: std::collections::BTreeMap)>> = Default::default(); for days in tiles.values() { @@ -857,7 +864,7 @@ fn add_cube(s: &mut Session, p: &Value) -> CmdResult { } }; let CubeFiles { tiles, scale, scales: _, nodata: _, items, named_tiles, without, duplicates, scales_differ, collection, asset } = - cube_files(s, p)?; + cube_files(s, p, MAX_CUBE_ITEMS)?; // One range can't hold if the files store their values alike no longer (a processing // change of scale or offset partway). if range.is_some() && scales_differ { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c8acff3ac..c40d164fa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -602,6 +602,12 @@ pub struct Session { /// Trends the app worked out on a thread, waiting for `cube.trend {"ran"}`: the id, the /// layers and how many dates (see [`cmd::cube::land`]). pub(crate) trend_results: Vec<(u64, Vec, usize)>, + /// Set where nothing waits on a command but a shell (`rustgis-cli run`): long runs a command + /// works out where it's called (a trend over a state from a data cube) take the app's larger + /// bounds, as its panes do on a thread, and tell their progress (a share, 0 to 1, and 1 when + /// one succeeds) through this. Python leaves it unset: a run there holds the interpreter's + /// lock and can't be interrupted, so it keeps the Script pane's bounds. + pub long_runs: Option, } /// The most `layer.addUrl` requests kept for the front end at a time. @@ -736,6 +742,7 @@ impl Session { #[cfg(not(target_arch = "wasm32"))] sql_results: Vec::new(), trend_results: Vec::new(), + long_runs: None, } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 40c25058b..4b6956df5 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9029,6 +9029,105 @@ fn a_zarr_variable_summarised_over_time() { let _ = std::fs::remove_dir_all(dir); } +/// Where nothing but a shell waits on them (`rustgis-cli run`: `long_runs` set), a trend and a +/// summary tell their progress up to its end and give what they give anywhere else; and there a +/// STAC trend's or summary's search takes more items than in the app (a state's four MODIS tiles +/// over 25 years are 2,304). +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_trend_where_nothing_waits_tells_its_progress() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; + use std::sync::Mutex; + // What was told, by this test alone: no other test sets `long_runs` with this `tell`. + static TOLD: Mutex> = Mutex::new(Vec::new()); + fn tell(what: &str, share: f64) { + assert!((0.0..=1.0).contains(&share), "{what}: {share}"); + TOLD.lock().unwrap().push((what.to_string(), share)); + } + // The last share each run told: its end, 1, however soon it finished, and told only then. + let ended = |what: &str| { + let told = TOLD.lock().unwrap(); + let ends = told.iter().filter(|(w, x)| w == what && *x >= 1.0).count(); + assert!(ends <= 1, "{what} told its end {ends} times"); + told.iter().rev().find(|(w, _)| w == what).map(|(_, x)| *x) + }; + let gt = GeoTransform { origin_x: -56.0, origin_y: -12.0, pixel_width: 0.01, pixel_height: -0.01 }; + let slope = |s: &mut Session| { + let mut names = Vec::new(); + for (k, y) in (2013..2025).enumerate() { + let name = format!("ndvi_{y}-07-01"); + let r = Raster::new(&name, 2, 1, vec![vec![0.3 + 0.02 * k as f32, 0.5]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32") + .unwrap(); + let bytes = geotiff::write(&r, 0).unwrap(); + crate::cmd::raster::add_raster(s, &name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); + names.push(name); + } + let band = |s: &Session, r: &Value| { + let id = r["layers"][0].as_u64().unwrap() as u32; + s.map().layer(id).unwrap().raster.as_ref().unwrap().data.clone().unwrap().bands[0].data.clone() + }; + let trend = s.run("cube.trend", &json!({"layers": names})).unwrap(); + let mean = s.run("cube.reduce", &json!({"layers": names, "stat": "mean"})).unwrap(); + (band(s, &trend), band(s, &mean)) + }; + let mut plain = Session::new(Project::default()); + let mut told = Session::new(Project::default()); + told.long_runs = Some(tell); + let ((a, mean_a), (b, mean_b)) = (slope(&mut plain), slope(&mut told)); + assert_eq!(a, b); + assert_eq!(mean_a, mean_b); + assert!((a[0] - 0.02).abs() < 1e-4, "{a:?}"); + assert!((mean_a[0] - 0.41).abs() < 1e-5 && (mean_a[1] - 0.5).abs() < 1e-6, "{mean_a:?}"); + assert_eq!(ended("cube.trend"), Some(1.0), "the trend's progress is told to its end"); + assert_eq!(ended("cube.reduce"), Some(1.0), "the summary's progress is told to its end"); + // 2,100 items (a tile each, one day) in eleven pages of 200: too many for the time slider and + // for a trend in the app, not for a trend or a summary where nothing waits, which go on to + // read their first file (here on a closed port). + let item = |k: usize| { + format!( + r#"{{"type": "Feature", "id": "i{k}", "collection": "c", "bbox": [10, 45, 11, 46], "geometry": null, "properties": {{"datetime": "2024-01-01T00:00:00Z", "bdc:tiles": ["t{k}"]}}, "assets": {{"NDVI": {{"href": "http://127.0.0.1:9/i{k}.tif"}}}}}}"# + ) + }; + let page = |n: usize| { + let items: Vec = (n * 200..((n + 1) * 200).min(2100)).map(item).collect(); + let next = if (n + 1) * 200 < 2100 { + format!(r#", "links": [{{"rel": "next", "href": "{{base}}/v1/search?page=p{:02}"}}]"#, n + 1) + } else { + String::new() + }; + format!(r#"{{"type": "FeatureCollection", "features": [{}]{next}}}"#, items.join(",")) + }; + // The pages by their marks first: the search's own address is a prefix of theirs. + const MARKS: [&str; 10] = [ + "/v1/search?page=p01", + "/v1/search?page=p02", + "/v1/search?page=p03", + "/v1/search?page=p04", + "/v1/search?page=p05", + "/v1/search?page=p06", + "/v1/search?page=p07", + "/v1/search?page=p08", + "/v1/search?page=p09", + "/v1/search?page=p10", + ]; + let mut routes: Vec<(&'static str, String)> = MARKS.iter().enumerate().map(|(i, m)| (*m, page(i + 1))).collect(); + routes.push(("/v1/search", page(0))); + // Eleven pages for each of the four searches, with room to spare. + let api = format!("{}/v1", serve_routes(routes, 60)); + let p = json!({"url": api, "collection": "c", "asset": "NDVI", "bbox": [10, 45, 11, 46], "start": "2024", "end": "2024"}); + let e = plain.run("stac.addCube", &p).unwrap_err(); + assert!(e.contains("more than 2000 items"), "{e}"); + let e = plain.run("cube.trend", &p).unwrap_err(); + assert!(e.contains("more than 2000 items"), "the app's trend doesn't take them: {e}"); + // Taken, they go on to the first file, which can't be read. + let e = told.run("cube.trend", &p).unwrap_err(); + assert!(e.contains("127.0.0.1:9/i") && !e.contains("items"), "a trend takes them: {e}"); + let mut summary = p.clone(); + summary["stat"] = json!("mean"); + let e = told.run("cube.reduce", &summary).unwrap_err(); + assert!(e.contains("127.0.0.1:9/i") && !e.contains("items"), "a summary takes them: {e}"); +} + /// A trend over a data cube (#579), from the three kinds of cube: a stack of raster layers dated /// by their names, a raster whose bands are dates, and a Zarr variable along its time dimension. /// A cell with a clear trend gets Sen's slope and a significant slope; noise gets a slope but no diff --git a/docs/cli.md b/docs/cli.md index 97e221c7c..c05b09e20 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,6 +47,14 @@ Each non-null command result goes to standard output as JSON. `--cmd 'id'` runs no parameters; otherwise use `--cmd 'id={json params}'`. What a command reports beside its result, which the app shows in its status bar, goes to standard error. +Nothing waits on `run` the way a window does, so a long run takes the bounds the app's panes give +it on a thread: a trend over a data cube (`cube.trend`) may compare up to 20 billion pairs, ten +times what the Script pane allows, and a trend's or a summary's STAC search up to 10,000 items, +where the app takes 2,000. A trend or a summary over time (`cube.reduce`) prints its progress to +standard error (`cube.trend: 42%`; a line every tenth when standard error isn't a terminal). +`ask` and `mcp` keep the Script pane's bounds, as an assistant or an MCP client waits on them, and +so does Python. See [Trend analysis](user-guide/analysis.md). + Installed [plugins](plugins.md) load first (`--no-plugins` skips them), and `--plugin ` (repeatable) loads one more for this run only. Their commands and tools have ids like `plugin:vector-toolkit/hexagon-grid`: diff --git a/docs/python.md b/docs/python.md index d99a0911d..a711e2a9c 100644 --- a/docs/python.md +++ b/docs/python.md @@ -119,6 +119,12 @@ both (keyword arguments win). It returns the decoded command result and raises `rustgis.RustGISError` when the command fails. UI-only commands (`ui.*`) need `Map.run_in_app`, as in the notebook example above. +A command runs to its end before `run` returns, holding Python's interpreter lock: other Python +threads, and calls to the session from them, wait their turn, and Ctrl+C (`KeyboardInterrupt`) is +raised only once the command has ended. So a trend over a data cube (`cube.trend`) keeps the Script +pane's bounds here (2 billion pairs compared, a STAC search of 2,000 items); run a larger one, such +as a trend over a state, with `rustgis-cli run` (see [the command line](cli.md)). + ```python s.commands() # every command, with its parameters s.tools() # the geoprocessing tools diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 052823219..6cf383b76 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -429,7 +429,7 @@ and a **Mann–Kendall** test of whether the trend is significant. the map shows one of them. This source is given as parameters (a script, the assistant, the command line); `cube.trend` waits for the search and the reads with no Cancel, the app with it, as for a Zarr store's address, so keep the area and period small there, or run a large - one from the command line. + one from the command line (see **Long runs** below). The rasters have to be on one grid; put them on one first with Raster Functions › Resample. - **The test:** NDVI and temperature series are autocorrelated, which makes the plain test report @@ -458,6 +458,14 @@ and a **Mann–Kendall** test of whether the trend is significant. 16-day composites (645 × 984 cells × 576 dates) is six pieces. Its area is up to 25 million cells. The seasonal test compares only pairs within a season, so its time limit counts those. It runs in the background with progress and Cancel. +- **Long runs:** in the app, a trend run from the Script pane or the assistant waits with no + Cancel, so it takes a tenth of the panes' limit on pairs compared (2 billion), as Python does. + `rustgis-cli run`, where nothing but a shell waits on it, takes the panes' limit (20 billion): + Piauí's 576 composites at about 1 km with the seasonal test (about 4.4 billion pairs) + run in one call there, and `rustgis-cli run` prints its progress (`cube.trend: 42%`). There, a + STAC collection's search also takes up to 10,000 items for a trend or a summary (a state's four + MODIS tiles over 25 years are 2,304). In the app, whose Script pane and assistant wait for the + search with no Cancel, and in Python, it takes 2,000, as a data cube on the time slider does. ```sh rustgis-cli run --cmd 'cube.trend={"layers":["ndvi_2015","ndvi_2016","ndvi_2017","ndvi_2018"],"per":"decade"}' @@ -485,7 +493,9 @@ over time does for a Zarr variable (see [Data](data.md)). outputs hold up to 32 million values (cells times groups; 8 million in the web app). A summary by month keeps every month's totals while it reads, up to five times the outputs' memory for **std** (three for mean, min, max, sum and countAbove), so a large area by month may need a - coarser cell size or a smaller area. The layers + coarser cell size or a smaller area. `rustgis-cli run` takes a STAC collection's search up to + 10,000 items, as for a trend (the app and Python 2,000), and prints a summary's progress + (`cube.reduce: 42%`). The layers are named after what they hold (`NDVI mean 2001`, `NDVI mean 2001–2025 month 03`, or the `"name"` given and the group) and kept in the project as GeoTIFFs. From 24eba9b439f6e8ebad81431c6b95d21a2c8b3b04 Mon Sep 17 00:00:00 2001 From: Li-Sheng Chou <142213184+choupeanut@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:34:11 +0800 Subject: [PATCH 096/297] feat(assistant): remember API keys in the system keychain (#795) * feat(assistant): remember API keys in the system keychain * fix(assistant): preserve unread keys and recover keychain failures Read an existing key when Remember is enabled after startup, and keep the worker responsive when a secret-store backend panics. Distinguish a failed replacement from legacy migration in storage notes. Add regression tests and accessible labels for the settings controls exercised by egui 0.37's UI tests. * fix(assistant): preserve stored keys and initial settings choices --- apps/rustgis/src/keychain.rs | 4 +- crates/ui-egui/src/assistantkeys.rs | 559 +++++++++++++++++++++ crates/ui-egui/src/assistantpane.rs | 726 +++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 2 + docs/architecture.md | 4 + docs/user-guide/assistant.md | 13 +- 6 files changed, 1250 insertions(+), 58 deletions(-) create mode 100644 crates/ui-egui/src/assistantkeys.rs diff --git a/apps/rustgis/src/keychain.rs b/apps/rustgis/src/keychain.rs index 23838eff9..6807eb5b5 100644 --- a/apps/rustgis/src/keychain.rs +++ b/apps/rustgis/src/keychain.rs @@ -5,8 +5,8 @@ use std::sync::{Arc, OnceLock}; use rustgis_engine::data::secrets::{SERVICE, Secret, SecretError, SecretStore, check_account}; -/// The OS credential store, opened on the first call: start-up never touches D-Bus and never -/// triggers a prompt. +/// The OS credential store, opened on the first call: constructing this hook never touches +/// D-Bus or triggers a prompt. Callers such as Assistant key migration use a worker thread. #[derive(Default)] pub struct Keychain { store: OnceLock, String>>, diff --git a/crates/ui-egui/src/assistantkeys.rs b/crates/ui-egui/src/assistantkeys.rs new file mode 100644 index 000000000..c15324737 --- /dev/null +++ b/crates/ui-egui/src/assistantkeys.rs @@ -0,0 +1,559 @@ +//! Session cache and ordered, off-frame keychain operations for Assistant API keys. + +use std::collections::BTreeMap; +use std::sync::{Arc, mpsc}; + +use rustgis_engine::data::secrets::{Secret, SecretError, SecretStore}; + +/// What the settings can truthfully say about the selected provider's key. +#[derive(Clone)] +pub(crate) enum Status { + Session, + Legacy, + Pending { loading: bool }, + Stored, + Failed { error: SecretError, legacy: bool, deleting: bool, reading: bool }, +} + +/// A user-entered key to remember; environment keys and empty keys never enter the store. +pub(crate) fn remembered(key: &str, remember: bool, environment: &str) -> Option { + let key = key.trim(); + (remember && !key.is_empty() && key != environment).then(|| Secret::new(key)) +} + +struct Cached { + key: Secret, + revision: u64, + status: Status, + saved: Option>, + changed: bool, + requested: bool, +} + +enum Action { + Load, + Save(Option), +} + +struct Job { + provider: String, + revision: u64, + legacy: Option, + action: Action, + store: Arc, + ctx: egui::Context, +} + +struct Completed { + provider: String, + revision: u64, + key: Option, + status: Status, + clear: Option, +} + +impl Completed { + /// A backend panic becomes a failure without exposing its payload or discarding a legacy key. + fn panicked(job: &Job) -> Self { + Self { + provider: job.provider.clone(), + revision: job.revision, + key: None, + status: Status::Failed { + error: SecretError::Failed("the keychain backend stopped unexpectedly".into()), + legacy: matches!(job.action, Action::Load) && job.legacy.is_some(), + deleting: matches!(job.action, Action::Save(None)), + reading: matches!(job.action, Action::Load) && job.legacy.is_none(), + }, + clear: None, + } + } +} + +/// Executes one operation without ever putting a secret in diagnostics. +fn perform(job: Job) -> Completed { + let account = format!("assistant/{}", job.provider); + let deleting = matches!(job.action, Action::Save(None)); + let reading = matches!(job.action, Action::Load) && job.legacy.is_none(); + let migrating = matches!(job.action, Action::Load) && job.legacy.is_some(); + let (result, key) = match job.action { + Action::Load => match job.store.load(&account) { + Ok(Some(key)) => (Ok(Status::Stored), Some(key)), + Ok(None) => match &job.legacy { + Some(key) => (job.store.save(&account, key).map(|_| Status::Stored), Some(key.clone())), + None => (Ok(Status::Session), None), + }, + Err(error) => (Err(error), None), + }, + Action::Save(Some(key)) => (job.store.save(&account, &key).map(|_| Status::Stored), None), + Action::Save(None) => (job.store.delete(&account).map(|_| Status::Session), None), + }; + let clear = result.is_ok().then(|| job.legacy.clone()).flatten(); + let status = result.unwrap_or_else(|error| Status::Failed { error, legacy: migrating, deleting, reading }); + Completed { provider: job.provider, revision: job.revision, key, status, clear } +} + +struct Worker { + send: mpsc::Sender, + receive: mpsc::Receiver, +} + +impl Worker { + /// One worker serializes every account, including a delete queued behind a pending save. + fn start() -> Result { + let (send, requests) = mpsc::channel::(); + let (replies, receive) = mpsc::channel(); + std::thread::Builder::new() + .name("assistant-keys".into()) + .spawn(move || { + for job in requests { + let ctx = job.ctx.clone(); + let failure = Completed::panicked(&job); + let completed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| perform(job))).unwrap_or(failure); + let _ = replies.send(completed); + ctx.request_repaint(); + } + }) + .map_err(|_| SecretError::Unavailable("could not start the keychain worker".into()))?; + Ok(Self { send, receive }) + } +} + +/// Cached keys survive provider switches; only the worker touches the OS store. +#[derive(Default)] +pub(crate) struct Keys { + entries: BTreeMap, + worker: Option, +} + +impl Keys { + /// Loads a provider once. The legacy key stays usable while its migration is pending. + pub fn load( + &mut self, + provider: &str, + environment: &str, + legacy: Option, + remember: bool, + store: Option>, + ctx: &egui::Context, + ) -> String { + if let Some(cached) = self.entries.get_mut(provider) { + let key = cached.key.expose().to_string(); + let read = remember && !cached.requested && !cached.changed; + cached.requested |= remember; + if read { + self.submit(provider, legacy.map(Secret::new), Action::Load, store, ctx); + } + return key; + } + let key = Secret::new(legacy.as_deref().unwrap_or(environment)); + self.entries.insert( + provider.into(), + Cached { + key: key.clone(), + revision: 0, + status: if legacy.is_some() { Status::Legacy } else { Status::Session }, + saved: None, + changed: false, + requested: remember, + }, + ); + // An unticked Remember must not re-open a key left behind by a failed deletion. + if remember { + self.submit(provider, legacy.map(Secret::new), Action::Load, store, ctx); + } + key.expose().to_string() + } + + /// Opting in reads an unedited key first, rather than deleting an empty/environment fallback. + pub fn enable(&mut self, provider: &str, legacy: Option, store: Option>, ctx: &egui::Context) -> bool { + let Some(cached) = self.entries.get_mut(provider) else { return false }; + if cached.changed { + return false; + } + cached.revision = cached.revision.saturating_add(1); + cached.requested = true; + self.submit(provider, legacy.map(Secret::new), Action::Load, store, ctx); + true + } + + /// Invalidates an old load before a typed key or Remember change can be overwritten. + pub fn edit(&mut self, provider: &str, key: &str) { + if let Some(cached) = self.entries.get_mut(provider) { + cached.key = Secret::new(key); + cached.revision = cached.revision.saturating_add(1); + cached.saved = None; + cached.changed = true; + cached.status = Status::Session; + } + } + + /// Saves or deletes once per change, without touching preference storage on the worker. + pub fn save( + &mut self, + provider: &str, + key: Option, + legacy: Option, + store: Option>, + ctx: &egui::Context, + ) { + let Some(cached) = self.entries.get_mut(provider) else { return }; + if !cached.changed { + return; + } + if cached.saved.as_ref() == Some(&key) && !matches!(cached.status, Status::Failed { .. }) { + return; + } + cached.saved = Some(key.clone()); + cached.revision = cached.revision.saturating_add(1); + self.submit(provider, legacy.map(Secret::new), Action::Save(key), store, ctx); + } + + /// Starts the worker lazily; a missing store is an error, never a plaintext fallback. + fn submit(&mut self, provider: &str, legacy: Option, action: Action, store: Option>, ctx: &egui::Context) { + let Some(cached) = self.entries.get_mut(provider) else { return }; + let deleting = matches!(action, Action::Save(None)); + let reading = matches!(action, Action::Load) && legacy.is_none(); + let migrating = matches!(action, Action::Load) && legacy.is_some(); + let failure = |error| Status::Failed { error, legacy: migrating, deleting, reading }; + let Some(store) = store else { + cached.status = failure(SecretError::Unavailable("no system keychain in this build".into())); + return; + }; + if self.worker.is_none() { + match Worker::start() { + Ok(worker) => self.worker = Some(worker), + Err(error) => { + cached.status = failure(error); + return; + } + } + } + cached.status = Status::Pending { loading: reading }; + let job = Job { provider: provider.into(), revision: cached.revision, legacy: legacy.clone(), action, store, ctx: ctx.clone() }; + if self.worker.as_ref().is_none_or(|worker| worker.send.send(job).is_err()) { + cached.status = failure(SecretError::Unavailable("the keychain worker stopped".into())); + } + } + + /// Applies current replies and returns legacy values safe to remove, even for stale replies. + pub fn poll(&mut self) -> Vec<(String, Secret)> { + let mut clear = Vec::new(); + if let Some(worker) = &self.worker { + for completed in worker.receive.try_iter() { + if let Some(key) = completed.clear { + clear.push((completed.provider.clone(), key)); + } + if let Some(cached) = self.entries.get_mut(&completed.provider) + && cached.revision == completed.revision + { + if let Some(key) = completed.key { + cached.key = key; + } + cached.status = completed.status; + } + } + } + clear + } + + /// The selected provider's session key, including edits made during a pending load. + pub fn key(&self, provider: &str) -> Option<&str> { + self.entries.get(provider).map(|cached| cached.key.expose()) + } + + /// Model, address and provider changes alone must never save or delete an unread key. + pub fn changed(&self, provider: &str) -> bool { + self.entries.get(provider).is_some_and(|cached| cached.changed) + } + + /// The last operation's state, used only for the storage note in settings. + pub fn status(&self, provider: &str) -> Option<&Status> { + self.entries.get(provider).map(|cached| &cached.status) + } + + /// An unread key must arrive before requests can use a temporary environment fallback. + pub fn loading(&self, provider: &str) -> bool { + matches!(self.status(provider), Some(Status::Pending { loading: true })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustgis_engine::data::secrets::MemorySecrets; + use std::time::{Duration, Instant}; + + /// Waits for a bounded fake-store operation, collecting successful migration receipts. + fn settle(keys: &mut Keys, provider: &str) -> Vec<(String, Secret)> { + let end = Instant::now() + Duration::from_secs(3); + let mut cleared = Vec::new(); + loop { + cleared.extend(keys.poll()); + if !matches!(keys.status(provider), Some(Status::Pending { .. })) { + return cleared; + } + assert!(Instant::now() < end, "keychain worker did not finish"); + std::thread::sleep(Duration::from_millis(1)); + } + } + + /// Existing plaintext stays available and is removable only after the store accepts it. + #[test] + fn migration_preserves_the_key_and_reports_when_plaintext_can_be_removed() { + let ctx = egui::Context::default(); + let store = Arc::new(MemorySecrets::new()); + let mut keys = Keys::default(); + assert_eq!(keys.load("custom", "environment", Some("legacy".into()), true, Some(store.clone()), &ctx), "legacy"); + let clear = settle(&mut keys, "custom"); + assert_eq!(clear, vec![("custom".into(), Secret::new("legacy"))]); + assert_eq!(store.load("assistant/custom").unwrap(), Some(Secret::new("legacy"))); + assert!(matches!(keys.status("custom"), Some(Status::Stored))); + for error in [SecretError::Denied, SecretError::Unavailable("no service".into()), SecretError::Failed("offline".into())] { + let mut keys = Keys::default(); + keys.load("custom", "", Some("legacy".into()), true, Some(Arc::new(MemorySecrets::failing(error))), &ctx); + assert!(settle(&mut keys, "custom").is_empty()); + assert_eq!(keys.key("custom"), Some("legacy")); + assert!(matches!(keys.status("custom"), Some(Status::Failed { legacy: true, .. }))); + } + } + + /// A stale plaintext copy must never replace a key already saved in the system store. + #[test] + fn migration_prefers_an_existing_stored_key_over_a_legacy_copy() { + let ctx = egui::Context::default(); + let store = Arc::new(MemorySecrets::new()); + store.save("assistant/custom", &Secret::new("stored-newer")).unwrap(); + let mut keys = Keys::default(); + assert_eq!(keys.load("custom", "", Some("legacy-older".into()), true, Some(store.clone()), &ctx), "legacy-older"); + assert_eq!(settle(&mut keys, "custom"), vec![("custom".into(), Secret::new("legacy-older"))]); + assert_eq!(keys.key("custom"), Some("stored-newer")); + assert_eq!(store.load("assistant/custom").unwrap(), Some(Secret::new("stored-newer"))); + assert!(matches!(keys.status("custom"), Some(Status::Stored))); + } + + /// A successful empty read does not authorize clearing plaintext when its migration write fails. + #[test] + fn migration_preserves_legacy_when_the_write_is_denied() { + struct RejectsWrites; + impl SecretStore for RejectsWrites { + fn load(&self, _account: &str) -> Result, SecretError> { + Ok(None) + } + + fn save(&self, _account: &str, _key: &Secret) -> Result<(), SecretError> { + Err(SecretError::Denied) + } + + fn delete(&self, _account: &str) -> Result<(), SecretError> { + Ok(()) + } + } + + let ctx = egui::Context::default(); + let mut keys = Keys::default(); + keys.load("custom", "", Some("legacy".into()), true, Some(Arc::new(RejectsWrites)), &ctx); + assert!(settle(&mut keys, "custom").is_empty()); + assert_eq!(keys.key("custom"), Some("legacy")); + assert!(matches!(keys.status("custom"), Some(Status::Failed { error: SecretError::Denied, legacy: true, .. }))); + } + + /// New keys stay in memory on failure, while environment values are never saved. + #[test] + fn remembering_excludes_environment_and_empty_keys_and_handles_missing_stores() { + assert_eq!(remembered(" environment ", true, "environment"), None); + assert_eq!(remembered("typed", false, "environment"), None); + assert_eq!(remembered(" ", true, ""), None); + assert_eq!(remembered(" typed ", true, "environment"), Some(Secret::new("typed"))); + let ctx = egui::Context::default(); + let mut keys = Keys::default(); + keys.load("custom", "environment", None, true, None, &ctx); + keys.edit("custom", "typed"); + keys.save("custom", remembered("typed", true, "environment"), None, None, &ctx); + assert_eq!(keys.key("custom"), Some("typed")); + assert!(matches!(keys.status("custom"), Some(Status::Failed { legacy: false, deleting: false, .. }))); + } + + /// Provider accounts remain independent and switching back retains a session-only edit. + #[test] + fn providers_are_isolated_and_forgetting_removes_only_the_selected_account() { + let ctx = egui::Context::default(); + let store = Arc::new(MemorySecrets::new()); + store.save("assistant/first", &Secret::new("one")).unwrap(); + store.save("assistant/second", &Secret::new("two")).unwrap(); + let mut keys = Keys::default(); + for provider in ["first", "second"] { + keys.load(provider, "", None, true, Some(store.clone()), &ctx); + settle(&mut keys, provider); + } + assert_eq!(keys.key("first"), Some("one")); + assert_eq!(keys.key("second"), Some("two")); + keys.edit("first", "edited"); + keys.save("first", None, None, Some(store.clone()), &ctx); + settle(&mut keys, "first"); + assert_eq!(store.load("assistant/first").unwrap(), None); + assert_eq!(store.load("assistant/second").unwrap(), Some(Secret::new("two"))); + assert_eq!(keys.load("first", "", None, false, Some(store), &ctx), "edited"); + } + + /// A refused deletion remains visible, and Remember off never reloads that stored key. + #[test] + fn deletion_failure_is_not_reported_as_success_or_reloaded_after_opt_out() { + let ctx = egui::Context::default(); + let failed = Arc::new(MemorySecrets::failing(SecretError::Denied)); + let mut keys = Keys::default(); + keys.load("custom", "", None, false, Some(failed.clone()), &ctx); + keys.edit("custom", ""); + keys.save("custom", None, None, Some(failed), &ctx); + settle(&mut keys, "custom"); + assert!(matches!(keys.status("custom"), Some(Status::Failed { deleting: true, .. }))); + let store = Arc::new(MemorySecrets::new()); + store.save("assistant/custom", &Secret::new("left-behind")).unwrap(); + let mut restarted = Keys::default(); + assert_eq!(restarted.load("custom", "", None, false, Some(store), &ctx), ""); + assert!(matches!(restarted.status("custom"), Some(Status::Session))); + } + + /// The first read blocks on a test gate, allowing edits and a queued delete to race it. + struct Gated { + gate: std::sync::Mutex>, + store: MemorySecrets, + caller: std::thread::ThreadId, + } + + impl SecretStore for Gated { + /// Holds the initial read until the test has queued its edits. + fn load(&self, account: &str) -> Result, SecretError> { + assert_ne!(self.caller, std::thread::current().id(), "keychain access must be off the UI thread"); + self.gate.lock().unwrap().recv_timeout(Duration::from_secs(3)).unwrap(); + self.store.load(account) + } + /// Records the ordered write in the fake store. + fn save(&self, account: &str, key: &Secret) -> Result<(), SecretError> { + self.store.save(account, key) + } + /// Removes the fake value after earlier operations finish. + fn delete(&self, account: &str) -> Result<(), SecretError> { + self.store.delete(account) + } + } + + /// A delayed load cannot overwrite typing, and a later delete runs after the queued save. + #[test] + fn delayed_loads_do_not_overwrite_edits_and_writes_remain_ordered() { + let (release, gate) = mpsc::channel(); + let store = Arc::new(Gated { gate: std::sync::Mutex::new(gate), store: MemorySecrets::new(), caller: std::thread::current().id() }); + store.store.save("assistant/custom", &Secret::new("old")).unwrap(); + let ctx = egui::Context::default(); + let mut keys = Keys::default(); + assert_eq!(keys.load("custom", "environment", None, true, Some(store.clone()), &ctx), "environment"); + assert!(keys.loading("custom")); + keys.edit("custom", "new"); + assert!(!keys.loading("custom"), "typing a session key must unblock requests before the read finishes"); + keys.save("custom", Some(Secret::new("new")), None, Some(store.clone()), &ctx); + assert!(!keys.loading("custom"), "a queued save must not block requests with the edited key"); + keys.edit("custom", "new"); + keys.save("custom", None, None, Some(store.clone()), &ctx); + release.send(()).unwrap(); + settle(&mut keys, "custom"); + assert_eq!(keys.key("custom"), Some("new")); + assert_eq!(store.store.load("assistant/custom").unwrap(), None); + } + + /// Saving unrelated settings while a key is unread must not enqueue a deletion. + #[test] + fn unread_keys_are_not_deleted_by_unrelated_settings_changes() { + let (release, gate) = mpsc::channel(); + let store = Arc::new(Gated { gate: std::sync::Mutex::new(gate), store: MemorySecrets::new(), caller: std::thread::current().id() }); + store.store.save("assistant/custom", &Secret::new("saved")).unwrap(); + let ctx = egui::Context::default(); + let mut keys = Keys::default(); + keys.load("custom", "", None, true, Some(store.clone()), &ctx); + keys.save("custom", None, None, Some(store.clone()), &ctx); + release.send(()).unwrap(); + settle(&mut keys, "custom"); + assert_eq!(keys.key("custom"), Some("saved")); + assert_eq!(store.store.load("assistant/custom").unwrap(), Some(Secret::new("saved"))); + } + + /// Opting in after an off startup reads the stored value before any empty/environment save. + #[test] + fn enabling_remember_reads_an_unedited_key_without_deleting_it() { + let ctx = egui::Context::default(); + for fallback in ["", "environment"] { + let store = Arc::new(MemorySecrets::new()); + store.save("assistant/custom", &Secret::new("saved")).unwrap(); + let mut keys = Keys::default(); + keys.load("custom", fallback, None, false, Some(store.clone()), &ctx); + assert!(keys.enable("custom", None, Some(store.clone()), &ctx)); + keys.save("custom", remembered(fallback, true, fallback), None, Some(store.clone()), &ctx); + settle(&mut keys, "custom"); + assert_eq!(keys.key("custom"), Some("saved")); + assert_eq!(store.load("assistant/custom").unwrap(), Some(Secret::new("saved"))); + // A global Remember change also applies when returning to a cached provider. + let mut cached = Keys::default(); + cached.load("custom", fallback, None, false, Some(store.clone()), &ctx); + cached.load("custom", fallback, None, true, Some(store.clone()), &ctx); + settle(&mut cached, "custom"); + assert_eq!(cached.key("custom"), Some("saved")); + let mut typed = Keys::default(); + typed.load("custom", fallback, None, false, Some(store.clone()), &ctx); + typed.edit("custom", "typed"); + assert!(!typed.enable("custom", None, Some(store.clone()), &ctx)); + typed.save("custom", Some(Secret::new("typed")), None, Some(store.clone()), &ctx); + settle(&mut typed, "custom"); + assert_eq!(store.load("assistant/custom").unwrap(), Some(Secret::new("typed"))); + } + } + + /// A synthetic backend panics on its first operation, then behaves like the real trait. + struct Panics { + first: std::sync::atomic::AtomicBool, + store: MemorySecrets, + } + + impl Panics { + /// Exercises an escaped dependency panic without putting a key in its payload. + fn check(&self) { + assert!(!self.first.swap(false, std::sync::atomic::Ordering::SeqCst), "synthetic backend panic"); + } + } + + impl SecretStore for Panics { + /// The first read may panic instead of returning a contract error. + fn load(&self, account: &str) -> Result, SecretError> { + self.check(); + self.store.load(account) + } + /// The first migration write may also panic. + fn save(&self, account: &str, key: &Secret) -> Result<(), SecretError> { + self.check(); + self.store.save(account, key) + } + /// Subsequent deletes still run on the surviving worker. + fn delete(&self, account: &str) -> Result<(), SecretError> { + self.check(); + self.store.delete(account) + } + } + + /// Panics produce failures, preserve legacy copies, and leave the worker able to handle edits. + #[test] + fn backend_panics_fail_the_operation_without_stalling_or_losing_keys() { + let ctx = egui::Context::default(); + for legacy in [None, Some("legacy".to_string())] { + let store = Arc::new(Panics { first: std::sync::atomic::AtomicBool::new(true), store: MemorySecrets::new() }); + let mut keys = Keys::default(); + keys.load("custom", "environment", legacy.clone(), true, Some(store.clone()), &ctx); + assert!(settle(&mut keys, "custom").is_empty()); + assert!(!keys.loading("custom")); + assert_eq!(keys.key("custom"), Some(legacy.as_deref().unwrap_or("environment"))); + assert!(matches!(keys.status("custom"), Some(Status::Failed { .. }))); + keys.edit("custom", "replacement"); + keys.save("custom", Some(Secret::new("replacement")), legacy.clone(), Some(store.clone()), &ctx); + let clear = settle(&mut keys, "custom"); + assert_eq!(clear.len(), usize::from(legacy.is_some())); + assert_eq!(store.store.load("assistant/custom").unwrap(), Some(Secret::new("replacement"))); + } + } +} diff --git a/crates/ui-egui/src/assistantpane.rs b/crates/ui-egui/src/assistantpane.rs index 71c218bb0..4260da0fd 100644 --- a/crates/ui-egui/src/assistantpane.rs +++ b/crates/ui-egui/src/assistantpane.rs @@ -35,9 +35,18 @@ pub struct AssistantState { chunks: Option<(u64, Chunks)>, /// The settings form is open. pub settings: bool, + /// Startup layout waits for the initial key read unless the user chooses it first. + #[cfg(not(target_arch = "wasm32"))] + settings_pending: Option, show_key: bool, - /// Keys are saved with the preferences only when the user says so. + /// Keys are remembered only when the user says so. remember_key: bool, + /// Desktop keychain operations and session keys, separate from preference storage. + #[cfg(not(target_arch = "wasm32"))] + keys: crate::assistantkeys::Keys, + /// Legacy migration is scheduled once after the platform preferences are available. + #[cfg(not(target_arch = "wasm32"))] + keys_migrated: bool, /// The AI provider section of the settings is folded away (kept in the preferences). provider_closed: bool, models: Vec, @@ -100,14 +109,17 @@ pub struct AssistantState { /// The settings fields typed into (saved when none has focus). const FIELDS: [&str; 3] = ["assistant_model", "assistant_url", "assistant_key"]; +/// Whether a provider settings field still owns the keyboard focus. fn typing(ctx: &egui::Context) -> bool { ctx.memory(|m| FIELDS.iter().any(|f| m.has_focus(egui::Id::unique(f)))) } +/// Reads a nonempty preference, including legacy keys awaiting migration. fn pref(app: &RustGisApp, key: &str) -> Option { app.services.load_pref.as_ref().and_then(|l| l(key)).filter(|v| !v.is_empty()) } +/// Saves a preference through the platform hook. fn save_pref(app: &RustGisApp, key: &str, value: &str) { if let Some(s) = &app.services.save_pref { s(key, value); @@ -115,7 +127,7 @@ fn save_pref(app: &RustGisApp, key: &str, value: &str) { } /// A provider's settings: its defaults and the environment, then what the user saved. -fn load_config(app: &RustGisApp, provider: &str) -> Config { +fn load_config(app: &mut RustGisApp, provider: &str, ctx: &egui::Context) -> Config { let mut c = Config::from_env(provider); if let Some(m) = pref(app, &format!("assistant_model_{provider}")) { c.model = m; @@ -123,13 +135,25 @@ fn load_config(app: &RustGisApp, provider: &str) -> Config { if let Some(u) = pref(app, &format!("assistant_url_{provider}")).filter(|_| !c.login()) { c.base_url = u; } - if let Some(k) = pref(app, &format!("assistant_key_{provider}")).filter(|_| !c.login()) { - c.key = k; + if !c.login() { + let legacy = pref(app, &format!("assistant_key_{provider}")); + #[cfg(not(target_arch = "wasm32"))] + { + c.key = app.assistant.keys.load(provider, &c.key, legacy, app.assistant.remember_key, app.services.secrets.clone(), ctx); + } + #[cfg(target_arch = "wasm32")] + { + let _ = ctx; + if let Some(key) = legacy { + c.key = key; + } + } } c } -fn ensure_loaded(app: &mut RustGisApp) { +/// Initializes the selected provider once, without blocking on its keychain. +fn ensure_loaded(app: &mut RustGisApp, ctx: &egui::Context) { if app.assistant.loaded { return; } @@ -141,16 +165,26 @@ fn ensure_loaded(app: &mut RustGisApp) { // The first provider with a key in the environment (a ChatGPT sign-in is chosen, not assumed). .or_else(|| PRESETS.iter().find(|p| !p.login && !Config::from_env(p.id).key.is_empty()).map(|p| p.id.to_string())) .unwrap_or_else(|| "anthropic".into()); - let mut cfg = load_config(app, &provider); + let mut cfg = load_config(app, &provider, ctx); + #[cfg(not(target_arch = "wasm32"))] + migrate_keys(app, ctx); if let Ok(m) = std::env::var("RUSTGIS_ASSISTANT_MODEL") && !m.trim().is_empty() && pref(app, &format!("assistant_model_{provider}")).is_none() { cfg.model = m.trim().to_string(); } - app.assistant.settings = cfg.missing().is_some(); - // A provider still to be set up shows its fields. - app.assistant.provider_closed = cfg.missing().is_none() && pref(app, "assistant_provider_closed").as_deref() == Some("on"); + #[cfg(not(target_arch = "wasm32"))] + let loading = app.assistant.keys.loading(&provider); + #[cfg(target_arch = "wasm32")] + let loading = false; + if !loading { + init_settings(app, &cfg); + } + #[cfg(not(target_arch = "wasm32"))] + if loading { + app.assistant.settings_pending = Some(provider); + } if cfg.login() && !cfg.key.is_empty() { app.assistant.account = signed_in_account(); } @@ -173,6 +207,39 @@ fn ensure_loaded(app: &mut RustGisApp) { app.assistant.agent = Some(agent); } +/// An unconfigured provider opens its fields; a configured one restores the saved fold state. +fn init_settings(app: &mut RustGisApp, cfg: &Config) { + let missing = cfg.missing().is_some(); + app.assistant.settings = missing; + app.assistant.provider_closed = !missing && pref(app, "assistant_provider_closed").as_deref() == Some("on"); +} + +/// An explicit settings choice takes precedence over startup's pending key read. +fn toggle_settings(app: &mut RustGisApp) { + #[cfg(not(target_arch = "wasm32"))] + { + app.assistant.settings_pending = None; + } + app.assistant.settings = !app.assistant.settings; +} + +/// Schedules all remembered legacy providers once, without opening the Assistant or making requests. +#[cfg(not(target_arch = "wasm32"))] +fn migrate_keys(app: &mut RustGisApp, ctx: &egui::Context) { + if app.assistant.keys_migrated { + return; + } + app.assistant.keys_migrated = true; + if pref(app, "assistant_remember_key").as_deref() != Some("on") { + return; + } + for p in PRESETS.iter().filter(|p| !p.login) { + if let Some(legacy) = pref(app, &format!("assistant_key_{}", p.id)) { + app.assistant.keys.load(p.id, "", Some(legacy), true, app.services.secrets.clone(), ctx); + } + } +} + /// Reads the skills again when they changed (`force`) or the plugins did. fn sync_skills(app: &mut RustGisApp, force: bool) { let rev = rustgis_engine::plugins::revision(); @@ -201,14 +268,69 @@ fn signed_in_account() -> Option { } } -fn save_config(app: &RustGisApp, c: &Config) { +/// Persists ordinary settings; desktop secrets are queued for the keychain worker. +fn save_config(app: &mut RustGisApp, c: &Config, ctx: &egui::Context) { save_pref(app, "assistant_provider", &c.provider); save_pref(app, &format!("assistant_model_{}", c.provider), c.model.trim()); save_pref(app, &format!("assistant_url_{}", c.provider), c.base_url.trim()); // A key from the environment stays there; a sign-in's tokens live in their own file. let from_env = Config::from_env(&c.provider).key; - let keep = app.assistant.remember_key && c.key.trim() != from_env && !c.login(); - save_pref(app, &format!("assistant_key_{}", c.provider), if keep { c.key.trim() } else { "" }); + if c.login() { + return; + } + let pref_key = format!("assistant_key_{}", c.provider); + #[cfg(not(target_arch = "wasm32"))] + { + if !app.assistant.keys.changed(&c.provider) { + return; + } + let key = crate::assistantkeys::remembered(&c.key, app.assistant.remember_key, &from_env); + let legacy = pref(app, &pref_key); + // Forgetting plaintext is immediate, even if the OS refuses to delete its copy. + if key.is_none() { + save_pref(app, &pref_key, ""); + } + app.assistant.keys.save(&c.provider, key, legacy, app.services.secrets.clone(), ctx); + } + #[cfg(target_arch = "wasm32")] + { + let _ = ctx; + let keep = app.assistant.remember_key && c.key.trim() != from_env; + save_pref(app, &pref_key, if keep { c.key.trim() } else { "" }); + } +} + +/// Applies background replies without overwriting a later edit or another provider. +#[cfg(not(target_arch = "wasm32"))] +fn poll_keys(app: &mut RustGisApp, ctx: &egui::Context) { + migrate_keys(app, ctx); + let previous = app + .assistant + .agent + .as_ref() + .and_then(|a| app.assistant.keys.key(&a.config.provider).map(|key| (a.config.provider.clone(), key.to_string()))); + for (provider, migrated) in app.assistant.keys.poll() { + let name = format!("assistant_key_{provider}"); + if pref(app, &name).as_deref() == Some(migrated.expose()) { + save_pref(app, &name, ""); + } + } + if let Some(agent) = app.assistant.agent.as_mut() + && !agent.config.login() + && previous.as_ref().is_some_and(|(provider, key)| *provider == agent.config.provider && *key == agent.config.key) + && let Some(key) = app.assistant.keys.key(&agent.config.provider) + { + agent.config.key = key.to_string(); + } + if let Some(provider) = app.assistant.settings_pending.as_deref() + && !app.assistant.keys.loading(provider) + { + let cfg = app.assistant.agent.as_ref().filter(|a| a.config.provider == provider).map(|a| a.config.clone()); + app.assistant.settings_pending = None; + if let Some(cfg) = cfg { + init_settings(app, &cfg); + } + } } /// Sends a request off the frame. A signed-in account's requests renew its tokens as needed. @@ -269,11 +391,16 @@ fn dispatch(app: &mut RustGisApp, req: HttpRequest, login: Option bool { + #[cfg(not(target_arch = "wasm32"))] + { + app.assistant.agent.as_ref().is_some_and(|a| app.assistant.keys.loading(&a.config.provider)) + } + #[cfg(target_arch = "wasm32")] + { + let _ = app; + false + } +} + +/// Draws the Assistant pane, loading its provider on first use. pub fn show(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { - ensure_loaded(app); + ensure_loaded(app, ui.ctx()); sync_skills(app, false); let ctx = ui.ctx().clone(); if app.assistant.catalog_pending @@ -551,11 +699,11 @@ pub fn show(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { .on_hover_cursor(egui::CursorIcon::PointingHand) .clicked() { - app.assistant.settings = !app.assistant.settings; + toggle_settings(app); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if crate::widgets::icon_button(ui, "settings", tl!("Assistant settings")).clicked() { - app.assistant.settings = !app.assistant.settings; + toggle_settings(app); } let has_chat = app.assistant.agent.as_ref().is_some_and(|a| !a.transcript.is_empty()); if has_chat && crate::widgets::icon_button(ui, "delete", tl!("New conversation")).clicked() { @@ -615,6 +763,7 @@ fn composer_panel(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, busy: bool, ctx .show(ui, |ui| composer(app, ui, t, busy, ctx)); } +/// Edits provider settings and invalidates pending key reads as soon as a key changes. fn settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let Some(mut cfg) = app.assistant.agent.as_ref().map(|a| a.config.clone()) else { return }; let before = (cfg.clone(), app.assistant.remember_key); @@ -625,9 +774,15 @@ fn settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { .show(ui, |ui| provider_settings(app, ui, t, &mut cfg)); if section.header_response.clicked() { app.assistant.provider_closed = open; + #[cfg(not(target_arch = "wasm32"))] + { + app.assistant.settings_pending = None; + } save_pref(app, "assistant_provider_closed", if open { "on" } else { "" }); } - if let Some(why) = cfg.missing() { + if !key_loading(app) + && let Some(why) = cfg.missing() + { ui.label(RichText::new(tl!(why)).small().color(t.amber)); } #[cfg(not(target_arch = "wasm32"))] @@ -641,6 +796,25 @@ fn settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { provider::use_catalog(&mut cfg, &app.assistant.catalog, true); } if (cfg.clone(), app.assistant.remember_key) != before { + if app.assistant.remember_key != before.1 { + save_pref(app, "assistant_remember_key", if app.assistant.remember_key { "on" } else { "off" }); + } + #[cfg(not(target_arch = "wasm32"))] + if !cfg.login() { + let key_changed = cfg.provider == before.0.provider && cfg.key != before.0.key; + let enabling = app.assistant.remember_key && !before.1; + let legacy = pref(app, &format!("assistant_key_{}", cfg.provider)); + let reading = + enabling && !key_changed && app.assistant.keys.enable(&cfg.provider, legacy, app.services.secrets.clone(), ui.ctx()); + if key_changed || (app.assistant.remember_key != before.1 && !reading) { + app.assistant.keys.edit(&cfg.provider, &cfg.key); + // Forget immediately on an explicit clear/opt-out, even while the key field has focus. + let opted_out = !app.assistant.remember_key && before.1; + if opted_out || (key_changed && cfg.key.trim().is_empty()) { + save_config(app, &cfg, ui.ctx()); + } + } + } app.assistant.unsaved = true; apply_config(app, cfg); } @@ -650,33 +824,42 @@ fn settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { /// notes on where they're kept and what is sent. fn provider_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Config) { egui::Grid::new("assistant_settings").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Provider")); + let provider_label = ui.label(tl!("Provider")); let current = provider::preset(&cfg.provider).map_or("", |p| p.label); - egui::ComboBox::from_id_salt("assistant_provider").selected_text(tl!(current)).width(180.0).show_ui(ui, |ui| { - for p in PRESETS { - // Signing in needs the browser and a server on this computer: the desktop app. - if p.login && cfg!(target_arch = "wasm32") { - continue; - } - if ui.selectable_label(cfg.provider == p.id, tl!(p.label)).clicked() && cfg.provider != p.id { - #[cfg(not(target_arch = "wasm32"))] - { - app.assistant.login = None; + egui::ComboBox::from_id_salt("assistant_provider") + .selected_text(tl!(current)) + .width(180.0) + .show_ui(ui, |ui| { + for p in PRESETS { + // Signing in needs the browser and a server on this computer: the desktop app. + if p.login && cfg!(target_arch = "wasm32") { + continue; } - *cfg = load_config(app, p.id); - app.assistant.models_slot = None; - app.assistant.models.clear(); - app.assistant.catalog.clear(); - app.assistant.models_error = None; - app.assistant.login_error = None; - if cfg.login() && !cfg.key.is_empty() { - app.assistant.account = signed_in_account(); - let ctx = ui.ctx().clone(); - list_models(app, cfg, &ctx); + if ui.selectable_label(cfg.provider == p.id, tl!(p.label)).clicked() && cfg.provider != p.id { + #[cfg(not(target_arch = "wasm32"))] + { + app.assistant.login = None; + } + // Switching providers is also a focus change: save the previous key first. + if app.assistant.unsaved { + save_config(app, cfg, ui.ctx()); + } + *cfg = load_config(app, p.id, ui.ctx()); + app.assistant.models_slot = None; + app.assistant.models.clear(); + app.assistant.catalog.clear(); + app.assistant.models_error = None; + app.assistant.login_error = None; + if cfg.login() && !cfg.key.is_empty() { + app.assistant.account = signed_in_account(); + let ctx = ui.ctx().clone(); + list_models(app, cfg, &ctx); + } } } - } - }); + }) + .response + .labelled_by(provider_label.id); ui.end_row(); if cfg.login() { ui.label(tl!("Account")); @@ -726,10 +909,11 @@ fn provider_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Co ui.end_row(); let preset = provider::preset(&cfg.provider); if !cfg.login() { - ui.label(tl!("Address")); - ui.add(egui::TextEdit::singleline(&mut cfg.base_url).id(egui::Id::unique(FIELDS[1])).desired_width(200.0)); + let address_label = ui.label(tl!("Address")); + ui.add(egui::TextEdit::singleline(&mut cfg.base_url).id(egui::Id::unique(FIELDS[1])).desired_width(200.0)) + .labelled_by(address_label.id); ui.end_row(); - ui.label(tl!("API key")); + let key_label = ui.label(tl!("API key")); ui.horizontal(|ui| { let hint = if preset.is_some_and(|p| p.keyless) { tl!("not needed") } else { tl!("paste your key") }; ui.add( @@ -738,7 +922,8 @@ fn provider_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Co .password(!app.assistant.show_key) .desired_width(150.0) .hint_text(hint), - ); + ) + .labelled_by(key_label.id); let eye = if app.assistant.show_key { tl!("Hide") } else { tl!("Show") }; if ui.small_button(eye).clicked() { app.assistant.show_key = !app.assistant.show_key; @@ -759,14 +944,7 @@ fn provider_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Co if let Some(p) = preset.filter(|p| !p.key_env.is_empty()) { notes.push(crate::i18n::fmt("On the desktop, the key can also come from {vars}.", &[("vars", &p.key_env.join(", "))])); } - notes.push( - if app.assistant.remember_key { - tl!("Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web).") - } else { - tl!("The key is kept for this session only.") - } - .to_string(), - ); + notes.push(key_storage_note(app, &cfg.provider)); } notes.push( tl!("Requests send your message and the map's layer names, fields and the rows the assistant reads to the provider.").to_string(), @@ -788,6 +966,51 @@ fn provider_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Co } } +/// Describes actual storage, including a legacy key retained after a failed migration. +fn key_storage_note(app: &RustGisApp, provider: &str) -> String { + #[cfg(target_arch = "wasm32")] + { + let _ = provider; + if app.assistant.remember_key { + tl!("Saved keys are kept unencrypted in the browser's storage.").to_string() + } else { + tl!("The key is kept for this session only.").to_string() + } + } + #[cfg(not(target_arch = "wasm32"))] + { + use crate::assistantkeys::Status; + use rustgis_engine::data::secrets::SecretError; + let mut notes = Vec::new(); + let plaintext = pref(app, &format!("assistant_key_{provider}")).is_some(); + match app.assistant.keys.status(provider) { + Some(Status::Stored) if app.assistant.remember_key => notes.push(tl!("The key is saved in the system keychain.")), + Some(Status::Pending { .. }) => notes.push(tl!("Accessing the system keychain…")), + Some(Status::Failed { error, legacy, deleting, reading }) => { + notes.push(match error { + SecretError::Denied => tl!("The system keychain is locked or access was refused."), + SecretError::Unavailable(_) => tl!("The system keychain is unavailable."), + _ => tl!("The system keychain could not complete the request."), + }); + if *reading { + notes.push(tl!("A saved key could not be read. A key entered now will be kept for this session only.")); + } else if *deleting { + notes.push(tl!("RustGIS could not confirm that any previously saved key was deleted from the system keychain.")); + } else if !legacy || !plaintext { + notes.push(tl!("The key is kept for this session only; it has not been remembered.")); + } + } + _ => notes.push(tl!("The key is kept for this session only.")), + } + if plaintext { + notes.push(tl!( + "A previously saved key is still kept unencrypted in the app's preferences until it can be moved to the system keychain." + )); + } + notes.join("\n") + } +} + /// The settings' account row of a sign-in provider: who is signed in and Sign out, or /// Continue with ChatGPT (the browser). #[cfg(not(target_arch = "wasm32"))] @@ -1444,6 +1667,401 @@ fn inline(ui: &Ui, text: &str, t: &Tokens, size: f32, heading: bool) -> egui::te mod tests { use super::*; + #[cfg(not(target_arch = "wasm32"))] + type KeyPrefs = std::sync::Arc>>; + + /// An Assistant with isolated preferences and a fake keychain, never the OS store. + #[cfg(not(target_arch = "wasm32"))] + fn keys_app( + legacy: Option<&str>, + store: Option>, + ) -> (RustGisApp, KeyPrefs) { + keys_app_remember(legacy, store, true) + } + + /// Builds either the opt-in or opt-out startup path with isolated preference storage. + #[cfg(not(target_arch = "wasm32"))] + fn keys_app_remember( + legacy: Option<&str>, + store: Option>, + remember: bool, + ) -> (RustGisApp, KeyPrefs) { + use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + }; + let prefs = Arc::new(Mutex::new(BTreeMap::from([ + ("assistant_provider".into(), "custom".into()), + ("assistant_remember_key".into(), if remember { "on" } else { "off" }.into()), + ]))); + if let Some(key) = legacy { + prefs.lock().unwrap().insert("assistant_key_custom".into(), key.into()); + } + let mut app = RustGisApp::with_sample(); + let read = prefs.clone(); + app.services.load_pref = Some(Box::new(move |key| read.lock().unwrap().get(key).cloned())); + let write = prefs.clone(); + app.services.save_pref = Some(Box::new(move |key, value| { + write.lock().unwrap().insert(key.into(), value.into()); + })); + app.services.secrets = store; + ensure_loaded(&mut app, &egui::Context::default()); + (app, prefs) + } + + /// Waits for the fake keychain and applies its preference cleanup on the UI side. + #[cfg(not(target_arch = "wasm32"))] + fn settle_keys(app: &mut RustGisApp) { + let end = std::time::Instant::now() + std::time::Duration::from_secs(3); + loop { + poll_keys(app, &egui::Context::default()); + let provider = app.assistant.agent.as_ref().map_or("custom", |a| a.config.provider.as_str()); + if !matches!(app.assistant.keys.status(provider), Some(crate::assistantkeys::Status::Pending { .. })) { + return; + } + assert!(std::time::Instant::now() < end, "fake keychain timed out"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + + /// Only successful migrations clear legacy preferences; failures keep them usable and visible. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_keys_migrate_without_losing_a_legacy_key() { + use rustgis_engine::data::secrets::{MemorySecrets, Secret, SecretError, SecretStore}; + let store = std::sync::Arc::new(MemorySecrets::new()); + let (mut app, prefs) = keys_app(Some("synthetic-legacy"), Some(store.clone())); + settle_keys(&mut app); + assert_eq!(store.load("assistant/custom").unwrap(), Some(Secret::new("synthetic-legacy"))); + assert_eq!(prefs.lock().unwrap().get("assistant_key_custom").map(String::as_str), Some("")); + assert_eq!(app.assistant.agent.as_ref().unwrap().config.key, "synthetic-legacy"); + assert!(key_storage_note(&app, "custom").contains("system keychain")); + let (mut failed, prefs) = + keys_app(Some("synthetic-legacy"), Some(std::sync::Arc::new(MemorySecrets::failing(SecretError::Denied)))); + settle_keys(&mut failed); + assert_eq!(prefs.lock().unwrap().get("assistant_key_custom").map(String::as_str), Some("synthetic-legacy")); + assert_eq!(failed.assistant.agent.as_ref().unwrap().config.key, "synthetic-legacy"); + let note = key_storage_note(&failed, "custom"); + assert!(note.contains("unencrypted") && note.contains("refused")); + assert!(!note.contains("synthetic-legacy")); + } + + /// Desktop without a store never writes a new plaintext key; opting out also clears legacy plaintext. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_keys_have_no_new_plaintext_fallback() { + let (mut app, prefs) = keys_app(None, None); + let mut cfg = app.assistant.agent.as_ref().unwrap().config.clone(); + cfg.key = "synthetic-new".into(); + app.assistant.keys.edit("custom", &cfg.key); + apply_config(&mut app, cfg.clone()); + save_config(&mut app, &cfg, &egui::Context::default()); + assert!(!prefs.lock().unwrap().contains_key("assistant_key_custom")); + let note = key_storage_note(&app, "custom"); + assert!(note.contains("unavailable") && note.contains("session only")); + assert!(!note.contains("synthetic-new")); + let (mut legacy, prefs) = keys_app(Some("synthetic-legacy"), None); + legacy.assistant.remember_key = false; + let cfg = legacy.assistant.agent.as_ref().unwrap().config.clone(); + legacy.assistant.keys.edit("custom", &cfg.key); + save_config(&mut legacy, &cfg, &egui::Context::default()); + assert_eq!(prefs.lock().unwrap().get("assistant_key_custom").map(String::as_str), Some("")); + } + + /// A failed new-key write leaves the key in the session and never echoes backend payloads. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_keys_report_failed_writes_without_leaking_the_key() { + use rustgis_engine::data::secrets::{MemorySecrets, SecretError}; + for error in [SecretError::Denied, SecretError::Failed("synthetic-new".into())] { + let (mut app, prefs) = keys_app(None, Some(std::sync::Arc::new(MemorySecrets::failing(error)))); + settle_keys(&mut app); + assert!(key_storage_note(&app, "custom").contains("could not be read")); + let mut cfg = app.assistant.agent.as_ref().unwrap().config.clone(); + cfg.key = "synthetic-new".into(); + app.assistant.keys.edit("custom", &cfg.key); + apply_config(&mut app, cfg.clone()); + save_config(&mut app, &cfg, &egui::Context::default()); + settle_keys(&mut app); + assert!(!prefs.lock().unwrap().contains_key("assistant_key_custom")); + assert_eq!(app.assistant.agent.as_ref().unwrap().config.key, "synthetic-new"); + let note = key_storage_note(&app, "custom"); + assert!(note.contains("session only") && !note.contains("synthetic-new")); + } + } + + /// A failed replacement is session-only even when a different legacy key still needs migration. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_keys_distinguish_a_failed_replacement_from_legacy_migration() { + use rustgis_engine::data::secrets::{MemorySecrets, SecretError, SecretStore}; + let failing: std::sync::Arc = + std::sync::Arc::new(MemorySecrets::failing(SecretError::Failed("synthetic-backend-payload".into()))); + for store in [None, Some(failing)] { + let (mut app, prefs) = keys_app(Some("synthetic-legacy"), store); + settle_keys(&mut app); + let mut cfg = app.assistant.agent.as_ref().unwrap().config.clone(); + cfg.key = "synthetic-replacement".into(); + app.assistant.keys.edit("custom", &cfg.key); + apply_config(&mut app, cfg.clone()); + save_config(&mut app, &cfg, &egui::Context::default()); + settle_keys(&mut app); + assert_eq!(prefs.lock().unwrap().get("assistant_key_custom").map(String::as_str), Some("synthetic-legacy")); + assert_eq!(app.assistant.agent.as_ref().unwrap().config.key, "synthetic-replacement"); + let note = key_storage_note(&app, "custom"); + assert!(note.contains("session only; it has not been remembered") && note.contains("unencrypted")); + for secret in ["synthetic-legacy", "synthetic-replacement", "synthetic-backend-payload"] { + assert!(!note.contains(secret)); + } + } + } + + /// Requests wait for the saved key instead of sending with a temporary empty/environment value. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_keys_wait_before_contacting_a_provider() { + use rustgis_engine::data::secrets::{MemorySecrets, Secret, SecretStore}; + let store = std::sync::Arc::new(MemorySecrets::new()); + store.save("assistant/custom", &Secret::new("synthetic-stored")).unwrap(); + let (mut app, _) = keys_app(None, Some(store)); + assert!(key_loading(&app)); + let cfg = app.assistant.agent.as_ref().unwrap().config.clone(); + let ctx = egui::Context::default(); + list_models(&mut app, &cfg, &ctx); + send(&mut app, "hello".into(), &ctx); + assert!(app.assistant.catalog_pending && app.assistant.models_slot.is_none()); + assert!(app.assistant.inflight.is_none()); + assert_eq!(app.assistant.queued.as_deref(), Some("hello")); + settle_keys(&mut app); + assert!(!key_loading(&app)); + assert_eq!(app.assistant.agent.as_ref().unwrap().config.key, "synthetic-stored"); + } + + /// Startup waits for the saved key before opening settings or restoring a folded provider. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_settings_follow_the_initial_key_read() { + use rustgis_engine::data::secrets::{MemorySecrets, Secret, SecretError, SecretStore}; + for saved in [false, true] { + for collapsed in [false, true] { + let store = std::sync::Arc::new(MemorySecrets::new()); + if saved { + store.save("assistant/openai", &Secret::new("synthetic-stored")).unwrap(); + } + let (mut app, prefs) = keys_app(None, None); + prefs.lock().unwrap().insert("assistant_provider".into(), "openai".into()); + prefs.lock().unwrap().insert("assistant_provider_closed".into(), if collapsed { "on" } else { "" }.into()); + app.assistant = AssistantState::default(); + app.services.secrets = Some(store); + let ctx = egui::Context::default(); + app.assistant.keys.load("openai", "", None, true, app.services.secrets.clone(), &ctx); + ensure_loaded(&mut app, &ctx); + assert!(key_loading(&app)); + assert!(!app.assistant.settings); + settle_keys(&mut app); + assert_eq!(app.assistant.settings, !saved); + assert_eq!(app.assistant.provider_closed, saved && collapsed); + } + } + let (mut failed, _) = keys_app(None, Some(std::sync::Arc::new(MemorySecrets::failing(SecretError::Denied)))); + failed.assistant.agent.as_mut().unwrap().config.model.clear(); + settle_keys(&mut failed); + assert!(failed.assistant.settings && !failed.assistant.provider_closed); + } + + /// A user's open or close choice wins even when it returns to the pending default. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_settings_keep_user_choices_during_the_initial_key_read() { + use rustgis_engine::data::secrets::MemorySecrets; + for open in [false, true] { + let (mut app, _) = keys_app(None, Some(std::sync::Arc::new(MemorySecrets::new()))); + assert!(key_loading(&app)); + app.assistant.agent.as_mut().unwrap().config.model = if open { "synthetic-model" } else { "" }.into(); + toggle_settings(&mut app); + if !open { + toggle_settings(&mut app); + } + settle_keys(&mut app); + assert_eq!(app.assistant.settings, open); + } + } + + /// Clearing or unticking Remember deletes the key while keeping an unticked key usable this session. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_keys_are_deleted_when_cleared_or_not_remembered() { + use rustgis_engine::data::secrets::{MemorySecrets, SecretStore}; + for clear in [false, true] { + let store = std::sync::Arc::new(MemorySecrets::new()); + let (mut app, _) = keys_app(Some("synthetic-legacy"), Some(store.clone())); + settle_keys(&mut app); + let mut cfg = app.assistant.agent.as_ref().unwrap().config.clone(); + if clear { + cfg.key.clear(); + } else { + app.assistant.remember_key = false; + } + app.assistant.keys.edit("custom", &cfg.key); + apply_config(&mut app, cfg.clone()); + save_config(&mut app, &cfg, &egui::Context::default()); + settle_keys(&mut app); + assert_eq!(store.load("assistant/custom").unwrap(), None); + assert_eq!(app.assistant.agent.as_ref().unwrap().config.key, if clear { "" } else { "synthetic-legacy" }); + } + } + + /// The shared settings harness installs the real fonts before drawing the form. + #[cfg(not(target_arch = "wasm32"))] + fn keys_harness(app: RustGisApp) -> egui_kittest::Harness<'static, RustGisApp> { + let mut fonts = false; + egui_kittest::Harness::builder().with_size(vec2(700.0, 900.0)).build_ui_state( + move |ui, app: &mut RustGisApp| { + if !fonts { + crate::theme::install_fonts(ui.ctx(), &[], None); + fonts = true; + ui.ctx().request_repaint(); + return; + } + let ctx = ui.ctx().clone(); + poll(app, &ctx); + settings(app, ui, &Tokens::light()); + }, + app, + ) + } + + /// The actual Remember checkbox goes through the deferred save path and deletes the stored key. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_remember_checkbox_forgets_the_key() { + use egui_kittest::kittest::Queryable; + use rustgis_engine::data::secrets::{MemorySecrets, SecretStore}; + let store = std::sync::Arc::new(MemorySecrets::new()); + let (mut app, prefs) = keys_app(Some("synthetic-legacy"), Some(store.clone())); + settle_keys(&mut app); + let mut h = keys_harness(app); + h.run_steps(3); + h.get_by_label("Remember the key on this computer").click(); + h.run_steps(4); + settle_keys(h.state_mut()); + assert!(!h.state().assistant.remember_key); + assert_eq!(store.load("assistant/custom").unwrap(), None); + assert_eq!(prefs.lock().unwrap().get("assistant_remember_key").map(String::as_str), Some("off")); + } + + /// Ticking Remember after an off startup loads the existing key instead of deleting it. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_remember_checkbox_loads_an_unread_key() { + use egui_kittest::kittest::Queryable; + use rustgis_engine::data::secrets::{MemorySecrets, Secret, SecretStore}; + let store = std::sync::Arc::new(MemorySecrets::new()); + store.save("assistant/custom", &Secret::new("synthetic-stored")).unwrap(); + let (app, prefs) = keys_app_remember(None, Some(store.clone()), false); + let mut h = keys_harness(app); + h.run_steps(3); + h.get_by_label("Remember the key on this computer").click(); + h.run_steps(4); + settle_keys(h.state_mut()); + assert!(h.state().assistant.remember_key); + assert_eq!(h.state().assistant.agent.as_ref().unwrap().config.key, "synthetic-stored"); + assert_eq!(store.load("assistant/custom").unwrap(), Some(Secret::new("synthetic-stored"))); + assert_eq!(prefs.lock().unwrap().get("assistant_remember_key").map(String::as_str), Some("on")); + } + + /// Clearing the focused password field deletes its saved key before the field loses focus. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_keys_clear_without_waiting_for_focus_loss() { + use egui_kittest::kittest::Queryable; + use rustgis_engine::data::secrets::{MemorySecrets, SecretStore}; + let store = std::sync::Arc::new(MemorySecrets::new()); + let (mut app, _) = keys_app(Some("synthetic-legacy"), Some(store.clone())); + settle_keys(&mut app); + let mut h = keys_harness(app); + h.run_steps(3); + h.get_by_label("API key").focus(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.key_press(egui::Key::Backspace); + h.run_steps(3); + assert!(h.get_by_label("API key").is_focused()); + assert!(h.state().assistant.agent.as_ref().unwrap().config.key.is_empty()); + settle_keys(h.state_mut()); + assert_eq!(store.load("assistant/custom").unwrap(), None); + } + + /// Session-only typing waits for focus loss instead of deleting a key on each keystroke. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_session_key_typing_defers_storage() { + use egui_kittest::kittest::Queryable; + use rustgis_engine::data::secrets::{MemorySecrets, SecretError}; + let (app, prefs) = keys_app_remember(None, Some(std::sync::Arc::new(MemorySecrets::failing(SecretError::Denied))), false); + let mut h = keys_harness(app); + h.run_steps(3); + h.get_by_label("API key").focus(); + h.run_steps(2); + prefs.lock().unwrap().remove("assistant_model_custom"); + for text in ["synthetic", "-session"] { + h.get_by_label("API key").type_text(text); + h.run_steps(3); + assert!(h.get_by_label("API key").is_focused()); + assert!(matches!(h.state().assistant.keys.status("custom"), Some(crate::assistantkeys::Status::Session))); + assert!(!prefs.lock().unwrap().contains_key("assistant_model_custom")); + } + assert_eq!(h.state().assistant.agent.as_ref().unwrap().config.key, "synthetic-session"); + h.get_by_label("Remember the key on this computer").focus(); + h.run_steps(3); + settle_keys(h.state_mut()); + assert!(prefs.lock().unwrap().contains_key("assistant_model_custom")); + assert!(matches!(h.state().assistant.keys.status("custom"), Some(crate::assistantkeys::Status::Failed { deleting: true, .. }))); + } + + /// Startup migrates keys for unselected providers without opening the Assistant or contacting models. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn assistant_keys_migrate_all_legacy_providers_at_startup() { + use rustgis_engine::data::secrets::{MemorySecrets, Secret, SecretStore}; + use std::{ + collections::BTreeMap, + sync::{Arc, Mutex}, + }; + let prefs = Arc::new(Mutex::new(BTreeMap::from([ + ("assistant_remember_key".to_string(), "on".to_string()), + ("assistant_key_custom".into(), "first".into()), + ("assistant_key_anthropic".into(), "second".into()), + ]))); + let store = Arc::new(MemorySecrets::new()); + let mut app = RustGisApp::with_sample(); + let read = prefs.clone(); + app.services.load_pref = Some(Box::new(move |key| read.lock().unwrap().get(key).cloned())); + let write = prefs.clone(); + app.services.save_pref = Some(Box::new(move |key, value| { + write.lock().unwrap().insert(key.into(), value.into()); + })); + app.services.secrets = Some(store.clone()); + let ctx = egui::Context::default(); + poll_keys(&mut app, &ctx); + let end = std::time::Instant::now() + std::time::Duration::from_secs(3); + while ["custom", "anthropic"] + .iter() + .any(|p| matches!(app.assistant.keys.status(p), Some(crate::assistantkeys::Status::Pending { .. }))) + { + assert!(std::time::Instant::now() < end); + std::thread::sleep(std::time::Duration::from_millis(1)); + poll_keys(&mut app, &ctx); + } + assert!(app.assistant.agent.is_none()); + assert_eq!(store.load("assistant/custom").unwrap(), Some(Secret::new("first"))); + assert_eq!(store.load("assistant/anthropic").unwrap(), Some(Secret::new("second"))); + for p in ["custom", "anthropic"] { + assert_eq!(prefs.lock().unwrap().get(&format!("assistant_key_{p}")).map(String::as_str), Some("")); + } + } + + /// A signed-out ChatGPT settings pane for tests that never contact a provider. fn plan_app() -> RustGisApp { let mut app = RustGisApp::with_sample(); let mut cfg = Config::from_env("chatgpt"); @@ -1667,7 +2285,7 @@ mod tests { .unwrap(); let mut app = RustGisApp::with_sample(); app.services.load_pref = Some(Box::new(|key| (key == "assistant_provider").then(|| "chatgpt".into()))); - ensure_loaded(&mut app); + ensure_loaded(&mut app, &egui::Context::default()); assert!(app.assistant.catalog_pending); let ctx = egui::Context::default(); poll(&mut app, &ctx); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ee0760af3..f98631042 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,6 +11,8 @@ pub mod i18n; #[cfg(not(target_arch = "wasm32"))] mod agentthreads; mod analysispane; +#[cfg(not(target_arch = "wasm32"))] +mod assistantkeys; mod assistantpane; mod backstage; mod bookmarkpane; diff --git a/docs/architecture.md b/docs/architecture.md index a8b74bdf9..8c88a0371 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,6 +59,10 @@ browser and the CLI, where callers keep a secret for the session only, as before - **Blocking.** A call can wait on an OS prompt or D-Bus. Make it on a user action or a worker thread, never every frame, and keep the value in memory for the session. Don't access one account from two threads at once. + The Assistant queues legacy API-key migrations once at startup and serializes its keychain + reads, writes and deletions on a worker. An existing keychain key takes precedence over a + legacy preference. The preference is removed only after a successful read of that key or a + successful migration write; new API keys are never written to desktop preferences. - **macOS.** The system asks again for access when the app's code signature changes, which happens with unsigned or re-signed builds. - **Tests.** Use `MemorySecrets`, and `MemorySecrets::failing(..)` for error paths. The desktop diff --git a/docs/user-guide/assistant.md b/docs/user-guide/assistant.md index 5397b61bb..b2e93b720 100644 --- a/docs/user-guide/assistant.md +++ b/docs/user-guide/assistant.md @@ -274,8 +274,17 @@ Assistant requests go directly to the provider and address you chose, using your ChatGPT sign-in. They carry your messages, the map's layer names, fields and extent, and tool results such as rows the assistant reads. Local agents use their own providers and tools, with their own data handling. A key typed in the settings is used for the session only unless -you tick **Remember the key on this computer**, which saves it unencrypted in the app's -preferences (in the browser's storage on the web); keys from the environment are never saved. +you tick **Remember the key on this computer**. On the desktop, this saves it in the system +keychain (macOS Keychain, Windows Credential Manager, or the Secret Service on Linux). Clearing +the key or unticking **Remember** deletes the saved key; a failed deletion is shown in settings. +Keys from the environment are never saved. If the keychain is unavailable, locked, or access +is refused, a new key stays in memory for this session and the settings explain why it was not +remembered. On upgrade, an existing keychain key takes precedence over an old key in the +preferences; otherwise the app moves the old key into the keychain. The plaintext preference +is cleared only after the keychain key is read or the old key is saved successfully. If either +operation fails, the old preference is retained and the unencrypted-storage +warning stays visible. In the web app, **Remember** still saves the key unencrypted in the +browser's storage, with a note in settings. A ChatGPT sign-in is kept in `chatgpt-auth.json` in the app's settings folder (owner-only on macOS and Linux; on Windows, protected like the rest of your user profile), never in the preferences or a project file, and its tokens are never logged; RustGIS renews them as they expire. From 9bebf19d3e39cf69a33aead98e0bd626226002c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Sat, 24 Oct 2026 06:52:43 -0500 Subject: [PATCH 097/297] fix(ui): name shortcuts by the OS egui reads, a Mac browser's too (#801) --- crates/ui-egui/src/analysispane.rs | 3 ++- crates/ui-egui/src/chrome.rs | 10 ++++++---- crates/ui-egui/src/keymap.rs | 29 +++++++++++++++++++++++------ crates/ui-egui/src/mapview.rs | 2 +- crates/ui-egui/src/widgets.rs | 12 ++++++------ crates/ui-egui/tests/smoke.rs | 8 ++++++-- 6 files changed, 44 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index 69ad2accb..d474e55c9 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -1152,7 +1152,8 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { crate::scriptcomplete::braces(ui, before, &out.inner, &mut app.workflow.script); } ui.horizontal(|ui| { - let shortcut = if cfg!(target_os = "macos") { "⌘+Enter" } else { "Ctrl+Enter" }; + // As egui reads the keys: ⌘ on a Mac, the browser's too (from its user agent). + let shortcut = if ui.ctx().os().is_mac() { "⌘+Enter" } else { "Ctrl+Enter" }; let run = ui.button(tl!("Run")).on_hover_text(crate::i18n::fmt("Run the script ({keys})", &[("keys", shortcut)])).clicked(); if run || ctrl_enter { let script = app.workflow.script.clone(); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index cada206fe..e220c042a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -160,7 +160,8 @@ pub fn header(app: &mut RustGisApp, ui: &mut Ui) { // Command search on the right: full width when there is room, a round icon button // otherwise. It never reaches into the toolbar. let label = tl!("Command Search"); - let hint = if cfg!(target_os = "macos") { "⌘K" } else { "Ctrl+K" }; + // As egui reads the keys: ⌘ on a Mac, the browser's too (from its user agent). + let hint = if ui.ctx().os().is_mac() { "⌘K" } else { "Ctrl+K" }; let right = full.max.x; let room = right - qat_end; let full_w = (text_w(ui, label, regular(12.0)) + text_w(ui, hint, regular(11.0)) + 70.0).clamp(200.0, 260.0); @@ -933,7 +934,7 @@ pub fn command_search(app: &mut RustGisApp, ctx: &egui::Context) { let on = why.is_none(); let mut b = egui::Button::new(egui::RichText::new(label).size(13.0)).frame(false).min_size(vec2(row_w, 26.0)); // The command's shortcut after its location. - let keys = if id.contains(':') { String::new() } else { crate::keymap::shortcut_text(app, id, &Value::Null) }; + let keys = if id.contains(':') { String::new() } else { crate::keymap::shortcut_text(ui.ctx(), app, id, &Value::Null) }; let right = match (show_loc, keys.is_empty()) { (true, false) => format!("{loc} {keys}"), (true, true) => loc.clone(), @@ -1066,6 +1067,7 @@ pub fn about(app: &mut RustGisApp, ctx: &egui::Context) { .pivot(Align2::CENTER_CENTER) .default_pos(ctx.content_rect().center()) .show(ctx, |ui| { + let mac = ctx.os().is_mac(); egui::Grid::new("keys").striped(true).spacing(vec2(24.0, 6.0)).show(ui, |ui| { // The keys that run now, from the keymap. let rows: Vec<(String, String)> = app @@ -1074,7 +1076,7 @@ pub fn about(app: &mut RustGisApp, ctx: &egui::Context) { .grouped() .into_iter() .map(|(command, params, keys)| { - let keys: Vec = keys.into_iter().map(crate::keymap::Chord::text).collect(); + let keys: Vec = keys.into_iter().map(|c| c.text(mac)).collect(); (keys.join(" / "), crate::keymap::label(app, command, params)) }) .collect(); @@ -1087,7 +1089,7 @@ pub fn about(app: &mut RustGisApp, ctx: &egui::Context) { let clipboard: Vec = crate::keymap::CLIPBOARD .iter() .filter_map(|(keys, _)| crate::keymap::Chord::parse(keys)) - .map(|c| c.text()) + .map(|c| c.text(mac)) .collect(); let clipboard = clipboard.join(" / "); for (k, d) in [ diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index cb0746b62..56a58e17c 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -114,14 +114,15 @@ impl Chord { m } - /// As shown to the user: `⇧⌘S` on macOS, `Ctrl+Shift+S` elsewhere. - pub fn text(self) -> String { + /// As shown to the user: `⇧⌘S` on a Mac (`mac`, as egui reads the keys: `ctx.os().is_mac()`, a + /// browser's too), `Ctrl+Shift+S` elsewhere. + pub fn text(self, mac: bool) -> String { let key = match self.key { Key::Escape => "Esc", Key::Plus | Key::Minus | Key::Equals => self.key.symbol_or_name(), k => k.name(), }; - if cfg!(target_os = "macos") { + if mac { let mut s = String::new(); for (on, sym) in [(self.alt, "⌥"), (self.shift, "⇧"), (self.command, "⌘")] { if on { @@ -261,9 +262,9 @@ pub fn is_clipboard(chord: Chord) -> bool { /// The first shortcut of `command` with `params` as shown to the user (a clipboard command's /// fixed key included), or empty for none. -pub fn shortcut_text(app: &RustGisApp, command: &str, params: &Value) -> String { +pub fn shortcut_text(ctx: &egui::Context, app: &RustGisApp, command: &str, params: &Value) -> String { let clipboard = || CLIPBOARD.iter().find(|(_, c)| *c == command).and_then(|(keys, _)| Chord::parse(keys)); - app.ui.keymap.keys_for(command, params).next().or_else(clipboard).map(Chord::text).unwrap_or_default() + app.ui.keymap.keys_for(command, params).next().or_else(clipboard).map(|c| c.text(ctx.os().is_mac())).unwrap_or_default() } /// What a binding runs, translated: the command's label, or the map tool it picks. @@ -374,6 +375,22 @@ mod tests { } } + /// Shown as egui reads the keys, by the OS it tells (a browser's from its user agent), not the + /// one the app was built for: a web app on a Mac shows ⌘ (#799). + #[test] + fn chords_show_the_keys_of_the_os_egui_reads() { + let c = Chord::parse("Mod+Shift+S"); + assert_eq!(c.map(|c| c.text(true)).as_deref(), Some("⇧⌘S")); + assert_eq!(c.map(|c| c.text(false)).as_deref(), Some("Ctrl+Shift+S")); + let app = RustGisApp::with_sample(); + let ctx = egui::Context::default(); + use egui::os::OperatingSystem as Os; + for (os, undo) in [(Os::Mac, "⌘Z"), (Os::IOS, "⌘Z"), (Os::Windows, "Ctrl+Z")] { + ctx.set_os(os); + assert_eq!(shortcut_text(&ctx, &app, "edit.undo", &Value::Null), undo, "{os:?}"); + } + } + #[test] fn every_default_parses_once() { let map = Keymap::default(); @@ -398,7 +415,7 @@ mod tests { // Copy, Cut and Paste declare Mod+C/X/V: binding them too would run each twice. let app = RustGisApp::with_sample(); assert!(app.ui.keymap.bindings.iter().all(|b| !is_clipboard(b.chord))); - assert!(!shortcut_text(&app, "edit.copy", &Value::Null).is_empty(), "but Copy still shows its key"); + assert!(!shortcut_text(&egui::Context::default(), &app, "edit.copy", &Value::Null).is_empty(), "but Copy still shows its key"); // A command's own key joins the defaults only where it is free. assert_eq!(app.ui.keymap.keys_for("layer.remove", &Value::Null).count(), 0, "Delete deletes features"); } diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index e0be21403..232b6237e 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -2777,7 +2777,7 @@ pub fn confirm_delete(app: &mut RustGisApp, ctx: &egui::Context) { Deletion::Map(n) => crate::i18n::fmt("Delete the map {name}?", &[("name", n)]), Deletion::Layout(n) => crate::i18n::fmt("Delete the layout {name}?", &[("name", n)]), }; - let undo = crate::widgets::shortcut_text(app, "edit.undo"); + let undo = crate::widgets::shortcut_text(ctx, app, "edit.undo"); let mut decision = None; egui::Modal::new(egui::Id::unique("confirm_delete")).show(ctx, |ui| { ui.set_width(360.0); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c16059b9e..ac9cf4d03 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -53,10 +53,10 @@ pub fn text_w(ui: &Ui, s: &str, font: egui::FontId) -> f32 { ui.ctx().fonts_mut(|f| f.layout_no_wrap(s.to_string(), font, egui::Color32::WHITE).size().x) } -/// The shortcut of a command run without parameters (`⌘S` on macOS, `Ctrl+S` elsewhere), or -/// empty for none. -pub fn shortcut_text(app: &RustGisApp, id: &str) -> String { - crate::keymap::shortcut_text(app, id, &Value::Null) +/// The shortcut of a command run without parameters (`⌘S` on a Mac, as egui reads the keys, +/// `Ctrl+S` elsewhere), or empty for none. +pub fn shortcut_text(ctx: &egui::Context, app: &RustGisApp, id: &str) -> String { + crate::keymap::shortcut_text(ctx, app, id, &Value::Null) } /// Whether a command exists and is enabled now. @@ -74,7 +74,7 @@ pub fn why_disabled(app: &RustGisApp, id: &str) -> Option<&'static str> { /// A rich tooltip: title (with the shortcut of `id` with `params`), description and why it's /// disabled. pub fn tooltip(app: &RustGisApp, resp: Response, title: &str, desc: &str, id: &str, params: &Value) -> Response { - let sc = crate::keymap::shortcut_text(app, id, params); + let sc = crate::keymap::shortcut_text(&resp.ctx, app, id, params); let why = why_disabled(app, id); resp.on_hover_ui(|ui| { ui.set_max_width(280.0); @@ -267,7 +267,7 @@ const MENU_ICON_W: f32 = 22.0; /// A menu row that runs a command (disabled when the command is). pub fn menu_item(ui: &mut Ui, app: &mut RustGisApp, icon: &str, label: &str, id: &str, params: Value) { let on = enabled(app, id); - let shortcut = crate::keymap::shortcut_text(app, id, ¶ms); + let shortcut = crate::keymap::shortcut_text(ui.ctx(), app, id, ¶ms); if menu_row(ui, icon, tl!(label), &shortcut, on).clicked() { let _ = app.run(id, params); ui.close(); diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 0bcbddad7..c64480996 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -2959,8 +2959,12 @@ fn help_shortcuts_lists_the_keymap() { h.step(); // What it opened shows on the next frame. h.step(); - let undo = if cfg!(target_os = "macos") { "⌘Z" } else { "Ctrl+Z" }; - assert!(h.query_by_label(undo).is_some(), "Help › Shortcuts lists Undo's key"); + // Named by the OS egui reads (a browser's from its user agent), not the build's (#799): the + // harness tells Linux, whatever runs it. + assert!(h.query_by_label("Ctrl+Z").is_some(), "Help › Shortcuts lists Undo's key"); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.step(); + assert!(h.query_by_label("⌘Z").is_some(), "with ⌘ on a Mac"); assert!(h.query_by_label("Map Tool: Explore").is_some(), "and what Esc does"); } From f903d0e498f4a6d8a6e3fc5eb1cf07a8f2bd93c8 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sat, 24 Oct 2026 14:28:35 +0200 Subject: [PATCH 098/297] feat(map): dashed line strokes (#765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(map): dashed line strokes A stacked line stroke can be dashed (`LineStroke::dash`): lengths in points, dash and gap in turn, as SVG's stroke-dasharray (an odd list is repeated). Kept in a project as that list; a crafted project's steps are clamped to 0..500 points, cut to 8, and a pattern without length is a solid line. - render: stroked with tiny-skia's dash, in pixels; drawn solid when a round of the pattern is under a pixel, or would cut a path into more than 100,000 dashes. - engine: `"dash": [points, ...] | null` on each stroke of `layer.lineStrokes`, refused when out of range. - ui: a Dash row on each stroke (typed, such as `4 2`, or picked: dashed, dotted, dash-dot, long dash, sized to the stroke's width), and the stroke preview draws the dashes. - docs: cartography.md › Line strokes. * fix(render): the dash limit counts a path's length, not its bounds * fix(ui): a dash being typed stays with its layer and class; stroke_json keeps its doc line * fix(render): the dash limit counts dashes, not rounds of the pattern * test(ui): the dash draft test sits under its own doc comment * fix(render): a dashed line cut at the clip box keeps its dashes where they'd be uncut * fix(ui): an invalid dash pattern is outlined; the preview draws solid when the map does * fix(ui): the dash box sets a pattern only when typed, not on Escape, and shows it as kept * fix(ui): the dash box's id from Ui::scope_id, as egui 0.37 names it * fix(map): a project's dash pattern without length reads as solid at the field, so no empty Dash is made * fix(render): dash starts apply only while a path's pieces match them one to one * test(render): an offset dashed stroke cut at the clip box keeps its dashes too * fix(map): a project's dash step too large for a number is the longest, not 0; notes on offset starts and DashStarts * docs(engine): why layer.lineStrokes rejects a dash out of range while a project's is clamped --- crates/engine/src/cmd/layer.rs | 23 +- crates/engine/src/tests.rs | 18 ++ crates/map/src/lib.rs | 34 +++ crates/map/src/symbology.rs | 65 +++++- crates/render/src/lib.rs | 283 ++++++++++++++++++++++-- crates/render/src/stroke_paths.rs | 5 +- crates/render/src/stroke_paths/tests.rs | 22 +- crates/ui-egui/src/panes.rs | 147 +++++++++++- docs/user-guide/cartography.md | 10 + 9 files changed, 579 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 5100e9784..7d63561ac 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -177,7 +177,7 @@ pub fn commands() -> Vec { C::new("layer.lineStrokes", "Line Strokes", "Style › Drawing", line_strokes) .when(need_lines) .params( - r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' )}"##, + r##"{"layer"?, "strokes": [{"color"?: "#RRGGBB"|"#RRGGBBAA"|null (null: the renderer's line colour), "width"?: points|null (null: the renderer's width), "offset"?: points (positive: right of the line's direction, negative: left), "cap"?: "round"|"flat"|"square", "join"?: "round"|"miter"|"bevel", "level"?: 0..255|null (with layer.strokeLevels: the level it draws at; null: its place in the stack), "dash"?: [points, …]|null (dash, gap, … in turn, at most 8; null: solid)}] (drawn bottom first; [] goes back to the single stroke, or for a class to the layer's strokes), "value"?: text|number (a Unique Values class's own strokes), "index"?: number (a Graduated Colors class's, 0 = the lowest), "other"?: true (Unique Values' )}"##, ), C::new("layer.strokeLevels", "Stroke Levels", "Style › Drawing", |s, p| { let on = b(p, "on").ok_or("on is true or false")?; @@ -423,7 +423,26 @@ fn line_strokes(s: &mut Session, p: &Value) -> CmdResult { .ok_or_else(|| format!("stroke {}: level is a whole number from 0 to 255", i + 1))?, ), }; - strokes.push(LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join, level }.clamped()); + let bad_dash = || { + format!( + "stroke {}: dash is a list of 1 to {} lengths in points (dash, gap, …), each 0 to {}, not all 0", + i + 1, + rustgis_map::symbology::MAX_DASH_STEPS, + rustgis_map::symbology::MAX_DASH_STEP + ) + }; + // A pattern out of range is an error here, so a script learns of it; a project file's is + // brought into range instead (`de_dash`), so a hand-edited file still opens. + let dash = match o.get("dash") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => { + let steps: Vec = + a.iter().map(|v| v.as_f64().map(|x| x.clamp(-1e6, 1e6) as f32)).collect::>().ok_or_else(bad_dash)?; + Some(rustgis_map::symbology::Dash::new(&steps).ok_or_else(bad_dash)?) + } + Some(_) => return Err(bad_dash()), + }; + strokes.push(LineStroke { color, width: num("width")?, offset: num("offset")?.unwrap_or(0.0), cap, join, level, dash }.clamped()); } let id = s.layer_id(p)?; let l = s.map_mut().layer_mut(id).ok_or("no such layer")?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4b6956df5..d717ec02b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -359,6 +359,7 @@ fn line_strokes_stack_and_survive_save_and_undo() { cap: LineCap::Round, join: LineJoin::Round, level: None, + dash: None, }, LineStroke { color: Some(Rgba::rgb(0xFF, 0x8C, 0)), @@ -367,6 +368,7 @@ fn line_strokes_stack_and_survive_save_and_undo() { cap: LineCap::Butt, join: LineJoin::Miter, level: None, + dash: None, }, LineStroke { offset: -2.0, ..LineStroke::default() }, ]; @@ -413,6 +415,22 @@ fn line_strokes_stack_and_survive_save_and_undo() { assert_eq!(s.map().layer_by_name("Rivers").unwrap().line_strokes.len(), 1); } +#[test] +fn a_stroke_is_dashed_by_command() { + let mut s = world(); + let stroke = |dash: Value| json!({"layer": "Rivers", "strokes": [{"color": "#000000", "width": 2, "dash": dash}]}); + s.run("layer.lineStrokes", &stroke(json!([4, 2.5]))).unwrap(); + let dash = |s: &Session| s.map().layer_by_name("Rivers").unwrap().line_strokes[0].dash.map(|d| d.steps().to_vec()); + assert_eq!(dash(&s), Some(vec![4.0, 2.5])); + let depth = s.undo_depth(); + for bad in [json!([]), json!([0, 0]), json!([-1, 2]), json!([1, "2"]), json!("4 2"), json!([1, 1, 1, 1, 1, 1, 1, 1, 1]), json!([1e9])] { + assert!(s.run("layer.lineStrokes", &stroke(bad.clone())).is_err(), "{bad}"); + } + assert_eq!((s.undo_depth(), dash(&s)), (depth, Some(vec![4.0, 2.5])), "a refused pattern changes nothing"); + s.run("layer.lineStrokes", &stroke(Value::Null)).unwrap(); + assert_eq!(dash(&s), None, "null: solid"); +} + #[test] fn a_class_stacks_strokes_of_its_own() { use rustgis_map::symbology::ClassRef; diff --git a/crates/map/src/lib.rs b/crates/map/src/lib.rs index a95ac42b4..b6aed7995 100644 --- a/crates/map/src/lib.rs +++ b/crates/map/src/lib.rs @@ -1654,6 +1654,40 @@ mod tests { ); } + #[test] + fn dash_patterns_are_kept_and_hostile_ones_brought_into_range() { + use symbology::{Dash, MAX_DASH_STEP, MAX_DASH_STEPS}; + // Built: in range, not all 0, at most MAX_DASH_STEPS. + assert_eq!(Dash::new(&[4.0, 2.0]).map(|d| d.steps().to_vec()), Some(vec![4.0, 2.0])); + assert_eq!(Dash::new(&[3.0]).map(|d| (d.even_steps(), d.period())), Some((vec![3.0, 3.0], 6.0)), "odd: twice over"); + for bad in [&[][..], &[0.0, 0.0], &[-1.0, 2.0], &[f32::NAN, 1.0], &[MAX_DASH_STEP + 1.0], &[1.0; MAX_DASH_STEPS + 1]] { + assert!(Dash::new(bad).is_none(), "{bad:?}"); + } + // Saved as a plain list and read back. + let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![])); + l.line_strokes = vec![symbology::LineStroke { dash: Dash::new(&[4.0, 2.0, 1.0, 2.0]), ..Default::default() }]; + let text = serde_json::to_string(&l).unwrap(); + assert!(text.contains(r#""dash":[4.0,2.0,1.0,2.0]"#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap().line_strokes, l.line_strokes); + // A crafted project's: steps clamped, too many cut, and one without length solid. + let mut v: serde_json::Value = serde_json::from_str(&text).unwrap(); + let read = |v: &serde_json::Value| serde_json::from_value::(v.clone()).unwrap().line_strokes[0].dash; + v["line_strokes"][0]["dash"] = serde_json::json!([1e30, -5, 2]); + assert_eq!(read(&v).map(|d| d.steps().to_vec()), Some(vec![MAX_DASH_STEP, 0.0, 2.0])); + // Too large for an f32 (infinite once read): the longest step, as a large one is. + v["line_strokes"][0]["dash"] = serde_json::json!([1e40, 2]); + assert_eq!(read(&v).map(|d| d.steps().to_vec()), Some(vec![MAX_DASH_STEP, 2.0])); + v["line_strokes"][0]["dash"] = serde_json::json!(vec![1.0; 5000]); + assert_eq!(read(&v).map(|d| d.steps().len()), Some(MAX_DASH_STEPS)); + for solid in [serde_json::json!([0, 0]), serde_json::json!([]), serde_json::json!([-3])] { + v["line_strokes"][0]["dash"] = solid.clone(); + assert_eq!(read(&v), None, "{solid}"); + // Read as a stroke alone too, so no empty pattern is ever made. + let st = serde_json::from_value::(v["line_strokes"][0].clone()).unwrap(); + assert_eq!(st.dash, None, "{solid}"); + } + } + #[test] fn a_reference_scale_scales_sizes_with_the_map_and_hostile_ones_are_dropped() { let mut l = Layer::new(1, Dataset::new("lines", GeometryType::Polyline, vec![])); diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 2b79dba9d..c5cb4dc6a 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -162,6 +162,66 @@ pub const MAX_STROKES: usize = 16; /// The widest stroke and the farthest offset, in points. pub const MAX_STROKE_WIDTH: f32 = 50.0; pub const MAX_STROKE_OFFSET: f32 = 100.0; +/// The most steps a dash pattern has, and the longest step, in points. +pub const MAX_DASH_STEPS: usize = 8; +pub const MAX_DASH_STEP: f32 = 500.0; + +/// A stroke's dash pattern: lengths in points, dash and gap in turn, starting with a dash (as +/// SVG's `stroke-dasharray`: an odd number of steps is repeated to make it even). Written to a +/// project as that list, `[4, 2]`. Built only through [`Dash::new`], so its steps are always +/// in range and add up to more than nothing. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +#[serde(into = "Vec")] +pub struct Dash { + steps: [f32; MAX_DASH_STEPS], + len: u8, +} + +impl Dash { + /// The pattern of `steps` (at most [`MAX_DASH_STEPS`], each 0 to [`MAX_DASH_STEP`]), or + /// `None` for none: no steps, a step that isn't a number from 0 to [`MAX_DASH_STEP`], too + /// many, or all of them 0. + pub fn new(steps: &[f32]) -> Option { + let ok = |v: &f32| v.is_finite() && (0.0..=MAX_DASH_STEP).contains(v); + if steps.is_empty() || steps.len() > MAX_DASH_STEPS || !steps.iter().all(ok) || steps.iter().sum::() <= 0.0 { + return None; + } + let mut d = Dash { steps: [0.0; MAX_DASH_STEPS], len: u8::try_from(steps.len()).ok()? }; + d.steps.get_mut(..steps.len())?.copy_from_slice(steps); + Some(d) + } + + /// Its steps, as given. + pub fn steps(&self) -> &[f32] { + self.steps.get(..usize::from(self.len)).unwrap_or(&[]) + } + + /// Its steps made even (an odd number twice over), as a renderer takes them. + pub fn even_steps(&self) -> Vec { + let s = self.steps(); + if s.len().is_multiple_of(2) { s.to_vec() } else { s.iter().chain(s).copied().collect() } + } + + /// One round of the pattern, in points. + pub fn period(&self) -> f32 { + self.even_steps().iter().sum() + } +} + +/// Reads a stroke's pattern from a project, brought into range: at most [`MAX_DASH_STEPS`] steps, +/// each clamped to 0 to [`MAX_DASH_STEP`] (one too large for a number, infinite, the longest); +/// one left without length is `None`, solid (a project file is hostile input). +fn de_dash<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let Some(v) = Option::>::deserialize(d)? else { return Ok(None) }; + let steps: Vec = v.iter().take(MAX_DASH_STEPS).map(|x| if x.is_nan() { 0.0 } else { x.clamp(0.0, MAX_DASH_STEP) }).collect(); + Ok(Dash::new(&steps)) +} + +impl From for Vec { + fn from(d: Dash) -> Vec { + d.steps().to_vec() + } +} /// One stroke of a line layer's stacked symbol (`Layer::line_strokes`). Widths and offsets are /// in points, so they keep their size on screen at every scale; the geometry is left as it is. @@ -188,11 +248,14 @@ pub struct LineStroke { /// join under the cores. `None` is its place in the stack, the bottom one 0. #[serde(default, skip_serializing_if = "Option::is_none")] pub level: Option, + /// Dashed: the pattern along the line, in points; `None` is a solid line. + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "de_dash")] + pub dash: Option, } impl Default for LineStroke { fn default() -> Self { - LineStroke { color: None, width: None, offset: 0.0, cap: LineCap::Round, join: LineJoin::Round, level: None } + LineStroke { color: None, width: None, offset: 0.0, cap: LineCap::Round, join: LineJoin::Round, level: None, dash: None } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f448e075b..5cf9c9693 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -358,6 +358,9 @@ fn paint(c: Rgba, opacity: f32) -> Paint<'static> { p } +/// The most dashes a dashed stroke is cut into along one path (`Ctx::dash`). +const MAX_DASHES: f32 = 100_000.0; + struct Ctx<'a> { view: &'a View, ppp: f32, @@ -499,6 +502,12 @@ impl Ctx<'_> { } fn ring_path(&self, pb: &mut PathBuilder, ring: &[Coord], close: bool) { + self.ring_path_at(pb, ring, close, &mut DashStarts::default()); + } + + /// [`Self::ring_path`], noting in `starts` how far along its line each piece it adds begins, + /// so a dash pattern can carry on across a cut. + fn ring_path_at(&self, pb: &mut PathBuilder, ring: &[Coord], close: bool, starts: &mut DashStarts) { if let Some(g) = &self.globe { if close { self.globe_ring(g, pb, ring) @@ -528,16 +537,19 @@ impl Ctx<'_> { } if clip.contains(Coord::new(b.min_x, b.min_y)) && clip.contains(Coord::new(b.max_x, b.max_y)) { emit_run(pb, run.iter().map(dev), close); + starts.push(0.0); } else if close { // Zoomed in on a big polygon: its far vertices would be millions of pixels away, // which the rasteriser can't take (and needn't walk). Clipping each ring to a box // well beyond the image keeps the even-odd fill the same inside it. let pts: Vec<(f64, f64)> = run.iter().map(dev).collect(); emit_run(pb, clip_ring(&pts, &clip).into_iter(), true); + starts.push(0.0); } else { let pts: Vec<(f64, f64)> = run.iter().map(dev).collect(); - for part in clip_line(&pts, &clip) { + for (start, part) in clip_line(&pts, &clip) { emit_run(pb, part.into_iter(), false); + starts.push(start as f32); } } } @@ -584,15 +596,34 @@ impl Ctx<'_> { /// A line drawn as its stacked strokes (`Layer::strokes`), bottom first, each /// shifted sideways by its offset. fn stacked_line(&self, pm: &mut Pixmap, parts: &[Vec], s: &Symbol, strokes: &[LineStroke]) { - let mut pb = PathBuilder::new(); + let (mut pb, mut starts) = (PathBuilder::new(), DashStarts::default()); for part in parts { - self.ring_path(&mut pb, part, false); + self.ring_path_at(&mut pb, part, false, &mut starts); } let Some(path) = pb.finish() else { return }; - self.stacked_path(pm, &path, s, strokes); + self.stacked_path(pm, &path, s, strokes, &starts); + } + + /// A stroke's dash pattern in pixels for `path`, or `None` to draw it solid: a pattern under a + /// pixel a round, or one that would cut the path into more than [`MAX_DASHES`] dashes (each + /// is a path of its own), would only cost time. The whole path counts, on screen or not: the + /// dashing doesn't skip what's off screen. + fn dash(&self, d: &rustgis_map::symbology::Dash, path: &tiny_skia::Path) -> Option> { + let steps: Vec = d.even_steps().iter().map(|v| v * self.ppp).collect(); + let period: f32 = steps.iter().sum(); + // Each round of the pattern draws one dash per pair of steps. + let dashes = path_length(path) / period * (steps.len() / 2) as f32; + if !(period >= 1.0 && dashes <= MAX_DASHES) { + return None; + } + Some(steps) } - fn stacked_path(&self, pm: &mut Pixmap, path: &tiny_skia::Path, s: &Symbol, strokes: &[LineStroke]) { + /// `starts`: where along its line each of `path`'s pieces begins ([`Self::ring_path_at`]), so a + /// line cut at the clip box keeps its dashes where they'd be uncut. An offset stroke takes the + /// same starts: exact on a straight line, a little off after bends, where the shifted line is + /// longer or shorter than the line. + fn stacked_path(&self, pm: &mut Pixmap, path: &tiny_skia::Path, s: &Symbol, strokes: &[LineStroke], starts: &DashStarts) { // Shifted once per offset: a casing and its core usually share one. let mut shifted: Vec<(f32, Option)> = Vec::new(); for st in strokes { @@ -622,7 +653,29 @@ impl Ctx<'_> { }, ..Stroke::default() }; - pm.stroke_path(drawn, &paint(c, 1.0), &stroke, Transform::identity(), None); + let steps = st.dash.and_then(|d| self.dash(&d, drawn)); + let period: f32 = steps.iter().flatten().sum(); + // A dashed line cut into pieces: each piece starts its pattern where its line is. Only + // while every piece has its start: one off would shift every later piece's dashes. + let pieces: Vec<(tiny_skia::Path, f32)> = match (&steps, starts.of(drawn)) { + (Some(_), Some(at)) => { + let c = contours(drawn); + if c.len() == at.len() { c.into_iter().zip(at.iter().copied()).collect() } else { Vec::new() } + } + _ => Vec::new(), + }; + match steps { + Some(steps) if !pieces.is_empty() => { + for (piece, at) in &pieces { + let dash = tiny_skia::StrokeDash::new(steps.clone(), at.rem_euclid(period)); + pm.stroke_path(piece, &paint(c, 1.0), &Stroke { dash, ..stroke.clone() }, Transform::identity(), None); + } + } + steps => { + let dash = steps.and_then(|steps| tiny_skia::StrokeDash::new(steps, 0.0)); + pm.stroke_path(drawn, &paint(c, 1.0), &Stroke { dash, ..stroke }, Transform::identity(), None); + } + } } } @@ -758,6 +811,35 @@ impl Ctx<'_> { } } +/// The length of `path` in pixels, at most a little over: a curve counts as its control polygon, +/// which is never shorter than the curve. +fn path_length(path: &tiny_skia::Path) -> f32 { + let (mut len, mut last, mut start) = (0.0, None, None); + for seg in path.segments() { + let pts = match seg { + tiny_skia::PathSegment::MoveTo(p) => { + (last, start) = (Some(p), Some(p)); + continue; + } + tiny_skia::PathSegment::LineTo(p) => [p, p, p], + tiny_skia::PathSegment::QuadTo(a, p) => [a, p, p], + tiny_skia::PathSegment::CubicTo(a, b, p) => [a, b, p], + tiny_skia::PathSegment::Close => match start { + Some(p) => [p, p, p], + None => continue, + }, + }; + if let Some(mut q) = last { + for p in pts { + len += q.distance(p); + q = p; + } + } + last = Some(pts[2]); + } + len +} + /// `path`'s polylines shifted `d` pixels to the right of their direction (screen y down). fn offset_path(path: &tiny_skia::Path, d: f32) -> Option { let mut pb = PathBuilder::new(); @@ -899,13 +981,19 @@ fn clip_ring(pts: &[(f64, f64)], b: &Extent) -> Vec<(f64, f64)> { if cur.len() < 3 { Vec::new() } else { cur } } -/// The parts of a line inside a box (each segment clipped by Liang–Barsky). -fn clip_line(pts: &[(f64, f64)], b: &Extent) -> Vec> { - let mut parts: Vec> = Vec::new(); +/// The parts of a line inside a box (each segment clipped by Liang–Barsky), each with how far +/// along the line it starts. +fn clip_line(pts: &[(f64, f64)], b: &Extent) -> Vec<(f64, Vec<(f64, f64)>)> { + let mut parts: Vec<(f64, Vec<(f64, f64)>)> = Vec::new(); let mut cur: Vec<(f64, f64)> = Vec::new(); + // Length of the line before this segment, and where the part being built starts. + let (mut along, mut start) = (0.0f64, 0.0f64); for w in pts.windows(2) { let (Some(&p), Some(&q)) = (w.first(), w.get(1)) else { continue }; let (dx, dy) = (q.0 - p.0, q.1 - p.1); + let len = dx.hypot(dy); + let before = along; + along += len; let (mut t0, mut t1) = (0.0f64, 1.0f64); let mut keep = true; for (num, den) in [(p.0 - b.min_x, -dx), (b.max_x - p.0, dx), (p.1 - b.min_y, -dy), (b.max_y - p.1, dy)] { @@ -925,7 +1013,7 @@ fn clip_line(pts: &[(f64, f64)], b: &Extent) -> Vec> { } if !keep || t0 > t1 { if cur.len() > 1 { - parts.push(std::mem::take(&mut cur)); + parts.push((start, std::mem::take(&mut cur))); } cur.clear(); continue; @@ -934,21 +1022,75 @@ fn clip_line(pts: &[(f64, f64)], b: &Extent) -> Vec> { let z = (p.0 + dx * t1, p.1 + dy * t1); if cur.is_empty() || t0 > 0.0 { if cur.len() > 1 { - parts.push(std::mem::take(&mut cur)); + parts.push((start, std::mem::take(&mut cur))); } cur = vec![a]; + start = before + len * t0; } cur.push(z); if t1 < 1.0 { - parts.push(std::mem::take(&mut cur)); + parts.push((start, std::mem::take(&mut cur))); } } if cur.len() > 1 { - parts.push(cur); + parts.push((start, cur)); } parts } +/// Where along its line each piece of a path begins, in device pixels, as [`Ctx::ring_path_at`] +/// adds them. Nothing is kept while every piece begins at its line's start (nothing was cut): +/// pieces starting at 0, 0 keep `at` empty; a third starting at 41 makes it [0, 0, 41]. `pieces` +/// counts every piece pushed, so the zeros before the first cut can be filled in then. +#[derive(Default)] +pub(crate) struct DashStarts { + pieces: usize, + at: Vec, +} + +impl DashStarts { + fn push(&mut self, at: f32) { + if self.at.is_empty() && at != 0.0 { + self.at.resize(self.pieces, 0.0); + } + if !self.at.is_empty() || at != 0.0 { + self.at.push(at); + } + self.pieces += 1; + } + + /// The starts for `path`'s pieces, when some piece doesn't begin at its line's start and they + /// match the path's pieces one to one. + fn of(&self, path: &tiny_skia::Path) -> Option<&[f32]> { + let n = path.segments().filter(|s| matches!(s, tiny_skia::PathSegment::MoveTo(_))).count(); + (!self.at.is_empty() && self.at.len() == n).then_some(self.at.as_slice()) + } +} + +/// A path's pieces (from each move-to), each a path of its own. +fn contours(path: &tiny_skia::Path) -> Vec { + let mut out = Vec::new(); + let mut pb = PathBuilder::new(); + for seg in path.segments() { + match seg { + tiny_skia::PathSegment::MoveTo(p) => { + if let Some(done) = std::mem::replace(&mut pb, PathBuilder::new()).finish() { + out.push(done); + } + pb.move_to(p.x, p.y); + } + tiny_skia::PathSegment::LineTo(p) => pb.line_to(p.x, p.y), + tiny_skia::PathSegment::QuadTo(a, p) => pb.quad_to(a.x, a.y, p.x, p.y), + tiny_skia::PathSegment::CubicTo(a, b, p) => pb.cubic_to(a.x, a.y, b.x, b.y, p.x, p.y), + tiny_skia::PathSegment::Close => pb.close(), + } + } + if let Some(done) = pb.finish() { + out.push(done); + } + out +} + /// Blends `color` at `coverage` (in pixels, spread bilinearly) around (`cx`, `cy`) device pixels. fn splat_color(pm: &mut Pixmap, cx: f32, cy: f32, color: Rgba, coverage: f32) { if color.a() == 0 || coverage.is_nan() || coverage <= 0.0 || !cx.is_finite() || !cy.is_finite() { @@ -4894,7 +5036,7 @@ mod tests { assert_eq!(clip_ring(&inside, &b), inside.to_vec()); // A line in and out of the box comes back as its two parts inside. let parts = clip_line(&[(-5.0, 2.0), (5.0, 2.0), (5.0, 20.0), (8.0, 20.0), (8.0, 5.0), (15.0, 5.0)], &b); - assert_eq!(parts, vec![vec![(0.0, 2.0), (5.0, 2.0), (5.0, 10.0)], vec![(8.0, 10.0), (8.0, 5.0), (10.0, 5.0)]]); + assert_eq!(parts, vec![(5.0, vec![(0.0, 2.0), (5.0, 2.0), (5.0, 10.0)]), (41.0, vec![(8.0, 10.0), (8.0, 5.0), (10.0, 5.0)])]); assert!(clip_line(&[(-5.0, -5.0), (-1.0, 20.0)], &b).is_empty()); } @@ -5475,6 +5617,119 @@ mod tests { assert_eq!(colour(&r, 60), (0, 0, 0)); } + #[test] + fn a_line_cut_at_the_clip_box_keeps_its_dashes_where_they_were() { + // A dashed line from far left of the image (beyond the clip box, where it's cut) across it: + // drawn cut, its dashes lie where the whole line, drawn uncut, has them. So do an offset + // stroke's, shifted beside it (straight, so the shifted line is as long as the line). + let camera = Camera { center: Coord::new(0.0, 0.0), res: 1.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 100.0, height: 20.0, pixels_per_point: 1.0, camera, sky: false }; + let ctx = Ctx { view: &view, ppp: 1.0, wireframe: false, cull: Extent::EMPTY, globe: None, sprites: Default::default() }; + // 1,003.5 px of line before the image, not a whole number of the pattern's 7 px rounds. + let line = [Coord::new(-1053.5, 0.0), Coord::new(60.0, 0.0)]; + let (mut pb, mut starts) = (PathBuilder::new(), DashStarts::default()); + ctx.ring_path_at(&mut pb, &line, false, &mut starts); + let cut = pb.finish().unwrap(); + assert!(cut.bounds().left() > -200.0, "cut at the clip box: {:?}", cut.bounds()); + for offset in [0.0, 3.0] { + let stroke = LineStroke { + color: Some(Rgba::rgb(0, 0, 0)), + width: Some(2.0), + offset, + cap: rustgis_map::LineCap::Butt, + dash: rustgis_map::symbology::Dash::new(&[4.0, 3.0]), + ..Default::default() + }; + let s = Symbol::default_for(GeometryType::Polyline, 0); + let mut drawn = Pixmap::new(100, 20).unwrap(); + ctx.stacked_path(&mut drawn, &cut, &s, &[stroke], &starts); + let mut whole = Pixmap::new(100, 20).unwrap(); + let mut pb = PathBuilder::new(); + let (x0, y) = view.to_screen(line[0]); + let (x1, _) = view.to_screen(line[1]); + // Right of the line's direction: left to right, that's down the screen. + let y = y as f32 + offset; + pb.move_to(x0 as f32, y); + pb.line_to(x1 as f32, y); + let st = + Stroke { width: 2.0, line_cap: LineCap::Butt, dash: tiny_skia::StrokeDash::new(vec![4.0, 3.0], 0.0), ..Stroke::default() }; + whole.stroke_path(&pb.finish().unwrap(), &paint(Rgba::rgb(0, 0, 0), 1.0), &st, Transform::identity(), None); + let row = |pm: &Pixmap| (0..100).map(|x| pm.pixel(x, (y as u32).min(19)).unwrap().alpha() > 128).collect::>(); + assert!(row(&whole).iter().any(|d| *d) && row(&whole).iter().any(|d| !*d), "dashed"); + let differ = row(&drawn).iter().zip(row(&whole)).filter(|(a, b)| **a != *b).count(); + assert!(differ <= 2, "offset {offset}: {differ} pixels differ:\n{:?}\n{:?}", row(&drawn), row(&whole)); + } + } + + #[test] + fn a_winding_path_counts_its_length_against_the_dash_limit() { + // A zigzag inside a 10-pixel square, 20,000 times across it: 200,000 pixels of line in + // bounds whose span is 40. + let mut pb = PathBuilder::new(); + pb.move_to(0.0, 0.0); + for i in 0..20_000 { + pb.line_to(if i % 2 == 0 { 10.0 } else { 0.0 }, 0.0); + } + let zigzag = pb.finish().unwrap(); + assert!((path_length(&zigzag) - 200_000.0).abs() < 1.0); + let mut pb = PathBuilder::new(); + pb.move_to(0.0, 0.0); + pb.line_to(30.0, 0.0); + pb.line_to(30.0, 40.0); + pb.close(); + assert!((path_length(&pb.finish().unwrap()) - 120.0).abs() < 1e-3); + let view = View { + width: 100.0, + height: 100.0, + pixels_per_point: 1.0, + camera: Camera { center: Coord::new(0.0, 0.0), res: 1.0, bearing: 0.0, pitch: 0.0 }, + sky: false, + }; + let ctx = Ctx { view: &view, ppp: 1.0, wireframe: false, cull: Extent::EMPTY, globe: None, sprites: Default::default() }; + // A 1-pixel period would cut it into 200,000 dashes: drawn solid. A 4-pixel one is fine. + assert!(ctx.dash(&rustgis_map::symbology::Dash::new(&[0.5, 0.5]).unwrap(), &zigzag).is_none()); + assert!(ctx.dash(&rustgis_map::symbology::Dash::new(&[2.0, 2.0]).unwrap(), &zigzag).is_some()); + // Dashes count, not rounds of the pattern: four dashes a 4-pixel round is 200,000 too. + let four = rustgis_map::symbology::Dash::new(&[0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5]).unwrap(); + assert!(ctx.dash(&four, &zigzag).is_none()); + } + + #[test] + fn a_dashed_stroke_leaves_gaps() { + // A line across the view, one 4-point black stroke: solid it covers the middle of the row; + // dashed 10 on, 10 off (flat caps), about half of it, in runs about 10 pixels long. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("road", GeometryType::Polyline, vec![]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, 0.0), Coord::new(1.0, 0.0)]])), vec![]); + let id = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, 0.0)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 200.0, height: 200.0, pixels_per_point: 1.0, camera, sky: false }; + let stroke = rustgis_map::LineStroke { + color: Some(Rgba::rgb(0, 0, 0)), + width: Some(4.0), + cap: rustgis_map::LineCap::Butt, + ..Default::default() + }; + let dark = |m: &mut Map| { + let r = render(m, &view).unwrap(); + (40..160).map(|x| r.pixmap.pixel(x, 100).unwrap().red() < 128).collect::>() + }; + m.layer_mut(id).unwrap().line_strokes = vec![stroke]; + assert!(dark(&mut m).iter().all(|d| *d), "solid"); + let dash = rustgis_map::symbology::Dash::new(&[10.0, 10.0]); + m.layer_mut(id).unwrap().line_strokes = vec![rustgis_map::LineStroke { dash, ..stroke }]; + let row = dark(&mut m); + let on = row.iter().filter(|d| **d).count(); + assert!((50..=70).contains(&on), "{on} of 120 pixels dark"); + let runs = row.windows(2).filter(|w| w[0] != w[1]).count(); + assert!((10..=13).contains(&runs), "{runs} changes along the row"); + // A pattern under a pixel a round draws solid rather than cut into slivers. + let dash = rustgis_map::symbology::Dash::new(&[0.2, 0.2]); + m.layer_mut(id).unwrap().line_strokes = vec![rustgis_map::LineStroke { dash, ..stroke }]; + assert!(dark(&mut m).iter().all(|d| *d), "too fine: solid"); + } + #[test] fn a_reference_scale_scales_lines_with_the_map() { // A 4-point black line: as wide as set without a reference scale or at it, twice as wide diff --git a/crates/render/src/stroke_paths.rs b/crates/render/src/stroke_paths.rs index 2a71ddade..866116b5e 100644 --- a/crates/render/src/stroke_paths.rs +++ b/crates/render/src/stroke_paths.rs @@ -14,11 +14,12 @@ impl Paths { if let Some(path) = self.cached.take() { builder = path.clear(); } + let mut starts = super::DashStarts::default(); for part in parts { - ctx.ring_path(&mut builder, part, false); + ctx.ring_path_at(&mut builder, part, false, &mut starts); } let Some(path) = builder.finish() else { return }; - ctx.stacked_path(pm, &path, symbol, strokes); + ctx.stacked_path(pm, &path, symbol, strokes, &starts); self.remember(path); } fn remember(&mut self, path: tiny_skia::Path) { diff --git a/crates/render/src/stroke_paths/tests.rs b/crates/render/src/stroke_paths/tests.rs index d289ecba3..714989355 100644 --- a/crates/render/src/stroke_paths/tests.rs +++ b/crates/render/src/stroke_paths/tests.rs @@ -21,9 +21,25 @@ fn cached_pixels_equal_sequential_strokes_including_overlap_and_alpha() { expected.fill(tiny_skia::Color::WHITE); actual.fill(tiny_skia::Color::WHITE); let strokes = [ - LineStroke { offset: 2.0, width: Some(3.0), color: Some(Rgba::new(40, 70, 180, 150)), join, cap, level: None }, - LineStroke { offset: -2.0, width: Some(1.0), color: Some(Rgba::new(220, 30, 40, 200)), join, cap, level: None }, - LineStroke { offset: 2.0, width: Some(1.0), color: Some(Rgba::rgb(30, 40, 50)), join, cap, level: None }, + LineStroke { + offset: 2.0, + width: Some(3.0), + color: Some(Rgba::new(40, 70, 180, 150)), + join, + cap, + level: None, + dash: None, + }, + LineStroke { + offset: -2.0, + width: Some(1.0), + color: Some(Rgba::new(220, 30, 40, 200)), + join, + cap, + level: None, + dash: None, + }, + LineStroke { offset: 2.0, width: Some(1.0), color: Some(Rgba::rgb(30, 40, 50)), join, cap, level: None, dash: None }, ]; let mut batch = Paths::default(); for i in 0..180 { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 8a5831884..b164bd565 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -9,7 +9,7 @@ use rustgis_engine::analysis::{ParamKind, tools}; use rustgis_engine::data::GeometryType; use rustgis_engine::map::raster::{Normalizer, RasterLegend, RasterStyle}; use rustgis_engine::map::symbology::{ - ClassRef, ClassStyle, LineCap, LineJoin, LineStroke, MAX_LABEL, MAX_STROKE_OFFSET, MAX_STROKE_WIDTH, MAX_STROKES, MAX_UNIQUE, + ClassRef, ClassStyle, Dash, LineCap, LineJoin, LineStroke, MAX_LABEL, MAX_STROKE_OFFSET, MAX_STROKE_WIDTH, MAX_STROKES, MAX_UNIQUE, OTHER_VALUES, normalize_label, }; use rustgis_engine::map::tiles::{TileKind, TileLayer}; @@ -3918,6 +3918,49 @@ fn symbol_editor(app: &mut RustGisApp, ui: &mut Ui, s: Symbol, g: GeometryType, }); } +/// The dash patterns the stroke editor offers, in stroke widths (dash, gap, …). +const DASH_PRESETS: [(&str, Option<&[f32]>); 5] = [ + ("Solid", None), + ("Dashed", Some(&[4.0, 2.0])), + ("Dotted", Some(&[1.0, 2.0])), + ("Dash-dot", Some(&[4.0, 2.0, 1.0, 2.0])), + ("Long dash", Some(&[8.0, 3.0])), +]; + +/// A dash pattern being typed in the stroke editor: the layer, the class (`None` for the layer's +/// own strokes) and the text. +type DashDraft = (u32, Option, String); + +/// The text of `draft` if it was typed for `owner`'s stroke, else `None` (the stroke's own pattern +/// shows). +fn draft_for(draft: Option, owner: &(u32, Option)) -> Option { + draft.filter(|(layer, class, _)| *layer == owner.0 && *class == owner.1).map(|(.., text)| text) +} + +/// A dash pattern as the stroke editor shows it: `4 2`. Each length is written as it is kept, so +/// the text reads back as the same pattern. +fn dash_text(d: &Dash) -> String { + d.steps().iter().map(|v| v.to_string()).collect::>().join(" ") +} + +/// The pattern the dash box sets when it loses focus: `None` unless the text was typed (focusing +/// and leaving the box is no change), Escape wasn't what ended it, and it is a pattern other than +/// `current`. +fn dash_commit(typed: bool, escaped: bool, text: &str, current: Option) -> Option> { + parse_dash(text).filter(|d| typed && !escaped && *d != current) +} + +/// A dash pattern typed in the stroke editor: `Some(None)` for none (solid), `None` when it isn't +/// one ([`Dash::new`]). Lengths are separated by spaces, commas or semicolons. +fn parse_dash(text: &str) -> Option> { + let parts: Vec<&str> = text.split([' ', ',', ';']).filter(|p| !p.is_empty()).collect(); + if parts.is_empty() { + return Some(None); + } + let steps: Vec = parts.iter().map(|p| p.parse::().ok()).collect::>()?; + Dash::new(&steps).map(Some) +} + /// A stroke as `layer.lineStrokes` takes it. fn stroke_json(st: &LineStroke) -> Value { json!({ @@ -3927,6 +3970,7 @@ fn stroke_json(st: &LineStroke) -> Value { "cap": st.cap.code(), "join": st.join.code(), "level": st.level, + "dash": st.dash.map(|d| d.steps().to_vec()), }) } @@ -4048,12 +4092,33 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma let w = (w * k).max(0.5); let y = r.center().y + st.offset * k; let pad = cap_pad(st, w); - if st.cap == LineCap::Round { - ui.painter().circle_filled(pos2(x0, y), w / 2.0, c32(c)); - ui.painter().circle_filled(pos2(x1, y), w / 2.0, c32(c)); + // The dashes along the preview, scaled as the strokes are. Solid when the map draws the + // pattern solid: under a pixel a round on this screen, at the pattern's own size. + let steps: Vec = st.dash.map(|d| d.even_steps().iter().map(|v| v * k).collect()).unwrap_or_default(); + let mut runs = Vec::new(); + if st.dash.is_some_and(|d| d.period() * ui.ctx().pixels_per_point() >= 1.0) && steps.iter().sum::() > 0.0 { + let (mut x, mut on) = (x0, true); + for step in steps.iter().cycle().take(4096) { + if x >= x1 { + break; + } + let end = (x + step).min(x1); + if on { + runs.push((x, end)); + } + (x, on) = (end, !on); + } + } else { + runs.push((x0, x1)); + } + for (from, to) in runs { + if st.cap == LineCap::Round { + ui.painter().circle_filled(pos2(from, y), w / 2.0, c32(c)); + ui.painter().circle_filled(pos2(to, y), w / 2.0, c32(c)); + } + let (a, b) = if st.cap == LineCap::Square { (from - pad, to + pad) } else { (from, to) }; + ui.painter().line_segment([pos2(a, y), pos2(b, y)], Stroke::new(w, c32(c))); } - let (a, b) = if st.cap == LineCap::Square { (x0 - pad, x1 + pad) } else { (x0, x1) }; - ui.painter().line_segment([pos2(a, y), pos2(b, y)], Stroke::new(w, c32(c))); } // The line's direction. let (ay, ax) = (r.max.y - 6.0, r.max.x - 8.0); @@ -4164,6 +4229,47 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma } }); ui.end_row(); + ui.label(tl!("Dash")); + ui.horizontal(|ui| { + // The pattern as typed, kept while the box has focus; set when it loses it. The + // draft remembers whose stroke it is, so it never shows or lands on another + // layer's or class's stroke at the same place. + let id = ui.scope_id().with(("stroke_dash", i)); + let owner = (l.id, class.cloned()); + let shown = st.dash.map(|d| dash_text(&d)).unwrap_or_default(); + let draft = draft_for(ui.data_mut(|m| m.get_temp::(id)), &owner); + let typed = draft.is_some(); + let mut text = draft.unwrap_or(shown); + let edit = egui::TextEdit::singleline(&mut text).hint_text(tl!("Solid")).desired_width(84.0); + let r = + ui.add(edit).on_hover_text(tl!("Dash and gap lengths in points, in turn, such as 4 2. Empty for a solid line.")); + if r.changed() { + ui.data_mut(|m| m.insert_temp::(id, (owner.0, owner.1.clone(), text.clone()))); + } + // Text that isn't a pattern is outlined: it won't be kept when the box loses focus. + if parse_dash(&text).is_none() { + ui.painter().rect_stroke(r.rect, CornerRadius::same(2), egui::Stroke::new(1.5, t.red), egui::StrokeKind::Outside); + } + if r.lost_focus() { + ui.data_mut(|m| m.remove::(id)); + // As the class label box does, Escape drops what was typed. + let escaped = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if let Some(dash) = dash_commit(typed || r.changed(), escaped, &text, st.dash) { + set(app, LineStroke { dash, ..st }); + } + } + let preset = egui::ComboBox::from_id_salt(("stroke_dash_preset", i)).selected_text("").width(24.0); + preset.show_ui(ui, |ui| { + for (label, steps) in DASH_PRESETS { + // Scaled by the stroke's width, so a thick line's dashes stay in proportion. + let dash = steps.and_then(|s| Dash::new(&s.iter().map(|v| v * w.max(1.0)).collect::>())); + if ui.selectable_label(dash == st.dash, tl!(label)).clicked() { + set(app, LineStroke { dash, ..st }); + } + } + }); + }); + ui.end_row(); if l.stroke_levels { ui.label(tl!("Level")); ui.horizontal(|ui| { @@ -4963,6 +5069,35 @@ mod tests { assert_eq!(f.values.get("output").map(String::as_str), Some("kept"), "not given"); } + /// A dash being typed shows only on the stroke of the layer and class it was typed for. + #[test] + fn a_dash_draft_stays_with_its_layer_and_class() { + let draft = || Some((3, Some(ClassRef::Index(1)), "4 2".to_string())); + assert_eq!(draft_for(draft(), &(3, Some(ClassRef::Index(1)))).as_deref(), Some("4 2")); + // Another layer, another class, or the layer's own strokes show their own pattern. + assert_eq!(draft_for(draft(), &(4, Some(ClassRef::Index(1)))), None); + assert_eq!(draft_for(draft(), &(3, Some(ClassRef::Index(2)))), None); + assert_eq!(draft_for(draft(), &(3, None)), None); + assert_eq!(draft_for(None, &(3, None)), None); + } + + /// The dash box shows a pattern as kept, and sets one only when it was typed and not escaped: + /// focusing and leaving it never rounds the stroke's pattern. + #[test] + fn the_dash_box_sets_only_a_typed_pattern() { + let kept = Dash::new(&[5.332, 2.666]); + let text = dash_text(&kept.unwrap()); + assert_eq!(text, "5.332 2.666"); + assert_eq!(parse_dash(&text), Some(kept), "the shown text reads back as the same pattern"); + assert_eq!(dash_commit(false, false, &text, kept), None, "focused and left"); + assert_eq!(dash_commit(false, false, "4 2", kept), None, "not typed"); + assert_eq!(dash_commit(true, true, "4 2", kept), None, "Escape drops it"); + assert_eq!(dash_commit(true, false, "4 2", kept), Some(Dash::new(&[4.0, 2.0]))); + assert_eq!(dash_commit(true, false, "", kept), Some(None), "emptied: solid"); + assert_eq!(dash_commit(true, false, "4 x", kept), None, "not a pattern"); + assert_eq!(dash_commit(true, false, "5.332 2.666", kept), None, "typed back as it was"); + } + /// The Strokes for picker lists a value listed twice once, for its first class (the one its /// features draw with), then ``. #[test] diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index a50178c82..b2388998b 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -127,6 +127,16 @@ same). By command, name the class as `layer.classStyle` does, with `value`, `ind layer.lineStrokes={"layer":"Roads","value":"Motorway","strokes":[{"color":"#5A3A1E","width":7},{"color":"#F28C28","width":5},{"color":"#FFFFFF","width":0.6}]} ``` +A stroke can be dashed: **Dash** takes the lengths in points, dash and gap in turn (`4 2`, or +`4 2 1 2` for dash-dot; an odd list is repeated, as in SVG), and its menu offers dashed, +dotted, dash-dot and long-dash patterns sized to the stroke's width. Empty is a solid line. With +flat caps the dashes are as long as given; round and square caps add half the width at each end. +By command, `"dash"` on a stroke (`null` for solid): + +```text +layer.lineStrokes={"layer":"Paths","strokes":[{"color":"#000000","width":1.2,"cap":"flat","dash":[6,3]}]} +``` + By default, each feature draws its whole stack in turn, so where two roads meet, the second one's casing cuts across the first one's core. **Draw strokes in levels** (symbol levels) draws all stacked strokes at a lower level before those at a higher one, so the casings join underneath From fa96baa45f1409cc4420a86bceed0d2cad6d9fd4 Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Sat, 24 Oct 2026 09:30:55 -0300 Subject: [PATCH 099/297] Analysis: summarise a stack of categorical rasters over time: mode, frequency, changes, longest run and more (#798) * cube.reduce: a stack of categorical rasters summarised as classes (mode, modeCount, frequency, variety, changes, longestRun, longestRunLength, countIn) Yearly land-cover maps (MapBiomas, ESA WorldCover) are a data cube of classes. cube.reduce takes seven statistics for them, over any cube it already reads (layers, a raster's bands, a Zarr variable, a STAC collection) and by the same groups: mode (the most frequent class, the least of those tied), modeCount (its count of dates), frequency (a layer for each class met: its count of dates), variety (how many classes), changes (how many times the class changed from one valued date to the next, in date order), longestRun and longestRunLength (the class and length of the longest run of dates in one class) and countIn (at how many dates a cell was one of "classes", 0-255). Values that aren't whole numbers are refused with the value, as are more than 1,000 classes or MAX_VALUES cells x classes. changes and longestRun read their dates in order: one reader (read_slices' in_order) and the steps sorted by date (a Zarr axis out of order). Frequency's classes, known as they are met, are stacked piece by piece by class (stack_columns). zarr.reduce refuses them and points to cube.reduce. Co-Authored-By: Claude Opus 5.5 * cube.reduce classes: one cap across the groups being read (frequency's at the outputs' cap); columns made at their full length; more than 256 classes refused; majority said Co-Authored-By: Claude Opus 5.5 * Address review feedback - Include completed groups' retained columns in the categorical reduction memory cap. - Recheck the cap after finishing a group and cover completed groups in the regression. --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 Co-authored-by: giswqs --- crates/engine/src/cmd/cube.rs | 524 ++++++++++++++++++++++++++++++---- crates/engine/src/cmd/zarr.rs | 7 + crates/engine/src/tests.rs | 168 +++++++++++ docs/user-guide/analysis.md | 33 ++- 4 files changed, 682 insertions(+), 50 deletions(-) diff --git a/crates/engine/src/cmd/cube.rs b/crates/engine/src/cmd/cube.rs index 206e12d8a..9d17e9d6a 100644 --- a/crates/engine/src/cmd/cube.rs +++ b/crates/engine/src/cmd/cube.rs @@ -27,7 +27,7 @@ pub fn commands() -> Vec { r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer (or "path" | "url", "variable", "index", "crs" as for zarr.reduce) or a raster of several bands, a date each (default: the active layer) | "collection": a STAC collection (desktop; read in place over an area, in pieces when large; with url, asset, bbox, maxCloud, cellSize and resampling below, and "start", "end"), "url"?: the collection's STAC API (as for stac.addCube), "asset"?: the collection's band, such as NDVI, "bbox"?: [w, s, e, n] | "view" (the collection's area), "maxCloud"?: a collection's cloud cover cap, "cellSize"?: a collection's cell size in the files' units (the overview level nearest it is read), "resampling"?: "nearest" (default: that overview's pixels as stored) | "average" (each cell the mean of the full images' pixels in it, which reads them), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "start"?, "end"?: dates (a whole number from 1000 to 9999 is a year; an end given as a year or a month takes all of it), "months"?: [1–12] (one season across years), "test"?: "hamedRao" (default) | "yueWang" | "seasonal" | "original", "seasons"?: parts of a year for the seasonal test (default 12), "alpha"?: 0.05 (above 0, below 0.5), "minValid"?: dates a cell needs (default: half of them, at least 4; the seasonal test also needs 4 in seasons of two or more), "per"?: "year" (default) | "decade", "name"?: text, "ran"?: (internal: a trend the app has worked out)} → four raster layers: Sen's slope, the test's Z and p-value, and the slope where p < alpha"#, ), C::new("cube.reduce", "Summarise a Data Cube over Time", "Analyze › Image Analysis", reduce_cmd).params( - r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer or a raster of several bands, a date each (default: the active layer) | "path"?: a Zarr store's folder | "url"?: a Zarr store's address, or a STAC collection's API (as for stac.addCube) | "collection": a STAC collection (desktop; read in place over an area, in pieces when large; with url, asset, bbox, maxCloud, cellSize and resampling below, and "start", "end"), "variable"?, "index"?, "crs"?: a Zarr variable's (as for zarr.reduce), "asset"?: the collection's band, such as NDVI, "bbox"?: [w, s, e, n] | "view" (the collection's area), "maxCloud"?: a collection's cloud cover cap, "cellSize"?: a collection's cell size in the files' units (the overview level nearest it is read), "resampling"?: "nearest" (default) | "average" (as for cube.trend), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "stat": "mean"|"min"|"max"|"sum"|"std" (the population standard deviation, n)|"count"|"countAbove", "above"?: number (countAbove's threshold), "by"?: "all" (default: one layer) | "year" (a layer for each calendar year) | "month" (a layer for each month of the year, across years), "start"?, "end"?: dates (as for cube.trend), "months"?: [1–12] (a season across years), "minValid"?: 0–1 (a cell with a smaller share of its group's dates is left empty; default 0), "name"?: text} → raster layers of the statistic, one for each group"#, + r#"{"layers"?: [raster layers, a date each] | "layer"?: a Zarr layer or a raster of several bands, a date each (default: the active layer) | "path"?: a Zarr store's folder | "url"?: a Zarr store's address, or a STAC collection's API (as for stac.addCube) | "collection": a STAC collection (desktop; read in place over an area, in pieces when large; with url, asset, bbox, maxCloud, cellSize and resampling below, and "start", "end"), "variable"?, "index"?, "crs"?: a Zarr variable's (as for zarr.reduce), "asset"?: the collection's band, such as NDVI, "bbox"?: [w, s, e, n] | "view" (the collection's area), "maxCloud"?: a collection's cloud cover cap, "cellSize"?: a collection's cell size in the files' units (the overview level nearest it is read), "resampling"?: "nearest" (default) | "average" (as for cube.trend), "band"?: 1-based (a stack's band), "dates"?: [dates] (a stack's or the bands' dates, in their order; default: read from the layers' names or files, or the bands' names), "stat": "mean"|"min"|"max"|"sum"|"std" (the population standard deviation, n)|"count"|"countAbove"|"mode" or "majority" (a categorical cube's most frequent class; the least of those tied)|"modeCount" (how many dates had the mode)|"variety" (how many classes)|"changes" (how many times the class changed from one valued date to the next)|"longestRun" (the class of the longest run of dates in one class; the least of those tied)|"longestRunLength" (how many dates that run lasted)|"frequency" (a layer for each class met: its count of dates)|"countIn" (how many dates in the classes given), "above"?: number (countAbove's threshold), "classes"?: [whole numbers 0–255, at most 256] (countIn's classes), "by"?: "all" (default: one layer) | "year" (a layer for each calendar year) | "month" (a layer for each month of the year, across years), "start"?, "end"?: dates (as for cube.trend), "months"?: [1–12] (a season across years), "minValid"?: 0–1 (a cell with a smaller share of its group's dates is left empty; default 0), "name"?: text} → raster layers of the statistic, one for each group (frequency: one for each class of each group)"#, )] } @@ -654,7 +654,7 @@ impl TrendRun { done.fetch_add(1, Ordering::Relaxed); Ok(()) }; - read_slices(self.cube.as_ref(), &self.steps, piece, cancel, &mut place)?; + read_slices(self.cube.as_ref(), &self.steps, piece, cancel, false, &mut place)?; let grid = grid.ok_or("no date to read")?; Ok((grid, block, cells)) } @@ -745,10 +745,13 @@ fn read_slices( steps: &[usize], piece: usize, cancel: &AtomicBool, + in_order: bool, place: &mut dyn FnMut(usize, Option<(Arc, usize)>) -> Result<(), String>, ) -> Result<(), String> { - // One at a time in the browser, which has no threads to read on. - let readers = if cfg!(target_arch = "wasm32") { 1 } else { cube.readers().clamp(1, 16).min(steps.len().max(1)) }; + // One at a time in the browser, which has no threads to read on, and where the slices must come + // in their order (`in_order`): several readers place them as they are read, and one slow read + // lets the others run ahead of it. + let readers = if cfg!(target_arch = "wasm32") || in_order { 1 } else { cube.readers().clamp(1, 16).min(steps.len().max(1)) }; if readers == 1 { for (k, &i) in steps.iter().enumerate() { if cancel.load(Ordering::Relaxed) { @@ -1049,6 +1052,23 @@ pub enum Stat { Count, /// How many of the values are above the threshold. CountAbove(f64), + /// A categorical cube's most frequent class (the least of those tied). + Mode, + /// How many classes a cell had. + Variety, + /// How many times a cell's class changed from one valued date to the next, in date order. + Changes, + /// How many of the values are one of these classes, 0 to 255 (a bit each). + CountIn([u64; 4]), + /// How many dates had the most frequent class (the mode's count). + ModeCount, + /// The class of the longest run of dates with one class, in date order (the least of those + /// tied). + LongestRun, + /// How many dates the longest run lasted. + LongestRunLength, + /// How many dates had each class: an output for each class met. + Frequency, } impl Stat { @@ -1061,12 +1081,52 @@ impl Stat { Stat::Std => "std", Stat::Count => "count", Stat::CountAbove(_) => "countAbove", + Stat::Mode => "mode", + Stat::Variety => "variety", + Stat::Changes => "changes", + Stat::CountIn(_) => "countIn", + Stat::ModeCount => "modeCount", + Stat::LongestRun => "longestRun", + Stat::LongestRunLength => "longestRunLength", + Stat::Frequency => "frequency", } } - /// The statistic asked for: `stat`, and countAbove's threshold, `above`. + /// Whether it takes the values as classes (whole numbers), as of a land-cover map. + pub(crate) fn categorical(self) -> bool { + matches!( + self, + Stat::Mode + | Stat::Variety + | Stat::Changes + | Stat::CountIn(_) + | Stat::ModeCount + | Stat::LongestRun + | Stat::LongestRunLength + | Stat::Frequency + ) + } + + /// Whether it compares each date with the one before, so its dates must come in date order. + pub(crate) fn in_order(self) -> bool { + matches!(self, Stat::Changes | Stat::LongestRun | Stat::LongestRunLength) + } + + /// Whether it follows each cell's runs of dates in one class. + fn runs(self) -> bool { + matches!(self, Stat::LongestRun | Stat::LongestRunLength) + } + + /// Whether it counts each class met in each cell. + fn counts_classes(self) -> bool { + matches!(self, Stat::Mode | Stat::Variety | Stat::ModeCount | Stat::Frequency) + } + + /// The statistic asked for: `stat`, countAbove's threshold (`above`) and countIn's classes + /// (`classes`). pub(crate) fn parse(p: &Value) -> Result { - Ok(match ps(p, "stat").map(str::trim).ok_or("stat is required (mean, min, max, sum, std, count or countAbove)")? { + const STATS: &str = "mean, min, max, sum, std, count, countAbove, mode (or majority), modeCount, variety, changes, longestRun, longestRunLength, frequency or countIn"; + Ok(match ps(p, "stat").map(str::trim).ok_or_else(|| format!("stat is required ({STATS})"))? { x if x.eq_ignore_ascii_case("mean") => Stat::Mean, x if x.eq_ignore_ascii_case("min") || x.eq_ignore_ascii_case("minimum") => Stat::Min, x if x.eq_ignore_ascii_case("max") || x.eq_ignore_ascii_case("maximum") => Stat::Max, @@ -1076,11 +1136,39 @@ impl Stat { x if x.eq_ignore_ascii_case("countAbove") => { Stat::CountAbove(super::f(p, "above").ok_or("countAbove takes \"above\", the threshold")?) } - x => return Err(format!("unknown stat '{x}' (mean, min, max, sum, std, count or countAbove)")), + x if x.eq_ignore_ascii_case("mode") || x.eq_ignore_ascii_case("majority") => Stat::Mode, + x if x.eq_ignore_ascii_case("variety") => Stat::Variety, + x if x.eq_ignore_ascii_case("changes") => Stat::Changes, + x if x.eq_ignore_ascii_case("countIn") => Stat::CountIn(classes(p)?), + x if x.eq_ignore_ascii_case("modeCount") => Stat::ModeCount, + x if x.eq_ignore_ascii_case("longestRun") => Stat::LongestRun, + x if x.eq_ignore_ascii_case("longestRunLength") => Stat::LongestRunLength, + x if x.eq_ignore_ascii_case("frequency") => Stat::Frequency, + x => return Err(format!("unknown stat '{x}' ({STATS})")), }) } } +/// countIn's classes (`classes`): whole numbers from 0 to 255, as a land-cover map's codes are. +fn classes(p: &Value) -> Result<[u64; 4], String> { + const NEED: &str = "countIn takes \"classes\", the classes to count: [whole numbers from 0 to 255]"; + let list = p.get("classes").and_then(Value::as_array).filter(|l| !l.is_empty()).ok_or(NEED)?; + let mut set = [0u64; 4]; + if list.len() > 256 { + return Err(NEED.into()); + } + for v in list { + let c = v.as_f64().filter(|c| c.fract() == 0.0 && (0.0..=255.0).contains(c)).ok_or(NEED)? as usize; + if let Some(word) = set.get_mut(c / 64) { + *word |= 1 << (c % 64); + } + } + Ok(set) +} + +/// The most classes a categorical summary tells apart in a run of cells: more is no land-cover map. +pub const MAX_CLASSES: usize = 1000; + /// The share of its dates a cell needs for a summary's value (`minValid`, 0 to 1; 0 by default). pub(crate) fn min_share(p: &Value) -> Result { match p.get("minValid").filter(|v| !v.is_null()) { @@ -1090,25 +1178,55 @@ pub(crate) fn min_share(p: &Value) -> Result { } /// A summary's running totals over a run of cells, only those its statistic needs: each cell's -/// count of values, one running value (the mean, the least, the most, the sum or the count above) -/// and, for std, the squared deviations (Welford's method). 4 to 20 bytes a cell. +/// count of values, one running value (the mean, the least, the most, the sum, a count, or the +/// changes so far) and, for std, the squared deviations (Welford's method); for changes, each +/// cell's last class; for the longest run (its class or length), its last class, the run it is +/// in and the longest so far; for mode, modeCount, variety and frequency, each class's count in each cell. 4 to 20 bytes +/// a cell, and 2 more for each class those four have met. pub(crate) struct Totals { stat: Stat, n: Vec, acc: Vec, m2: Vec, + /// Changes and the longest run: each cell's class at its last valued date. + last: Vec, + /// The longest run: each cell's run of dates in its last class, and its longest run's class + /// and length so far. + run: Vec, + longest: Vec<(f32, u16)>, + /// Mode, modeCount, variety and frequency: the classes met, in order, and each one's count in + /// each cell. + classes: Vec, + counts: Vec>, + /// Why the values can't be summarised as classes, at the first that can't. + refused: Option, } impl Totals { pub(crate) fn new(stat: Stat, cells: usize) -> Totals { let acc = match stat { - Stat::Count => Vec::new(), + Stat::Count | Stat::Mode | Stat::Variety | Stat::ModeCount | Stat::LongestRun | Stat::LongestRunLength | Stat::Frequency => { + Vec::new() + } Stat::Min => vec![f64::INFINITY; cells], Stat::Max => vec![f64::NEG_INFINITY; cells], _ => vec![0.0; cells], }; let m2 = if matches!(stat, Stat::Std) { vec![0.0; cells] } else { Vec::new() }; - Totals { stat, n: vec![0; cells], acc, m2 } + let last = if stat.in_order() { vec![f32::NAN; cells] } else { Vec::new() }; + let (run, longest) = if stat.runs() { (vec![0; cells], vec![(f32::NAN, 0); cells]) } else { (Vec::new(), Vec::new()) }; + Totals { stat, n: vec![0; cells], acc, m2, last, run, longest, classes: Vec::new(), counts: Vec::new(), refused: None } + } + + /// How many class counts it holds: its cells × the classes it has met. + pub(crate) fn held(&self) -> u64 { + (self.classes.len() as u64).saturating_mul(self.n.len() as u64) + } + + /// Why the values can't be summarised as classes: one that isn't a whole number, or more + /// classes than [`MAX_CLASSES`] or than the run's cells × classes cap ([`MAX_VALUES`]). + pub(crate) fn refused(&self) -> Option<&str> { + self.refused.as_deref() } /// How many cells it totals. @@ -1116,10 +1234,24 @@ impl Totals { self.n.len() } - /// Cell `c`'s next value, a finite one. + /// Cell `c`'s next value, a finite one; a categorical statistic's in date order. pub(crate) fn add(&mut self, c: usize, v: f64) { + if self.refused.is_some() || c >= self.n.len() { + return; + } + if self.stat.categorical() && v.fract() != 0.0 { + self.refused = Some(format!("{v} isn't a class: {} takes whole numbers, as a land-cover map's", self.stat.code())); + return; + } + if self.stat.counts_classes() && !self.count_class(c, v as f32) { + return; + } let Some(k) = self.n.get_mut(c) else { return }; *k += 1; + if self.stat.runs() { + self.extend_run(c, v as f32); + return; + } let k = f64::from(*k); let (Some(a), m) = (self.acc.get_mut(c), self.m2.get_mut(c)) else { return }; match self.stat { @@ -1134,15 +1266,73 @@ impl Totals { Stat::Max => *a = a.max(v), Stat::Sum => *a += v, Stat::CountAbove(t) if v > t => *a += 1.0, - Stat::CountAbove(_) | Stat::Count => {} + Stat::CountIn(set) if in_classes(&set, v) => *a += 1.0, + Stat::Changes => { + if let Some(last) = self.last.get_mut(c) { + if last.is_finite() && *last != v as f32 { + *a += 1.0; + } + *last = v as f32; + } + } + Stat::CountAbove(_) + | Stat::CountIn(_) + | Stat::Count + | Stat::Mode + | Stat::Variety + | Stat::ModeCount + | Stat::LongestRun + | Stat::LongestRunLength + | Stat::Frequency => {} + } + } + + /// Cell `c`'s run of dates in one class, with class `v` next: one date longer in the same class, + /// a new run of one in another; the longest so far kept, the least class of those tied. + fn extend_run(&mut self, c: usize, v: f32) { + let (Some(last), Some(run), Some((class, length))) = (self.last.get_mut(c), self.run.get_mut(c), self.longest.get_mut(c)) else { + return; + }; + *run = if *last == v { run.saturating_add(1) } else { 1 }; + *last = v; + if *run > *length || (*run == *length && v < *class) { + (*class, *length) = (v, *run); + } + } + + /// Counts class `v` in cell `c`, a class met for the first time taking a count for every + /// cell; false, and said, past the classes' caps. + fn count_class(&mut self, c: usize, v: f32) -> bool { + let at = match self.classes.binary_search_by(|x| x.total_cmp(&v)) { + Ok(at) => at, + Err(at) => { + let cells = self.n.len(); + let held = (self.classes.len() as u64 + 1).saturating_mul(cells as u64); + let cap = class_cap(self.stat); + if self.classes.len() >= MAX_CLASSES || held > cap { + self.refused = Some(format!( + "more than {} classes in {cells} cells: {} takes a categorical raster (a land-cover map), up to {MAX_CLASSES} classes and {cap} cells × classes; take a smaller area", + self.classes.len(), + self.stat.code() + )); + return false; + } + self.classes.insert(at, v); + self.counts.insert(at, vec![0; cells]); + at + } + }; + if let Some(n) = self.counts.get_mut(at).and_then(|counts| counts.get_mut(c)) { + *n = n.saturating_add(1); } + true } /// Each cell's statistic over `dates` dates: empty with fewer values than `min_share` of them, /// except that with no share asked for a count is 0 where a cell never had a value. pub(crate) fn finish(&self, min_share: f64, dates: usize) -> Vec { let need = (min_share.clamp(0.0, 1.0) * dates as f64 - 1e-9).ceil().max(1.0) as u32; - let count = matches!(self.stat, Stat::Count | Stat::CountAbove(_)); + let count = self.counts_zero(); self.n .iter() .enumerate() @@ -1154,11 +1344,70 @@ impl Totals { (match self.stat { Stat::Std => (self.m2.get(c).copied().unwrap_or(f64::NAN) / f64::from((*k).max(1))).sqrt(), Stat::Count => f64::from(*k), + Stat::Variety => self.counts.iter().filter(|n| n.get(c).is_some_and(|n| *n > 0)).count() as f64, + Stat::Mode => self.mode(c).map_or(f64::NAN, |(_, class)| f64::from(class)), + Stat::ModeCount => self.mode(c).map_or(0.0, |(n, _)| f64::from(n)), + Stat::LongestRun => self.longest.get(c).map_or(f64::NAN, |(class, _)| f64::from(*class)), + Stat::LongestRunLength => self.longest.get(c).map_or(0.0, |(_, length)| f64::from(*length)), _ => a, }) as f32 }) .collect() } + + /// Whether a cell with no value counts 0 (with no share asked for) rather than none: a count's. + fn counts_zero(&self) -> bool { + matches!( + self.stat, + Stat::Count + | Stat::CountAbove(_) + | Stat::CountIn(_) + | Stat::Changes + | Stat::Variety + | Stat::ModeCount + | Stat::LongestRunLength + | Stat::Frequency + ) + } + + /// Cell `c`'s most frequent class and its count: the least of the classes tied, as they are in + /// order. + fn mode(&self, c: usize) -> Option<(u16, f32)> { + let mut best: Option<(u16, f32)> = None; + for (class, counts) in self.classes.iter().zip(&self.counts) { + let n = counts.get(c).copied().unwrap_or(0); + if n > 0 && best.is_none_or(|(most, _)| n > most) { + best = Some((n, *class)); + } + } + best + } + + /// The outputs, as columns of cells: one for the statistic (keyed `None`), or, for frequency, + /// one for each class met, in order, each cell's count of dates in it. + pub(crate) fn columns(&self, min_share: f64, dates: usize) -> Vec<(Option, Vec)> { + if self.stat != Stat::Frequency { + return vec![(None, self.finish(min_share, dates))]; + } + let mask = self.mask(min_share, dates); + self.classes + .iter() + .zip(&self.counts) + .map(|(class, counts)| (Some(*class), mask.iter().zip(counts).map(|(m, n)| m + f32::from(*n)).collect())) + .collect() + } + + /// Each cell's value in a class's column where it never had that class: 0, or none where it + /// has fewer values than `min_share` of its dates (a count's rule). + pub(crate) fn mask(&self, min_share: f64, dates: usize) -> Vec { + let need = (min_share.clamp(0.0, 1.0) * dates as f64 - 1e-9).ceil().max(1.0) as u32; + self.n.iter().map(|k| if *k < need && min_share != 0.0 { f32::NAN } else { 0.0 }).collect() + } +} + +/// Whether class `v` is one of countIn's. +fn in_classes(set: &[u64; 4], v: f64) -> bool { + (0.0..=255.0).contains(&v) && set.get(v as usize / 64).is_some_and(|w| w >> (v as usize % 64) & 1 == 1) } /// The most values a summary's outputs hold (cells × outputs): 32 million on the desktop, 8 @@ -1166,9 +1415,13 @@ impl Totals { /// are read when it doesn't. Its running totals are a piece's at a time and only for the groups /// whose dates are being read: one year's in a summary by year read one date at a time (those of /// the dates in flight with several readers), but every month's in one by month. They take 4 bytes -/// a cell for count, 12 for mean, min, max, sum and countAbove, and 20 for std, so a summary by -/// month at the cap holds up to five times its outputs' 4 bytes a value: some 640 MB for std on the -/// desktop. A STAC cube is read in pieces whose cells × dates stay within [`MAX_VALUES`], so a long +/// a cell for count, 12 for mean, min, max, sum, countAbove and countIn, 16 for changes and the +/// longest run (its class or length) and 20 for std, so a summary by month at the cap holds up to five times its outputs' +/// 4 bytes a value: some 640 MB for std on the desktop. Mode, modeCount, variety and frequency take +/// 4 bytes a cell and 2 more for each class met, the cells × classes of the groups being read at +/// most [`MAX_VALUES`] in all (128 MB of counts; for frequency [`MAX_REDUCED`], its outputs' cap), +/// checked as each slice is placed. Frequency's outputs are a column for each class of each group, all within +/// this cap. A STAC cube is read in pieces whose cells × dates stay within [`MAX_VALUES`], so a long /// series keeps each piece's totals small. pub const MAX_REDUCED: u64 = MAX_VALUES / 2; @@ -1207,8 +1460,9 @@ impl ReduceRun { } /// Reads the slices piece by piece ([`TimeCube::pieces`]), each folded into its group's - /// running totals, and puts the pieces' outputs together row by row: a raster for each group. - /// `done` counts the slices read; `cancel` stops it between slices. + /// running totals, and puts the pieces' outputs together row by row: a raster for each group + /// (for frequency, one for each class of each group). `done` counts the slices read; `cancel` + /// stops it between slices. pub fn run(&self, cancel: &AtomicBool, done: &AtomicUsize) -> Result, String> { let outputs = self.groups.len(); let too_many = |cells: usize| { @@ -1223,7 +1477,11 @@ impl ReduceRun { return Err(e); } let mut stitch = Stitch::default(); - let mut bands: Vec> = vec![Vec::new(); outputs]; + // Each group's columns so far, by class for frequency; and for frequency each group's + // value where a class is missing, for a class met first in a later piece. + let mut bands: Vec = vec![Vec::new(); outputs]; + let mut masks: Vec> = vec![Vec::new(); outputs]; + let frequency = self.stat == Stat::Frequency; // The cells of the pieces read so far, for a cube that didn't know its size. let mut read_cells = 0usize; for piece in 0..self.cube.pieces().max(1) { @@ -1236,7 +1494,7 @@ impl ReduceRun { // order of arrival can move an output's last bit at most, and only then. let mut totals: Vec> = (0..outputs).map(|_| None).collect(); let mut seen = vec![0usize; outputs]; - let mut finished: Vec>> = vec![None; outputs]; + let mut finished: Vec)>> = vec![None; outputs]; let mut place = |k: usize, slice: Option<(Arc, usize)>| -> Result<(), String> { let g = self.group_of.get(k).copied().filter(|g| *g < outputs).ok_or("a date in no group")?; // A date with nothing written only lowers the counts. @@ -1259,6 +1517,13 @@ impl ReduceRun { t.add(c, f64::from(*v)); } } + if let Some(e) = t.refused() { + return Err(e.to_string()); + } + // The groups being read at once (every month's, by month) share the cap. + if let Some(e) = classes_over(&totals, &finished, class_cap(self.stat)) { + return Err(e); + } } let n = seen.get_mut(g).ok_or("a date in no group")?; *n += 1; @@ -1266,20 +1531,18 @@ impl ReduceRun { if *n >= dates && let (Some(t), Some(out)) = (totals.get_mut(g).and_then(Option::take), finished.get_mut(g)) { - *out = Some(t.finish(self.min_share, dates)); + *out = Some(finish(&t, self.min_share, dates, frequency)); + if let Some(e) = classes_over(&totals, &finished, class_cap(self.stat)) { + return Err(e); + } } done.fetch_add(1, Ordering::Relaxed); Ok(()) }; - read_slices(self.cube.as_ref(), &self.steps, piece, cancel, &mut place)?; + // Changes and the longest run compare each date with the one before: read in date order. + read_slices(self.cube.as_ref(), &self.steps, piece, cancel, self.stat.in_order(), &mut place)?; let grid = grid.ok_or("none of the dates in the period has data written")?; stitch.check(&grid)?; - // Their full length when the cube knows it, so they don't grow by doubling. - if let (0, Some(total)) = (piece, whole) { - for band in &mut bands { - band.reserve_exact(total); - } - } let cells = (grid.width as usize).saturating_mul(grid.height as usize); read_cells = read_cells.saturating_add(cells); if whole.is_none() @@ -1287,15 +1550,30 @@ impl ReduceRun { { return Err(e); } - for (g, band) in bands.iter_mut().enumerate() { + for (g, (band, mask)) in bands.iter_mut().zip(&mut masks).enumerate() { let dates = self.groups.get(g).map_or(0, |(_, d)| *d); // A group with no data written in this piece: no values, as its totals would give. - let out = match (finished.get_mut(g).and_then(Option::take), totals.get_mut(g).and_then(Option::take)) { + let (columns, piece_mask) = match (finished.get_mut(g).and_then(Option::take), totals.get_mut(g).and_then(Option::take)) { (Some(out), _) => out, - (None, Some(t)) => t.finish(self.min_share, dates), - (None, None) => Totals::new(self.stat, cells).finish(self.min_share, dates), + (None, Some(t)) => finish(&t, self.min_share, dates, frequency), + (None, None) => finish(&Totals::new(self.stat, cells), self.min_share, dates, frequency), }; - band.extend(out); + // Their full length when the cube knows it, so they don't grow by doubling. + stack_columns(band, mask, columns, &piece_mask, whole); + if let (0, Some(total)) = (piece, whole) + && frequency + { + mask.reserve_exact(total); + } + mask.extend(piece_mask); + } + // Frequency's columns are known as they are met: their values within the outputs' cap. + let columns: usize = bands.iter().map(Vec::len).sum(); + let values = (read_cells as u64).saturating_mul(columns as u64); + if values > MAX_REDUCED { + return Err(format!( + "{read_cells} cells × {columns} classes and groups is {values} values, more than {MAX_REDUCED}: take a smaller area, a coarser cell size or fewer groups" + )); } stitch.add(grid); } @@ -1303,28 +1581,92 @@ impl ReduceRun { if let Some(e) = self.cube.nothing_read() { return Err(e); } + if bands.iter().all(Vec::is_empty) { + return Err("none of the dates in the period has a class".into()); + } done.store(self.total(), Ordering::Relaxed); + let grid = &grid; self.groups .iter() .zip(bands) - .map(|((name, _), band)| { - Raster::new( - name.clone(), - grid.width, - height, - vec![band], - grid.transform, - grid.crs, - None, - raster::ColorHint::Gray, - "Float32", - ) - .map_err(|e| e.to_string()) + .flat_map(|((name, _), columns)| { + columns.into_iter().map(move |(class, band)| { + let name = match class { + Some(class) => format!("{name} class {class}"), + None => name.clone(), + }; + Raster::new(name, grid.width, height, vec![band], grid.transform, grid.crs, None, raster::ColorHint::Gray, "Float32") + .map_err(|e| e.to_string()) + }) }) .collect() } } +/// A group's outputs, as columns of cells: one keyed `None`, or frequency's one for each class. +type Columns = Vec<(Option, Vec)>; + +/// A group's columns from its totals, and for frequency the value of a class it didn't meet. +fn finish(t: &Totals, min_share: f64, dates: usize, frequency: bool) -> (Columns, Vec) { + let mask = if frequency { t.mask(min_share, dates) } else { Vec::new() }; + (t.columns(min_share, dates), mask) +} + +/// The most cells × classes the groups being read count at once: [`MAX_VALUES`], or for frequency, +/// whose classes are each an output column, the outputs' cap [`MAX_REDUCED`], so it is refused +/// before its counts and columns are made rather than after. +fn class_cap(stat: Stat) -> u64 { + if stat == Stat::Frequency { MAX_REDUCED } else { MAX_VALUES } +} + +/// Why the class counts of the groups being read (`totals`) are too many: more than `cap` cells × +/// classes in all, including columns retained by completed groups. +fn classes_over(totals: &[Option], finished: &[Option<(Columns, Vec)>], cap: u64) -> Option { + let completed = finished + .iter() + .flatten() + .flat_map(|(columns, _)| columns.iter()) + .map(|(_, values)| values.len() as u64) + .fold(0u64, u64::saturating_add); + let held = totals.iter().flatten().map(Totals::held).fold(completed, u64::saturating_add); + (held > cap).then(|| { + format!( + "{held} cells × classes across the groups being read, more than {cap}: take a smaller area, a coarser cell size, or a group by year rather than by month" + ) + }) +} + +/// Puts a piece's columns under a group's so far, by key: a class the piece didn't meet gets the +/// piece's `piece_mask`, and one met first in this piece the pieces' before it (`mask`). A column +/// made here takes its full length, `whole` cells, when the cube knows it. +fn stack_columns(outs: &mut Columns, mask: &[f32], columns: Columns, piece_mask: &[f32], whole: Option) { + let order = |a: &Option, b: &Option| match (a, b) { + (Some(a), Some(b)) => a.total_cmp(b), + _ => a.is_some().cmp(&b.is_some()), + }; + let before = outs.first().map_or(mask.len(), |(_, b)| b.len()); + for (key, column) in columns { + match outs.binary_search_by(|(k, _)| order(k, &key)) { + Ok(at) => { + if let Some((_, b)) = outs.get_mut(at) { + b.extend(column); + } + } + Err(at) => { + let mut b = Vec::with_capacity(whole.unwrap_or(0).max(mask.len() + column.len())); + b.extend_from_slice(mask); + b.extend(column); + outs.insert(at, (key, b)); + } + } + } + for (_, b) in outs.iter_mut() { + if b.len() == before { + b.extend_from_slice(piece_mask); + } + } +} + /// Prepares `cube.reduce`: the cube, the statistic, and the dates of the period (and months) /// asked for, in their groups. pub fn reduce_start(s: &Session, p: &Value) -> Result { @@ -1338,8 +1680,13 @@ pub fn reduce_start(s: &Session, p: &Value) -> Result { Some(x) => return Err(format!("by is all, year or month, not {x}")), }; let cube = cube_from(s, p, SUMMARY)?; - let steps = steps(cube.dates(), cube.period(), p)?; + let mut steps = steps(cube.dates(), cube.period(), p)?; let date = |i: usize| cube.dates().get(i).copied().unwrap_or(f64::NAN); + // Changes and runs are counted from each date to the next: the dates in order, which a Zarr + // axis out of order doesn't give. + if stat.in_order() { + steps.sort_by(|x, y| date(*x).total_cmp(&date(*y))); + } // A date that can't be read would go to no year or month: said, not put in a group of its own. if let Some(&i) = steps.iter().find(|&&i| !date(i).is_finite()) { return Err(format!("the cube's date {} can't be read: give the dates (\"dates\")", i + 1)); @@ -1447,4 +1794,85 @@ mod tests { assert!(super::same_grid(&a, &hair)); assert!(!super::same_grid(&a, &half)); } + + /// Several readers place a cube's slices as they are read; in order, one reads them, in the + /// steps' order, however long each read takes. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn slices_asked_in_order_come_in_order() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + struct Slow(Vec); + impl super::TimeCube for Slow { + fn dates(&self) -> &[f64] { + &self.0 + } + fn slice(&self, i: usize) -> Result, usize)>, String> { + // The early dates the slowest, so readers that place as they read finish them last. + std::thread::sleep(std::time::Duration::from_millis(((12 - i.min(12)) * 3) as u64)); + let gt = GeoTransform { origin_x: 0.0, origin_y: 0.0, pixel_width: 1.0, pixel_height: -1.0 }; + let r = Raster::new("s", 1, 1, vec![vec![i as f32]], gt, Crs::LonLat, None, ColorHint::Gray, "Float32") + .map_err(|e| e.to_string())?; + Ok(Some((Arc::new(r), 0))) + } + fn label(&self) -> String { + "slow".into() + } + fn readers(&self) -> usize { + 8 + } + } + let cube = Slow((0..12).map(f64::from).collect()); + let steps: Vec = (0..12).collect(); + let mut came = Vec::new(); + let read = super::read_slices(&cube, &steps, 0, &AtomicBool::new(false), true, &mut |k, _| { + came.push(k); + Ok(()) + }); + assert!(read.is_ok(), "{read:?}"); + assert_eq!(came, steps); + } + + /// Frequency's columns over pieces: a class met first in a later piece gets the earlier + /// pieces' mask (0, or none where a cell had too few values), and a class a piece didn't meet + /// gets that piece's. + #[test] + fn a_class_met_in_a_later_piece_is_counted_0_before() { + let nan = f32::NAN; + let mut outs = Vec::new(); + let mut mask = Vec::new(); + super::stack_columns(&mut outs, &mask, vec![(Some(3.0), vec![1.0, nan])], &[0.0, nan], None); + mask.extend([0.0, nan]); + super::stack_columns(&mut outs, &mask, vec![(Some(5.0), vec![4.0])], &[0.0], Some(3)); + let got: Vec<(Option, String)> = outs.iter().map(|(k, b)| (*k, format!("{b:?}"))).collect(); + assert_eq!(got, vec![(Some(3.0), "[1.0, NaN, 0.0]".to_string()), (Some(5.0), "[0.0, NaN, 4.0]".to_string())]); + // One output, keyed None, is extended piece after piece. + let mut one = Vec::new(); + super::stack_columns(&mut one, &[], vec![(None, vec![1.0])], &[], Some(3)); + assert!(one.first().is_some_and(|(_, b)| b.capacity() >= 3), "made at its full length"); + super::stack_columns(&mut one, &[], vec![(None, vec![2.0, 3.0])], &[], Some(3)); + assert_eq!(one, vec![(None, vec![1.0, 2.0, 3.0])]); + } + + /// The class counts of the groups being read share one cap: two groups each within it can be + /// over it together. + #[test] + fn groups_being_read_share_the_class_cap() { + let group = |classes: &[f64]| { + let mut t = super::Totals::new(super::Stat::Mode, 10); + for (c, v) in classes.iter().enumerate() { + t.add(c, *v); + } + t + }; + // 10 cells × 3 classes and 10 × 2: 30 and 20 counts, 50 in all. + let totals = vec![Some(group(&[1.0, 2.0, 3.0])), None, Some(group(&[1.0, 2.0]))]; + assert!(super::classes_over(&totals, &[], 50).is_none()); + let finished = vec![Some((vec![(Some(1.0), vec![0.0; 20])], Vec::new()))]; + assert!(super::classes_over(&totals, &finished, 70).is_none()); + assert!(super::classes_over(&totals, &finished, 69).is_some(), "finished groups still consume the shared cap"); + let e = super::classes_over(&totals, &[], 49).unwrap_or_default(); + assert!(e.contains("50 cells × classes across the groups being read, more than 49"), "{e}"); + } } diff --git a/crates/engine/src/cmd/zarr.rs b/crates/engine/src/cmd/zarr.rs index e0a4a4943..e451ad201 100644 --- a/crates/engine/src/cmd/zarr.rs +++ b/crates/engine/src/cmd/zarr.rs @@ -975,6 +975,13 @@ impl ReduceRun { #[cfg(not(target_arch = "wasm32"))] pub fn reduce_start(s: &Session, p: &Value) -> Result { let stat = Stat::parse(p)?; + // A variable of classes is summarised by cube.reduce, which reads its dates in order. + if stat.categorical() { + return Err(format!( + "zarr.reduce takes mean, min, max, sum, std, count or countAbove: {} is cube.reduce's (give it this layer or store)", + stat.code() + )); + } let min_valid = super::cube::min_share(p)?; let ZarrCube { zt, period } = zarr_cube(s, p)?; let steps = super::cube::steps(&zt.dates, period, p)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d717ec02b..c7a0fc8cc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1342,6 +1342,11 @@ fn hostile_params_never_panic() { // A summary over time: a statistic given, so its threshold, groups and share are reached. json!({"stat": "countAbove", "above": "x", "by": 7, "minValid": -1e308, "months": [13, "x", null], "name": {"a": 1}}), json!({"stat": "std", "by": "month", "minValid": f64::NAN, "dates": ["x", 7, null]}), + // A categorical summary: countIn's classes, and a mode by groups with a share out of range. + json!({"stat": "countIn", "classes": [1e308, -1, "x", null, 3.5, {"a": 1}]}), + json!({"stat": "countIn", "classes": "all", "by": "year"}), + json!({"stat": "mode", "by": "month", "minValid": 2, "months": []}), + json!({"stat": "frequency", "by": "year", "minValid": -1, "name": []}), // MySQL addresses that can't be read, so nothing connects. json!({"database": "mysql://u:p#@[::1/x?ssl-mode=nope", "password": 7, "layer": ":", "crs": "EPSG:-1"}), json!({"database": "MARIADB://u@h:99999999/%zz?ssl-mode=VERIFY_CA&ssl-ca=/nonexistent", "sql": "SELECT 1"}), @@ -9453,6 +9458,169 @@ fn a_short_cube_is_summarised_but_not_trended() { assert!(e.contains("summary's stack needs 2 layers"), "{e}"); } +/// A stack of land-cover maps summarised as classes: each cell's most frequent class (the least +/// of those tied) and how many dates had it, how many classes it had, how many times its class +/// changed and the class and length of its longest run, in date order (the layers added out of it), how many +/// dates it was in some classes, and a layer for each class with its count of dates. A cell left +/// without a class at a date (no data) is skipped. What isn't a class, too many classes, and a categorical +/// statistic asked of zarr.reduce are refused. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_categorical_stack_summarised_over_time() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, geotiff}; + let gt = GeoTransform { origin_x: -44.0, origin_y: -7.0, pixel_width: 0.01, pixel_height: -0.01 }; + let add = |s: &mut Session, name: &str, cells: Vec| { + let r = Raster::new(name, cells.len() as u32, 1, vec![cells], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let bytes = geotiff::write(&r, 0).unwrap(); + crate::cmd::raster::add_raster(s, name, rustgis_map::RasterLayer::from_bytes(r, format!("{name}.tif"), bytes)).unwrap(); + }; + let nan = f32::NAN; + // Four cells over six years: 3 3 4 3 4 4; always 15; 21 21 33 with no data between; and + // 5 7 7 7 5 5, whose mode (5, the least of two tied) isn't its longest run's class (7). + let years: [(u32, [f32; 4]); 6] = [ + (2000, [3.0, 15.0, nan, 5.0]), + (2001, [3.0, 15.0, 21.0, 7.0]), + (2002, [4.0, 15.0, nan, 7.0]), + (2003, [3.0, 15.0, 21.0, 7.0]), + (2004, [4.0, 15.0, 33.0, 5.0]), + (2005, [4.0, 15.0, nan, 5.0]), + ]; + let mut s = Session::new(Project::default()); + let mut names = Vec::new(); + // Added out of date order: changes go by the dates, not the layers' order. + for k in [3, 0, 5, 1, 4, 2] { + let (y, cells) = years[k]; + let name = format!("cobertura_{y}"); + add(&mut s, &name, cells.to_vec()); + names.push(name); + } + let run = |s: &mut Session, p: Value| { + let r = s.run("cube.reduce", &p).unwrap(); + let id = r["layers"][0].as_u64().unwrap() as u32; + let l = s.map().layer(id).unwrap(); + (l.name.clone(), l.raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].data.clone()) + }; + let (name, mode) = run(&mut s, json!({"layers": names, "stat": "mode"})); + assert_eq!(mode, vec![3.0, 15.0, 21.0, 5.0]); + assert_eq!(name, "cobertura mode 2000-01-01–2005-01-01"); + assert_eq!(run(&mut s, json!({"layers": names, "stat": "majority"})).1, mode); + assert_eq!(run(&mut s, json!({"layers": names, "stat": "modeCount"})).1, vec![3.0, 6.0, 2.0, 3.0]); + assert_eq!(run(&mut s, json!({"layers": names, "stat": "variety"})).1, vec![2.0, 1.0, 2.0, 2.0]); + assert_eq!(run(&mut s, json!({"layers": names, "stat": "changes"})).1, vec![3.0, 0.0, 1.0, 2.0]); + // Runs of 2 in 3 and in 4 (the least), 6 in 15, 21 through its gap, 3 in 7. + assert_eq!(run(&mut s, json!({"layers": names, "stat": "longestRun"})).1, vec![3.0, 15.0, 21.0, 7.0]); + assert_eq!(run(&mut s, json!({"layers": names, "stat": "longestRunLength"})).1, vec![2.0, 6.0, 2.0, 3.0]); + assert_eq!(run(&mut s, json!({"layers": names, "stat": "countIn", "classes": [4, 33]})).1, vec![3.0, 0.0, 1.0, 0.0]); + // A share of the dates: the third cell has a class at half of them, so it's left out at 0.6. + let (_, half) = run(&mut s, json!({"layers": names, "stat": "mode", "minValid": 0.6})); + assert_eq!((half[0], half[1], half[3]), (3.0, 15.0, 5.0)); + assert!(half[2].is_nan(), "{half:?}"); + // Frequency: a layer for each class met, each cell's count of dates in it. + let layers = |s: &Session, r: &Value| -> Vec<(String, Vec)> { + r["layers"] + .as_array() + .unwrap() + .iter() + .map(|id| { + let l = s.map().layer(id.as_u64().unwrap() as u32).unwrap(); + (l.name.clone(), l.raster.as_ref().unwrap().data.as_ref().unwrap().bands[0].data.clone()) + }) + .collect() + }; + let r = s.run("cube.reduce", &json!({"layers": names, "stat": "frequency"})).unwrap(); + let got = layers(&s, &r); + let want: Vec<(String, Vec)> = [ + (3, [3.0, 0.0, 0.0, 0.0]), + (4, [3.0, 0.0, 0.0, 0.0]), + (5, [0.0, 0.0, 0.0, 3.0]), + (7, [0.0, 0.0, 0.0, 3.0]), + (15, [0.0, 6.0, 0.0, 0.0]), + (21, [0.0, 0.0, 2.0, 0.0]), + (33, [0.0, 0.0, 1.0, 0.0]), + ] + .iter() + .map(|(c, n)| (format!("cobertura frequency 2000-01-01–2005-01-01 class {c}"), n.to_vec())) + .collect(); + assert_eq!(got, want); + // With a share, a cell with too few values is left out of every class's layer. + let r = s.run("cube.reduce", &json!({"layers": names, "stat": "frequency", "minValid": 0.6, "name": "freq"})).unwrap(); + let got = layers(&s, &r); + assert_eq!(got.len(), 7); + assert!(got.iter().all(|(_, n)| n[2].is_nan()), "{got:?}"); + assert_eq!(got[0].0, "freq class 3"); + assert_eq!((got[0].1[0], got[0].1[1], got[0].1[3]), (3.0, 0.0, 0.0)); + // A raster whose bands are the years out of order, their dates given in the bands' order: + // changes still go by the dates. + let order = [3, 0, 5, 1, 4, 2]; + let bands: Vec> = order.iter().map(|&k| years[k].1.to_vec()).collect(); + let dates: Vec = order.iter().map(|&k| format!("{}-01-01", years[k].0)).collect(); + let r = Raster::new("bandas", 4, 1, bands, gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let bytes = geotiff::write(&r, 0).unwrap(); + crate::cmd::raster::add_raster(&mut s, "bandas", rustgis_map::RasterLayer::from_bytes(r, "bandas.tif".to_string(), bytes)).unwrap(); + assert_eq!(run(&mut s, json!({"layer": "bandas", "dates": dates, "stat": "changes"})).1, vec![3.0, 0.0, 1.0, 2.0]); + assert_eq!(run(&mut s, json!({"layer": "bandas", "dates": dates, "stat": "longestRun"})).1, vec![3.0, 15.0, 21.0, 7.0]); + assert_eq!(run(&mut s, json!({"layer": "bandas", "dates": dates, "stat": "longestRunLength"})).1, vec![2.0, 6.0, 2.0, 3.0]); + // By year, a change is one within the year: none, with one date a year. + let r = s.run("cube.reduce", &json!({"layers": names, "stat": "changes", "by": "year"})).unwrap(); + assert_eq!(r["layers"].as_array().unwrap().len(), 6); + // Every key a categorical summary takes is read, none said to be ignored. + let mark = s.notice_mark(); + run(&mut s, json!({"layers": names, "stat": "countIn", "classes": [3], "minValid": 0.5, "name": "floresta"})); + let ignored: Vec = s.notices_since(mark).iter().map(|n| n.text.clone()).filter(|t| t.contains("ignores")).collect(); + assert!(ignored.is_empty(), "{ignored:?}"); + // Refused, nothing added. + let before = s.map().layers.len(); + for (bad, said) in [ + (json!({"layers": names, "stat": "countIn"}), "countIn takes \"classes\""), + (json!({"layers": names, "stat": "countIn", "classes": []}), "countIn takes \"classes\""), + (json!({"layers": names, "stat": "countIn", "classes": [3.5]}), "whole numbers from 0 to 255"), + (json!({"layers": names, "stat": "countIn", "classes": [256]}), "whole numbers from 0 to 255"), + (json!({"layers": names, "stat": "countIn", "classes": "3"}), "countIn takes \"classes\""), + (json!({"layers": names, "stat": "countIn", "classes": vec![3; 257]}), "countIn takes \"classes\""), + ] { + let e = s.run("cube.reduce", &bad).unwrap_err(); + assert!(e.contains(said), "{bad}: {e}"); + } + // Values that aren't classes (an NDVI): said, with the value. + let ndvi = ["ndvi_2020", "ndvi_2021"]; + for (k, name) in ndvi.iter().enumerate() { + add(&mut s, name, vec![0.25 + k as f32 * 0.5]); + } + let before_ndvi = s.map().layers.len(); + for stat in ["mode", "variety", "changes", "countIn"] { + let e = s.run("cube.reduce", &json!({"layers": ndvi, "stat": stat, "classes": [0]})).unwrap_err(); + assert!(e.contains("0.25 isn't a class") && e.contains(stat), "{stat}: {e}"); + } + assert_eq!(s.map().layers.len(), before_ndvi); + // More classes than a land-cover map has: refused, not a count for each. + let many = ["muitas_2020", "muitas_2021"]; + for name in many { + add(&mut s, name, (0..1001).map(|c| c as f32).collect()); + } + let after_many = s.map().layers.len(); + let e = s.run("cube.reduce", &json!({"layers": many, "stat": "variety"})).unwrap_err(); + assert!(e.contains("more than 1000 classes") && e.contains("variety"), "{e}"); + assert_eq!(s.map().layers.len(), after_many); + assert_eq!(before_ndvi - before, ndvi.len()); + // A Zarr axis out of date order (days 31, 0, 59) whose classes are 2, 1, 2 along it: in date + // order they are 1, 2, 2, one change, not the axis's two, and a longest run in 2, where the + // axis's runs are all of one date (and the least class, 1, would win). + let (dir, path) = zarr_store_at("cube-reduce-classes-order", &[31, 0, 59]); + for (t, class) in [(0, 2.0f32), (1, 1.0), (2, 2.0)] { + let chunk: Vec = std::iter::repeat_n(class, 12).flat_map(|x| x.to_le_bytes()).collect(); + std::fs::write(std::path::Path::new(&path).join(format!("t/{t}.0.0")), chunk).unwrap(); + } + assert_eq!(run(&mut s, json!({"path": path, "variable": "t", "stat": "changes"})).1, vec![1.0; 12]); + assert_eq!(run(&mut s, json!({"path": path, "variable": "t", "stat": "longestRun"})).1, vec![2.0; 12]); + assert_eq!(run(&mut s, json!({"path": path, "variable": "t", "stat": "longestRunLength"})).1, vec![2.0; 12]); + let _ = std::fs::remove_dir_all(dir); + // zarr.reduce doesn't take them: it points to cube.reduce. + let (dir, path) = zarr_store_over_time("cube-reduce-classes"); + let e = s.run("zarr.reduce", &json!({"path": path, "variable": "t", "stat": "mode"})).unwrap_err(); + assert!(e.contains("cube.reduce"), "{e}"); + let _ = std::fs::remove_dir_all(dir); +} + /// A Zarr axis in decreasing order: a trend refuses it, a summary takes it and names its period from /// the earliest date to the latest. #[test] diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 6cf383b76..04e0c314c 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -480,14 +480,42 @@ variable, a STAC collection's band): **mean**, **min**, **max**, **sum**, **std* standard deviation), **count** of values, or **countAbove** a threshold (`"above"`), as Summarise over time does for a Zarr variable (see [Data](data.md)). +- **Classes:** a series of categorical rasters, such as yearly land-cover maps (MapBiomas, ESA + WorldCover), is summarised as classes with eight more statistics: + - **mode**, each cell's most frequent class, the least of those tied (`"majority"` too); + - **modeCount**, at how many dates it had that class; + - **variety**, how many classes it had; + - **changes**, how many times its class changed from one date with a class to the next, in date + order. A date with no data is skipped, so A, no data, B is one change. Within each group, so by + year a change is one within the year; + - **longestRun**, the class it kept for the most dates in a row, in date order, the least of those + tied. A date with no data neither breaks a run nor adds to it, so A, no data, A is a run of two; + - **longestRunLength**, how many dates that run lasted; + - **frequency**, a layer for each class met (`… frequency 1985–2023 class 3`), each cell's count + of dates in that class. By year or month, a layer for each class of each group; + - **countIn**, at how many dates it was one of the classes given (`"classes": [3, 4, 5]`, whole + numbers from 0 to 255), such as the years a cell was forest. + + They take the values as classes: a value that isn't a whole number (an NDVI) is refused with the + value, as is a run with more than 1,000 classes, or more than 64 million cells × classes in the + groups being read at once (16 million in the web app; for frequency, whose classes are each a + layer, the outputs' 32 million values). These are the data cube summary's only: Summarise over time for a + Zarr variable refuses them and points here. Mode, modeCount, variety and frequency keep a count + of each class in each cell while they read, 2 bytes a class, for each group being read: by + month, every month's at once, so a large area by month may need a smaller area or a group by + year. **changes**, + **longestRun** and **longestRunLength** read a STAC collection one date at a time, in order, so + they are slower there than the other statistics, which read several dates at once. + - **Groups:** `"by": "all"` (the default) makes one layer of the whole period, `"year"` one for each calendar year (an annual mean NDVI from 16-day composites), `"month"` one for each month of the year across the years (a climatology). `"start"`, `"end"` and `"months"` pick the dates as for a trend. - **Missing values:** no-data and missing values are skipped cell by cell. A cell keeps no value where it had fewer values than `"minValid"` (a share of its group's dates, 0 by default: 0.5 - wants half of a year's composites); with the default, **count** and **countAbove** give 0 where - a cell never had a value. + wants half of a year's composites); with the default, **count**, **countAbove**, **countIn**, + **modeCount**, **variety**, **changes**, **longestRunLength** and **frequency** give 0 where a cell + never had a value, and **mode** and **longestRun** give none. - **Size:** the slices are folded into running totals as they are read, so a long period takes no more memory than a short one; a STAC collection's area is read in pieces, as for a trend. The outputs hold up to 32 million values (cells times groups; 8 million in the web app). A summary by @@ -501,6 +529,7 @@ over time does for a Zarr variable (see [Data](data.md)). ```sh rustgis-cli run --cmd 'cube.reduce={"layers":["ndvi_2015","ndvi_2016","ndvi_2017","ndvi_2018"],"stat":"max"}' +rustgis-cli run --data cobertura_1985.tif --data cobertura_1986.tif --data cobertura_1987.tif --cmd 'cube.reduce={"layers":["cobertura_1985","cobertura_1986","cobertura_1987"],"stat":"changes"}' rustgis-cli run --cmd 'cube.reduce={"url":"https://data.inpe.br/bdc/stac/v1","collection":"mod13q1-6.1","asset":"NDVI","bbox":[-44.8,-7.5,-44.3,-7.0],"start":"2001","end":"2025","cellSize":1000,"stat":"mean","by":"year","minValid":0.5,"name":"NDVI"}' --save urucui.rgis ``` From 1cc0402bb4da7ded0fb6239a9c4037bb014057eb Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 24 Oct 2026 09:35:51 -0400 Subject: [PATCH 100/297] UI: name input widgets and restore accessibility checks (#806) * UI: name input widgets and restore accessibility checks Connect form controls to their row labels and give standalone selectors a purpose-based accessible name. Restore accessibility checks in every harness and settle the broad UI sweep so it checks each view. Add a regression test for finding and using labeling inputs by name. Fixes #790 * Address Claude review feedback Name both suitability weight inputs and exercise every workflow section\nwith accessibility checks enabled. --- crates/ui-egui/examples/planet_bench.rs | 1 - crates/ui-egui/examples/ui_shot.rs | 1 - crates/ui-egui/src/analysispane.rs | 176 +++++--- crates/ui-egui/src/assistantpane.rs | 88 ++-- crates/ui-egui/src/catalogsui.rs | 43 +- crates/ui-egui/src/chrome.rs | 2 +- crates/ui-egui/src/connectiontree.rs | 21 +- crates/ui-egui/src/connectionui.rs | 69 ++- crates/ui-egui/src/cswui.rs | 12 +- crates/ui-egui/src/dialogs.rs | 265 ++++++----- crates/ui-egui/src/docpanes.rs | 17 +- crates/ui-egui/src/earthaccessui.rs | 25 +- crates/ui-egui/src/editpanes.rs | 45 +- crates/ui-egui/src/embeddingsui.rs | 45 +- crates/ui-egui/src/geocodeui.rs | 43 +- crates/ui-egui/src/geometrycalc.rs | 52 ++- crates/ui-egui/src/graphicformat.rs | 2 +- crates/ui-egui/src/labelpane.rs | 72 ++- crates/ui-egui/src/layoutview.rs | 299 +++++++----- crates/ui-egui/src/mapview.rs | 28 +- crates/ui-egui/src/modelbuilder.rs | 67 +-- crates/ui-egui/src/navui.rs | 15 +- crates/ui-egui/src/nldiui.rs | 55 ++- crates/ui-egui/src/opendataui.rs | 84 ++-- crates/ui-egui/src/panes.rs | 524 ++++++++++++---------- crates/ui-egui/src/pluginsui.rs | 17 +- crates/ui-egui/src/portalsui.rs | 78 ++-- crates/ui-egui/src/reportpane.rs | 117 +++-- crates/ui-egui/src/ribbon.rs | 97 ++-- crates/ui-egui/src/s3ui.rs | 72 +-- crates/ui-egui/src/scriptcolor.rs | 12 +- crates/ui-egui/src/scriptcomplete.rs | 16 +- crates/ui-egui/src/sqlpane.rs | 10 +- crates/ui-egui/src/stacui.rs | 91 ++-- crates/ui-egui/src/textform.rs | 65 +-- crates/ui-egui/src/timebar.rs | 77 ++-- crates/ui-egui/src/webdataui.rs | 25 +- crates/ui-egui/src/widgets.rs | 24 +- crates/ui-egui/src/workspacepanes.rs | 105 +++-- crates/ui-egui/src/zarrui.rs | 25 +- crates/ui-egui/tests/define_projection.rs | 20 +- crates/ui-egui/tests/gpu_fastpath.rs | 1 - crates/ui-egui/tests/measurement_units.rs | 20 +- crates/ui-egui/tests/smoke.rs | 132 +++--- 44 files changed, 1770 insertions(+), 1285 deletions(-) diff --git a/crates/ui-egui/examples/planet_bench.rs b/crates/ui-egui/examples/planet_bench.rs index 0e9e35a28..8b5f9547e 100644 --- a/crates/ui-egui/examples/planet_bench.rs +++ b/crates/ui-egui/examples/planet_bench.rs @@ -47,7 +47,6 @@ fn main() -> Result<(), Box> { app.session.map_mut().camera.center = lonlat_to_merc(Coord::new(15.0, 25.0)); app.session.map_mut().camera.res = 20_000.0; let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) .with_size(egui::vec2(1480.0, 900.0)) .with_pixels_per_point(2.0) .with_step_dt(1.0 / 60.0) diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index 58fa91051..d6333868f 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -49,7 +49,6 @@ fn main() { }; app.set_render_state(Some(rs.clone())); let mut harness = egui_kittest::Harness::builder() - .with_accessibility_check(false) .with_size(egui::vec2(w, h)) .with_pixels_per_point(2.0) .with_max_steps(10_000) diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index d474e55c9..bcab7947b 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -88,12 +88,17 @@ fn heading(ui: &mut Ui, text: &str) { ui.add_space(2.0); } -fn combo(ui: &mut Ui, id: &str, value: &mut String, options: &[String]) { - egui::ComboBox::from_id_salt(id).selected_text(value.as_str()).width(180.0).show_ui(ui, |ui| { - for o in options { - ui.selectable_value(value, o.clone(), o.as_str()); - } - }); +fn combo(ui: &mut Ui, id: &str, name: &str, value: &mut String, options: &[String]) { + egui::ComboBox::from_id_salt(id) + .selected_text(value.as_str()) + .width(180.0) + .show_ui(ui, |ui| { + for o in options { + ui.selectable_value(value, o.clone(), o.as_str()); + } + }) + .response + .on_hover_text(tl!(name)); } fn run(app: &mut RustGisApp, id: &str, p: Value) { @@ -177,7 +182,9 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { app.workflow.pick = None; } } - }); + }) + .response + .on_hover_text(tl!("Workflow")); let map = app.session.map(); let rasters: Vec = map.layers.iter().filter(|l| l.is_raster()).map(|l| l.name.clone()).collect(); let features: Vec = map.layers.iter().filter(|l| !l.is_raster() && !l.graphics).map(|l| l.name.clone()).collect(); @@ -213,14 +220,14 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { match app.workflow.section { "rasterFunctions" | "functionEditor" => { heading(ui, if app.workflow.section == "rasterFunctions" { "Apply a function to a raster" } else { "Chain functions" }); - combo(ui, "wf_r", &mut app.workflow.layer, &rasters); + combo(ui, "wf_r", "Layer", &mut app.workflow.layer, &rasters); let funcs: Vec = ["slope", "aspect", "hillshade", "ndvi", "bandMath", "threshold"].iter().map(|s| s.to_string()).collect(); if app.workflow.function.is_empty() { app.workflow.function = "hillshade".into(); } ui.horizontal(|ui| { - combo(ui, "wf_f", &mut app.workflow.function, &funcs); + combo(ui, "wf_f", "Function", &mut app.workflow.function, &funcs); match app.workflow.function.as_str() { "bandMath" => { ui.add(egui::TextEdit::singleline(&mut app.workflow.text).hint_text("(b4-b3)/(b4+b3)").desired_width(120.0)); @@ -260,12 +267,13 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { "suitability" => { heading(ui, "Score two rasters 1–10 and weight them"); ui.label(egui::RichText::new(tl!("High values of the first raster and low values of the second score best.")).small().weak()); - combo(ui, "wf_s1", &mut app.workflow.layer, &rasters); - combo(ui, "wf_s2", &mut app.workflow.layer2, &rasters); + combo(ui, "wf_s1", "First raster", &mut app.workflow.layer, &rasters); + combo(ui, "wf_s2", "Second raster", &mut app.workflow.layer2, &rasters); ui.horizontal(|ui| { - ui.label(tl!("Weights")); - ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text("1").desired_width(40.0)); - ui.add(egui::TextEdit::singleline(&mut app.workflow.number2).hint_text("1").desired_width(40.0)); + let input_label = ui.label(tl!("Weights")); + ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text("1").desired_width(40.0)).labelled_by(input_label.id); + ui.add(egui::TextEdit::singleline(&mut app.workflow.number2).hint_text("1").desired_width(40.0)) + .labelled_by(input_label.id); }); if ui.button(tl!("Run")).clicked() { let crit = |app: &RustGisApp, n: &str, high: bool, w: f64| -> Value { @@ -295,7 +303,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "visibility" => { heading(ui, "Viewshed from a point"); - combo(ui, "wf_v", &mut app.workflow.layer, &rasters); + combo(ui, "wf_v", "Layer", &mut app.workflow.layer, &rasters); ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text(tl!("observer height m")).desired_width(110.0)); ui.add(egui::TextEdit::singleline(&mut app.workflow.number2).hint_text(tl!("radius m")).desired_width(80.0)); @@ -311,7 +319,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "lineOfSight" => { heading(ui, "Line of sight between two points"); - combo(ui, "wf_l", &mut app.workflow.layer, &rasters); + combo(ui, "wf_l", "Layer", &mut app.workflow.layer, &rasters); pick_button(app, ui, "los", 2, "Click the observer, then the target"); if let Some(p) = picked(app, "los") && let (Some(a), Some(b)) = (p.first(), p.get(1)) @@ -321,8 +329,8 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "interpolate" => { heading(ui, "Interpolate a surface from points (IDW)"); - combo(ui, "wf_i", &mut app.workflow.layer, &points); - combo(ui, "wf_if", &mut app.workflow.field, &numeric); + combo(ui, "wf_i", "Layer", &mut app.workflow.layer, &points); + combo(ui, "wf_if", "Field", &mut app.workflow.field, &numeric); ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text(tl!("cell size °")).desired_width(80.0)); ui.add(egui::TextEdit::singleline(&mut app.workflow.number2).hint_text(tl!("power 2")).desired_width(60.0)); @@ -337,13 +345,13 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "network" => { heading(ui, "Shortest route over a line network"); - combo(ui, "wf_n", &mut app.workflow.layer, &lines); + combo(ui, "wf_n", "Layer", &mut app.workflow.layer, &lines); let cost_fields = fields_of(&app.workflow.layer, true); ui.horizontal(|ui| { ui.label(tl!("Cost")); let mut options = vec![String::new()]; options.extend(cost_fields); - combo(ui, "wf_nc", &mut app.workflow.field, &options); + combo(ui, "wf_nc", "Cost", &mut app.workflow.field, &options); }); pick_button(app, ui, "route", 2, "Click the start, then the end"); if let Some(p) = picked(app, "route") @@ -370,7 +378,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "simulation" => { heading(ui, "Random points in polygons"); - combo(ui, "wf_sim", &mut app.workflow.layer, &polygons); + combo(ui, "wf_sim", "Layer", &mut app.workflow.layer, &polygons); ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text(tl!("points each")).desired_width(80.0)); ui.add(egui::TextEdit::singleline(&mut app.workflow.number2).hint_text(tl!("seed")).desired_width(60.0)); @@ -385,7 +393,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "profile" | "explore" => { heading(ui, if app.workflow.section == "profile" { "Profile every field" } else { "Histograms and correlations" }); - combo(ui, "wf_p", &mut app.workflow.layer, &features); + combo(ui, "wf_p", "Layer", &mut app.workflow.layer, &features); let cmd = if app.workflow.section == "profile" { "data.profile" } else { "data.explore" }; if ui.button(tl!("Run")).clicked() { run(app, cmd, json!({"layer": app.workflow.layer})); @@ -393,19 +401,19 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "similar" => { heading(ui, "Features most like the selected one"); - combo(ui, "wf_e", &mut app.workflow.layer, &features); + combo(ui, "wf_e", "Layer", &mut app.workflow.layer, &features); if ui.button(tl!("Find Similar")).clicked() { run(app, "data.similar", json!({"layer": app.workflow.layer, "count": 10})); } } "interop" => { heading(ui, "Convert a layer to another format"); - combo(ui, "wf_x", &mut app.workflow.layer, &features); + combo(ui, "wf_x", "Layer", &mut app.workflow.layer, &features); let formats: Vec = ["kml", "gpx", "wkt", "geojson", "csv"].iter().map(|s| s.to_string()).collect(); if app.workflow.format.is_empty() { app.workflow.format = "kml".into(); } - combo(ui, "wf_xf", &mut app.workflow.format, &formats); + combo(ui, "wf_xf", "Format", &mut app.workflow.format, &formats); if ui.button(tl!("Convert…")).clicked() { match app.session.run("data.convert", &json!({"layer": app.workflow.layer, "format": app.workflow.format})) { Ok(v) => { @@ -448,7 +456,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "georeference" => { heading(ui, "Place a raster on the map"); - combo(ui, "wf_g", &mut app.workflow.layer, &rasters); + combo(ui, "wf_g", "Layer", &mut app.workflow.layer, &rasters); ui.label( egui::RichText::new(tl!("Stretch it over the map view, or give control points (one per line: column row lon lat).")) .small() @@ -496,13 +504,13 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "change" => { heading(ui, "Compare two rasters"); - combo(ui, "wf_c1", &mut app.workflow.layer, &rasters); - combo(ui, "wf_c2", &mut app.workflow.layer2, &rasters); + combo(ui, "wf_c1", "Before", &mut app.workflow.layer, &rasters); + combo(ui, "wf_c2", "After", &mut app.workflow.layer2, &rasters); let methods: Vec = ["difference", "ratio", "percent"].iter().map(|s| s.to_string()).collect(); if app.workflow.format.is_empty() || !methods.contains(&app.workflow.format) { app.workflow.format = "difference".into(); } - combo(ui, "wf_cm", &mut app.workflow.format, &methods); + combo(ui, "wf_cm", "Method", &mut app.workflow.format, &methods); if ui.button(tl!("Run")).clicked() { run( app, @@ -514,7 +522,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { "trend" => trend(app, ui, &rasters), "spectral" => { heading(ui, "Band values at a point"); - combo(ui, "wf_sp", &mut app.workflow.layer, &rasters); + combo(ui, "wf_sp", "Layer", &mut app.workflow.layer, &rasters); pick_button(app, ui, "spectral", 1, "Click a pixel on the map"); if let Some(p) = picked(app, "spectral").and_then(|v| v.first().copied()) { run(app, "raster.spectral", json!({"layer": app.workflow.layer, "lon": p.x, "lat": p.y})); @@ -522,7 +530,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "classify" => { heading(ui, "Unsupervised classes (k-means)"); - combo(ui, "wf_k", &mut app.workflow.layer, &rasters); + combo(ui, "wf_k", "Layer", &mut app.workflow.layer, &rasters); ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text(tl!("classes")).desired_width(60.0)); if ui.button(tl!("Classify")).clicked() { run(app, "raster.classify", json!({"layer": app.workflow.layer, "classes": num(&app.workflow.number, 5.0).round() as u64})); @@ -530,10 +538,10 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "supervised" => { heading(ui, "Classify from training polygons"); - combo(ui, "wf_su", &mut app.workflow.layer, &rasters); - combo(ui, "wf_st", &mut app.workflow.layer2, &polygons); + combo(ui, "wf_su", "Raster", &mut app.workflow.layer, &rasters); + combo(ui, "wf_st", "Training polygons", &mut app.workflow.layer2, &polygons); let fields = fields_of(&app.workflow.layer2, false); - combo(ui, "wf_sf", &mut app.workflow.field, &fields); + combo(ui, "wf_sf", "Field", &mut app.workflow.field, &fields); if ui.button(tl!("Classify")).clicked() { run( app, @@ -544,10 +552,10 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "deepLearning" => { heading(ui, "Train a neural network on training polygons"); - combo(ui, "wf_dl", &mut app.workflow.layer, &rasters); - combo(ui, "wf_dt", &mut app.workflow.layer2, &polygons); + combo(ui, "wf_dl", "Raster", &mut app.workflow.layer, &rasters); + combo(ui, "wf_dt", "Training polygons", &mut app.workflow.layer2, &polygons); let fields = fields_of(&app.workflow.layer2, false); - combo(ui, "wf_df", &mut app.workflow.field, &fields); + combo(ui, "wf_df", "Field", &mut app.workflow.field, &fields); ui.horizontal(|ui| { ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text(tl!("epochs")).desired_width(60.0)); if ui.button(tl!("Train And Classify")).clicked() { @@ -580,16 +588,17 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "hosted" => { heading(ui, "Cut a raster into web map tiles"); - combo(ui, "wf_ho", &mut app.workflow.layer, &rasters); + combo(ui, "wf_ho", "Layer", &mut app.workflow.layer, &rasters); if ui.button(tl!("Create Hosted Imagery…")).clicked() { let _ = app.run("ui.saveItem", json!({"kind": "hostedImagery", "layer": app.workflow.layer})); } } "indices" => { heading(ui, "Spectral indices"); - combo(ui, "wf_ix", &mut app.workflow.layer, &rasters); - ui.label(egui::RichText::new(tl!("Bands: blue green red NIR SWIR")).small().weak()); - ui.add(egui::TextEdit::singleline(&mut app.workflow.text).hint_text("1 2 3 4 5").desired_width(120.0)); + combo(ui, "wf_ix", "Layer", &mut app.workflow.layer, &rasters); + let input_label = ui.label(egui::RichText::new(tl!("Bands: blue green red NIR SWIR")).small().weak()); + ui.add(egui::TextEdit::singleline(&mut app.workflow.text).hint_text("1 2 3 4 5").desired_width(120.0)) + .labelled_by(input_label.id); ui.horizontal_wrapped(|ui| { for idx in ["ndvi", "ndwi", "savi", "nbr", "ndbi", "evi"] { if ui.button(idx.to_uppercase()).clicked() { @@ -602,7 +611,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "process" => { heading(ui, "Clip or resample"); - combo(ui, "wf_pr", &mut app.workflow.layer, &rasters); + combo(ui, "wf_pr", "Layer", &mut app.workflow.layer, &rasters); ui.horizontal(|ui| { if ui.button(tl!("Clip To Map View")).clicked() { run(app, "raster.process", json!({"layer": app.workflow.layer, "operation": "clip"})); @@ -619,7 +628,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "pixels" => { heading(ui, "Set the pixels inside a polygon"); - combo(ui, "wf_px", &mut app.workflow.layer, &rasters); + combo(ui, "wf_px", "Layer", &mut app.workflow.layer, &rasters); ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text(tl!("value (empty: no-data)")).desired_width(140.0)); pick_button(app, ui, "pixels", 100, "Click the polygon's corners"); let n = app.workflow.pick.as_ref().filter(|p| p.purpose == "pixels").map_or(0, |p| p.points.len()); @@ -635,7 +644,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "mensuration" => { heading(ui, "Measure with an elevation raster"); - combo(ui, "wf_me", &mut app.workflow.layer, &rasters); + combo(ui, "wf_me", "Layer", &mut app.workflow.layer, &rasters); ui.horizontal_wrapped(|ui| { pick_button(app, ui, "m_distance", 2, "Distance"); pick_button(app, ui, "m_height", 2, "Height"); @@ -696,7 +705,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }); if kind == "reality" { - combo(ui, "wf_mesh", &mut app.workflow.layer, &rasters); + combo(ui, "wf_mesh", "Layer", &mut app.workflow.layer, &rasters); if ui.button(tl!("Export Mesh (OBJ)…")).clicked() { match app.session.run("imagery.mesh", &json!({"layer": app.workflow.layer})) { Ok(v) => { @@ -813,11 +822,11 @@ fn trend(app: &mut RustGisApp, ui: &mut Ui, rasters: &[String]) { } }); } else { - combo(ui, "wf_tr_layer", &mut app.workflow.layer, rasters); + combo(ui, "wf_tr_layer", "Layer", &mut app.workflow.layer, rasters); } let f = &mut app.workflow.trend; egui::Grid::new("trend_grid").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Test")); + let input_label = ui.label(tl!("Test")); let tests = [ ("hamedRao", "Modified Mann–Kendall (Hamed & Rao)"), ("yueWang", "Modified Mann–Kendall (Yue & Wang)"), @@ -825,19 +834,24 @@ fn trend(app: &mut RustGisApp, ui: &mut Ui, rasters: &[String]) { ("original", "Mann–Kendall"), ]; let shown = tests.iter().find(|t| t.0 == f.test).map_or("", |t| t.1); - egui::ComboBox::from_id_salt("trend_test").selected_text(tl!(shown)).width(240.0).show_ui(ui, |ui| { - for (code, label) in tests { - ui.selectable_value(&mut f.test, code.to_string(), tl!(label)); - } - }); + egui::ComboBox::from_id_salt("trend_test") + .selected_text(tl!(shown)) + .width(240.0) + .show_ui(ui, |ui| { + for (code, label) in tests { + ui.selectable_value(&mut f.test, code.to_string(), tl!(label)); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); if f.test == "seasonal" { - ui.label(tl!("Seasons a year")); - ui.add(egui::DragValue::new(&mut f.seasons).range(2..=366)); + let input_label = ui.label(tl!("Seasons a year")); + ui.add(egui::DragValue::new(&mut f.seasons).range(2..=366)).labelled_by(input_label.id); ui.end_row(); } - ui.label(tl!("Significance level")); - ui.add(egui::TextEdit::singleline(&mut f.alpha).desired_width(60.0)); + let input_label = ui.label(tl!("Significance level")); + ui.add(egui::TextEdit::singleline(&mut f.alpha).desired_width(60.0)).labelled_by(input_label.id); ui.end_row(); ui.label(tl!("Slope per")); ui.horizontal(|ui| { @@ -845,17 +859,17 @@ fn trend(app: &mut RustGisApp, ui: &mut Ui, rasters: &[String]) { ui.radio_value(&mut f.decade, true, tl!("decade")); }); ui.end_row(); - ui.label(tl!("From")); - ui.add(egui::TextEdit::singleline(&mut f.start).hint_text("2000-01-01").desired_width(110.0)); + let input_label = ui.label(tl!("From")); + ui.add(egui::TextEdit::singleline(&mut f.start).hint_text("2000-01-01").desired_width(110.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("To")); - ui.add(egui::TextEdit::singleline(&mut f.end).hint_text("2024-12-31").desired_width(110.0)); + let input_label = ui.label(tl!("To")); + ui.add(egui::TextEdit::singleline(&mut f.end).hint_text("2024-12-31").desired_width(110.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Months")); - ui.add(egui::TextEdit::singleline(&mut f.months).hint_text("12, 1, 2").desired_width(110.0)); + let input_label = ui.label(tl!("Months")); + ui.add(egui::TextEdit::singleline(&mut f.months).hint_text("12, 1, 2").desired_width(110.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Dates a cell needs")); - ui.add(egui::TextEdit::singleline(&mut f.min_valid).hint_text(tl!("half")).desired_width(60.0)); + let input_label = ui.label(tl!("Dates a cell needs")); + ui.add(egui::TextEdit::singleline(&mut f.min_valid).hint_text(tl!("half")).desired_width(60.0)).labelled_by(input_label.id); ui.end_row(); }); ui.label( @@ -1200,3 +1214,37 @@ fn script(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, fit: bool) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + use egui_kittest::kittest::Queryable; + + /// Every workflow has named inputs, including both weights sharing a row (#790). + #[test] + fn every_workflow_names_its_inputs() { + for &(section, _) in SECTIONS { + let mut app = RustGisApp::with_sample(); + app.workflow.section = section; + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 1200.0)).build_ui_state( + move |ui, app: &mut RustGisApp| { + if !fonts { + crate::theme::install_fonts(ui.ctx(), &[], None); + fonts = true; + return; + } + workflows(app, ui, &Tokens::light()); + }, + app, + ); + h.run(); + if section == "suitability" { + assert_eq!(h.query_all_by_role_and_label(egui::accesskit::Role::TextInput, "Weights").count(), 2); + for label in ["First raster", "Second raster"] { + assert!(h.query_by_role_and_label(egui::accesskit::Role::ComboBox, label).is_some()); + } + } + } + } +} diff --git a/crates/ui-egui/src/assistantpane.rs b/crates/ui-egui/src/assistantpane.rs index 4260da0fd..f1858011d 100644 --- a/crates/ui-egui/src/assistantpane.rs +++ b/crates/ui-egui/src/assistantpane.rs @@ -866,7 +866,7 @@ fn provider_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Co ui.vertical(|ui| account_row(app, ui, t, cfg)); ui.end_row(); } - ui.label(tl!("Model")); + let model_label = ui.label(tl!("Model")); let signed_out = cfg.login() && cfg.key.is_empty(); ui.add_enabled_ui(!signed_out, |ui| { ui.horizontal(|ui| { @@ -879,21 +879,28 @@ fn provider_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Co .id(egui::Id::unique(FIELDS[0])) .desired_width(150.0) .hint_text(tl!("model id")), - ); + ) + .labelled_by(model_label.id); if !app.assistant.models.is_empty() { - egui::ComboBox::from_id_salt("assistant_models").selected_text("").width(24.0).height(320.0).show_ui(ui, |ui| { - for m in &app.assistant.models { - if ui - .selectable_label( - cfg.model == *m, - app.assistant.catalog.iter().find(|c| c.id == *m).map_or(m.as_str(), |c| c.name.as_str()), - ) - .clicked() - { - cfg.model = m.clone(); + egui::ComboBox::from_id_salt("assistant_models") + .selected_text("") + .width(24.0) + .height(320.0) + .show_ui(ui, |ui| { + for m in &app.assistant.models { + if ui + .selectable_label( + cfg.model == *m, + app.assistant.catalog.iter().find(|c| c.id == *m).map_or(m.as_str(), |c| c.name.as_str()), + ) + .clicked() + { + cfg.model = m.clone(); + } } - } - }); + }) + .response + .on_hover_text(tl!("Model")); } let loading = app.assistant.models_slot.is_some(); if ui @@ -1055,6 +1062,7 @@ fn account_row(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, cfg: &mut Config) } } }); + picker.response.clone().on_hover_text(tl!("Choose an account")); // Opening the list reads it again: another window or the command line may have added one. if picker.response.clicked() { app.assistant.accounts = None; @@ -2092,7 +2100,7 @@ mod tests { app.assistant.chunks = Some((generation, Chunks::default())); app.assistant.inflight = Some((generation, Slot::default())); // Render just the composer: no polling or network dispatch is needed to press Stop. - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(600.0, 160.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 160.0)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); let busy = app.assistant.agent.as_ref().is_some_and(Agent::busy); @@ -2112,7 +2120,7 @@ mod tests { #[test] fn plan_settings_order_and_signed_out_model() { use egui_kittest::kittest::{NodeT, Queryable}; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(800.0, 600.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -2144,7 +2152,7 @@ mod tests { let saved = Arc::new(Mutex::new(Vec::<(String, String)>::new())); let log = saved.clone(); app.services.save_pref = Some(Box::new(move |k, v| log.lock().unwrap().push((k.to_string(), v.to_string())))); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(800.0, 600.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(800.0, 600.0)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -2170,7 +2178,7 @@ mod tests { fn waiting_controls_reopen_copy_and_cancel() { use egui_kittest::kittest::Queryable; let url = "https://auth.openai.com/synthetic-sign-in"; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(260.0, 220.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(260.0, 220.0)).build_ui_state( |ui, actions: &mut (bool, bool)| { let (reopen, cancel) = waiting_for_browser(ui, &Tokens::light(), url); actions.0 |= reopen; @@ -2210,7 +2218,7 @@ mod tests { app }; app.assistant.login_error = Some(rustgis_assistant::Error::new(rustgis_assistant::Recovery::Auth, "")); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(width, height)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(width, height)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -2296,7 +2304,7 @@ mod tests { // A fixed number of frames, not `run`: each catalog request ends on a thread that asks for a // repaint, which can land mid-run (it did on Windows) and run past `run`'s step limit. let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(400.0, 600.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 600.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2390,18 +2398,14 @@ mod tests { let mut app = plan_app(); app.assistant.settings = false; app.assistant.agent.as_mut().unwrap().transcript.push(Entry::Error(error)); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(1400.0, 860.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.run(); assert!(h.query_by_label("Weekly usage").is_some()); h.get_by_label("Weekly usage").click(); @@ -2476,18 +2480,14 @@ mod tests { /// The window with the assistant open and `entries` in the conversation. fn chat(entries: Vec, model: &str) -> egui_kittest::Harness<'static, RustGisApp> { - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(1400.0, 860.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - RustGisApp::with_sample(), - ); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + RustGisApp::with_sample(), + ); h.state_mut().run("ui.pane.assistant", serde_json::Value::Null).unwrap(); for _ in 0..4 { h.step(); diff --git a/crates/ui-egui/src/catalogsui.rs b/crates/ui-egui/src/catalogsui.rs index 23b02b1d1..67e19539c 100644 --- a/crates/ui-egui/src/catalogsui.rs +++ b/crates/ui-egui/src/catalogsui.rs @@ -240,23 +240,33 @@ pub fn overture(app: &mut RustGisApp, ui: &mut egui::Ui) { let busy = app.ui.catalogs.waiting.is_some() || app.ui.catalogs.opening.is_some(); egui::Grid::new("overture").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { let st = &mut app.ui.catalogs; - ui.label(tl!("Theme")); - egui::ComboBox::from_id_salt("ov_theme").selected_text(st.theme.clone()).width(260.0).show_ui(ui, |ui| { - for (theme, types) in overture::THEMES { - if ui.selectable_label(st.theme == *theme, *theme).clicked() { - st.theme = (*theme).to_string(); - st.ty = types.first().copied().unwrap_or_default().to_string(); + let input_label = ui.label(tl!("Theme")); + egui::ComboBox::from_id_salt("ov_theme") + .selected_text(st.theme.clone()) + .width(260.0) + .show_ui(ui, |ui| { + for (theme, types) in overture::THEMES { + if ui.selectable_label(st.theme == *theme, *theme).clicked() { + st.theme = (*theme).to_string(); + st.ty = types.first().copied().unwrap_or_default().to_string(); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Type")); + let input_label = ui.label(tl!("Type")); let types = overture::THEMES.iter().find(|(t, _)| *t == st.theme).map(|(_, y)| *y).unwrap_or(&[]); - egui::ComboBox::from_id_salt("ov_type").selected_text(st.ty.clone()).width(260.0).show_ui(ui, |ui| { - for ty in types { - ui.selectable_value(&mut st.ty, (*ty).to_string(), *ty); - } - }); + egui::ComboBox::from_id_salt("ov_type") + .selected_text(st.ty.clone()) + .width(260.0) + .show_ui(ui, |ui| { + for ty in types { + ui.selectable_value(&mut st.ty, (*ty).to_string(), *ty); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); ui.label(tl!("Add")); ui.vertical(|ui| { @@ -366,8 +376,9 @@ pub fn sourcecoop(app: &mut RustGisApp, ui: &mut egui::Ui) { let mut ask = None; if !locked { ui.horizontal(|ui| { - ui.label(tl!("Account")); - ui.add(egui::TextEdit::singleline(&mut app.ui.catalogs.account).hint_text(tl!("featured products")).desired_width(220.0)); + let input_label = ui.label(tl!("Account")); + ui.add(egui::TextEdit::singleline(&mut app.ui.catalogs.account).hint_text(tl!("featured products")).desired_width(220.0)) + .labelled_by(input_label.id); if ui.add_enabled(!busy, egui::Button::new(tl!("List Products"))).clicked() { let a = app.ui.catalogs.account.trim().to_string(); ask = Some(if a.is_empty() { Ok(sourcecoop::featured_url()) } else { sourcecoop::account_url(&a) }); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e220c042a..40ae019b9 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1199,7 +1199,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = RustGisApp::with_sample(); app.run("labels.style", serde_json::json!({"layer": "Cities", "size": 20})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); shortcuts(app, &ctx); diff --git a/crates/ui-egui/src/connectiontree.rs b/crates/ui-egui/src/connectiontree.rs index 7e2022535..36d8ded5b 100644 --- a/crates/ui-egui/src/connectiontree.rs +++ b/crates/ui-egui/src/connectiontree.rs @@ -610,19 +610,14 @@ mod tests { type Harness = egui_kittest::Harness<'static, RustGisApp>; fn harness() -> Harness { - egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(egui::vec2(1400.0, 860.0)) - .with_max_steps(64) - .with_step_dt(0.05) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - RustGisApp::with_sample(), - ) + egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).with_step_dt(0.05).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + RustGisApp::with_sample(), + ) } /// Answers every request on a local port with `answer(request head)`: status and body. diff --git a/crates/ui-egui/src/connectionui.rs b/crates/ui-egui/src/connectionui.rs index 43e5d2fc5..2e8f6f554 100644 --- a/crates/ui-egui/src/connectionui.rs +++ b/crates/ui-egui/src/connectionui.rs @@ -90,7 +90,9 @@ pub(crate) fn picker_combo( picked = Some(c.clone()); } } - }); + }) + .response + .on_hover_text(tl!("Saved connection")); picked } @@ -477,20 +479,29 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ui.label(tl!("Type")); ui.label(tl!(label(&st.kind))); ui.end_row(); - ui.label(tl!("Name")); - changed |= ui.add(egui::TextEdit::singleline(&mut st.name).hint_text(tl!("From the address")).desired_width(360.0)).changed(); + let input_label = ui.label(tl!("Name")); + changed |= ui + .add(egui::TextEdit::singleline(&mut st.name).hint_text(tl!("From the address")).desired_width(360.0)) + .labelled_by(input_label.id) + .changed(); ui.end_row(); - ui.label(tl!("Group")); - changed |= ui.add(egui::TextEdit::singleline(&mut st.group).hint_text(tl!("None")).desired_width(360.0)).changed(); + let input_label = ui.label(tl!("Group")); + changed |= ui + .add(egui::TextEdit::singleline(&mut st.group).hint_text(tl!("None")).desired_width(360.0)) + .labelled_by(input_label.id) + .changed(); ui.end_row(); if database { - ui.label(tl!("Connection")); + let input_label = ui.label(tl!("Connection")); let hint = match st.kind.as_str() { "mysql" => "mysql://user@host:3306/db", "sqlserver" => "mssql://user@host:1433/db", _ => "host=… dbname=… user=…", }; - changed |= ui.add(egui::TextEdit::singleline(&mut st.address).hint_text(hint).desired_width(360.0)).changed(); + changed |= ui + .add(egui::TextEdit::singleline(&mut st.address).hint_text(hint).desired_width(360.0)) + .labelled_by(input_label.id) + .changed(); ui.end_row(); ui.label(tl!("Password")); changed |= ui @@ -506,24 +517,30 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { (tl!("Region"), &mut st.region, s3::DEFAULT_REGION), (tl!("Buckets"), &mut st.buckets, tl!("Comma-separated (optional)")), ] { - ui.label(label); - changed |= ui.add(egui::TextEdit::singleline(field).hint_text(hint).desired_width(360.0)).changed(); + let input_label = ui.label(label); + changed |= + ui.add(egui::TextEdit::singleline(field).hint_text(hint).desired_width(360.0)).labelled_by(input_label.id).changed(); ui.end_row(); } - ui.label(tl!("Keys from")); + let input_label = ui.label(tl!("Keys from")); let shown = crate::s3ui::SOURCES.iter().find(|(c, _)| *c == st.credentials).map(|(_, l)| tl!(*l)).unwrap_or_default(); - egui::ComboBox::from_id_salt("connection_credentials").selected_text(shown).width(360.0).show_ui(ui, |ui| { - for (code, label) in crate::s3ui::SOURCES { - if cfg!(target_arch = "wasm32") && matches!(code, "profile" | "environment") { - continue; + egui::ComboBox::from_id_salt("connection_credentials") + .selected_text(shown) + .width(360.0) + .show_ui(ui, |ui| { + for (code, label) in crate::s3ui::SOURCES { + if cfg!(target_arch = "wasm32") && matches!(code, "profile" | "environment") { + continue; + } + changed |= ui.selectable_value(&mut st.credentials, code.to_owned(), tl!(label)).changed(); } - changed |= ui.selectable_value(&mut st.credentials, code.to_owned(), tl!(label)).changed(); - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); if st.credentials == "profile" { - ui.label(tl!("Profile")); - changed |= ui.add(egui::TextEdit::singleline(&mut st.profile).desired_width(360.0)).changed(); + let input_label = ui.label(tl!("Profile")); + changed |= ui.add(egui::TextEdit::singleline(&mut st.profile).desired_width(360.0)).labelled_by(input_label.id).changed(); ui.end_row(); } if st.credentials == "keys" { @@ -532,20 +549,26 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { (tl!("Secret access key"), &mut st.secret_key, true), (tl!("Session token"), &mut st.session_token, true), ] { - ui.label(label); - changed |= ui.add(egui::TextEdit::singleline(field).password(secret).desired_width(360.0)).changed(); + let input_label = ui.label(label); + changed |= ui + .add(egui::TextEdit::singleline(field).password(secret).desired_width(360.0)) + .labelled_by(input_label.id) + .changed(); ui.end_row(); } } } else { - ui.label(tl!("Address")); + let input_label = ui.label(tl!("Address")); let hint = match st.kind.as_str() { "stac" => "https://…/stac/v1", "arcgis" => "https://…/rest/services", "csw" => "https://…/csw", _ => "https://…", }; - changed |= ui.add(egui::TextEdit::singleline(&mut st.address).hint_text(hint).desired_width(360.0)).changed(); + changed |= ui + .add(egui::TextEdit::singleline(&mut st.address).hint_text(hint).desired_width(360.0)) + .labelled_by(input_label.id) + .changed(); ui.end_row(); } }); diff --git a/crates/ui-egui/src/cswui.rs b/crates/ui-egui/src/cswui.rs index 10e72a199..bd2f548a5 100644 --- a/crates/ui-egui/src/cswui.rs +++ b/crates/ui-egui/src/cswui.rs @@ -99,14 +99,18 @@ pub fn poll(app: &mut RustGisApp) { pub fn form(app: &mut RustGisApp, ui: &mut egui::Ui, kinds: impl FnOnce(&mut RustGisApp, &mut egui::Ui)) { let mut go = false; egui::Grid::new("csw").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label(tl!("Address")); - let r = ui.add(egui::TextEdit::singleline(&mut app.ui.csw.url).hint_text("https://…/csw").desired_width(420.0)); + let input_label = ui.label(tl!("Address")); + let r = ui + .add(egui::TextEdit::singleline(&mut app.ui.csw.url).hint_text("https://…/csw").desired_width(420.0)) + .labelled_by(input_label.id); go |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); kinds(app, ui); ui.end_row(); - ui.label(tl!("Words")); - let r = ui.add(egui::TextEdit::singleline(&mut app.ui.csw.query).hint_text(tl!("anywhere in the records")).desired_width(420.0)); + let input_label = ui.label(tl!("Words")); + let r = ui + .add(egui::TextEdit::singleline(&mut app.ui.csw.query).hint_text(tl!("anywhere in the records")).desired_width(420.0)) + .labelled_by(input_label.id); go |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); ui.label(""); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 124ad1f3d..92b51333d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -213,14 +213,14 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "selectByAttributes" => close = select_by_attributes(app, ui, &t), "goToXY" => { egui::Grid::new("xy").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label(tl!("Longitude (X)")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).desired_width(200.0)); + let input_label = ui.label(tl!("Longitude (X)")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).desired_width(200.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Latitude (Y)")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.b).desired_width(200.0)); + let input_label = ui.label(tl!("Latitude (Y)")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.b).desired_width(200.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Scale 1:")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.c).hint_text(tl!("keep current")).desired_width(200.0)); + let input_label = ui.label(tl!("Scale 1:")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.c).hint_text(tl!("keep current")).desired_width(200.0)).labelled_by(input_label.id); ui.end_row(); }); close = buttons(app, ui, "Go", |app| { @@ -234,8 +234,8 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "coordinateSystem" => close = coordinate_system(app, ui, &t), "addShapefile" => { ui.label(tl!("Choose the source coordinate system before importing. It replaces a missing or incorrect .prj without changing the original file.")); - ui.label(tl!("Source coordinate system")); - let response = ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:2263").desired_width(f32::INFINITY)); + let input_label = ui.label(tl!("Source coordinate system")); + let response = ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:2263").desired_width(f32::INFINITY)).labelled_by(input_label.id); response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Source coordinate system"))); ui.label(tl!("In the browser, pick the .shp and .dbf together, with any sidecars, or pick a ZIP. The override applies to every Shapefile in the ZIP.")); close = buttons(app, ui, "Choose File…", |app| app.run("ui.importShapefile", json!({"crs": app.dialogs.text.trim()})).map(|_| ())); @@ -243,8 +243,8 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "defineProjection" => { ui.label(egui::RichText::new(&app.dialogs.layer).strong()); ui.label(tl!("Interpret the layer's native coordinates in this coordinate system. This changes where it draws; the source file is unchanged.")); - ui.label(tl!("Source coordinate system")); - let label = ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:2263").desired_width(f32::INFINITY)); + let input_label = ui.label(tl!("Source coordinate system")); + let label = ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:2263").desired_width(f32::INFINITY)).labelled_by(input_label.id); label.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Source coordinate system"))); ui.label(egui::RichText::new(tl!("Geometry lost during import cannot be recovered here. Use Import Original File to choose its coordinate system before reading it. Streamed layers must first be loaded into memory.")).weak()); if ui.button(tl!("Import Original File…")).clicked() { @@ -310,8 +310,8 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "camera" => { egui::Grid::new("cam").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { for (label, v) in [("Longitude", &mut app.dialogs.a), ("Latitude", &mut app.dialogs.b), ("Scale 1:", &mut app.dialogs.c)] { - ui.label(tl!(label)); - ui.add(egui::TextEdit::singleline(v).desired_width(160.0)); + let input_label = ui.label(tl!(label)); + ui.add(egui::TextEdit::singleline(v).desired_width(160.0)).labelled_by(input_label.id); ui.end_row(); } ui.label(tl!("Projection")); @@ -332,8 +332,8 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { }); } "bookmark" => { - ui.label(tl!("Name")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)); + let input_label = ui.label(tl!("Name")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)).labelled_by(input_label.id); // What the place is (#351); Enter creates the bookmark, Shift+Enter starts a line. ui.label(tl!("Description")); let hint = tl!("Optional. Shift+Enter starts a new line."); @@ -344,15 +344,15 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { } "addField" => { egui::Grid::new("af").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label(tl!("Field name")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).desired_width(220.0)); + let input_label = ui.label(tl!("Field name")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).desired_width(220.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Data type")); + let input_label = ui.label(tl!("Data type")); egui::ComboBox::from_id_salt("af_type").selected_text(app.dialogs.b.clone()).show_ui(ui, |ui| { for ty in ["Long", "Double", "Text", "Boolean"] { ui.selectable_value(&mut app.dialogs.b, ty.to_string(), ty); } - }); + }).response.labelled_by(input_label.id); ui.end_row(); }); close = buttons(app, ui, "Add", |app| app.run("edit.addField", json!({"name": app.dialogs.a, "type": app.dialogs.b})).map(|_| ())); @@ -360,15 +360,15 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "calculateField" => { let fields: Vec = app.session.active_layer.and_then(|id| app.session.map().layer(id)).map(|l| l.dataset.fields.iter().map(|f| f.name.clone()).collect()).unwrap_or_default(); ui.horizontal(|ui| { - ui.label(tl!("Field")); + let input_label = ui.label(tl!("Field")); egui::ComboBox::from_id_salt("cf_field").selected_text(app.dialogs.a.clone()).width(220.0).show_ui(ui, |ui| { for f in &fields { ui.selectable_value(&mut app.dialogs.a, f.clone(), f); } - }); + }).response.labelled_by(input_label.id); }); - ui.label(format!("{} =", app.dialogs.a)); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text(tl!("e.g. pop_est / 1000000 or 'Africa' or name")).desired_width(f32::INFINITY)); + let input_label = ui.label(format!("{} =", app.dialogs.a)); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text(tl!("e.g. pop_est / 1000000 or 'Africa' or name")).desired_width(f32::INFINITY)).labelled_by(input_label.id); ui.label(egui::RichText::new(tl!("A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected.")).small().weak()); close = buttons(app, ui, "Calculate", |app| app.run("edit.calculateField", json!({"field": app.dialogs.a, "expression": app.dialogs.text})).map(|_| ())); } @@ -383,8 +383,8 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { "saveProject" => close = save_project(app, ui), // `a` is the map or layout being renamed; an empty or unchanged name just closes. "renameMap" | "renameLayout" => { - ui.label(tl!("Name")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)); + let input_label = ui.label(tl!("Name")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)).labelled_by(input_label.id); close = buttons(app, ui, "Rename", |app| { let (old, new) = (app.dialogs.a.clone(), app.dialogs.text.trim().to_string()); if new.is_empty() || new == old { @@ -399,24 +399,24 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { }); } "renameServiceGroup" => { - ui.label(tl!("Name")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)); + let input_label = ui.label(tl!("Name")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)).labelled_by(input_label.id); close = buttons(app, ui, "Rename", |app| { let group = app.dialogs.layer.parse::().map_err(|e| e.to_string())?; app.run("service.group", json!({"group":group,"name":app.dialogs.text})).map(|_| ()) }); } "renameGroup" => { - ui.label(tl!("Name")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)); + let input_label = ui.label(tl!("Name")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)).labelled_by(input_label.id); close = buttons(app, ui, "Rename", |app| { let group = app.dialogs.layer.parse::().map_err(|e| e.to_string())?; app.run("group.set", json!({"group": group, "name": app.dialogs.text})).map(|_| ()) }); } _ => { - ui.label(tl!("Name")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)); + let input_label = ui.label(tl!("Name")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).desired_width(f32::INFINITY)).labelled_by(input_label.id); close = buttons(app, ui, "Rename", |app| app.run("layer.rename", json!({"name": app.dialogs.text})).map(|_| ())); } } @@ -473,14 +473,19 @@ fn export_layer(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ui.label(tl!("Layer")); ui.label(&app.dialogs.layer); ui.end_row(); - ui.label(tl!("Format")); + let input_label = ui.label(tl!("Format")); let shown = EXPORT_FORMATS.iter().find(|(_, e)| *e == app.dialogs.mode).map(|(l, _)| *l).unwrap_or("GeoJSON"); - egui::ComboBox::from_id_salt("export_format").selected_text(shown).width(220.0).show_ui(ui, |ui| { - // GeoPackages are written with SQLite, which the web app doesn't have. - for (label, ext) in EXPORT_FORMATS.iter().filter(|(_, e)| !(web && *e == "gpkg")) { - ui.selectable_value(&mut app.dialogs.mode, *ext, *label); - } - }); + egui::ComboBox::from_id_salt("export_format") + .selected_text(shown) + .width(220.0) + .show_ui(ui, |ui| { + // GeoPackages are written with SQLite, which the web app doesn't have. + for (label, ext) in EXPORT_FORMATS.iter().filter(|(_, e)| !(web && *e == "gpkg")) { + ui.selectable_value(&mut app.dialogs.mode, *ext, *label); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); }); if app.dialogs.mode == "shp" && web { @@ -565,7 +570,7 @@ fn add_csv(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { egui::Grid::new("csv").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { let columns = app.dialogs.list.clone(); for (i, label) in ["X column", "Y column", "WKT column"].into_iter().enumerate() { - ui.label(tl!(label)); + let input_label = ui.label(tl!(label)); let d = &mut app.dialogs; let v = match i { 0 => &mut d.a, @@ -574,12 +579,17 @@ fn add_csv(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { }; let before = v.clone(); let selected = if v.is_empty() { tl!("None").to_string() } else { v.clone() }; - egui::ComboBox::from_id_salt(("csv_column", i)).selected_text(selected).width(240.0).show_ui(ui, |ui| { - ui.selectable_value(v, String::new(), tl!("None")); - for c in &columns { - ui.selectable_value(v, c.clone(), c.as_str()); - } - }); + egui::ComboBox::from_id_salt(("csv_column", i)) + .selected_text(selected) + .width(240.0) + .show_ui(ui, |ui| { + ui.selectable_value(v, String::new(), tl!("None")); + for c in &columns { + ui.selectable_value(v, c.clone(), c.as_str()); + } + }) + .response + .labelled_by(input_label.id); // x/y columns or a WKT column, not both. if *v != before && !v.is_empty() { if i == 2 { @@ -590,19 +600,24 @@ fn add_csv(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { } ui.end_row(); } - ui.label(tl!("Coordinate system")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:4326").desired_width(240.0)); + let input_label = ui.label(tl!("Coordinate system")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("EPSG:4326").desired_width(240.0)).labelled_by(input_label.id); ui.end_row(); // A sheet has no separator. if !sheet { - ui.label(tl!("Separator")); + let input_label = ui.label(tl!("Separator")); let before = app.dialogs.mode; let shown = SEPARATORS.iter().find(|(c, _)| *c == app.dialogs.mode).map(|(_, l)| tl!(*l).to_string()).unwrap_or_default(); - egui::ComboBox::from_id_salt("csv_sep").selected_text(shown).width(240.0).show_ui(ui, |ui| { - for (code, label) in SEPARATORS { - ui.selectable_value(&mut app.dialogs.mode, code, tl!(label)); - } - }); + egui::ComboBox::from_id_salt("csv_sep") + .selected_text(shown) + .width(240.0) + .show_ui(ui, |ui| { + for (code, label) in SEPARATORS { + ui.selectable_value(&mut app.dialogs.mode, code, tl!(label)); + } + }) + .response + .labelled_by(input_label.id); reread = app.dialogs.mode != before; ui.end_row(); } @@ -684,13 +699,18 @@ fn add_service(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { return false; } let kinds = |app: &mut RustGisApp, ui: &mut egui::Ui| { - ui.label(tl!("Type")); + let input_label = ui.label(tl!("Type")); let shown = KINDS.iter().find(|(c, _)| *c == app.dialogs.mode).map(|(_, l)| tl!(*l).to_string()).unwrap_or_default(); - egui::ComboBox::from_id_salt("service_kind").selected_text(shown).width(240.0).show_ui(ui, |ui| { - for (code, label) in KINDS { - ui.selectable_value(&mut app.dialogs.mode, code, tl!(label)); - } - }); + egui::ComboBox::from_id_salt("service_kind") + .selected_text(shown) + .width(240.0) + .show_ui(ui, |ui| { + for (code, label) in KINDS { + ui.selectable_value(&mut app.dialogs.mode, code, tl!(label)); + } + }) + .response + .labelled_by(input_label.id); }; // A catalogue: searched for records, whose links connect here or download. if app.dialogs.mode == "csw" { @@ -701,8 +721,10 @@ fn add_service(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { ui.label(egui::RichText::new(tl!("A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse).")).weak()); let mut connect = false; egui::Grid::new("service").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label(tl!("Address")); - let r = ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("https://…/wms").desired_width(420.0)); + let input_label = ui.label(tl!("Address")); + let r = ui + .add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("https://…/wms").desired_width(420.0)) + .labelled_by(input_label.id); connect |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.end_row(); kinds(app, ui); @@ -780,16 +802,18 @@ fn add_service(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { back = true; } if layers.len() > 12 { - ui.label(tl!("Filter")); - ui.add(egui::TextEdit::singleline(&mut browse.filter).hint_text(tl!("words in the name")).desired_width(260.0)); + let input_label = ui.label(tl!("Filter")); + ui.add(egui::TextEdit::singleline(&mut browse.filter).hint_text(tl!("words in the name")).desired_width(260.0)) + .labelled_by(input_label.id); } }); } // Layers that change with time (NASA GIBS's) are added at a date. if layers.iter().any(|l| l.get("time").is_some()) { ui.horizontal(|ui| { - ui.label(tl!("Date")); - ui.add(egui::TextEdit::singleline(&mut browse.time).hint_text(tl!("latest")).desired_width(120.0)); + let input_label = ui.label(tl!("Date")); + ui.add(egui::TextEdit::singleline(&mut browse.time).hint_text(tl!("latest")).desired_width(120.0)) + .labelled_by(input_label.id); ui.label(egui::RichText::new(tl!("YYYY-MM-DD, for the layers that change with time")).small().weak()); }); } @@ -917,11 +941,13 @@ fn add_url(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { }; ui.label(egui::RichText::new(hint).weak()); egui::Grid::new("url").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label(tl!("Address")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("https://…/data.geojson").desired_width(400.0)); + let input_label = ui.label(tl!("Address")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text("https://…/data.geojson").desired_width(400.0)) + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Name")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text(tl!("from the address")).desired_width(400.0)); + let input_label = ui.label(tl!("Name")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text(tl!("from the address")).desired_width(400.0)) + .labelled_by(input_label.id); ui.end_row(); }); buttons(app, ui, "Add", |app| { @@ -958,31 +984,39 @@ fn add_database(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { pick = crate::connectionui::picker_combo(ui, "database_saved", &connections, kinds, current); ui.end_row(); } - ui.label(tl!("Connection")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text("host=localhost dbname=gis user=postgres").desired_width(280.0)); + let input_label = ui.label(tl!("Connection")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text("host=localhost dbname=gis user=postgres").desired_width(280.0)) + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Password")); + let input_label = ui.label(tl!("Password")); ui.add( egui::TextEdit::singleline(&mut app.dialogs.b) .password(true) .hint_text(tl!("none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD")) .desired_width(280.0), - ); + ) + .labelled_by(input_label.id); ui.end_row(); ui.label(tl!("Table")); ui.horizontal(|ui| { let shown = if app.dialogs.c.is_empty() { tl!("All tables with geometry").to_string() } else { app.dialogs.c.clone() }; - egui::ComboBox::from_id_salt("db_table").selected_text(shown).width(190.0).show_ui(ui, |ui| { - ui.selectable_value(&mut app.dialogs.c, String::new(), tl!("All tables with geometry")); - for t in app.dialogs.list.clone() { - ui.selectable_value(&mut app.dialogs.c, t.clone(), t); - } - }); + egui::ComboBox::from_id_salt("db_table") + .selected_text(shown) + .width(190.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut app.dialogs.c, String::new(), tl!("All tables with geometry")); + for t in app.dialogs.list.clone() { + ui.selectable_value(&mut app.dialogs.c, t.clone(), t); + } + }) + .response + .on_hover_text(tl!("Table")); list = ui.button(tl!("List Tables")).clicked(); }); ui.end_row(); - ui.label(tl!("Coordinate system")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text(tl!("of a geometry that doesn't say")).desired_width(280.0)); + let input_label = ui.label(tl!("Coordinate system")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.text).hint_text(tl!("of a geometry that doesn't say")).desired_width(280.0)) + .labelled_by(input_label.id); ui.end_row(); }); // The tables listed and the one chosen belong to the address they were listed for. @@ -1069,27 +1103,35 @@ fn add_tiles(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { } }); ui.end_row(); - ui.label(tl!("Type")); + let input_label = ui.label(tl!("Type")); let guessed = TileKind::guess(app.dialogs.text.trim()); let shown = match app.dialogs.mode { "auto" | "new" => crate::i18n::fmt("Automatic ({kind})", &[("kind", tl_raw!(guessed.label()))]), code => TileKind::from_code(code).map(|k| tl!(k.label()).to_string()).unwrap_or_default(), }; - egui::ComboBox::from_id_salt("tiles_kind").selected_text(shown).width(260.0).show_ui(ui, |ui| { - ui.selectable_value(&mut app.dialogs.mode, "auto", tl!("Automatic")); - for k in TileKind::ALL { - ui.selectable_value(&mut app.dialogs.mode, k.code(), tl!(k.label())); - } - }); + egui::ComboBox::from_id_salt("tiles_kind") + .selected_text(shown) + .width(260.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut app.dialogs.mode, "auto", tl!("Automatic")); + for k in TileKind::ALL { + ui.selectable_value(&mut app.dialogs.mode, k.code(), tl!(k.label())); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Name")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text(tl!("from the address")).desired_width(400.0)); + let input_label = ui.label(tl!("Name")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text(tl!("from the address")).desired_width(400.0)) + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Attribution")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.b).hint_text("© OpenStreetMap contributors").desired_width(400.0)); + let input_label = ui.label(tl!("Attribution")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.b).hint_text("© OpenStreetMap contributors").desired_width(400.0)) + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Maximum zoom")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.c).hint_text(tl!("from the service")).desired_width(60.0)); + let input_label = ui.label(tl!("Maximum zoom")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.c).hint_text(tl!("from the service")).desired_width(60.0)) + .labelled_by(input_label.id); ui.end_row(); }); ui.checkbox(&mut app.dialogs.flag, tl!("Overlay (a transparent layer kept on top, such as labels or roads)")); @@ -1169,14 +1211,19 @@ fn field_chips(app: &mut RustGisApp, ui: &mut egui::Ui) { fn select_by_attributes(app: &mut RustGisApp, ui: &mut egui::Ui, t: &Tokens) -> bool { let layers: Vec = app.session.map().layers.iter().map(|l| l.name.clone()).collect(); egui::Grid::new("sba").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label(tl!("Input Rows")); - egui::ComboBox::from_id_salt("sba_layer").selected_text(app.dialogs.layer.clone()).width(260.0).show_ui(ui, |ui| { - for l in &layers { - ui.selectable_value(&mut app.dialogs.layer, l.clone(), l); - } - }); + let input_label = ui.label(tl!("Input Rows")); + egui::ComboBox::from_id_salt("sba_layer") + .selected_text(app.dialogs.layer.clone()) + .width(260.0) + .show_ui(ui, |ui| { + for l in &layers { + ui.selectable_value(&mut app.dialogs.layer, l.clone(), l); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Selection Type")); + let input_label = ui.label(tl!("Selection Type")); egui::ComboBox::from_id_salt("sba_mode") .selected_text(match app.dialogs.mode { "add" => tl!("Add To Current Selection"), @@ -1194,7 +1241,9 @@ fn select_by_attributes(app: &mut RustGisApp, ui: &mut egui::Ui, t: &Tokens) -> ] { ui.selectable_value(&mut app.dialogs.mode, m, l); } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); }); ui.label(egui::RichText::new(tl!("Where")).font(semibold(12.5))); @@ -1280,8 +1329,10 @@ fn coordinate_system(app: &mut RustGisApp, ui: &mut egui::Ui, t: &Tokens) -> boo }); ui.separator(); ui.horizontal(|ui| { - ui.label(tl!("EPSG or ESRI code")); - let r = ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text("3035, vn2000tm3").desired_width(120.0)); + let input_label = ui.label(tl!("EPSG or ESRI code")); + let r = ui + .add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text("3035, vn2000tm3").desired_width(120.0)) + .labelled_by(input_label.id); if r.changed() && !app.dialogs.a.trim().is_empty() { app.dialogs.text = typed_crs(&app.dialogs.a); } @@ -1357,9 +1408,11 @@ fn preset_fields(app: &mut RustGisApp, ui: &mut egui::Ui) { "lat_1" => tl!("First standard parallel"), _ => tl!("Second standard parallel"), }; - ui.label(label); + let input_label = ui.label(label); let mut v = params.iter().find(|(k, _)| *k == p.key).map(|(_, v)| *v).or_else(|| preset_value(id, p.key)).unwrap_or(0.0); - let r = ui.add(egui::DragValue::new(&mut v).range(p.min..=p.max).speed(0.5).max_decimals(4).suffix("°")); + let r = ui + .add(egui::DragValue::new(&mut v).range(p.min..=p.max).speed(0.5).max_decimals(4).suffix("°")) + .labelled_by(input_label.id); r.widget_info(|| egui::WidgetInfo { label: Some(label.to_string()), ..egui::WidgetInfo::drag_value(true, v) }); if r.changed() { next.retain(|(k, _)| *k != p.key); @@ -1511,7 +1564,7 @@ mod tests { app.run("map.projection", json!({"type": "robinson"})).unwrap(); app.run("ui.dialog", json!({"name": "coordinateSystem"})).unwrap(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); diff --git a/crates/ui-egui/src/docpanes.rs b/crates/ui-egui/src/docpanes.rs index 671fc970e..f9712cf5d 100644 --- a/crates/ui-egui/src/docpanes.rs +++ b/crates/ui-egui/src/docpanes.rs @@ -79,7 +79,9 @@ pub fn presentation(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { app.docs.page = 0; } } - }); + }) + .response + .on_hover_text(tl!("Presentation")); if ui.small_button(tl!("Delete")).clicked() { run(app, "presentation.remove", json!({"presentation": i})); app.docs.page = 0; @@ -152,8 +154,9 @@ pub fn presentation(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }; ui.horizontal(|ui| { - ui.label(tl!("Title")); - let resp = ui.add(egui::TextEdit::singleline(&mut app.docs.title).desired_width(ui.available_width())); + let input_label = ui.label(tl!("Title")); + let resp = + ui.add(egui::TextEdit::singleline(&mut app.docs.title).desired_width(ui.available_width())).labelled_by(input_label.id); let v = app.docs.title.clone(); if let Some(fresh) = field(app, resp, "title", v, &page.title) { app.docs.title = fresh; @@ -177,8 +180,8 @@ pub fn presentation(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { app.docs.notes = fresh; } ui.horizontal(|ui| { - ui.label(tl!("Seconds")); - let resp = ui.add(egui::DragValue::new(&mut app.docs.seconds).range(0.5..=3600.0).speed(0.5)); + let input_label = ui.label(tl!("Seconds")); + let resp = ui.add(egui::DragValue::new(&mut app.docs.seconds).range(0.5..=3600.0).speed(0.5)).labelled_by(input_label.id); if (resp.drag_stopped() || resp.lost_focus()) && app.docs.seconds != page.seconds { let secs = app.docs.seconds; run(app, "presentation.updatePage", json!({"presentation": i, "page": k, "seconds": secs})); @@ -302,7 +305,9 @@ pub fn notebook(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let _ = app.run("notebook.activate", json!({"notebook": j})); } } - }); + }) + .response + .on_hover_text(tl!("Notebook")); if ui.small_button(tl!("Run All")).clicked() { run(app, "notebook.run", json!({"notebook": i})); app.map.invalidate(); diff --git a/crates/ui-egui/src/earthaccessui.rs b/crates/ui-egui/src/earthaccessui.rs index ce6acad7a..e82ef5604 100644 --- a/crates/ui-egui/src/earthaccessui.rs +++ b/crates/ui-egui/src/earthaccessui.rs @@ -320,13 +320,18 @@ pub fn pane(app: &mut RustGisApp, ui: &mut egui::Ui) { change = ui.add_enabled(!busy, egui::Button::new(tl!("Change dataset"))).clicked(); } else { let st = &mut app.ui.earthaccess; - egui::ComboBox::from_id_salt("ea_preset").selected_text(tl!("Popular datasets…")).width(w - 8.0).show_ui(ui, |ui| { - for (short, label) in earthdata::PRESETS { - if ui.selectable_label(false, format!("{label} ({short})")).clicked() { - preset = Some((*short).to_string()); + egui::ComboBox::from_id_salt("ea_preset") + .selected_text(tl!("Popular datasets…")) + .width(w - 8.0) + .show_ui(ui, |ui| { + for (short, label) in earthdata::PRESETS { + if ui.selectable_label(false, format!("{label} ({short})")).clicked() { + preset = Some((*short).to_string()); + } } - } - }); + }) + .response + .on_hover_text(tl!("Dataset")); ui.horizontal(|ui| { let r = ui.add( egui::TextEdit::singleline(&mut st.keyword).hint_text(tl!("Search datasets, e.g. GEDI biomass")).desired_width(w - 80.0), @@ -382,11 +387,11 @@ pub fn pane(app: &mut RustGisApp, ui: &mut egui::Ui) { let mut search = false; egui::Grid::new("ea_dates").num_columns(2).spacing(egui::vec2(10.0, 6.0)).show(ui, |ui| { let st = &mut app.ui.earthaccess; - ui.label(tl!("Start date")); - ui.add(egui::TextEdit::singleline(&mut st.start).hint_text("YYYY-MM-DD").desired_width(120.0)); + let input_label = ui.label(tl!("Start date")); + ui.add(egui::TextEdit::singleline(&mut st.start).hint_text("YYYY-MM-DD").desired_width(120.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("End date")); - ui.add(egui::TextEdit::singleline(&mut st.end).hint_text("YYYY-MM-DD").desired_width(120.0)); + let input_label = ui.label(tl!("End date")); + ui.add(egui::TextEdit::singleline(&mut st.end).hint_text("YYYY-MM-DD").desired_width(120.0)).labelled_by(input_label.id); ui.end_row(); }); let label = if busy && app.ui.earthaccess.granules.is_empty() { tl!("Searching…") } else { tl!("Search this view") }; diff --git a/crates/ui-egui/src/editpanes.rs b/crates/ui-egui/src/editpanes.rs index fa5b9dd8a..8e5d86d8d 100644 --- a/crates/ui-egui/src/editpanes.rs +++ b/crates/ui-egui/src/editpanes.rs @@ -94,12 +94,17 @@ fn layer_names(app: &RustGisApp, pred: impl Fn(&rustgis_engine::map::Layer) -> b app.session.map().layers.iter().filter(|l| pred(l)).map(|l| l.name.clone()).collect() } -fn combo(ui: &mut Ui, id: &str, value: &mut String, options: &[String]) { - egui::ComboBox::from_id_salt(id).selected_text(value.as_str()).width(170.0).show_ui(ui, |ui| { - for o in options { - ui.selectable_value(value, o.clone(), o.as_str()); - } - }); +fn combo(ui: &mut Ui, id: &str, name: &str, value: &mut String, options: &[String]) { + egui::ComboBox::from_id_salt(id) + .selected_text(value.as_str()) + .width(170.0) + .show_ui(ui, |ui| { + for o in options { + ui.selectable_value(value, o.clone(), o.as_str()); + } + }) + .response + .on_hover_text(tl!(name)); } pub fn editing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { @@ -155,8 +160,8 @@ pub fn editing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } } ui.end_row(); - ui.label(tl!("Rotate (°)")); - ui.add(egui::TextEdit::singleline(&mut app.forms.angle).hint_text("90").desired_width(124.0)); + let input_label = ui.label(tl!("Rotate (°)")); + ui.add(egui::TextEdit::singleline(&mut app.forms.angle).hint_text("90").desired_width(124.0)).labelled_by(input_label.id); if ui.add_enabled(!only_texts, egui::Button::new(tl!("Rotate"))).on_disabled_hover_text(not_for_texts).clicked() { let t = app.forms.angle.clone(); if let Some(a) = number(app, &t, "Angle") { @@ -164,8 +169,8 @@ pub fn editing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } } ui.end_row(); - ui.label(tl!("Scale (×)")); - ui.add(egui::TextEdit::singleline(&mut app.forms.factor).hint_text("2").desired_width(124.0)); + let input_label = ui.label(tl!("Scale (×)")); + ui.add(egui::TextEdit::singleline(&mut app.forms.factor).hint_text("2").desired_width(124.0)).labelled_by(input_label.id); if ui.add_enabled(!only_texts, egui::Button::new(tl!("Scale"))).on_disabled_hover_text(not_for_texts).clicked() { let t = app.forms.factor.clone(); if let Some(k) = number(app, &t, "Scale") { @@ -318,10 +323,10 @@ pub fn editing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if app.forms.rule_kind.is_empty() { app.forms.rule_kind = "mustNotOverlap".into(); } - combo(ui, "topo_kind", &mut app.forms.rule_kind, &kinds); - combo(ui, "topo_layer", &mut app.forms.rule_layer, &feature_layers); + combo(ui, "topo_kind", "Rule", &mut app.forms.rule_kind, &kinds); + combo(ui, "topo_layer", "Layer", &mut app.forms.rule_layer, &feature_layers); if app.forms.rule_kind == "mustBeCoveredBy" { - combo(ui, "topo_by", &mut app.forms.rule_by, &feature_layers); + combo(ui, "topo_by", "By layer", &mut app.forms.rule_by, &feature_layers); } if ui.button(tl!("Add Rule")).clicked() { let add = json!({"rule": app.forms.rule_kind, "layer": app.forms.rule_layer, "by": app.forms.rule_by}); @@ -399,7 +404,7 @@ pub fn design(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }); } ui.horizontal(|ui| { - combo(ui, "dom_field", &mut app.forms.field, &fields); + combo(ui, "dom_field", "Field", &mut app.forms.field, &fields); ui.add(egui::TextEdit::singleline(&mut app.forms.min).hint_text("min").desired_width(46.0)); ui.add(egui::TextEdit::singleline(&mut app.forms.max).hint_text("max").desired_width(46.0)); }); @@ -442,7 +447,7 @@ pub fn design(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let ints: Vec = l.dataset.fields.iter().filter(|f| f.ty == rustgis_engine::data::FieldType::Integer).map(|f| f.name.clone()).collect(); ui.horizontal(|ui| { - combo(ui, "sub_field", &mut app.forms.subtype_field, &ints); + combo(ui, "sub_field", "Field", &mut app.forms.subtype_field, &ints); if ui.button(tl!("Set Subtypes")).on_hover_text(tl!("One per line: code=name")).clicked() { let subs: Vec = app .forms @@ -478,7 +483,7 @@ pub fn design(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.add(egui::TextEdit::singleline(&mut app.forms.rule_name).hint_text(tl!("Rule name")).desired_width(110.0)); ui.checkbox(&mut app.forms.calc, tl!("Calculation")); if app.forms.calc { - combo(ui, "rule_field", &mut app.forms.calc_field, &fields); + combo(ui, "rule_field", "Field", &mut app.forms.calc_field, &fields); } }); ui.horizontal(|ui| { @@ -572,11 +577,11 @@ pub fn design(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }; let (of, df) = (fields_of(app, &app.forms.rel_origin), fields_of(app, &app.forms.rel_dest)); egui::Grid::new("rel").num_columns(2).spacing(vec2(6.0, 4.0)).show(ui, |ui| { - combo(ui, "rel_o", &mut app.forms.rel_origin, &layers); - combo(ui, "rel_ok", &mut app.forms.rel_origin_key, &of); + combo(ui, "rel_o", "Origin", &mut app.forms.rel_origin, &layers); + combo(ui, "rel_ok", "Origin key", &mut app.forms.rel_origin_key, &of); ui.end_row(); - combo(ui, "rel_d", &mut app.forms.rel_dest, &layers); - combo(ui, "rel_dk", &mut app.forms.rel_dest_key, &df); + combo(ui, "rel_d", "Destination", &mut app.forms.rel_dest, &layers); + combo(ui, "rel_dk", "Destination key", &mut app.forms.rel_dest_key, &df); ui.end_row(); }); ui.add(egui::TextEdit::singleline(&mut app.forms.rel_name).hint_text(tl!("Relationship name")).desired_width(180.0)); diff --git a/crates/ui-egui/src/embeddingsui.rs b/crates/ui-egui/src/embeddingsui.rs index 90f2b8c3c..d644e3a2e 100644 --- a/crates/ui-egui/src/embeddingsui.rs +++ b/crates/ui-egui/src/embeddingsui.rs @@ -357,9 +357,9 @@ fn restyle(app: &mut RustGisApp) { fn band_row(ui: &mut egui::Ui, bands: &mut [usize; 3], max: usize) { ui.horizontal(|ui| { for (b, label) in bands.iter_mut().zip(["R", "G", "B"]) { - ui.label(label); + let input_label = ui.label(label); let mut v = *b + 1; - if ui.add(egui::DragValue::new(&mut v).range(1..=max).speed(0.2)).changed() { + if ui.add(egui::DragValue::new(&mut v).range(1..=max).speed(0.2)).labelled_by(input_label.id).changed() { *b = v.clamp(1, max) - 1; } } @@ -380,28 +380,38 @@ pub fn pane(app: &mut RustGisApp, ui: &mut egui::Ui) { let mut go = false; egui::Grid::new("embeddings").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { let st = &mut app.ui.embeddings; - ui.label(tl!("Dataset")); + let input_label = ui.label(tl!("Dataset")); let shown = picked(st).name.split(" (").next().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt("emb_dataset").selected_text(shown).width(w).show_ui(ui, |ui| { - for d in emb::DATASETS.iter() { - let label = d.name.split(" (").next().unwrap_or(d.name); - if ui.selectable_label(st.dataset == d.id, label).clicked() { - st.select(d); + egui::ComboBox::from_id_salt("emb_dataset") + .selected_text(shown) + .width(w) + .show_ui(ui, |ui| { + for d in emb::DATASETS.iter() { + let label = d.name.split(" (").next().unwrap_or(d.name); + if ui.selectable_label(st.dataset == d.id, label).clicked() { + st.select(d); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); let d = picked(st); if d.search && let Some((first, last)) = d.years && last > first { - ui.label(tl!("Year")); - egui::ComboBox::from_id_salt("emb_year").selected_text(st.year.to_string()).width(w).show_ui(ui, |ui| { - for y in (first..=last).rev() { - ui.selectable_value(&mut st.year, y, y.to_string()); - } - }); + let input_label = ui.label(tl!("Year")); + egui::ComboBox::from_id_salt("emb_year") + .selected_text(st.year.to_string()) + .width(w) + .show_ui(ui, |ui| { + for y in (first..=last).rev() { + ui.selectable_value(&mut st.year, y, y.to_string()); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); } match d.id { @@ -409,8 +419,9 @@ pub fn pane(app: &mut RustGisApp, ui: &mut egui::Ui) { ui.label(tl!("Bands")); band_row(ui, &mut st.aef_bands, 64); ui.end_row(); - ui.label(tl!("Stretch")); + let input_label = ui.label(tl!("Stretch")); ui.add(egui::Slider::new(&mut st.stretch, 0.05..=1.0).step_by(0.05).text(tl!("±, dequantised"))) + .labelled_by(input_label.id) .on_hover_text(tl!("Values from −stretch to +stretch are shown from black to full colour")); ui.end_row(); } diff --git a/crates/ui-egui/src/geocodeui.rs b/crates/ui-egui/src/geocodeui.rs index c2cfe1d48..e21b8d87b 100644 --- a/crates/ui-egui/src/geocodeui.rs +++ b/crates/ui-egui/src/geocodeui.rs @@ -190,17 +190,20 @@ fn provider(app: &mut RustGisApp, ui: &mut egui::Ui) { app.dialogs.mode = "nominatim"; } egui::Grid::new("geocoder").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label(tl!("Geocoder")); + let input_label = ui.label(tl!("Geocoder")); egui::ComboBox::from_id_salt("geocoder_kind") .selected_text(if app.dialogs.mode == "photon" { "Photon" } else { "Nominatim" }) .width(200.0) .show_ui(ui, |ui| { ui.selectable_value(&mut app.dialogs.mode, "nominatim", "Nominatim"); ui.selectable_value(&mut app.dialogs.mode, "photon", "Photon"); - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Server")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.b).hint_text(tl!("OpenStreetMap's public server")).desired_width(280.0)); + let input_label = ui.label(tl!("Server")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.b).hint_text(tl!("OpenStreetMap's public server")).desired_width(280.0)) + .labelled_by(input_label.id); ui.end_row(); }); } @@ -225,22 +228,28 @@ pub fn geocode(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool { None => app.dialogs.layer.clone(), }; egui::Grid::new("geocode").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label(tl!("Table or layer")); - egui::ComboBox::from_id_salt("geocode_source").selected_text(shown).width(280.0).show_ui(ui, |ui| { - for (id, n) in &tables { - if ui.selectable_label(table == Some(*id), n).clicked() { - (app.dialogs.layer, app.dialogs.flag) = (id.to_string(), true); + let input_label = ui.label(tl!("Table or layer")); + egui::ComboBox::from_id_salt("geocode_source") + .selected_text(shown) + .width(280.0) + .show_ui(ui, |ui| { + for (id, n) in &tables { + if ui.selectable_label(table == Some(*id), n).clicked() { + (app.dialogs.layer, app.dialogs.flag) = (id.to_string(), true); + } } - } - for n in &layers { - if ui.selectable_label(table.is_none() && app.dialogs.layer == *n, n).clicked() { - (app.dialogs.layer, app.dialogs.flag) = (n.clone(), false); + for n in &layers { + if ui.selectable_label(table.is_none() && app.dialogs.layer == *n, n).clicked() { + (app.dialogs.layer, app.dialogs.flag) = (n.clone(), false); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Address fields")); - ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text(tl!("street, city, country")).desired_width(280.0)); + let input_label = ui.label(tl!("Address fields")); + ui.add(egui::TextEdit::singleline(&mut app.dialogs.a).hint_text(tl!("street, city, country")).desired_width(280.0)) + .labelled_by(input_label.id); ui.end_row(); }); provider(app, ui); diff --git a/crates/ui-egui/src/geometrycalc.rs b/crates/ui-egui/src/geometrycalc.rs index 5d80fa8f2..0651458bd 100644 --- a/crates/ui-egui/src/geometrycalc.rs +++ b/crates/ui-egui/src/geometrycalc.rs @@ -50,14 +50,19 @@ impl Form { } /// A combo box over `choices` (`(key, label)` pairs) editing the key `value`; true when changed. -fn choice(ui: &mut egui::Ui, id: &str, value: &mut String, choices: &[(&str, &str)], width: f32) -> bool { +fn choice(ui: &mut egui::Ui, id: &str, value: &mut String, choices: &[(&str, &str)], width: f32, label_id: egui::Id) -> bool { let mut changed = false; let label = choices.iter().find(|(k, _)| *k == value).map(|v| v.1).unwrap_or(""); - egui::ComboBox::from_id_salt(id).selected_text(tl!(label)).width(width).show_ui(ui, |ui| { - for (key, label) in choices { - changed |= ui.selectable_value(value, (*key).into(), tl!(*label)).changed(); - } - }); + egui::ComboBox::from_id_salt(id) + .selected_text(tl!(label)) + .width(width) + .show_ui(ui, |ui| { + for (key, label) in choices { + changed |= ui.selectable_value(value, (*key).into(), tl!(*label)).changed(); + } + }) + .response + .labelled_by(label_id); changed } @@ -96,14 +101,14 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { ui.label(&name); ui.end_row(); let previous = (f.property.clone(), f.unit.clone()); - ui.label(tl!("Property")); - if choice(ui, "geom_property", &mut f.property, props, control_width) { + let property_label = ui.label(tl!("Property")); + if choice(ui, "geom_property", &mut f.property, props, control_width, property_label.id) { f.unit = units(&f.property).first().map(|v| v.0).unwrap_or("m").into(); f.overwrite = false; } ui.end_row(); - ui.label(tl!("Unit")); - if choice(ui, "geom_unit", &mut f.unit, units(&f.property), control_width) { + let unit_label = ui.label(tl!("Unit")); + if choice(ui, "geom_unit", &mut f.unit, units(&f.property), control_width, unit_label.id) { f.overwrite = false; } ui.end_row(); @@ -117,8 +122,12 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { } } if f.unit == "projected" { - ui.label(tl!("EPSG code")); - if ui.add(egui::TextEdit::singleline(&mut f.epsg).desired_width(control_width)).changed() { + let input_label = ui.label(tl!("EPSG code")); + if ui + .add(egui::TextEdit::singleline(&mut f.epsg).desired_width(control_width)) + .labelled_by(input_label.id) + .changed() + { f.overwrite = false; } ui.end_row(); @@ -146,19 +155,24 @@ pub fn show(app: &mut RustGisApp, ctx: &egui::Context) { } }); ui.end_row(); - ui.label(tl!("Field")); + let input_label = ui.label(tl!("Field")); if f.create { f.field_edited |= ui .add(egui::TextEdit::singleline(&mut f.field).hint_text(tl!("Field name")).desired_width(control_width)) .changed(); } else { - egui::ComboBox::from_id_salt("geom_field").selected_text(&f.field).width(control_width).show_ui(ui, |ui| { - for (name, _) in &fields { - if ui.selectable_value(&mut f.field, name.clone(), name).changed() { - f.overwrite = false; + egui::ComboBox::from_id_salt("geom_field") + .selected_text(&f.field) + .width(control_width) + .show_ui(ui, |ui| { + for (name, _) in &fields { + if ui.selectable_value(&mut f.field, name.clone(), name).changed() { + f.overwrite = false; + } } - } - }); + }) + .response + .labelled_by(input_label.id); } ui.end_row(); ui.label(tl!("Features")); diff --git a/crates/ui-egui/src/graphicformat.rs b/crates/ui-egui/src/graphicformat.rs index 00e0257d3..647af2a8f 100644 --- a/crates/ui-egui/src/graphicformat.rs +++ b/crates/ui-egui/src/graphicformat.rs @@ -192,7 +192,7 @@ mod tests { fn harness(app: RustGisApp) -> egui_kittest::Harness<'static, RustGisApp> { let mut fonts = false; - egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(1000.0, 800.0)).build_ui_state( + egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index 53dbd33a5..ed88f82f5 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -98,15 +98,20 @@ fn class(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { /// The field the class labels with. fn field(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { grid(ui, "class", |ui| { - ui.label(tl!("Field")); + let input_label = ui.label(tl!("Field")); let cur = &l.labels.default.field; - egui::ComboBox::from_id_salt("label_pane_field").selected_text(cur.as_str()).width(180.0).show_ui(ui, |ui| { - for f in &l.dataset.fields { - if ui.selectable_label(f.name == *cur, &f.name).clicked() { - run(app, "labels.field", json!({"field": f.name})); + egui::ComboBox::from_id_salt("label_pane_field") + .selected_text(cur.as_str()) + .width(180.0) + .show_ui(ui, |ui| { + for f in &l.dataset.fields { + if ui.selectable_label(f.name == *cur, &f.name).clicked() { + run(app, "labels.field", json!({"field": f.name})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); }); } @@ -157,11 +162,11 @@ fn sql(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { fn text_symbol(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { section(ui, "Text Symbol", |ui| { grid(ui, "symbol", |ui| { - ui.label(tl!("Size")); + let input_label = ui.label(tl!("Size")); // The whole range `labels.style` accepts (#457); the map follows a drag and letting // go runs the command once, so a drag is one undo step. let mut size = f64::from(l.labels.default.size); - let r = ui.add(egui::DragValue::new(&mut size).range(LABEL_SIZE).speed(0.1).suffix(" pt")); + let r = ui.add(egui::DragValue::new(&mut size).range(LABEL_SIZE).speed(0.1).suffix(" pt")).labelled_by(input_label.id); let id = l.id; crate::ribbon::live_drag( app, @@ -224,7 +229,7 @@ fn visibility(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { section(ui, "Visibility Range", |ui| { grid(ui, "range", |ui| { for (label, cur, key) in [("Out Beyond", l.labels.default.min_scale, "min"), ("In Beyond", l.labels.default.max_scale, "max")] { - ui.label(tl!(label)); + let input_label = ui.label(tl!(label)); egui::ComboBox::from_id_salt(("label_pane_scale", key)) .selected_text(crate::ribbon::scale_label(cur)) .width(140.0) @@ -234,22 +239,29 @@ fn visibility(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { run(app, "labels.scaleRange", json!({key: c})); } } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); } for (label, cur, key) in [("Closest", l.labels.default.closest, "closest"), ("Farthest", l.labels.default.farthest, "farthest")] { - ui.label(tl!(label)); + let input_label = ui.label(tl!(label)); let imperial = app.ui.measurement_imperial; let text = |d: Option| d.map(|d| crate::measure_units::distance(d, imperial)).unwrap_or_else(|| tl!("").to_string()); - egui::ComboBox::from_id_salt(("label_pane_distance", key)).selected_text(text(cur)).width(140.0).show_ui(ui, |ui| { - for d in std::iter::once(None).chain(DISTANCES.iter().map(|d| Some(*d))) { - if ui.selectable_label(d == cur, text(d)).clicked() { - run(app, "labels.distanceRange", json!({key: d})); + egui::ComboBox::from_id_salt(("label_pane_distance", key)) + .selected_text(text(cur)) + .width(140.0) + .show_ui(ui, |ui| { + for d in std::iter::once(None).chain(DISTANCES.iter().map(|d| Some(*d))) { + if ui.selectable_label(d == cur, text(d)).clicked() { + run(app, "labels.distanceRange", json!({key: d})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); } }); @@ -379,7 +391,7 @@ mod tests { fn harness(app: RustGisApp) -> egui_kittest::Harness<'static, RustGisApp> { // The pane's fonts (its semibold heading) are installed on the first frame, as the app does. let mut fonts = false; - egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(360.0, 900.0)).build_ui_state( + egui_kittest::Harness::builder().with_size(egui::vec2(360.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -393,6 +405,24 @@ mod tests { ) } + /// Inputs remain reachable by their purpose, independently of the selected value (#790). + #[test] + fn inputs_are_reachable_by_their_row_labels() { + use egui::accesskit::Role; + let mut h = harness(with_cities()); + h.run(); + for label in ["Field", "Out Beyond", "In Beyond", "Closest", "Farthest"] { + assert!(h.query_by_role_and_label(Role::ComboBox, label).is_some(), "missing input: {label}"); + } + assert!(h.query_by_role_and_label(Role::SpinButton, "Size").is_some()); + h.get_by_role_and_label(Role::ComboBox, "Field").click(); + h.run(); + h.get_by_label("name").click(); + h.run(); + assert_eq!(h.state().session.map().layer_by_name("Cities").unwrap().labels.default.field, "name"); + assert!(h.query_by_role_and_label(Role::ComboBox, "Field").is_some()); + } + #[test] fn the_pane_command_needs_a_feature_layer() { let mut app = RustGisApp::with_sample(); @@ -476,7 +506,7 @@ mod tests { let color = |app: &RustGisApp, id| app.session.map().layer(id).unwrap().labels.default.color; let (cities_before, countries_before) = (color(&app, cities), color(&app, countries)); let steps = app.session.undo_depth(); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).build_ui_state( + let mut h = egui_kittest::Harness::builder().build_ui_state( |ui, (app, pick): &mut (RustGisApp, Option)| { if let Some(c) = pick.take() { let l = app.session.map().layer(app.session.active_layer.unwrap()).unwrap().clone(); @@ -530,7 +560,7 @@ mod tests { let before = app.session.map().layer(id).unwrap().labels.default.color; let steps = app.session.undo_depth(); // The picker's colour each frame, if it reports one, as `text_symbol` hands it on. - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).build_ui_state( + let mut h = egui_kittest::Harness::builder().build_ui_state( |ui, (app, pick): &mut (RustGisApp, Option)| { if let Some(c) = pick.take() { let l = app.session.map().layer(app.session.active_layer.unwrap()).unwrap().clone(); diff --git a/crates/ui-egui/src/layoutview.rs b/crates/ui-egui/src/layoutview.rs index acc0d6e8a..cab5167b1 100644 --- a/crates/ui-egui/src/layoutview.rs +++ b/crates/ui-egui/src/layoutview.rs @@ -1152,14 +1152,21 @@ pub(crate) fn save_drafts(app: &mut RustGisApp) { /// A text field whose typing is kept in the view's state while it has focus. Returns the new text /// when the field loses focus with a change. -fn text_field(app: &mut RustGisApp, ui: &mut Ui, key: (usize, u32, &'static str), current: &str, multiline: bool) -> Option { +fn text_field( + app: &mut RustGisApp, + ui: &mut Ui, + key: (usize, u32, &'static str), + current: &str, + multiline: bool, + label_id: egui::Id, +) -> Option { let mut buf = app.layout.typing.remove(&key).unwrap_or_else(|| current.to_string()); let edit = if multiline { egui::TextEdit::multiline(&mut buf).desired_rows(3).desired_width(240.0) } else { egui::TextEdit::singleline(&mut buf) }; - let r = ui.add(edit); + let r = ui.add(edit).labelled_by(label_id); if r.lost_focus() { return (buf != current).then_some(buf); } @@ -1179,28 +1186,34 @@ fn grid_properties(app: &mut RustGisApp, ui: &mut Ui, grid: Option<&Grid>, id: u // Each number kept across a drag until it is let go (#607). let key = |field: &str| egui::Id::unique(("grid_drag", id, field)); egui::Grid::new(("grid_props", id)).num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Interval")); + let input_label = ui.label(tl!("Interval")); let mut step = g.step; let drag = Dragged::lend(ui, key("step"), &mut step); - let r = ui.add( - egui::DragValue::new(&mut step) - .range(0.0..=90.0) - .speed(0.05) - .max_decimals(4) - .custom_formatter(|v, _| if v <= 0.0 { tl!("Auto").to_string() } else { format!("{v}°") }), - ); + let r = ui + .add( + egui::DragValue::new(&mut step) + .range(0.0..=90.0) + .speed(0.05) + .max_decimals(4) + .custom_formatter(|v, _| if v <= 0.0 { tl!("Auto").to_string() } else { format!("{v}°") }), + ) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut step) && (step - g.step).abs() > 1e-9 { set(app, json!({"step": step})); } ui.end_row(); - ui.label(tl!("Style")); - egui::ComboBox::from_id_salt(("grid_style", id)).selected_text(tl!(g.style.label())).show_ui(ui, |ui| { - for v in GridStyle::ALL { - if ui.selectable_label(g.style == v, tl!(v.label())).clicked() && g.style != v { - set(app, json!({"style": v.code()})); + let input_label = ui.label(tl!("Style")); + egui::ComboBox::from_id_salt(("grid_style", id)) + .selected_text(tl!(g.style.label())) + .show_ui(ui, |ui| { + for v in GridStyle::ALL { + if ui.selectable_label(g.style == v, tl!(v.label())).clicked() && g.style != v { + set(app, json!({"style": v.code()})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); ui.label(tl!("Color")); let mut c = crate::theme::c32(g.color); @@ -1208,27 +1221,31 @@ fn grid_properties(app: &mut RustGisApp, ui: &mut Ui, grid: Option<&Grid>, id: u set(app, json!({"color": crate::theme::rgba(c).hex()})); } ui.end_row(); - ui.label(tl!("Line width")); + let input_label = ui.label(tl!("Line width")); let mut w = g.width; let drag = Dragged::lend(ui, key("width"), &mut w); - let r = ui.add(egui::DragValue::new(&mut w).range(0.0..=MAX_GRID_WIDTH).speed(0.05).max_decimals(2).suffix(" pt")); + let r = ui + .add(egui::DragValue::new(&mut w).range(0.0..=MAX_GRID_WIDTH).speed(0.05).max_decimals(2).suffix(" pt")) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut w) && (w - g.width).abs() > 1e-9 { set(app, json!({"width": w})); } ui.end_row(); if g.style.ticks() { - ui.label(tl!("Tick length")); + let input_label = ui.label(tl!("Tick length")); let mut t = g.tick_length; let drag = Dragged::lend(ui, key("tickLength"), &mut t); - let r = ui.add(egui::DragValue::new(&mut t).range(0.0..=MAX_TICK_MM).speed(0.1).max_decimals(1).suffix(" mm")); + let r = ui + .add(egui::DragValue::new(&mut t).range(0.0..=MAX_TICK_MM).speed(0.1).max_decimals(1).suffix(" mm")) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut t) && (t - g.tick_length).abs() > 1e-9 { set(app, json!({"tickLength": t})); } ui.end_row(); } - ui.label(tl!("Labels")); + let input_label = ui.label(tl!("Labels")); let mut labels = g.labels; - if ui.checkbox(&mut labels, "").changed() { + if ui.checkbox(&mut labels, "").labelled_by(input_label.id).changed() { set(app, json!({"labels": labels})); } ui.end_row(); @@ -1244,9 +1261,10 @@ fn grid_properties(app: &mut RustGisApp, ui: &mut Ui, grid: Option<&Grid>, id: u } }); ui.end_row(); - ui.label(tl!("N/S/E/W")); + let input_label = ui.label(tl!("N/S/E/W")); let mut hemi = g.hemisphere; - if ui.checkbox(&mut hemi, "").on_hover_text(tl!("Off, west and south are negative: -112.5°.")).changed() { + if ui.checkbox(&mut hemi, "").labelled_by(input_label.id).on_hover_text(tl!("Off, west and south are negative: -112.5°.")).changed() + { set(app, json!({"hemisphere": hemi})); } ui.end_row(); @@ -1331,24 +1349,24 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { egui::Grid::new(("layout_props", id)).num_columns(2).min_col_width(crate::textform::LABEL_W).spacing(vec2(8.0, 6.0)).show( ui, |ui| { - ui.label(tl!("Name")); - if let Some(name) = text_field(app, ui, (li, id, "name"), &e.name, false) { + let input_label = ui.label(tl!("Name")); + if let Some(name) = text_field(app, ui, (li, id, "name"), &e.name, false, input_label.id) { update(app, json!({"name": name})); } ui.end_row(); for (label, key, v) in [("X (mm)", "x", e.x), ("Y (mm)", "y", e.y), ("Width (mm)", "w", e.w), ("Height (mm)", "h", e.h)] { - ui.label(tl!(label)); + let input_label = ui.label(tl!(label)); let mut val = v; let drag = Dragged::lend(ui, drag_key(key), &mut val); - let r = ui.add(egui::DragValue::new(&mut val).speed(0.5).max_decimals(1)); + let r = ui.add(egui::DragValue::new(&mut val).speed(0.5).max_decimals(1)).labelled_by(input_label.id); if drag.settled(ui, &r, &mut val) && (val - v).abs() > 1e-6 { update(app, json!({key: val})); } ui.end_row(); } - ui.label(tl!("Visible")); + let input_label = ui.label(tl!("Visible")); let mut vis = e.visible; - if ui.checkbox(&mut vis, "").changed() { + if ui.checkbox(&mut vis, "").labelled_by(input_label.id).changed() { update(app, json!({"visible": vis})); } ui.end_row(); @@ -1356,33 +1374,33 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { // Its text, size, colour and boldness: the shared form, below the grid. ElementKind::Text { .. } => {} ElementKind::Legend { title, all_classes, hide_other, .. } => { - ui.label(tl!("Title")); - if let Some(tt) = text_field(app, ui, (li, id, "title"), title, false) { + let input_label = ui.label(tl!("Title")); + if let Some(tt) = text_field(app, ui, (li, id, "title"), title, false, input_label.id) { update(app, json!({"title": tt})); } ui.end_row(); // Off, the legend lists only what the frame draws (#207). - ui.label(tl!("All classes")); + let input_label = ui.label(tl!("All classes")); let mut all = *all_classes; - if ui.checkbox(&mut all, "").changed() { + if ui.checkbox(&mut all, "").labelled_by(input_label.id).changed() { update(app, json!({"allClasses": all})); } ui.end_row(); - ui.label(tl!("Hide other values")); + let input_label = ui.label(tl!("Hide other values")); let mut hide = *hide_other; - if ui.checkbox(&mut hide, "").changed() { + if ui.checkbox(&mut hide, "").labelled_by(input_label.id).changed() { update(app, json!({"hideOther": hide})); } ui.end_row(); } ElementKind::Colorbar { frame, layer, title, orientation, ticks, tick_side, reverse } => { - ui.label(tl!("Title")); - if let Some(tt) = text_field(app, ui, (li, id, "title"), title, false) { + let input_label = ui.label(tl!("Title")); + if let Some(tt) = text_field(app, ui, (li, id, "title"), title, false, input_label.id) { update(app, json!({"title": tt})); } ui.end_row(); // The rasters of the frame's map; none picked follows the top visible one. - ui.label(tl!("Layer")); + let input_label = ui.label(tl!("Layer")); let rasters: Vec = match layout.element(*frame).map(|f| &f.kind) { Some(ElementKind::MapFrame { map, .. }) => app .session @@ -1395,9 +1413,9 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { _ => Vec::new(), }; let top = tl!("Top visible raster"); - egui::ComboBox::from_id_salt(("colorbar_layer", id)).selected_text(layer.as_deref().unwrap_or(top)).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt(("colorbar_layer", id)) + .selected_text(layer.as_deref().unwrap_or(top)) + .show_ui(ui, |ui| { if ui.selectable_label(layer.is_none(), top).clicked() { update(app, json!({"layer": ""})); } @@ -1406,8 +1424,9 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { update(app, json!({"layer": n})); } } - }, - ); + }) + .response + .labelled_by(input_label.id); ui.end_row(); let vertical = *orientation == Orientation::Vertical; ui.label(tl!("Orientation")); @@ -1430,37 +1449,43 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { } }); ui.end_row(); - ui.label(tl!("Ticks")); + let input_label = ui.label(tl!("Ticks")); let mut n = *ticks as u64; let drag = Dragged::lend(ui, drag_key("ticks"), &mut n); - let r = ui.add(egui::DragValue::new(&mut n).range(2..=MAX_COLORBAR_TICKS as u64)); + let r = ui.add(egui::DragValue::new(&mut n).range(2..=MAX_COLORBAR_TICKS as u64)).labelled_by(input_label.id); if drag.settled(ui, &r, &mut n) && n as usize != *ticks { update(app, json!({"ticks": n})); } ui.end_row(); - ui.label(tl!("Reverse")); + let input_label = ui.label(tl!("Reverse")); let mut rev = *reverse; - if ui.checkbox(&mut rev, "").changed() { + if ui.checkbox(&mut rev, "").labelled_by(input_label.id).changed() { update(app, json!({"reverse": rev})); } ui.end_row(); } ElementKind::MapFrame { map, scale, bearing, pitch, .. } => { - ui.label(tl!("Map")); + let input_label = ui.label(tl!("Map")); let maps: Vec = app.session.project.maps.iter().map(|m| m.name.clone()).collect(); - egui::ComboBox::from_id_salt(("frame_map", id)).selected_text(map.as_str()).show_ui(ui, |ui| { - for m in maps { - if ui.selectable_label(m == *map, m.as_str()).clicked() { - update(app, json!({"map": m})); + egui::ComboBox::from_id_salt(("frame_map", id)) + .selected_text(map.as_str()) + .show_ui(ui, |ui| { + for m in maps { + if ui.selectable_label(m == *map, m.as_str()).clicked() { + update(app, json!({"map": m})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Scale 1:")); + let input_label = ui.label(tl!("Scale 1:")); let mut sc = *scale; let speed = sc * 0.01; let drag = Dragged::lend(ui, drag_key("scale"), &mut sc); - let r = ui.add(egui::DragValue::new(&mut sc).range(100.0..=1e10).speed(speed).max_decimals(0)); + let r = ui + .add(egui::DragValue::new(&mut sc).range(100.0..=1e10).speed(speed).max_decimals(0)) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut sc) && (sc - scale).abs() > 0.5 { update(app, json!({"scale": sc})); } @@ -1469,10 +1494,12 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { for (label, key, current, range) in [("Bearing", "bearing", *bearing, -180.0..=180.0), ("Pitch", "pitch", *pitch, 0.0..=85.0)] { - ui.label(tl!(label)); + let input_label = ui.label(tl!(label)); let mut v = current; let drag = Dragged::lend(ui, drag_key(key), &mut v); - let r = ui.add(egui::DragValue::new(&mut v).range(range).speed(0.5).max_decimals(1).suffix("°")); + let r = ui + .add(egui::DragValue::new(&mut v).range(range).speed(0.5).max_decimals(1).suffix("°")) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut v) && (v - current).abs() > 1e-6 { update(app, json!({key: v})); } @@ -1480,14 +1507,18 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { } } ElementKind::NorthArrow { style, fill, outline, .. } => { - ui.label(tl!("Style")); - egui::ComboBox::from_id_salt(("arrow_style", id)).selected_text(tl!(style.label())).show_ui(ui, |ui| { - for v in ArrowStyle::ALL { - if ui.selectable_label(*style == v, tl!(v.label())).clicked() && *style != v { - update(app, json!({"style": v.code()})); + let input_label = ui.label(tl!("Style")); + egui::ComboBox::from_id_salt(("arrow_style", id)) + .selected_text(tl!(style.label())) + .show_ui(ui, |ui| { + for v in ArrowStyle::ALL { + if ui.selectable_label(*style == v, tl!(v.label())).clicked() && *style != v { + update(app, json!({"style": v.code()})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); for (label, key, current) in [("Fill", "fill", *fill), ("Outline", "outline", *outline)] { ui.label(tl!(label)); @@ -1499,38 +1530,46 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { } } ElementKind::ScaleBar { units, divisions, style, .. } => { - ui.label(tl!("Style")); - egui::ComboBox::from_id_salt(("scale_style", id)).selected_text(tl!(style.label())).show_ui(ui, |ui| { - for v in ScaleBarStyle::ALL { - if ui.selectable_label(*style == v, tl!(v.label())).clicked() && *style != v { - update(app, json!({"style": v.code()})); + let input_label = ui.label(tl!("Style")); + egui::ComboBox::from_id_salt(("scale_style", id)) + .selected_text(tl!(style.label())) + .show_ui(ui, |ui| { + for v in ScaleBarStyle::ALL { + if ui.selectable_label(*style == v, tl!(v.label())).clicked() && *style != v { + update(app, json!({"style": v.code()})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Units")); - egui::ComboBox::from_id_salt(("scale_units", id)).selected_text(tl!(units.label())).show_ui(ui, |ui| { - for v in ScaleUnits::ALL { - if ui.selectable_label(*units == v, tl!(v.label())).clicked() && *units != v { - update(app, json!({"units": v.code()})); + let input_label = ui.label(tl!("Units")); + egui::ComboBox::from_id_salt(("scale_units", id)) + .selected_text(tl!(units.label())) + .show_ui(ui, |ui| { + for v in ScaleUnits::ALL { + if ui.selectable_label(*units == v, tl!(v.label())).clicked() && *units != v { + update(app, json!({"units": v.code()})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Divisions")); + let input_label = ui.label(tl!("Divisions")); let mut n = *divisions as u64; let drag = Dragged::lend(ui, drag_key("divisions"), &mut n); - let r = ui.add(egui::DragValue::new(&mut n).range(1..=MAX_SCALE_DIVISIONS as u64)); + let r = ui.add(egui::DragValue::new(&mut n).range(1..=MAX_SCALE_DIVISIONS as u64)).labelled_by(input_label.id); if drag.settled(ui, &r, &mut n) && n as usize != *divisions { update(app, json!({"divisions": n})); } ui.end_row(); } ElementKind::Table { rows, .. } => { - ui.label(tl!("Rows")); + let input_label = ui.label(tl!("Rows")); let mut n = *rows as u64; let drag = Dragged::lend(ui, drag_key("rows"), &mut n); - let r = ui.add(egui::DragValue::new(&mut n).range(1..=500)); + let r = ui.add(egui::DragValue::new(&mut n).range(1..=500)).labelled_by(input_label.id); if drag.settled(ui, &r, &mut n) && n as usize != *rows { update(app, json!({"rows": n})); } @@ -1546,10 +1585,12 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { } ui.end_row(); } - ui.label(tl!("Outline width")); + let input_label = ui.label(tl!("Outline width")); let mut wd = *width; let drag = Dragged::lend(ui, drag_key("width"), &mut wd); - let r = ui.add(egui::DragValue::new(&mut wd).range(0.0..=50.0).speed(0.1).max_decimals(1).suffix(" pt")); + let r = ui + .add(egui::DragValue::new(&mut wd).range(0.0..=50.0).speed(0.1).max_decimals(1).suffix(" pt")) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut wd) && (wd - width).abs() > 1e-6 { update(app, json!({"width": wd})); } @@ -1562,29 +1603,37 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { update(app, json!({"color": crate::theme::rgba(c).hex()})); } ui.end_row(); - ui.label(tl!("Width")); + let input_label = ui.label(tl!("Width")); let mut wd = *width; let drag = Dragged::lend(ui, drag_key("width"), &mut wd); - let r = ui.add(egui::DragValue::new(&mut wd).range(0.1..=50.0).speed(0.1).max_decimals(1).suffix(" pt")); + let r = ui + .add(egui::DragValue::new(&mut wd).range(0.1..=50.0).speed(0.1).max_decimals(1).suffix(" pt")) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut wd) && (wd - width).abs() > 1e-6 { update(app, json!({"width": wd})); } ui.end_row(); - ui.label(tl!("Arrowheads")); - egui::ComboBox::from_id_salt(("line_head", id)).selected_text(tl!(head.label())).show_ui(ui, |ui| { - for h in LineHead::ALL { - if ui.selectable_label(h == *head, tl!(h.label())).clicked() { - update(app, json!({"head": h.code()})); + let input_label = ui.label(tl!("Arrowheads")); + egui::ComboBox::from_id_salt(("line_head", id)) + .selected_text(tl!(head.label())) + .show_ui(ui, |ui| { + for h in LineHead::ALL { + if ui.selectable_label(h == *head, tl!(h.label())).clicked() { + update(app, json!({"head": h.code()})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); // Anticlockwise from pointing right; the rotation handle above it turns it too. - ui.label(tl!("Angle")); + let input_label = ui.label(tl!("Angle")); let current = line_angle((e.x, e.y, e.w, e.h), *dir, *head); let mut a = current; let drag = Dragged::lend(ui, drag_key("angle"), &mut a); - let r = ui.add(egui::DragValue::new(&mut a).range(-180.0..=180.0).speed(1.0).max_decimals(1).suffix("°")); + let r = ui + .add(egui::DragValue::new(&mut a).range(-180.0..=180.0).speed(1.0).max_decimals(1).suffix("°")) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut a) && (a - current).abs() > 1e-6 { update(app, json!({"angle": a})); } @@ -1700,16 +1749,18 @@ fn properties(app: &mut RustGisApp, ui: &mut Ui, layout: &Layout, li: usize) { update(app, json!({"extentIndicator": {"color": crate::theme::rgba(c).hex()}})); } ui.end_row(); - ui.label(tl!("Line width")); + let input_label = ui.label(tl!("Line width")); let mut w = *extent_width; let drag = Dragged::lend(ui, drag_key("extentWidth"), &mut w); - let r = ui.add( - egui::DragValue::new(&mut w) - .range(rustgis_engine::map::layout::MIN_INDICATOR_WIDTH..=rustgis_engine::map::layout::MAX_INDICATOR_WIDTH) - .speed(0.05) - .max_decimals(2) - .suffix(" pt"), - ); + let r = ui + .add( + egui::DragValue::new(&mut w) + .range(rustgis_engine::map::layout::MIN_INDICATOR_WIDTH..=rustgis_engine::map::layout::MAX_INDICATOR_WIDTH) + .speed(0.05) + .max_decimals(2) + .suffix(" pt"), + ) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut w) && (w - extent_width).abs() > 1e-6 { update(app, json!({"extentIndicator": {"width": w}})); } @@ -1793,7 +1844,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -1845,7 +1896,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -1906,7 +1957,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -1961,7 +2012,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2005,7 +2056,7 @@ mod tests { let shown = area.clone(); // Fonts take effect on the frame after they are installed. let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2125,7 +2176,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2169,7 +2220,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().clone(); app.layout.selected = Some(title.id); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2222,7 +2273,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().clone(); app.layout.selected = Some(title.id); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2273,7 +2324,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2328,7 +2379,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().id; app.layout.selected = Some(title); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2382,7 +2433,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().clone(); app.layout.selected = Some(title.id); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2429,7 +2480,7 @@ mod tests { app.run("layout.addElement", json!({"kind": "arrow", "angle": 0, "length": 60})).unwrap()["element"].as_u64().unwrap() as u32; app.layout.selected = Some(id); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2475,7 +2526,7 @@ mod tests { let title = app.session.project.layouts[li].elements.iter().find(|e| e.name == "Title").unwrap().id; app.layout.selected = Some(title); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2522,7 +2573,7 @@ mod tests { app.run("layout.addElement", json!({"kind": "extentIndicator", "frame": inset, "of": main, "width": 1.5})).unwrap(); app.layout.selected = Some(inset); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2560,7 +2611,7 @@ mod tests { app.run("layout.update", json!({"element": legend, "exclude": ["Cities"]})).unwrap(); app.layout.selected = Some(legend); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2611,7 +2662,7 @@ mod tests { let ids: Vec = app.session.project.layouts[li].elements.iter().map(|e| e.id).collect(); app.run("ui.pane.bookmarks", json!({})).unwrap(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2697,7 +2748,7 @@ mod tests { let text = serde_json::to_string(&app.session.project).unwrap(); app.layout.selected = Some(title); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1200.0, 900.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -2775,7 +2826,7 @@ mod tests { let area = Rc::new(Cell::new(Rect::NOTHING)); let shown = area.clone(); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1000.0, 760.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 760.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 232b6237e..e4e4fd26d 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -2970,18 +2970,14 @@ mod tests { let mut app = RustGisApp::with_sample(); app.run("map.body", json!({"name": "moon", "basemap": false})).expect("Moon"); app.run("map.projection", json!({"type": "globe"})).expect("globe"); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(900.0, 550.0)) - .with_max_steps(10_000) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 550.0)).with_max_steps(10_000).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); let end = std::time::Instant::now() + std::time::Duration::from_secs(10); while h.state().map.key.is_none() || h.state().map.drawing() { assert!(std::time::Instant::now() < end, "exact globe frame did not arrive"); @@ -2999,12 +2995,8 @@ mod tests { /// the map is drawn once at it. #[test] fn a_pane_drag_draws_the_map_once_it_stops() { - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(1200.0, 700.0)) - .with_step_dt(1.0 / 60.0) - .with_max_steps(10_000) - .build_ui_state( + let mut h = + egui_kittest::Harness::builder().with_size(vec2(1200.0, 700.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); diff --git a/crates/ui-egui/src/modelbuilder.rs b/crates/ui-egui/src/modelbuilder.rs index dae8f48c3..6fa73b6ac 100644 --- a/crates/ui-egui/src/modelbuilder.rs +++ b/crates/ui-egui/src/modelbuilder.rs @@ -1268,9 +1268,11 @@ fn inspector_ui(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, problems: &[model match n.kind { ModelNodeKind::Input => input_settings(app, ui, i, &n), ModelNodeKind::Output => { - ui.label(egui::RichText::new(tl!("Result name")).size(12.0)); + let input_label = ui.label(egui::RichText::new(tl!("Result name")).size(12.0)); let mut name = n.name.clone().unwrap_or_default(); - let r = ui.add(egui::TextEdit::singleline(&mut name).hint_text(tl!("Model output")).desired_width(f32::INFINITY)); + let r = ui + .add(egui::TextEdit::singleline(&mut name).hint_text(tl!("Model output")).desired_width(f32::INFINITY)) + .labelled_by(input_label.id); if r.gained_focus() { app.mb.edit(); } @@ -1291,19 +1293,24 @@ fn inspector_ui(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, problems: &[model } fn input_settings(app: &mut RustGisApp, ui: &mut Ui, i: usize, n: &ModelNode) { - ui.label(egui::RichText::new(tl!("Layer")).size(12.0)); + let input_label = ui.label(egui::RichText::new(tl!("Layer")).size(12.0)); let mut names: Vec = app.session.map().layers.iter().map(|l| l.name.clone()).collect(); names.extend(app.session.project.tables.iter().map(|t| t.name.clone())); let cur = n.layer.clone().unwrap_or_default(); let shown = if cur.is_empty() { tl!("Choose a layer…").to_string() } else { cur.clone() }; let mut picked = None; - egui::ComboBox::from_id_salt(("mb_layer", &n.id)).selected_text(shown).width(ui.available_width() - 4.0).show_ui(ui, |ui| { - for name in names { - if ui.selectable_label(name == cur, &name).clicked() { - picked = Some(name); + egui::ComboBox::from_id_salt(("mb_layer", &n.id)) + .selected_text(shown) + .width(ui.available_width() - 4.0) + .show_ui(ui, |ui| { + for name in names { + if ui.selectable_label(name == cur, &name).clicked() { + picked = Some(name); + } } - } - }); + }) + .response + .labelled_by(input_label.id); if let Some(p) = picked.filter(|p| *p != cur) { app.mb.edit(); if let Some(m) = app.mb.graph.nodes.get_mut(i) { @@ -1394,9 +1401,10 @@ fn tool_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, i: usize, n: &Mo .map(|l| l.name.clone()) .collect(); let shown = if text.is_empty() { tl!("Connect, or pick a layer").to_string() } else { text.clone() }; - egui::ComboBox::from_id_salt(("mb_p", &n.id, p.name)).selected_text(shown).width(ui.available_width() - 4.0).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt(("mb_p", &n.id, p.name)) + .selected_text(shown) + .width(ui.available_width() - 4.0) + .show_ui(ui, |ui| { if ui.selectable_label(text.is_empty(), "—").clicked() { set = Some((p.name, Value::Null)); } @@ -1405,15 +1413,16 @@ fn tool_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, i: usize, n: &Mo set = Some((p.name, json!(name))); } } - }, - ); + }) + .response + .on_hover_text(tl!(p.label)); ui.add_space(4.0); continue; } match p.kind { ParamKind::Bool => { let mut on = cur.as_bool().unwrap_or(text == "true" || (cur.is_null() && default == "true")); - if ui.checkbox(&mut on, "").changed() { + if ui.checkbox(&mut on, "").on_hover_text(tl!(p.label)).changed() { set = Some((p.name, json!(on))); } } @@ -1423,23 +1432,26 @@ fn tool_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, i: usize, n: &Mo } else { tl!(&text).to_string() }; - egui::ComboBox::from_id_salt(("mb_p", &n.id, p.name)).selected_text(shown).width(ui.available_width() - 4.0).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt(("mb_p", &n.id, p.name)) + .selected_text(shown) + .width(ui.available_width() - 4.0) + .show_ui(ui, |ui| { for o in c { if ui.selectable_label(*o == text, tl!(o)).clicked() { set = Some((p.name, json!(o))); } } - }, - ); + }) + .response + .on_hover_text(tl!(p.label)); } ParamKind::Field | ParamKind::NumericField | ParamKind::CoordinateField if !fields.is_empty() => { let numeric = p.kind == ParamKind::NumericField; let shown = if text.is_empty() { "—".to_string() } else { text.clone() }; - egui::ComboBox::from_id_salt(("mb_p", &n.id, p.name)).selected_text(shown).width(ui.available_width() - 4.0).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt(("mb_p", &n.id, p.name)) + .selected_text(shown) + .width(ui.available_width() - 4.0) + .show_ui(ui, |ui| { if !p.required && ui.selectable_label(text.is_empty(), "—").clicked() { set = Some((p.name, Value::Null)); } @@ -1448,8 +1460,9 @@ fn tool_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, i: usize, n: &Mo set = Some((p.name, json!(f))); } } - }, - ); + }) + .response + .on_hover_text(tl!(p.label)); } _ => { let mut s = text.clone(); @@ -1459,7 +1472,7 @@ fn tool_settings(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens, i: usize, n: &Mo } else { default.clone() }; - let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(hint).desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(&mut s).hint_text(hint).desired_width(f32::INFINITY)).on_hover_text(tl!(p.label)); if r.gained_focus() { focus = true; } @@ -1685,7 +1698,7 @@ mod tests { fn harness(app: RustGisApp) -> egui_kittest::Harness<'static, RustGisApp> { let mut fonts = false; - egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1300.0, 800.0)).build_ui_state( + egui_kittest::Harness::builder().with_size(vec2(1300.0, 800.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); diff --git a/crates/ui-egui/src/navui.rs b/crates/ui-egui/src/navui.rs index 58678aa9c..a311d940f 100644 --- a/crates/ui-egui/src/navui.rs +++ b/crates/ui-egui/src/navui.rs @@ -743,11 +743,16 @@ fn actions(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } sim.playing = !sim.playing; } - egui::ComboBox::from_id_salt("nav_speed").selected_text(format!("{}×", sim.speed)).width(56.0).show_ui(ui, |ui| { - for s in SPEEDS { - ui.selectable_value(&mut sim.speed, s, format!("{s}×")); - } - }); + egui::ComboBox::from_id_salt("nav_speed") + .selected_text(format!("{}×", sim.speed)) + .width(56.0) + .show_ui(ui, |ui| { + for s in SPEEDS { + ui.selectable_value(&mut sim.speed, s, format!("{s}×")); + } + }) + .response + .on_hover_text(tl!("Speed")); end = ui.button(tl!("End")).clicked(); }); } diff --git a/crates/ui-egui/src/nldiui.rs b/crates/ui-egui/src/nldiui.rs index 310b33f91..7547174f6 100644 --- a/crates/ui-egui/src/nldiui.rs +++ b/crates/ui-egui/src/nldiui.rs @@ -265,13 +265,18 @@ pub fn pane(app: &mut RustGisApp, ui: &mut egui::Ui) { let mut go = None; egui::Grid::new("nldi").num_columns(2).spacing(egui::vec2(10.0, 8.0)).show(ui, |ui| { let st = &mut app.ui.nldi; - ui.label(tl!("Flowline")); + let input_label = ui.label(tl!("Flowline")); let shown = nldi::DIRECTIONS.iter().find(|(k, _)| *k == st.direction).map(|(_, l)| tl!(*l)).unwrap_or_default(); - egui::ComboBox::from_id_salt("nldi_dir").selected_text(shown).width(field).show_ui(ui, |ui| { - for (k, l) in nldi::DIRECTIONS { - ui.selectable_value(&mut st.direction, k, tl!(*l)); - } - }); + egui::ComboBox::from_id_salt("nldi_dir") + .selected_text(shown) + .width(field) + .show_ui(ui, |ui| { + for (k, l) in nldi::DIRECTIONS { + ui.selectable_value(&mut st.direction, k, tl!(*l)); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); ui.label(tl!("Point")); ui.horizontal(|ui| { @@ -326,26 +331,36 @@ pub fn pane(app: &mut RustGisApp, ui: &mut egui::Ui) { let mut plot = false; egui::Grid::new("nldi_nav").num_columns(2).spacing(egui::vec2(10.0, 8.0)).show(ui, |ui| { let st = &mut app.ui.nldi; - ui.label(tl!("Navigation")); + let input_label = ui.label(tl!("Navigation")); let shown = nldi::MODES.iter().find(|(c, _, _)| *c == st.mode).map(|(_, _, l)| tl!(*l)).unwrap_or(tl!("Select a direction…")); - egui::ComboBox::from_id_salt("nldi_mode").selected_text(shown).width(field).show_ui(ui, |ui| { - for (code, _, label) in nldi::MODES { - ui.selectable_value(&mut st.mode, code, tl!(*label)); - } - }); + egui::ComboBox::from_id_salt("nldi_mode") + .selected_text(shown) + .width(field) + .show_ui(ui, |ui| { + for (code, _, label) in nldi::MODES { + ui.selectable_value(&mut st.mode, code, tl!(*label)); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Distance (km)")); - ui.add(egui::DragValue::new(&mut st.distance).range(1.0..=nldi::MAX_DISTANCE).speed(5.0)); + let input_label = ui.label(tl!("Distance (km)")); + ui.add(egui::DragValue::new(&mut st.distance).range(1.0..=nldi::MAX_DISTANCE).speed(5.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Source")); + let input_label = ui.label(tl!("Source")); let loaded = st.comid.is_some() && st.sources_for == st.comid.map(|c| (c, st.mode)); if loaded { let shown = st.sources.iter().find(|s| s.id == st.source).map(|s| s.name.clone()).unwrap_or_else(|| st.source.clone()); - egui::ComboBox::from_id_salt("nldi_src").selected_text(shown).width(field).show_ui(ui, |ui| { - for s in &st.sources { - ui.selectable_value(&mut st.source, s.id.clone(), format!("{} ({})", s.name, s.id)); - } - }); + egui::ComboBox::from_id_salt("nldi_src") + .selected_text(shown) + .width(field) + .show_ui(ui, |ui| { + for s in &st.sources { + ui.selectable_value(&mut st.source, s.id.clone(), format!("{} ({})", s.name, s.id)); + } + }) + .response + .labelled_by(input_label.id); } else { ui.label(egui::RichText::new(tl!("Press Load sources & plot first")).color(t.text_dim)); } diff --git a/crates/ui-egui/src/opendataui.rs b/crates/ui-egui/src/opendataui.rs index 04fafbf2e..230a7f297 100644 --- a/crates/ui-egui/src/opendataui.rs +++ b/crates/ui-egui/src/opendataui.rs @@ -471,26 +471,31 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { let st = &mut app.ui.opendata; match svc { None => { - ui.label(tl!("Portal")); + let input_label = ui.label(tl!("Portal")); let shown = PORTALS.iter().find(|(k, _, _)| *k == st.portal).map(|(_, n, _)| tl!(*n).to_string()).unwrap_or_default(); - egui::ComboBox::from_id_salt("od_portal").selected_text(shown).width(w).show_ui(ui, |ui| { - for (kind, name, _) in PORTALS { - if ui.selectable_label(st.portal == kind, tl!(name)).clicked() && st.portal != kind { - st.portal = kind; - st.url.clear(); + egui::ComboBox::from_id_salt("od_portal") + .selected_text(shown) + .width(w) + .show_ui(ui, |ui| { + for (kind, name, _) in PORTALS { + if ui.selectable_label(st.portal == kind, tl!(name)).clicked() && st.portal != kind { + st.portal = kind; + st.url.clear(); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Address")); + let input_label = ui.label(tl!("Address")); let hint = PORTALS.iter().find(|(k, _, _)| *k == st.portal).map(|(_, _, h)| tl!(*h)).unwrap_or_default(); - ui.add(egui::TextEdit::singleline(&mut st.url).hint_text(hint).desired_width(w)); + ui.add(egui::TextEdit::singleline(&mut st.url).hint_text(hint).desired_width(w)).labelled_by(input_label.id); ui.end_row(); } Some(s) => { if let Some(hint) = s.address { - ui.label(tl!("Address")); - ui.add(egui::TextEdit::singleline(&mut st.url).hint_text(tl!(hint)).desired_width(w)); + let input_label = ui.label(tl!("Address")); + ui.add(egui::TextEdit::singleline(&mut st.url).hint_text(tl!(hint)).desired_width(w)).labelled_by(input_label.id); ui.end_row(); } // A service with one portal (Tennessee) has nothing to pick. @@ -498,13 +503,18 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { catalog_picker(ui, st, sets, w); } if s.id == "usgs-dem" { - ui.label(tl!("Elevation model")); + let input_label = ui.label(tl!("Elevation model")); let shown = DEM_DATASETS.get(st.dem).map(|(l, _)| tl!(*l).to_string()).unwrap_or_default(); - egui::ComboBox::from_id_salt("od_dem").selected_text(shown).width(w).show_ui(ui, |ui| { - for (i, (label, _)) in DEM_DATASETS.iter().enumerate() { - ui.selectable_value(&mut st.dem, i, tl!(*label)); - } - }); + egui::ComboBox::from_id_salt("od_dem") + .selected_text(shown) + .width(w) + .show_ui(ui, |ui| { + for (i, (label, _)) in DEM_DATASETS.iter().enumerate() { + ui.selectable_value(&mut st.dem, i, tl!(*label)); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); } } @@ -766,26 +776,38 @@ fn card( /// The US portal picker: a set (department or state), then its portal. fn catalog_picker(ui: &mut egui::Ui, st: &mut State, sets: &'static [Set], w: f32) { - ui.label(tl!("Group")); + let input_label = ui.label(tl!("Group")); let shown = sets.get(st.set).map(|s| s.name).unwrap_or_default(); - egui::ComboBox::from_id_salt("od_set").selected_text(shown).width(w).height(400.0).show_ui(ui, |ui| { - for (i, s) in sets.iter().enumerate() { - if ui.selectable_label(st.set == i, s.name).clicked() && st.set != i { - st.set = i; - st.catalog = 0; + egui::ComboBox::from_id_salt("od_set") + .selected_text(shown) + .width(w) + .height(400.0) + .show_ui(ui, |ui| { + for (i, s) in sets.iter().enumerate() { + if ui.selectable_label(st.set == i, s.name).clicked() && st.set != i { + st.set = i; + st.catalog = 0; + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); ui.label(tl!("Portal")); let catalogs = sets.get(st.set).map(|s| s.catalogs).unwrap_or_default(); let shown = catalogs.get(st.catalog).map(|c| c.name).unwrap_or_default(); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("od_catalog").selected_text(shown).width(w - 44.0).height(400.0).show_ui(ui, |ui| { - for (i, c) in catalogs.iter().enumerate() { - ui.selectable_value(&mut st.catalog, i, c.name); - } - }); + egui::ComboBox::from_id_salt("od_catalog") + .selected_text(shown) + .width(w - 44.0) + .height(400.0) + .show_ui(ui, |ui| { + for (i, c) in catalogs.iter().enumerate() { + ui.selectable_value(&mut st.catalog, i, c.name); + } + }) + .response + .on_hover_text(tl!("Catalog")); if let Some(c) = catalogs.get(st.catalog) { ui.hyperlink_to(tl!("Open"), c.url); } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index b164bd565..f9d925844 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1228,8 +1228,8 @@ fn layer_group_row( } let mut tr = group.transparency; ui.horizontal(|ui| { - ui.label(tl!("Transparency")); - let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix(" %").integer()); + let input_label = ui.label(tl!("Transparency")); + let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix(" %").integer()).labelled_by(input_label.id); if r.drag_stopped() || (r.changed() && !r.dragged()) { let _ = app.run("group.set", json!({"group": group.id, "transparency": tr})); } @@ -1435,9 +1435,9 @@ fn tile_row(app: &mut RustGisApp, ui: &mut Ui, i: usize, count: usize, reorder: ui.separator(); } ui.horizontal(|ui| { - ui.label(tl!("Opacity")); + let input_label = ui.label(tl!("Opacity")); let mut o = tile.opacity * 100.0; - let slider = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).suffix("%")); + let slider = ui.add(egui::Slider::new(&mut o, 0.0..=100.0).suffix("%")).labelled_by(input_label.id); if slider.drag_stopped() || (slider.changed() && !slider.dragged()) { action = Some(("map.setTiles", json!({"index": i, "opacity": o / 100.0}))); } @@ -2484,14 +2484,19 @@ fn layer_list(form: &mut GpForm, ui: &mut Ui, param: &'static str, cur: &str, of // A name holding the separator would come back as two entries: not offered. let left: Vec = offered.into_iter().filter(|o| !o.contains(';') && !names.contains(o)).collect(); ui.add_enabled_ui(!left.is_empty(), |ui| { - egui::ComboBox::from_id_salt(("gp_list", param)).selected_text(tl!("Add")).width(ui.available_width() - 4.0).show_ui(ui, |ui| { - for o in left { - if ui.selectable_label(false, &o).clicked() { - names.push(o); - changed = true; + egui::ComboBox::from_id_salt(("gp_list", param)) + .selected_text(tl!("Add")) + .width(ui.available_width() - 4.0) + .show_ui(ui, |ui| { + for o in left { + if ui.selectable_label(false, &o).clicked() { + names.push(o); + changed = true; + } } - } - }); + }) + .response + .on_hover_text(tl!("Add")); }); // A file not opened as a layer, typed in (the runner reads paths on disk). #[cfg(not(target_arch = "wasm32"))] @@ -2984,20 +2989,25 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ParamKind::Layer => { let picked = app.gp.tables.get(p.name).copied(); let shown = if cur.is_empty() { "—" } else { cur.as_str() }; - egui::ComboBox::from_id_salt(("gp", p.name)).selected_text(shown).width(ui.available_width() - 4.0).show_ui(ui, |ui| { - for name in layer_names(None) { - if ui.selectable_label(picked.is_none() && name == cur, &name).clicked() { - app.gp.values.insert(p.name, name); - app.gp.tables.remove(p.name); + egui::ComboBox::from_id_salt(("gp", p.name)) + .selected_text(shown) + .width(ui.available_width() - 4.0) + .show_ui(ui, |ui| { + for name in layer_names(None) { + if ui.selectable_label(picked.is_none() && name == cur, &name).clicked() { + app.gp.values.insert(p.name, name); + app.gp.tables.remove(p.name); + } } - } - for (id, name) in &tables { - if ui.selectable_label(picked == Some(*id), name).clicked() { - app.gp.values.insert(p.name, name.clone()); - app.gp.tables.insert(p.name, *id); + for (id, name) in &tables { + if ui.selectable_label(picked == Some(*id), name).clicked() { + app.gp.values.insert(p.name, name.clone()); + app.gp.tables.insert(p.name, *id); + } } - } - }); + }) + .response + .on_hover_text(tl!(p.label)); ui.add_space(4.0); continue; } @@ -3068,7 +3078,7 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } ParamKind::Bool => { let mut on = cur == "true"; - if ui.checkbox(&mut on, "").changed() { + if ui.checkbox(&mut on, "").on_hover_text(tl!(p.label)).changed() { app.gp.values.insert(p.name, on.to_string()); } ui.add_space(4.0); @@ -3088,16 +3098,18 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { v.to_string() } }; - egui::ComboBox::from_id_salt(("gp", p.name)).selected_text(shown(&cur)).width(ui.available_width() - 4.0).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt(("gp", p.name)) + .selected_text(shown(&cur)) + .width(ui.available_width() - 4.0) + .show_ui(ui, |ui| { for o in opts { if ui.selectable_label(o == cur, shown(&o)).clicked() { app.gp.values.insert(p.name, o); } } - }, - ); + }) + .response + .on_hover_text(tl!(p.label)); } None => { let mut v = cur; @@ -3115,11 +3127,15 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let row = vec2(ui.available_width(), ui.spacing().interact_size.y); ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { browse = ui.button(tl!("Browse")).clicked(); - ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY)).changed() + ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY)) + .on_hover_text(tl!(p.label)) + .changed() }) .inner } else { - ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY)).changed() + ui.add(egui::TextEdit::singleline(&mut v).hint_text(hint).desired_width(f32::INFINITY)) + .on_hover_text(tl!(p.label)) + .changed() }; if changed { // A path typed in replaces a file picked in the browser. @@ -3449,20 +3465,24 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let fields = if pending.kind == "unique" { &all } else { &numeric }; if !fields.is_empty() { ui.horizontal(|ui| { - ui.label(tl!("Choose a field to apply this renderer:")); - egui::ComboBox::from_id_salt("symbology_pending_field_choice").selected_text(tl!("Select a field")).show_ui(ui, |ui| { - for field in fields { - if ui.selectable_label(false, field).clicked() { - match app.run("layer.symbology", json!({"type": pending.kind, "field": field})) { - Ok(_) => clear_pending_symbology(ui.ctx()), - Err(error) => { - let pending = PendingSymbology { error: error.into(), ..pending.clone() }; - ui.data_mut(|data| data.insert_temp(egui::Id::unique(PENDING_SYMBOLOGY), pending)); + let input_label = ui.label(tl!("Choose a field to apply this renderer:")); + egui::ComboBox::from_id_salt("symbology_pending_field_choice") + .selected_text(tl!("Select a field")) + .show_ui(ui, |ui| { + for field in fields { + if ui.selectable_label(false, field).clicked() { + match app.run("layer.symbology", json!({"type": pending.kind, "field": field})) { + Ok(_) => clear_pending_symbology(ui.ctx()), + Err(error) => { + let pending = PendingSymbology { error: error.into(), ..pending.clone() }; + ui.data_mut(|data| data.insert_temp(egui::Id::unique(PENDING_SYMBOLOGY), pending)); + } } } } - } - }); + }) + .response + .labelled_by(input_label.id); }); } ui.add_space(6.0); @@ -3481,19 +3501,19 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { Renderer::Graduated { field, breaks, ramp, method, .. } => { field_combo(app, ui, "Field", field, &numeric, "graduated"); ui.horizontal(|ui| { - ui.label(tl!("Method")); + let input_label = ui.label(tl!("Method")); egui::ComboBox::from_id_salt("sym_method").selected_text(tl!(method.label())).width(180.0).show_ui(ui, |ui| { for m in ClassMethod::ALL { if ui.selectable_label(m == *method, tl!(m.label())).clicked() { let _ = app.run("layer.symbology", json!({"type": "graduated", "field": field, "method": format!("{m:?}"), "classes": breaks.len(), "ramp": ramp.name()})); } } - }); + }).response.labelled_by(input_label.id); }); ui.horizontal(|ui| { - ui.label(tl!("Classes")); + let input_label = ui.label(tl!("Classes")); let mut k = breaks.len(); - if ui.add(egui::DragValue::new(&mut k).range(1..=12)).changed() { + if ui.add(egui::DragValue::new(&mut k).range(1..=12)).labelled_by(input_label.id).changed() { let _ = app.run( "layer.symbology", json!({"type": "graduated", "field": field, "method": format!("{method:?}"), "classes": k, "ramp": ramp.name()}), @@ -3519,9 +3539,9 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.label(egui::RichText::new(tl!("Layer")).size(11.5).color(t.text_dim)); let mut tr = l.transparency; ui.horizontal(|ui| { - ui.label(tl!("Transparency")); + let input_label = ui.label(tl!("Transparency")); ui.spacing_mut().slider_width = 150.0; - let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix(" %").integer()); + let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix(" %").integer()).labelled_by(input_label.id); transparency_drag(app, ui, &r, &l, tr); }); if !l.graphics { @@ -3540,9 +3560,10 @@ fn reference_scale_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { ui.label(tl!("Reference scale")).on_hover_text(tl!( "The scale the layer's symbol and label sizes are set for. At other scales they grow and shrink with the map, as on paper." )); - egui::ComboBox::from_id_salt("reference_scale").selected_text(crate::ribbon::scale_label(l.reference_scale)).width(130.0).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt("reference_scale") + .selected_text(crate::ribbon::scale_label(l.reference_scale)) + .width(130.0) + .show_ui(ui, |ui| { let mut pick = |ui: &mut Ui, label: String, scale: Value, on: bool| { if ui.selectable_label(on, label).clicked() { let _ = app.run("layer.referenceScale", json!({"scale": scale})); @@ -3554,8 +3575,9 @@ fn reference_scale_row(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { } let label = crate::i18n::fmt("Current scale ({scale})", &[("scale", &crate::ribbon::scale_label(Some(current)))]); pick(ui, label, json!("current"), l.reference_scale == Some(current) && !REFERENCE_SCALE_CHOICES.contains(¤t)); - }, - ); + }) + .response + .on_hover_text(tl!("Reference scale")); }); } @@ -3582,14 +3604,19 @@ fn transparency_drag(app: &mut RustGisApp, ui: &Ui, r: &egui::Response, l: &Laye fn field_combo(app: &mut RustGisApp, ui: &mut Ui, label: &str, cur: &str, fields: &[String], kind: &str) { ui.horizontal(|ui| { - ui.label(tl!(label)); - egui::ComboBox::from_id_salt(("sym_field", kind)).selected_text(cur).width(200.0).show_ui(ui, |ui| { - for f in fields { - if ui.selectable_label(f == cur, f).clicked() { - let _ = app.run("layer.symbology", json!({"type": kind, "field": f})); + let input_label = ui.label(tl!(label)); + egui::ComboBox::from_id_salt(("sym_field", kind)) + .selected_text(cur) + .width(200.0) + .show_ui(ui, |ui| { + for f in fields { + if ui.selectable_label(f == cur, f).clicked() { + let _ = app.run("layer.symbology", json!({"type": kind, "field": f})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); }); } @@ -3891,27 +3918,31 @@ fn symbol_editor(app: &mut RustGisApp, ui: &mut Ui, s: Symbol, g: GeometryType, let _ = app.run(key, json!({"color": rgba(c).hex()})); } ui.end_row(); - ui.label(tl!(if g == GeometryType::Polyline { "Line width" } else { "Outline width" })); + let input_label = ui.label(tl!(if g == GeometryType::Polyline { "Line width" } else { "Outline width" })); let mut w = s.stroke_width; - if ui.add(egui::DragValue::new(&mut w).range(0.0..=12.0).speed(0.05).suffix(" pt")).changed() { + if ui.add(egui::DragValue::new(&mut w).range(0.0..=12.0).speed(0.05).suffix(" pt")).labelled_by(input_label.id).changed() { let _ = app.run("layer.outline", json!({"width": w})); } ui.end_row(); if g == GeometryType::Point { - ui.label(tl!("Size")); + let input_label = ui.label(tl!("Size")); let mut sz = s.size; - if ui.add(egui::DragValue::new(&mut sz).range(1.0..=60.0).speed(0.1).suffix(" pt")).changed() { + if ui.add(egui::DragValue::new(&mut sz).range(1.0..=60.0).speed(0.1).suffix(" pt")).labelled_by(input_label.id).changed() { let _ = app.run("layer.marker", json!({"size": sz})); } ui.end_row(); - ui.label(tl!("Shape")); - egui::ComboBox::from_id_salt("marker_shape").selected_text(tl!(s.marker.label())).show_ui(ui, |ui| { - for m in Marker::ALL { - if ui.selectable_label(m == s.marker, tl!(m.label())).clicked() { - let _ = app.run("layer.marker", json!({"shape": m.label()})); + let input_label = ui.label(tl!("Shape")); + egui::ComboBox::from_id_salt("marker_shape") + .selected_text(tl!(s.marker.label())) + .show_ui(ui, |ui| { + for m in Marker::ALL { + if ui.selectable_label(m == s.marker, tl!(m.label())).clicked() { + let _ = app.run("layer.marker", json!({"shape": m.label()})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); } }); @@ -4046,24 +4077,28 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma }); if !classes.is_empty() { ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Strokes for")).size(11.5).color(t.text_dim)); + let input_label = ui.label(egui::RichText::new(tl!("Strokes for")).size(11.5).color(t.text_dim)); let name = |c: Option<&ClassRef>| match c.and_then(|c| classes.iter().find(|(k, ..)| k == c)) { Some((_, label, _)) => label.clone(), None => tl!("All classes").to_string(), }; - egui::ComboBox::from_id_salt(("stroke_class_pick", l.id)).selected_text(name(class)).show_ui(ui, |ui| { - if ui.selectable_label(class.is_none(), tl!("All classes")).on_hover_text(tl!("The layer's strokes")).clicked() { - ui.data_mut(|d| d.insert_temp::>(picked_id, None)); - } - for (k, label, _) in &classes { - // A class with strokes of its own is marked. - let has_own = l.renderer.class_strokes(k).is_ok_and(|s| !s.is_empty()); - let text = if has_own { format!("{label} •") } else { label.clone() }; - if ui.selectable_label(class == Some(k), text).clicked() { - ui.data_mut(|d| d.insert_temp(picked_id, Some(k.clone()))); + egui::ComboBox::from_id_salt(("stroke_class_pick", l.id)) + .selected_text(name(class)) + .show_ui(ui, |ui| { + if ui.selectable_label(class.is_none(), tl!("All classes")).on_hover_text(tl!("The layer's strokes")).clicked() { + ui.data_mut(|d| d.insert_temp::>(picked_id, None)); } - } - }); + for (k, label, _) in &classes { + // A class with strokes of its own is marked. + let has_own = l.renderer.class_strokes(k).is_ok_and(|s| !s.is_empty()); + let text = if has_own { format!("{label} •") } else { label.clone() }; + if ui.selectable_label(class == Some(k), text).clicked() { + ui.data_mut(|d| d.insert_temp(picked_id, Some(k.clone()))); + } + } + }) + .response + .labelled_by(input_label.id); }); } if l.has_strokes() { @@ -4191,7 +4226,7 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma ui.horizontal(|ui| { let mut ww = w; let drag = egui::DragValue::new(&mut ww).range(0.0..=MAX_STROKE_WIDTH).speed(0.05).suffix(" pt"); - if ui.add_enabled(st.width.is_some(), drag).changed() { + if ui.add_enabled(st.width.is_some(), drag).on_hover_text(tl!("Width")).changed() { set(app, LineStroke { width: Some(ww), ..st }); } let mut from_symbol = st.width.is_none(); @@ -4211,23 +4246,31 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma set(app, LineStroke { offset: off, ..st }); } ui.end_row(); - ui.label(tl!("Cap")); - egui::ComboBox::from_id_salt(("stroke_cap", i)).selected_text(tl!(st.cap.label())).show_ui(ui, |ui| { - for c in LineCap::ALL { - if ui.selectable_label(c == st.cap, tl!(c.label())).clicked() { - set(app, LineStroke { cap: c, ..st }); + let input_label = ui.label(tl!("Cap")); + egui::ComboBox::from_id_salt(("stroke_cap", i)) + .selected_text(tl!(st.cap.label())) + .show_ui(ui, |ui| { + for c in LineCap::ALL { + if ui.selectable_label(c == st.cap, tl!(c.label())).clicked() { + set(app, LineStroke { cap: c, ..st }); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Join")); - egui::ComboBox::from_id_salt(("stroke_join", i)).selected_text(tl!(st.join.label())).show_ui(ui, |ui| { - for j in LineJoin::ALL { - if ui.selectable_label(j == st.join, tl!(j.label())).clicked() { - set(app, LineStroke { join: j, ..st }); + let input_label = ui.label(tl!("Join")); + egui::ComboBox::from_id_salt(("stroke_join", i)) + .selected_text(tl!(st.join.label())) + .show_ui(ui, |ui| { + for j in LineJoin::ALL { + if ui.selectable_label(j == st.join, tl!(j.label())).clicked() { + set(app, LineStroke { join: j, ..st }); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); ui.label(tl!("Dash")); ui.horizontal(|ui| { @@ -4259,22 +4302,29 @@ fn line_strokes_editor(app: &mut RustGisApp, ui: &mut Ui, l: &rustgis_engine::ma } } let preset = egui::ComboBox::from_id_salt(("stroke_dash_preset", i)).selected_text("").width(24.0); - preset.show_ui(ui, |ui| { - for (label, steps) in DASH_PRESETS { - // Scaled by the stroke's width, so a thick line's dashes stay in proportion. - let dash = steps.and_then(|s| Dash::new(&s.iter().map(|v| v * w.max(1.0)).collect::>())); - if ui.selectable_label(dash == st.dash, tl!(label)).clicked() { - set(app, LineStroke { dash, ..st }); + preset + .show_ui(ui, |ui| { + for (label, steps) in DASH_PRESETS { + // Scaled by the stroke's width, so a thick line's dashes stay in proportion. + let dash = steps.and_then(|s| Dash::new(&s.iter().map(|v| v * w.max(1.0)).collect::>())); + if ui.selectable_label(dash == st.dash, tl!(label)).clicked() { + set(app, LineStroke { dash, ..st }); + } } - } - }); + }) + .response + .on_hover_text(tl!("Dash")); }); ui.end_row(); if l.stroke_levels { ui.label(tl!("Level")); ui.horizontal(|ui| { let mut lv = st.level_at(i); - if ui.add_enabled(st.level.is_some(), egui::DragValue::new(&mut lv).range(0..=255).speed(0.1)).changed() { + if ui + .add_enabled(st.level.is_some(), egui::DragValue::new(&mut lv).range(0..=255).speed(0.1)) + .on_hover_text(tl!("Level")) + .changed() + { set(app, LineStroke { level: Some(lv), ..st }); } let mut auto = st.level.is_none(); @@ -4588,10 +4638,10 @@ fn rgb_stretch_bounds(app: &mut RustGisApp, ui: &mut Ui, layer: impl std::hash:: let speed = ((hi - lo).abs() / 200.0).clamp(1e-6, 1e6); ui.horizontal(|ui| { ui.add_sized([44.0, 18.0], egui::Label::new(name)); - ui.label(tl!("Min")); - let a = ui.add(egui::DragValue::new(&mut lo).speed(speed)); - ui.label(tl!("Max")); - let b = ui.add(egui::DragValue::new(&mut hi).speed(speed)); + let input_label = ui.label(tl!("Min")); + let a = ui.add(egui::DragValue::new(&mut lo).speed(speed)).labelled_by(input_label.id); + let input_label = ui.label(tl!("Max")); + let b = ui.add(egui::DragValue::new(&mut hi).speed(speed)).labelled_by(input_label.id); done |= a.drag_stopped() || b.drag_stopped() || ((a.changed() || b.changed()) && !a.dragged() && !b.dragged()); changed |= a.changed() || b.changed(); }); @@ -4674,7 +4724,7 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { let bands: Vec = d.bands.iter().map(|b| b.name.clone()).collect(); let band_combo = |app: &mut RustGisApp, ui: &mut Ui, label: &str, cur: usize, run: &dyn Fn(usize) -> (String, Value)| { ui.horizontal(|ui| { - ui.label(tl!(label)); + let input_label = ui.label(tl!(label)); egui::ComboBox::from_id_salt(("raster_band", label)) .selected_text(band_name(bands.get(cur).map(String::as_str).unwrap_or(""))) .width(170.0) @@ -4685,7 +4735,9 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { let _ = app.run(&id, p); } } - }); + }) + .response + .labelled_by(input_label.id); }); }; match &style.renderer { @@ -4710,20 +4762,27 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { ("raster.symbology".into(), json!({"type": "classify", "band": i + 1, "classes": k, "method": format!("{m:?}")})) }); ui.horizontal(|ui| { - ui.label(tl!("Method")); - egui::ComboBox::from_id_salt("raster_method").selected_text(tl!(method.label())).width(180.0).show_ui(ui, |ui| { - for mm in ClassMethod::ALL { - if ui.selectable_label(mm == m, tl!(mm.label())).clicked() { - let _ = app - .run("raster.symbology", json!({"type": "classify", "method": format!("{mm:?}"), "classes": k, "ramp": r})); + let input_label = ui.label(tl!("Method")); + egui::ComboBox::from_id_salt("raster_method") + .selected_text(tl!(method.label())) + .width(180.0) + .show_ui(ui, |ui| { + for mm in ClassMethod::ALL { + if ui.selectable_label(mm == m, tl!(mm.label())).clicked() { + let _ = app.run( + "raster.symbology", + json!({"type": "classify", "method": format!("{mm:?}"), "classes": k, "ramp": r}), + ); + } } - } - }); + }) + .response + .labelled_by(input_label.id); }); ui.horizontal(|ui| { - ui.label(tl!("Classes")); + let input_label = ui.label(tl!("Classes")); let mut kk = k; - if ui.add(egui::DragValue::new(&mut kk).range(1..=32)).changed() { + if ui.add(egui::DragValue::new(&mut kk).range(1..=32)).labelled_by(input_label.id).changed() { let _ = app.run("raster.symbology", json!({"type": "classify", "method": format!("{m:?}"), "classes": kk, "ramp": r})); } }); @@ -4754,11 +4813,11 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { for (label, v, range, speed, which) in [("Azimuth", az, 0.0..=360.0, 1.0, 0), ("Altitude", alt, 0.0..=90.0, 0.5, 1), ("Z factor", zf, 0.001..=1000.0, 0.01, 2)] { - ui.label(tl!(label)); + let input_label = ui.label(tl!(label)); let mut x = v; // Kept across a drag until it is let go (#607). let drag = Dragged::lend(ui, egui::Id::unique(("relief_drag", l.id, which)), &mut x); - let r = ui.add(egui::DragValue::new(&mut x).range(range).speed(speed).max_decimals(3)); + let r = ui.add(egui::DragValue::new(&mut x).range(range).speed(speed).max_decimals(3)).labelled_by(input_label.id); if drag.settled(ui, &r, &mut x) && x != v { let (a, b, c) = match which { 0 => (x, alt, zf), @@ -4789,27 +4848,32 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { if !matches!(style.renderer, RasterRenderer::Colormap | RasterRenderer::UniqueValues { .. } | RasterRenderer::Classify { .. }) { ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(tl!("Stretch type")); - egui::ComboBox::from_id_salt("raster_stretch").selected_text(tl!(style.stretch.label())).width(170.0).show_ui(ui, |ui| { - for (code, label) in Stretch::KINDS { - if ui.selectable_label(style.stretch.code() == code, tl!(label)).clicked() { - let _ = app.run("raster.stretch", json!({"type": code})); + let input_label = ui.label(tl!("Stretch type")); + egui::ComboBox::from_id_salt("raster_stretch") + .selected_text(tl!(style.stretch.label())) + .width(170.0) + .show_ui(ui, |ui| { + for (code, label) in Stretch::KINDS { + if ui.selectable_label(style.stretch.code() == code, tl!(label)).clicked() { + let _ = app.run("raster.stretch", json!({"type": code})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); }); match style.stretch { Stretch::PercentClip { low, high } => { ui.horizontal(|ui| { let (mut lo, mut hi) = (low, high); // Each kept across a drag until it is let go (#607); both are sent together. - ui.label(tl!("Min %")); + let input_label = ui.label(tl!("Min %")); let drag = Dragged::lend(ui, egui::Id::unique(("percent_clip_drag", l.id, "low")), &mut lo); - let a = ui.add(egui::DragValue::new(&mut lo).range(0.0..=49.0).speed(0.1).max_decimals(2)); + let a = ui.add(egui::DragValue::new(&mut lo).range(0.0..=49.0).speed(0.1).max_decimals(2)).labelled_by(input_label.id); let low_settled = drag.settled(ui, &a, &mut lo); - ui.label(tl!("Max %")); + let input_label = ui.label(tl!("Max %")); let drag = Dragged::lend(ui, egui::Id::unique(("percent_clip_drag", l.id, "high")), &mut hi); - let b = ui.add(egui::DragValue::new(&mut hi).range(0.0..=49.0).speed(0.1).max_decimals(2)); + let b = ui.add(egui::DragValue::new(&mut hi).range(0.0..=49.0).speed(0.1).max_decimals(2)).labelled_by(input_label.id); let high_settled = drag.settled(ui, &b, &mut hi); if (low_settled || high_settled) && (lo != low || hi != high) { let _ = app.run("raster.stretch", json!({"type": "percentClip", "low": lo, "high": hi})); @@ -4836,10 +4900,10 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { let [mut lo, mut hi] = pending.map_or([min, max], |(_, edit)| edit); let speed = ((max - min).abs() / 200.0).clamp(1e-6, 1e6); ui.horizontal(|ui| { - ui.label(tl!("Min")); - let a = ui.add(egui::DragValue::new(&mut lo).speed(speed)); - ui.label(tl!("Max")); - let b = ui.add(egui::DragValue::new(&mut hi).speed(speed)); + let input_label = ui.label(tl!("Min")); + let a = ui.add(egui::DragValue::new(&mut lo).speed(speed)).labelled_by(input_label.id); + let input_label = ui.label(tl!("Max")); + let b = ui.add(egui::DragValue::new(&mut hi).speed(speed)).labelled_by(input_label.id); let done = a.drag_stopped() || b.drag_stopped() || ((a.changed() || b.changed()) && !a.dragged() && !b.dragged()); if lo.is_finite() && hi.is_finite() && lo < hi && done { ui.data_mut(|d| d.remove::<([f64; 2], [f64; 2])>(key)); @@ -4855,10 +4919,10 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { Stretch::StandardDeviation { n } => { ui.horizontal(|ui| { let mut nn = n; - ui.label(tl!("Deviations")); + let input_label = ui.label(tl!("Deviations")); // Kept across a drag until it is let go (#607). let drag = Dragged::lend(ui, egui::Id::unique(("deviations_drag", l.id)), &mut nn); - let a = ui.add(egui::DragValue::new(&mut nn).range(0.1..=10.0).speed(0.05).max_decimals(2)); + let a = ui.add(egui::DragValue::new(&mut nn).range(0.1..=10.0).speed(0.05).max_decimals(2)).labelled_by(input_label.id); if drag.settled(ui, &a, &mut nn) && nn != n { let _ = app.run("raster.stretch", json!({"type": "standardDeviation", "n": nn})); } @@ -4872,14 +4936,19 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { } } ui.horizontal(|ui| { - ui.label(tl!("Resampling")); - egui::ComboBox::from_id_salt("raster_resampling").selected_text(tl!(style.resampling.label())).width(170.0).show_ui(ui, |ui| { - for r in Resampling::ALL { - if ui.selectable_label(r == style.resampling, tl!(r.label())).clicked() { - let _ = app.run("raster.resampling", json!({"type": format!("{r:?}")})); + let input_label = ui.label(tl!("Resampling")); + egui::ComboBox::from_id_salt("raster_resampling") + .selected_text(tl!(style.resampling.label())) + .width(170.0) + .show_ui(ui, |ui| { + for r in Resampling::ALL { + if ui.selectable_label(r == style.resampling, tl!(r.label())).clicked() { + let _ = app.run("raster.resampling", json!({"type": format!("{r:?}")})); + } } - } - }); + }) + .response + .labelled_by(input_label.id); }); if matches!(style.renderer, RasterRenderer::Stretch { .. }) { raster_default_buttons(app, ui, &style); @@ -4898,9 +4967,9 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { ui.label(egui::RichText::new(tl!("Layer")).size(11.5).color(t.text_dim)); let mut tr = l.transparency; ui.horizontal(|ui| { - ui.label(tl!("Transparency")); + let input_label = ui.label(tl!("Transparency")); ui.spacing_mut().slider_width = 150.0; - let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix(" %").integer()); + let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix(" %").integer()).labelled_by(input_label.id); transparency_drag(app, ui, &r, l, tr); }); } @@ -4949,25 +5018,30 @@ fn raster_masking(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, d: &rustgis_engi 1 }; ui.horizontal(|ui| { - ui.label(tl!("Range")); + let input_label = ui.label(tl!("Range")); let shown = modes.iter().find(|(m, _)| *m == cur).map(|(_, label)| label.to_string()).unwrap_or_default(); - egui::ComboBox::from_id_salt(("raster_mask_range", l.id)).selected_text(shown).width(190.0).show_ui(ui, |ui| { - for (m, label) in modes { - if ui.selectable_label(m == cur, label).clicked() && m != cur { - let p = match m { - 0 => json!({"min": null, "max": null, "inside": false}), - // Starting from the values of the band drawn. - _ if !ranged => { - let band = style.renderer.bands().first().copied().unwrap_or(0); - let (lo, hi) = d.bands.get(band).map_or((0.0, 1.0), |b| (b.stats.min, b.stats.max)); - json!({"min": lo, "max": hi, "inside": m == 2}) - } - _ => json!({"inside": m == 2}), - }; - let _ = app.run("raster.mask", p); + egui::ComboBox::from_id_salt(("raster_mask_range", l.id)) + .selected_text(shown) + .width(190.0) + .show_ui(ui, |ui| { + for (m, label) in modes { + if ui.selectable_label(m == cur, label).clicked() && m != cur { + let p = match m { + 0 => json!({"min": null, "max": null, "inside": false}), + // Starting from the values of the band drawn. + _ if !ranged => { + let band = style.renderer.bands().first().copied().unwrap_or(0); + let (lo, hi) = d.bands.get(band).map_or((0.0, 1.0), |b| (b.stats.min, b.stats.max)); + json!({"min": lo, "max": hi, "inside": m == 2}) + } + _ => json!({"inside": m == 2}), + }; + let _ = app.run("raster.mask", p); + } } - } - }); + }) + .response + .labelled_by(input_label.id); }); if ranged { // As with a custom stretch: kept here until both bounds are valid. @@ -4978,10 +5052,10 @@ fn raster_masking(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, d: &rustgis_engi let span = if min.is_finite() && max.is_finite() { (max - min).abs() } else { 100.0 }; let speed = (span / 200.0).clamp(1e-6, 1e6); ui.horizontal(|ui| { - ui.label(tl!("Min")); - let a = ui.add(egui::DragValue::new(&mut lo).speed(speed)); - ui.label(tl!("Max")); - let b = ui.add(egui::DragValue::new(&mut hi).speed(speed)); + let input_label = ui.label(tl!("Min")); + let a = ui.add(egui::DragValue::new(&mut lo).speed(speed)).labelled_by(input_label.id); + let input_label = ui.label(tl!("Max")); + let b = ui.add(egui::DragValue::new(&mut hi).speed(speed)).labelled_by(input_label.id); let done = a.drag_stopped() || b.drag_stopped() || ((a.changed() || b.changed()) && !a.dragged() && !b.dragged()); if lo <= hi && done { ui.data_mut(|d| d.remove::<([f64; 2], [f64; 2])>(key)); @@ -5170,18 +5244,14 @@ mod tests { app.session.project.folders.push(long.clone()); let fav = rustgis_engine::sources::Source { kind: "file".into(), name: long.clone(), address: long, ..Default::default() }; app.session.sources.favourites.push(fav); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(1400.0, 860.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); let width = |h: &mut egui_kittest::Harness<'_, RustGisApp>, pane: LeftPane| { h.state_mut().ui.left = Some(pane); for _ in 0..6 { @@ -5255,18 +5325,14 @@ mod tests { .unwrap(); app.run("layer.definitionQuery", json!({"where": "c_valid < 30"})).unwrap(); app.ui.open_right(RightPane::Symbology); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(1400.0, 900.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.run_steps(6); h } @@ -5424,18 +5490,14 @@ mod tests { let id = layer(&app, "Countries"); app.session.active_layer = Some(id); app.ui.open_right(RightPane::Symbology); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(1400.0, 1400.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 1400.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.run_steps(6); let transparency = |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer(id).map(|l| l.transparency); let start = transparency(&h); @@ -5473,18 +5535,14 @@ mod tests { app.run("layer.symbology", json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 1})).unwrap(); app.run("layer.classStyle", json!({"layer": "Countries", "index": 0, "color": original.hex8()})).unwrap(); let id = layer(&app, "Countries"); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(600.0, 600.0)) - .with_max_steps(64) - .build_ui_state( - move |ui, app: &mut RustGisApp| { - finish_class_color_drag(app, ui); - let layer = app.session.map().layer(id).unwrap().clone(); - legend_list(app, ui, &layer, &Tokens::light()); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 600.0)).with_max_steps(64).build_ui_state( + move |ui, app: &mut RustGisApp| { + finish_class_color_drag(app, ui); + let layer = app.session.map().layer(id).unwrap().clone(); + legend_list(app, ui, &layer, &Tokens::light()); + }, + app, + ); h.run_steps(3); let fill = |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer(id).unwrap().renderer.legend()[0].symbol.fill; @@ -5530,18 +5588,14 @@ mod tests { app.run("layer.symbology", json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "classes": 1})).unwrap(); app.run("layer.classStyle", json!({"layer": "Countries", "index": 0, "color": "#FF000000"})).unwrap(); let id = layer(&app, "Countries"); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(600.0, 600.0)) - .with_max_steps(64) - .build_ui_state( - move |ui, app: &mut RustGisApp| { - finish_class_color_drag(app, ui); - let layer = app.session.map().layer(id).unwrap().clone(); - legend_list(app, ui, &layer, &Tokens::light()); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(vec2(600.0, 600.0)).with_max_steps(64).build_ui_state( + move |ui, app: &mut RustGisApp| { + finish_class_color_drag(app, ui); + let layer = app.session.map().layer(id).unwrap().clone(); + legend_list(app, ui, &layer, &Tokens::light()); + }, + app, + ); h.run_steps(3); let fill = |h: &egui_kittest::Harness<'_, RustGisApp>| h.state().session.map().layer(id).unwrap().renderer.legend()[0].symbol.fill; let inactive_theme = match h.ctx.theme() { @@ -5591,9 +5645,7 @@ mod tests { fn an_open_layer_menu_leaves_the_map_alone() { let app = RustGisApp::with_sample(); let layer = app.session.map().layers.iter().find(|l| l.name == "Cities").cloned().unwrap(); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .build_ui_state(|ui, app: &mut RustGisApp| layer_menu(app, ui, &layer, true), app); + let mut h = egui_kittest::Harness::builder().build_ui_state(|ui, app: &mut RustGisApp| layer_menu(app, ui, &layer, true), app); h.step(); assert_eq!(h.state().session.active_layer, Some(layer.id)); let rev = h.state().session.revision; diff --git a/crates/ui-egui/src/pluginsui.rs b/crates/ui-egui/src/pluginsui.rs index e9f66e385..aff068d5c 100644 --- a/crates/ui-egui/src/pluginsui.rs +++ b/crates/ui-egui/src/pluginsui.rs @@ -673,12 +673,17 @@ fn browse_tab(app: &mut RustGisApp, ui: &mut egui::Ui, t: &Tokens) { ui.add(egui::TextEdit::singleline(filter).hint_text(tl!("Search plugins")).desired_width(220.0)); let all = tl!("All categories"); let shown = if category.is_empty() { all.to_string() } else { category.clone() }; - egui::ComboBox::from_id_salt("plugin-category").selected_text(shown).width(150.0).show_ui(ui, |ui| { - ui.selectable_value(category, String::new(), all); - for c in &browse.categories { - ui.selectable_value(category, c.clone(), c.as_str()); - } - }); + egui::ComboBox::from_id_salt("plugin-category") + .selected_text(shown) + .width(150.0) + .show_ui(ui, |ui| { + ui.selectable_value(category, String::new(), all); + for c in &browse.categories { + ui.selectable_value(category, c.clone(), c.as_str()); + } + }) + .response + .on_hover_text(tl!("Category")); if ui.button(tl!("Refresh")).clicked() { app.plugins.set_registry(None); refresh(app, ui.ctx()); diff --git a/crates/ui-egui/src/portalsui.rs b/crates/ui-egui/src/portalsui.rs index 7c94619dd..62c9151f8 100644 --- a/crates/ui-egui/src/portalsui.rs +++ b/crates/ui-egui/src/portalsui.rs @@ -135,24 +135,29 @@ pub fn osm(app: &mut RustGisApp, ui: &mut egui::Ui) { let busy = app.ui.portals.waiting.is_some(); egui::Grid::new("osm").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { let st = &mut app.ui.portals; - ui.label(tl!("Preset")); + let input_label = ui.label(tl!("Preset")); let shown = overpass::PRESETS .iter() .find(|(id, _, _)| *id == st.preset) .map(|(_, l, _)| tl!(*l).to_string()) .unwrap_or_else(|| tl!("None").to_string()); - egui::ComboBox::from_id_salt("osm_preset").selected_text(shown).width(260.0).show_ui(ui, |ui| { - ui.selectable_value(&mut st.preset, String::new(), tl!("None")); - for (id, label, _) in overpass::PRESETS { - ui.selectable_value(&mut st.preset, (*id).to_string(), tl!(*label)); - } - }); + egui::ComboBox::from_id_salt("osm_preset") + .selected_text(shown) + .width(260.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut st.preset, String::new(), tl!("None")); + for (id, label, _) in overpass::PRESETS { + ui.selectable_value(&mut st.preset, (*id).to_string(), tl!(*label)); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Tags")); - ui.add(egui::TextEdit::singleline(&mut st.tags).hint_text("amenity=cafe, shop").desired_width(260.0)); + let input_label = ui.label(tl!("Tags")); + ui.add(egui::TextEdit::singleline(&mut st.tags).hint_text("amenity=cafe, shop").desired_width(260.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Server")); - ui.add(egui::TextEdit::singleline(&mut st.server).hint_text(overpass::OVERPASS).desired_width(260.0)); + let input_label = ui.label(tl!("Server")); + ui.add(egui::TextEdit::singleline(&mut st.server).hint_text(overpass::OVERPASS).desired_width(260.0)).labelled_by(input_label.id); ui.end_row(); }); if ui.add_enabled(!busy, egui::Button::new(tl!("Download"))).clicked() { @@ -191,9 +196,9 @@ pub fn boundaries(app: &mut RustGisApp, ui: &mut egui::Ui) { } egui::Grid::new("boundaries").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { let st = &mut app.ui.portals; - ui.label(tl!("Country")); + let input_label = ui.label(tl!("Country")); if st.countries.is_empty() { - ui.add(egui::TextEdit::singleline(&mut st.country).hint_text("CHE").desired_width(260.0)); + ui.add(egui::TextEdit::singleline(&mut st.country).hint_text("CHE").desired_width(260.0)).labelled_by(input_label.id); } else { let shown = st .countries @@ -201,26 +206,37 @@ pub fn boundaries(app: &mut RustGisApp, ui: &mut egui::Ui) { .find(|(iso, _)| *iso == st.country) .map(|(iso, n)| format!("{n} ({iso})")) .unwrap_or_else(|| tl!("Choose a country").to_string()); - egui::ComboBox::from_id_salt("gb_country").selected_text(shown).width(260.0).height(360.0).show_ui(ui, |ui| { - for (iso, name) in &st.countries { - ui.selectable_value(&mut st.country, iso.clone(), format!("{name} ({iso})")); - } - }); + egui::ComboBox::from_id_salt("gb_country") + .selected_text(shown) + .width(260.0) + .height(360.0) + .show_ui(ui, |ui| { + for (iso, name) in &st.countries { + ui.selectable_value(&mut st.country, iso.clone(), format!("{name} ({iso})")); + } + }) + .response + .on_hover_text(tl!("Country")); } ui.end_row(); - ui.label(tl!("Level")); - egui::ComboBox::from_id_salt("gb_level").selected_text(st.level.clone()).width(260.0).show_ui(ui, |ui| { - for (l, label) in [ - ("ADM0", "ADM0 — the country"), - ("ADM1", "ADM1 — states, provinces"), - ("ADM2", "ADM2 — districts, counties"), - ("ADM3", "ADM3"), - ("ADM4", "ADM4"), - ("ADM5", "ADM5"), - ] { - ui.selectable_value(&mut st.level, l.to_string(), tl!(label)); - } - }); + let input_label = ui.label(tl!("Level")); + egui::ComboBox::from_id_salt("gb_level") + .selected_text(st.level.clone()) + .width(260.0) + .show_ui(ui, |ui| { + for (l, label) in [ + ("ADM0", "ADM0 — the country"), + ("ADM1", "ADM1 — states, provinces"), + ("ADM2", "ADM2 — districts, counties"), + ("ADM3", "ADM3"), + ("ADM4", "ADM4"), + ("ADM5", "ADM5"), + ] { + ui.selectable_value(&mut st.level, l.to_string(), tl!(label)); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); }); if ui.add_enabled(!busy, egui::Button::new(tl!("Add"))).clicked() { diff --git a/crates/ui-egui/src/reportpane.rs b/crates/ui-egui/src/reportpane.rs index 900f07d9e..398f768a1 100644 --- a/crates/ui-egui/src/reportpane.rs +++ b/crates/ui-egui/src/reportpane.rs @@ -63,14 +63,19 @@ pub fn report(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let names: Vec = app.session.project.reports.iter().map(|r| r.name.clone()).collect(); let current = names.get(i).cloned().unwrap_or_default(); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("report_pick").selected_text(current.as_str()).width(ui.available_width() - 70.0).show_ui(ui, |ui| { - for (j, n) in names.iter().enumerate() { - if ui.selectable_label(j == i, n.as_str()).clicked() { - let _ = app.run("report.activate", json!({"report": j})); - app.reports.page = 0; + egui::ComboBox::from_id_salt("report_pick") + .selected_text(current.as_str()) + .width(ui.available_width() - 70.0) + .show_ui(ui, |ui| { + for (j, n) in names.iter().enumerate() { + if ui.selectable_label(j == i, n.as_str()).clicked() { + let _ = app.run("report.activate", json!({"report": j})); + app.reports.page = 0; + } } - } - }); + }) + .response + .on_hover_text(tl!("Report")); if ui.small_button(tl!("Delete")).clicked() { run(app, "report.remove", json!({"report": i})); app.reports.page = 0; @@ -92,8 +97,9 @@ pub fn report(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { app.reports.where_clause = r.where_clause.clone(); } ui.horizontal(|ui| { - ui.label(tl!("Title")); - let resp = ui.add(egui::TextEdit::singleline(&mut app.reports.title).desired_width(ui.available_width())); + let input_label = ui.label(tl!("Title")); + let resp = + ui.add(egui::TextEdit::singleline(&mut app.reports.title).desired_width(ui.available_width())).labelled_by(input_label.id); if resp.lost_focus() { if app.reports.title != r.title { let title = app.reports.title.clone(); @@ -104,10 +110,14 @@ pub fn report(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }); ui.horizontal(|ui| { - ui.label(tl!("Where")); - let resp = ui.add( - egui::TextEdit::singleline(&mut app.reports.where_clause).hint_text("pop_max > 1000000").desired_width(ui.available_width()), - ); + let input_label = ui.label(tl!("Where")); + let resp = ui + .add( + egui::TextEdit::singleline(&mut app.reports.where_clause) + .hint_text("pop_max > 1000000") + .desired_width(ui.available_width()), + ) + .labelled_by(input_label.id); if resp.lost_focus() { if app.reports.where_clause != r.where_clause { let w = app.reports.where_clause.clone(); @@ -136,9 +146,9 @@ pub fn report(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { run(app, "report.autofill", json!({"report": i})); } let sort = r.sort.clone().unwrap_or_default(); - egui::ComboBox::from_id_salt("report_sort").selected_text(crate::i18n::fmt("Sort: {field}", &[("field", &sort)])).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt("report_sort") + .selected_text(crate::i18n::fmt("Sort: {field}", &[("field", &sort)])) + .show_ui(ui, |ui| { if ui.selectable_label(r.sort.is_none(), tl!("None")).clicked() { run(app, "report.update", json!({"report": i, "sort": null})); } @@ -147,8 +157,9 @@ pub fn report(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { run(app, "report.update", json!({"report": i, "sort": f})); } } - }, - ); + }) + .response + .on_hover_text(tl!("Sort")); let mut desc = r.descending; if ui.checkbox(&mut desc, tl!("Descending")).changed() { run(app, "report.update", json!({"report": i, "descending": desc})); @@ -164,13 +175,17 @@ pub fn report(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }); } - egui::ComboBox::from_id_salt("report_group").selected_text(tl!("Add a group…")).show_ui(ui, |ui| { - for f in &fields { - if ui.selectable_label(false, f.as_str()).clicked() { - run(app, "report.group", json!({"report": i, "field": f})); + egui::ComboBox::from_id_salt("report_group") + .selected_text(tl!("Add a group…")) + .show_ui(ui, |ui| { + for f in &fields { + if ui.selectable_label(false, f.as_str()).clicked() { + run(app, "report.group", json!({"report": i, "field": f})); + } } - } - }); + }) + .response + .on_hover_text(tl!("Add a group…")); heading(ui, "Statistics"); for st in &r.stats { @@ -186,16 +201,26 @@ pub fn report(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if app.reports.stat_kind.is_empty() { app.reports.stat_kind = "sum".into(); } - egui::ComboBox::from_id_salt("report_stat_kind").selected_text(app.reports.stat_kind.as_str()).width(70.0).show_ui(ui, |ui| { - for k in ["count", "sum", "mean", "min", "max"] { - ui.selectable_value(&mut app.reports.stat_kind, k.to_string(), k); - } - }); - egui::ComboBox::from_id_salt("report_stat_field").selected_text(app.reports.stat_field.as_str()).width(120.0).show_ui(ui, |ui| { - for f in &fields { - ui.selectable_value(&mut app.reports.stat_field, f.clone(), f.as_str()); - } - }); + egui::ComboBox::from_id_salt("report_stat_kind") + .selected_text(app.reports.stat_kind.as_str()) + .width(70.0) + .show_ui(ui, |ui| { + for k in ["count", "sum", "mean", "min", "max"] { + ui.selectable_value(&mut app.reports.stat_kind, k.to_string(), k); + } + }) + .response + .on_hover_text(tl!("Statistic")); + egui::ComboBox::from_id_salt("report_stat_field") + .selected_text(app.reports.stat_field.as_str()) + .width(120.0) + .show_ui(ui, |ui| { + for f in &fields { + ui.selectable_value(&mut app.reports.stat_field, f.clone(), f.as_str()); + } + }) + .response + .on_hover_text(tl!("Field")); if ui.small_button(tl!("Add")).clicked() { let (field, kind) = (app.reports.stat_field.clone(), app.reports.stat_kind.clone()); run(app, "report.statistics", json!({"report": i, "field": field, "kind": kind})); @@ -209,18 +234,22 @@ pub fn report(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ("Chart categories", "report.chart", r.chart.as_ref().map(|c| c.category.clone())), ] { ui.horizontal(|ui| { - ui.label(tl!(label)); + let input_label = ui.label(tl!(label)); let key = if id == "report.chart" { "category" } else { "field" }; - egui::ComboBox::from_id_salt(id).selected_text(cur.clone().unwrap_or_else(|| tl!("None").to_string())).show_ui(ui, |ui| { - if ui.selectable_label(cur.is_none(), tl!("None")).clicked() { - run(app, id, json!({"report": i, key: null})); - } - for f in &fields { - if ui.selectable_label(cur.as_deref() == Some(f), f.as_str()).clicked() { - run(app, id, json!({"report": i, key: f})); + egui::ComboBox::from_id_salt(id) + .selected_text(cur.clone().unwrap_or_else(|| tl!("None").to_string())) + .show_ui(ui, |ui| { + if ui.selectable_label(cur.is_none(), tl!("None")).clicked() { + run(app, id, json!({"report": i, key: null})); } - } - }); + for f in &fields { + if ui.selectable_label(cur.as_deref() == Some(f), f.as_str()).clicked() { + run(app, id, json!({"report": i, key: f})); + } + } + }) + .response + .labelled_by(input_label.id); }); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e7543fde6..941bc0924 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1817,8 +1817,8 @@ fn view_tab(app: &mut RustGisApp, ui: &mut Ui) { ("Sepia", "sepia", fx.sepia, 0.0), ] { ui.horizontal(|ui| { - ui.label(tl!(label)); - let r = ui.add(egui::Slider::new(&mut val, lo..=100.0)); + let input_label = ui.label(tl!(label)); + let r = ui.add(egui::Slider::new(&mut val, lo..=100.0)).labelled_by(input_label.id); if r.drag_stopped() || (r.changed() && !r.dragged()) { let _ = app.run("map.effects", json!({key: val})); } @@ -3392,8 +3392,10 @@ fn effects_groups(app: &mut RustGisApp, ui: &mut Ui, layer: &rustgis_engine::map tl!("Display Filters").to_string() }; small_form_menu(ui, app, "filter", &label, "Show different features at different scales.", |ui, app| { - ui.label(egui::RichText::new(tl!("Below 1:5,000,000 show everything; beyond it only what matches:")).small().weak()); - ui.add(egui::TextEdit::singleline(&mut app.forms.expression).hint_text("pop_max > 1000000").desired_width(220.0)); + let input_label = + ui.label(egui::RichText::new(tl!("Below 1:5,000,000 show everything; beyond it only what matches:")).small().weak()); + ui.add(egui::TextEdit::singleline(&mut app.forms.expression).hint_text("pop_max > 1000000").desired_width(220.0)) + .labelled_by(input_label.id); if ui.button(tl!("Set Filter")).clicked() { let w = app.forms.expression.trim().to_string(); let _ = @@ -3455,10 +3457,12 @@ fn effects_groups(app: &mut RustGisApp, ui: &mut Ui, layer: &rustgis_engine::map ui.add_space(8.0); let mut k = xx.factor; ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Factor")).size(11.5)); + let input_label = ui.label(egui::RichText::new(tl!("Factor")).size(11.5)); // Kept across a drag until it is let go (#607). let drag = Dragged::lend(ui, egui::Id::unique(("extrusion_factor", layer.id)), &mut k); - let r = ui.add(egui::DragValue::new(&mut k).range(0.0..=1e6).speed(xx.factor.max(0.0001) * 0.02)); + let r = ui + .add(egui::DragValue::new(&mut k).range(0.0..=1e6).speed(xx.factor.max(0.0001) * 0.02)) + .labelled_by(input_label.id); if drag.settled(ui, &r, &mut k) && k != xx.factor { let t = if xx.field.is_some() { "field" } else { "height" }; let _ = app.run("layer.extrusion", json!({"type": t, "field": xx.field, "factor": k})); @@ -3669,22 +3673,27 @@ fn visibility_and_effects(app: &mut RustGisApp, ui: &mut Ui, layer: &rustgis_eng ui.add_space(6.0); for (label, cur, key) in [("Out Beyond", layer.min_scale, "min"), ("In Beyond", layer.max_scale, "max")] { ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(label)).size(11.5)); - egui::ComboBox::from_id_salt(format!("scale_{key}")).selected_text(scale_label(cur)).width(118.0).show_ui(ui, |ui| { - let mut choices: Vec> = vec![None]; - choices.extend(SCALES.iter().map(|s| Some(*s))); - for c in choices { - if ui.selectable_label(c == cur, scale_label(c)).clicked() { - let (mut min, mut max) = (layer.min_scale, layer.max_scale); - if key == "min" { - min = c; - } else { - max = c; + let input_label = ui.label(egui::RichText::new(tl!(label)).size(11.5)); + egui::ComboBox::from_id_salt(format!("scale_{key}")) + .selected_text(scale_label(cur)) + .width(118.0) + .show_ui(ui, |ui| { + let mut choices: Vec> = vec![None]; + choices.extend(SCALES.iter().map(|s| Some(*s))); + for c in choices { + if ui.selectable_label(c == cur, scale_label(c)).clicked() { + let (mut min, mut max) = (layer.min_scale, layer.max_scale); + if key == "min" { + min = c; + } else { + max = c; + } + let _ = app.run("layer.scaleRange", json!({"min": min, "max": max})); } - let _ = app.run("layer.scaleRange", json!({"min": min, "max": max})); } - } - }); + }) + .response + .labelled_by(input_label.id); }); } }); @@ -3701,7 +3710,7 @@ fn visibility_and_effects(app: &mut RustGisApp, ui: &mut Ui, layer: &rustgis_eng }); let mut v = layer.transparency; ui.spacing_mut().slider_width = 130.0; - let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix(" %").integer()); + let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix(" %").integer()).on_hover_text(tl!("Transparency")); live_drag( app, ui, @@ -3888,7 +3897,7 @@ fn raster_style_groups(app: &mut RustGisApp, ui: &mut Ui) { let mut x = app.session.map().swipe.map_or(0.5, |(_, x)| x); ui.vertical(|ui| { ui.add_space(14.0); - if ui.add(egui::Slider::new(&mut x, 0.0..=1.0).show_value(false)).changed() { + if ui.add(egui::Slider::new(&mut x, 0.0..=1.0).show_value(false)).on_hover_text(tl!("Swipe")).changed() { let _ = app.run("layer.swipe", json!({"position": x})); } }); @@ -4022,10 +4031,10 @@ fn raster_style_groups(app: &mut RustGisApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); icons::paint(ui.painter(), r, icon, t.icon, t.accent); - ui.label(egui::RichText::new(tl!(label)).size(11.5)); + let input_label = ui.label(egui::RichText::new(tl!(label)).size(11.5)); ui.add_space((70.0 - text_w(ui, tl!(label), regular(11.5))).max(0.0)); let mut v = value; - let resp = ui.add(egui::DragValue::new(&mut v).range(range).speed(speed).max_decimals(2)); + let resp = ui.add(egui::DragValue::new(&mut v).range(range).speed(speed).max_decimals(2)).labelled_by(input_label.id); live_drag( app, ui, @@ -4177,10 +4186,11 @@ fn label_groups(app: &mut RustGisApp, ui: &mut Ui) { [("Out Beyond", layer.labels.default.min_scale, "min"), ("In Beyond", layer.labels.default.max_scale, "max")] { ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(label)).size(11.5)); - egui::ComboBox::from_id_salt(format!("label_scale_{key}")).selected_text(scale_label(cur)).width(118.0).show_ui( - ui, - |ui| { + let input_label = ui.label(egui::RichText::new(tl!(label)).size(11.5)); + egui::ComboBox::from_id_salt(format!("label_scale_{key}")) + .selected_text(scale_label(cur)) + .width(118.0) + .show_ui(ui, |ui| { let mut choices: Vec> = vec![None]; choices.extend(SCALES.iter().map(|s| Some(*s))); for c in choices { @@ -4188,8 +4198,9 @@ fn label_groups(app: &mut RustGisApp, ui: &mut Ui) { let _ = app.run("labels.scaleRange", json!({key: c})); } } - }, - ); + }) + .response + .labelled_by(input_label.id); }); } }); @@ -4247,11 +4258,11 @@ fn label_groups(app: &mut RustGisApp, ui: &mut Ui) { // The whole range `labels.style` accepts (#457), so a larger label isn't cut back. let mut size = f64::from(layer.labels.default.size); ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Size")).size(11.5)); + let input_label = ui.label(egui::RichText::new(tl!("Size")).size(11.5)); let range = rustgis_engine::map::symbology::LABEL_SIZE; // Another layer gets a distinct widget, so an in-flight drag cannot move to it. ui.push_id(("label_size", layer.id), |ui| { - if ui.add(egui::DragValue::new(&mut size).range(range).speed(0.1).suffix(" pt")).changed() { + if ui.add(egui::DragValue::new(&mut size).range(range).speed(0.1).suffix(" pt")).labelled_by(input_label.id).changed() { label_size_drag(app, ui, &layer, size); } }); @@ -4468,7 +4479,7 @@ mod tests { app.session.run("layer.select", &json!({"layer": "Cities"})).unwrap(); app.ws.recording = Some(Vec::new()); let mut fonts = false; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(vec2(1600.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(vec2(1600.0, 700.0)).build_ui_state( move |ui, app: &mut RustGisApp| { if !fonts { crate::theme::install_fonts(ui.ctx(), &[], None); @@ -4566,18 +4577,14 @@ mod tests { let mut app = RustGisApp::with_sample(); app.session.run("layer.select", &json!({"layer": "Cities"})).unwrap(); app.ui.tab = "Style".into(); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(vec2(1200.0, 800.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.run(); for _ in 0..2 { h.get_by_label("Scroll the ribbon right").click(); diff --git a/crates/ui-egui/src/s3ui.rs b/crates/ui-egui/src/s3ui.rs index 18fc03154..a4757c867 100644 --- a/crates/ui-egui/src/s3ui.rs +++ b/crates/ui-egui/src/s3ui.rs @@ -286,37 +286,43 @@ fn connection_form(app: &mut RustGisApp, ui: &mut egui::Ui) { } let typed = st.source == "keys"; egui::Grid::new("s3_conn").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Name")); - ui.add(egui::TextEdit::singleline(&mut st.name).hint_text("default").desired_width(300.0)); + let input_label = ui.label(tl!("Name")); + ui.add(egui::TextEdit::singleline(&mut st.name).hint_text("default").desired_width(300.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Endpoint")); - ui.add(egui::TextEdit::singleline(&mut st.endpoint).hint_text(tl!("empty for AWS; https://… for others")).desired_width(300.0)); + let input_label = ui.label(tl!("Endpoint")); + ui.add(egui::TextEdit::singleline(&mut st.endpoint).hint_text(tl!("empty for AWS; https://… for others")).desired_width(300.0)) + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Region")); - ui.add(egui::TextEdit::singleline(&mut st.region).hint_text(s3::DEFAULT_REGION).desired_width(300.0)); + let input_label = ui.label(tl!("Region")); + ui.add(egui::TextEdit::singleline(&mut st.region).hint_text(s3::DEFAULT_REGION).desired_width(300.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Keys from")); + let input_label = ui.label(tl!("Keys from")); let shown = SOURCES.iter().find(|(c, _)| *c == st.source).map(|(_, l)| tl!(*l).to_string()).unwrap_or_default(); - egui::ComboBox::from_id_salt("s3_source").selected_text(shown).width(300.0).show_ui(ui, |ui| { - for (code, label) in SOURCES { - // AWS profiles and the environment are the desktop's. - if cfg!(target_arch = "wasm32") && matches!(code, "profile" | "environment") { - continue; + egui::ComboBox::from_id_salt("s3_source") + .selected_text(shown) + .width(300.0) + .show_ui(ui, |ui| { + for (code, label) in SOURCES { + // AWS profiles and the environment are the desktop's. + if cfg!(target_arch = "wasm32") && matches!(code, "profile" | "environment") { + continue; + } + ui.selectable_value(&mut st.source, code.to_string(), tl!(label)); } - ui.selectable_value(&mut st.source, code.to_string(), tl!(label)); - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); if st.source == "profile" { - ui.label(tl!("Profile")); - ui.add(egui::TextEdit::singleline(&mut st.profile).hint_text("default").desired_width(300.0)); + let input_label = ui.label(tl!("Profile")); + ui.add(egui::TextEdit::singleline(&mut st.profile).hint_text("default").desired_width(300.0)).labelled_by(input_label.id); ui.end_row(); } - ui.label(tl!("Access key ID")); - ui.add_enabled(typed, egui::TextEdit::singleline(&mut st.key).desired_width(300.0)); + let input_label = ui.label(tl!("Access key ID")); + ui.add_enabled(typed, egui::TextEdit::singleline(&mut st.key).desired_width(300.0)).labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Secret access key")); - ui.add_enabled(typed, egui::TextEdit::singleline(&mut st.secret).password(true).desired_width(300.0)); + let input_label = ui.label(tl!("Secret access key")); + ui.add_enabled(typed, egui::TextEdit::singleline(&mut st.secret).password(true).desired_width(300.0)).labelled_by(input_label.id); ui.end_row(); }); if ui.button(tl!("Connect")).clicked() { @@ -349,13 +355,18 @@ fn browse(app: &mut RustGisApp, ui: &mut egui::Ui) { let shown = if st.conn.is_empty() { tl!("AWS, anonymous").to_string() } else { st.conn.clone() }; let mut go = false; ui.horizontal(|ui| { - ui.label(tl!("Browse with")); - egui::ComboBox::from_id_salt("s3_browse_conn").selected_text(shown).width(180.0).show_ui(ui, |ui| { - ui.selectable_value(&mut st.conn, String::new(), tl!("AWS, anonymous")); - for c in &conns { - ui.selectable_value(&mut st.conn, c.name.clone(), &c.name); - } - }); + let input_label = ui.label(tl!("Browse with")); + egui::ComboBox::from_id_salt("s3_browse_conn") + .selected_text(shown) + .width(180.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut st.conn, String::new(), tl!("AWS, anonymous")); + for c in &conns { + ui.selectable_value(&mut st.conn, c.name.clone(), &c.name); + } + }) + .response + .labelled_by(input_label.id); let signed = conns.iter().any(|c| c.name == st.conn && c.credentials.is_some()); if ui.add_enabled(signed && st.buckets_job.is_none(), egui::Button::new(tl!("My Buckets"))).clicked() && let Some(c) = s3::connection(&st.conn) @@ -376,8 +387,9 @@ fn browse(app: &mut RustGisApp, ui: &mut egui::Ui) { }); } ui.horizontal(|ui| { - ui.label(tl!("Bucket")); - let r = ui.add(egui::TextEdit::singleline(&mut st.bucket).hint_text("sentinel-cogs").desired_width(260.0)); + let input_label = ui.label(tl!("Bucket")); + let r = + ui.add(egui::TextEdit::singleline(&mut st.bucket).hint_text("sentinel-cogs").desired_width(260.0)).labelled_by(input_label.id); go |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); go |= ui.add_enabled(st.listing_job.is_none(), egui::Button::new(tl!("List"))).clicked(); }); diff --git a/crates/ui-egui/src/scriptcolor.rs b/crates/ui-egui/src/scriptcolor.rs index 22e89d83e..3bc34e6ff 100644 --- a/crates/ui-egui/src/scriptcolor.rs +++ b/crates/ui-egui/src/scriptcolor.rs @@ -429,7 +429,7 @@ mod tests { let mut app = crate::RustGisApp::with_sample(); let script = "# zoom\nnope.cmd {\"a\": \"é\"}\nlabels.style {\"size\": }\n"; app.workflow.script = script.into(); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 400.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 400.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -480,7 +480,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = crate::RustGisApp::with_sample(); app.workflow.script = "map.zoomFull\n".repeat(300); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -496,7 +496,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = crate::RustGisApp::with_sample(); app.workflow.script = "map.zoomFull".into(); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -531,7 +531,7 @@ mod tests { app.workflow.script = "map.zoomFull\n".repeat(300); app.workflow.script_out = (0..12).map(|i| (format!("line {i}: {}", "a long error ".repeat(30)), true)).collect(); app.workflow.script_last = Some(crate::analysispane::ScriptRun { n: 1, secs: 0.5, at: 1_800_000_000 }); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -565,7 +565,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = crate::RustGisApp::with_sample(); app.workflow.script = "map.zoomFull\n".repeat(300); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 150.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 150.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); @@ -595,7 +595,7 @@ mod tests { use egui_kittest::kittest::Queryable; let mut app = crate::RustGisApp::with_sample(); app.workflow.script = "map.zoomFull\n".repeat(30); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(500.0, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(500.0, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), app, ); diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index cac082fc5..84f485c8b 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -1411,14 +1411,10 @@ mod tests { fn pane() -> egui_kittest::Harness<'static, crate::RustGisApp> { use egui_kittest::kittest::Queryable; // Without the interface fonts (`theme::install_fonts`) egui's faces have no "→". - let mut h = egui_kittest::Harness::builder() - .allow_missing_glyphs() - .with_accessibility_check(false) - .with_size(egui::vec2(500.0, 700.0)) - .build_ui_state( - |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), - crate::RustGisApp::with_sample(), - ); + let mut h = egui_kittest::Harness::builder().allow_missing_glyphs().with_size(egui::vec2(500.0, 700.0)).build_ui_state( + |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), + crate::RustGisApp::with_sample(), + ); h.run(); h.get_by_role(egui::accesskit::Role::MultilineTextInput).focus(); h.run(); @@ -1824,7 +1820,7 @@ mod tests { fn a_narrow_window_puts_the_help_under_the_list() { use egui_kittest::kittest::Queryable; let width = 380.0; - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(width, 700.0)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, 700.0)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), crate::RustGisApp::with_sample(), ); @@ -1846,7 +1842,7 @@ mod tests { fn a_short_window_scrolls_the_list() { use egui_kittest::kittest::Queryable; let (width, height) = (380.0, 300.0); - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(width, height)).build_ui_state( + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(width, height)).build_ui_state( |ui, app: &mut crate::RustGisApp| crate::analysispane::script_pane(app, ui, &crate::theme::Tokens::light()), crate::RustGisApp::with_sample(), ); diff --git a/crates/ui-egui/src/sqlpane.rs b/crates/ui-egui/src/sqlpane.rs index e9a523d50..b32f28e98 100644 --- a/crates/ui-egui/src/sqlpane.rs +++ b/crates/ui-egui/src/sqlpane.rs @@ -316,16 +316,18 @@ fn body(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } ui.label(RichText::new(tl!("One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it.")).small().weak()); egui::Grid::new("sql_db").num_columns(2).spacing(egui::vec2(8.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Database")); + let input_label = ui.label(tl!("Database")); ui.add( egui::TextEdit::singleline(&mut app.ui.sql.database) .hint_text(tl!("none, a .duckdb file, or host=… dbname=… user=…")) .desired_width(f32::INFINITY), - ); + ) + .labelled_by(input_label.id); ui.end_row(); if database_text(&app.ui.sql.database).starts_with("postgres") || app.ui.sql.database.contains("://") { - ui.label(tl!("Password")); - ui.add(egui::TextEdit::singleline(&mut app.ui.sql.password).password(true).desired_width(f32::INFINITY)); + let input_label = ui.label(tl!("Password")); + ui.add(egui::TextEdit::singleline(&mut app.ui.sql.password).password(true).desired_width(f32::INFINITY)) + .labelled_by(input_label.id); ui.end_row(); } }); diff --git a/crates/ui-egui/src/stacui.rs b/crates/ui-egui/src/stacui.rs index 19c64af7c..b20995849 100644 --- a/crates/ui-egui/src/stacui.rs +++ b/crates/ui-egui/src/stacui.rs @@ -741,7 +741,9 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { api_changed = true; } } - }); + }) + .response + .on_hover_text(tl!("Catalog")); // A catalog typed in is browsed with Browse, from its top. if ui.add(egui::TextEdit::singleline(&mut st.api).desired_width(ui.available_width().max(80.0))).changed() { drop_listing(st); @@ -755,19 +757,26 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { // Its text is cut to the room left beside Browse. let w = (ui.available_width() - 110.0).max(120.0); ui.allocate_ui(vec2(w, 30.0), |ui| { - egui::ComboBox::from_id_salt("stac_node").truncate().selected_text(shown).width(w).height(360.0).show_ui(ui, |ui| { - for (i, (title, _)) in st.trail.iter().enumerate() { - let title = if title.is_empty() { tl!("all of it").to_string() } else { title.clone() }; - if ui.selectable_label(false, format!("↑ {title}")).clicked() { - go_up = Some(i); + egui::ComboBox::from_id_salt("stac_node") + .truncate() + .selected_text(shown) + .width(w) + .height(360.0) + .show_ui(ui, |ui| { + for (i, (title, _)) in st.trail.iter().enumerate() { + let title = if title.is_empty() { tl!("all of it").to_string() } else { title.clone() }; + if ui.selectable_label(false, format!("↑ {title}")).clicked() { + go_up = Some(i); + } } - } - for c in &st.children { - if ui.selectable_label(false, &c.title).on_hover_text(&c.url).clicked() { - go_to = Some((c.title.clone(), c.url.clone())); + for c in &st.children { + if ui.selectable_label(false, &c.title).on_hover_text(&c.url).clicked() { + go_to = Some((c.title.clone(), c.url.clone())); + } } - } - }); + }) + .response + .on_hover_text(tl!("Catalog")); }); list = ui.add_enabled(!busy, egui::Button::new(tl!("Browse"))).clicked(); }); @@ -785,16 +794,20 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { let shown = if st.collection.is_empty() { tl!("any").to_string() } else { st.collection.clone() }; let w = (ui.available_width() - 110.0).max(100.0); ui.allocate_ui(vec2(w, 30.0), |ui| { - egui::ComboBox::from_id_salt("stac_collection").truncate().selected_text(shown).width(w).height(320.0).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt("stac_collection") + .truncate() + .selected_text(shown) + .width(w) + .height(320.0) + .show_ui(ui, |ui| { ui.selectable_value(&mut st.collection, String::new(), tl!("any")); for (id, title) in &st.collections { let label = if title.is_empty() { id.clone() } else { format!("{id} — {title}") }; ui.selectable_value(&mut st.collection, id.clone(), label); } - }, - ); + }) + .response + .on_hover_text(tl!("Collection")); }); } list = ui.add_enabled(!busy, egui::Button::new(tl!("Collections"))).clicked(); @@ -808,12 +821,14 @@ pub fn dialog(app: &mut RustGisApp, ui: &mut egui::Ui) { .hint_text("2024-06-01") .desired_width(((ui.available_width() - 30.0) / 2.0).clamp(70.0, 110.0)), ); - ui.label("–"); - ui.add(egui::TextEdit::singleline(&mut st.to).hint_text("2024-06-30").desired_width(ui.available_width().clamp(70.0, 110.0))); + let input_label = ui.label("–"); + ui.add(egui::TextEdit::singleline(&mut st.to).hint_text("2024-06-30").desired_width(ui.available_width().clamp(70.0, 110.0))) + .labelled_by(input_label.id); }); ui.end_row(); - ui.label(tl!("Most cloud cover (%)")); - ui.add(egui::TextEdit::singleline(&mut st.max_cloud).hint_text(tl!("any")).desired_width(ui.available_width().clamp(70.0, 110.0))); + let input_label = ui.label(tl!("Most cloud cover (%)")); + ui.add(egui::TextEdit::singleline(&mut st.max_cloud).hint_text(tl!("any")).desired_width(ui.available_width().clamp(70.0, 110.0))) + .labelled_by(input_label.id); ui.end_row(); if is_static && (st.preset == Some(Preset::Vantor) || !st.phase.is_empty()) { ui.label(tl!("Phase")); @@ -913,11 +928,16 @@ fn cube_row(app: &mut RustGisApp, ui: &mut egui::Ui, busy: bool) { } let mut add = false; ui.horizontal_wrapped(|ui| { - egui::ComboBox::from_id_salt("stac_cube_asset").selected_text(st.cube_asset.as_str()).width(110.0).show_ui(ui, |ui| { - for k in &keys { - ui.selectable_value(&mut st.cube_asset, k.clone(), k.as_str()); - } - }); + egui::ComboBox::from_id_salt("stac_cube_asset") + .selected_text(st.cube_asset.as_str()) + .width(110.0) + .show_ui(ui, |ui| { + for k in &keys { + ui.selectable_value(&mut st.cube_asset, k.clone(), k.as_str()); + } + }) + .response + .on_hover_text(tl!("Asset")); ui.add(egui::TextEdit::singleline(&mut st.cube_range).hint_text(tl!("range: 0, 0.9")).desired_width(90.0)).on_hover_text(tl!( "The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch." )); @@ -1073,12 +1093,18 @@ fn results(app: &mut RustGisApp, ui: &mut egui::Ui) { zoom = Some(i); } if let Some(key) = st.chosen.get_mut(i) { - egui::ComboBox::from_id_salt(("stac_asset", i)).selected_text(key.clone()).width(90.0).show_ui(ui, |ui| { - for a in &item.assets { - let label = if a.title.is_empty() { a.key.clone() } else { format!("{} — {}", a.key, a.title) }; - ui.selectable_value(key, a.key.clone(), label); - } - }); + egui::ComboBox::from_id_salt(("stac_asset", i)) + .selected_text(key.clone()) + .width(90.0) + .show_ui(ui, |ui| { + for a in &item.assets { + let label = + if a.title.is_empty() { a.key.clone() } else { format!("{} — {}", a.key, a.title) }; + ui.selectable_value(key, a.key.clone(), label); + } + }) + .response + .on_hover_text(tl!("Asset")); } if ui.button(tl!("Add")).clicked() { add = Some(i); @@ -1205,7 +1231,6 @@ mod tests { app.ui.stac.api = "https://one.example/stac".into(); app.ui.stac.waiting = Some((Asked::Collections("https://one.example/stac".into()), Slot::default())); let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) .with_size(egui::vec2(900.0, 700.0)) .build_ui_state(|ui, app: &mut RustGisApp| dialog(app, ui), app); h.step(); diff --git a/crates/ui-egui/src/textform.rs b/crates/ui-egui/src/textform.rs index b6ebf22b0..21304482e 100644 --- a/crates/ui-egui/src/textform.rs +++ b/crates/ui-egui/src/textform.rs @@ -95,10 +95,12 @@ pub fn show(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, tokens: &[& }); out.typing = r.has_focus() && !out.text; egui::Grid::new(id.with("style")).num_columns(2).min_col_width(LABEL_W).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Size")); + let input_label = ui.label(tl!("Size")); let saved = *f.size; let drag = Dragged::lend(ui, id.with("size"), f.size); - let r = ui.add(egui::DragValue::new(f.size).range(rustgis_engine::map::graphics::FONT_SIZE).speed(0.2).suffix(" pt")); + let r = ui + .add(egui::DragValue::new(f.size).range(rustgis_engine::map::graphics::FONT_SIZE).speed(0.2).suffix(" pt")) + .labelled_by(input_label.id); // Named for assistive technology, as its label sits in the other column. let size = *f.size; r.widget_info(|| egui::WidgetInfo { label: Some(tl!("Size").to_string()), ..egui::WidgetInfo::drag_value(true, size) }); @@ -110,41 +112,46 @@ pub fn show(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, tokens: &[& ui.end_row(); } if let Some(b) = f.bold { - ui.label(tl!("Bold")); - let r = ui.checkbox(b, ""); + let input_label = ui.label(tl!("Bold")); + let r = ui.checkbox(b, "").labelled_by(input_label.id); let on = *b; r.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, on, tl!("Bold"))); out.bold = r.changed(); ui.end_row(); } if let Some(it) = f.italic { - ui.label(tl!("Italic")); + let input_label = ui.label(tl!("Italic")); // Named for assistive technology, as its label sits in the other column. - let r = ui.checkbox(it, ""); + let r = ui.checkbox(it, "").labelled_by(input_label.id); let on = *it; r.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, on, tl!("Italic"))); out.italic = r.changed(); ui.end_row(); } if let Some(a) = f.align { - ui.label(tl!("Alignment")); + let input_label = ui.label(tl!("Alignment")); let shown = tl!(a.label()); - egui::ComboBox::from_id_salt(id.with("align")).selected_text(shown).show_ui(ui, |ui| { - for v in TextAlign::ALL { - if ui.selectable_label(*a == v, tl!(v.label())).clicked() && *a != v { - *a = v; - out.align = true; + egui::ComboBox::from_id_salt(id.with("align")) + .selected_text(shown) + .show_ui(ui, |ui| { + for v in TextAlign::ALL { + if ui.selectable_label(*a == v, tl!(v.label())).clicked() && *a != v { + *a = v; + out.align = true; + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); } if let Some(ls) = f.letter_spacing { - ui.label(tl!("Letter spacing")); + let input_label = ui.label(tl!("Letter spacing")); let saved = *ls; let drag = Dragged::lend(ui, id.with("letter_spacing"), ls); - let r = - ui.add(egui::DragValue::new(ls).range(MIN_LETTER_SPACING..=MAX_LETTER_SPACING).speed(0.1).max_decimals(1).suffix(" pt")); + let r = ui + .add(egui::DragValue::new(ls).range(MIN_LETTER_SPACING..=MAX_LETTER_SPACING).speed(0.1).max_decimals(1).suffix(" pt")) + .labelled_by(input_label.id); let now = *ls; r.widget_info(|| egui::WidgetInfo { label: Some(tl!("Letter spacing").to_string()), @@ -154,17 +161,21 @@ pub fn show(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, tokens: &[& ui.end_row(); } if let Some(fam) = f.font { - ui.label(tl!("Font")); - let list = egui::ComboBox::from_id_salt(id.with("font")).selected_text(tl!(fam.label())).show_ui(ui, |ui| { - for v in FontFamily::ALL { - if ui.selectable_label(*fam == v, tl!(v.label())).clicked() && *fam != v { - *fam = v; - out.font = true; + let input_label = ui.label(tl!("Font")); + let list = egui::ComboBox::from_id_salt(id.with("font")) + .selected_text(tl!(fam.label())) + .show_ui(ui, |ui| { + for v in FontFamily::ALL { + if ui.selectable_label(*fam == v, tl!(v.label())).clicked() && *fam != v { + *fam = v; + out.font = true; + } } - } - }); + }) + .response + .labelled_by(input_label.id); // Named for assistive technology, as its label sits in the other column. - list.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, tl!("Font"))); + list.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, tl!("Font"))); ui.end_row(); // A serif asked for where none is installed is drawn in the sans: said here, so the // page never differs from what was asked unsaid. @@ -199,7 +210,7 @@ mod tests { } fn harness(form: Form) -> egui_kittest::Harness<'static, Form> { - egui_kittest::Harness::builder().with_accessibility_check(false).build_ui_state( + egui_kittest::Harness::builder().build_ui_state( |ui, f: &mut Form| { // The numbers are lent saved and kept only once settled, as the pane's callers do. let (mut size, mut spacing) = (f.size, f.letter_spacing); diff --git a/crates/ui-egui/src/timebar.rs b/crates/ui-egui/src/timebar.rs index 8fc2c4738..49b42e114 100644 --- a/crates/ui-egui/src/timebar.rs +++ b/crates/ui-egui/src/timebar.rs @@ -251,30 +251,43 @@ fn header(app: &mut RustGisApp, ui: &mut Ui, t: &TimeSlider, steps: &[f64], tk: // Speed, show, step. let speeds = [(2000u32, "0.5×"), (1200, "0.75×"), (800, "1×"), (400, "2×"), (200, "4×")]; let now = speeds.iter().min_by_key(|(ms, _)| ms.abs_diff(t.speed_ms)).map_or("1×", |(_, l)| *l); - egui::ComboBox::from_id_salt("time_speed").selected_text(now).width(56.0).show_ui(ui, |ui| { - for (ms, l) in speeds { - if ui.selectable_label(t.speed_ms == ms, l).clicked() { - let _ = app.run("time.set", json!({"speed": ms})); + egui::ComboBox::from_id_salt("time_speed") + .selected_text(now) + .width(56.0) + .show_ui(ui, |ui| { + for (ms, l) in speeds { + if ui.selectable_label(t.speed_ms == ms, l).clicked() { + let _ = app.run("time.set", json!({"speed": ms})); + } } - } - }); + }) + .response + .on_hover_text(tl!("Speed")); let looped = t.looping; if ui.selectable_label(looped, tl!("Loop")).on_hover_text(tl!("Start over at the end while playing.")).clicked() { let _ = app.run("time.set", json!({"loop": !looped})); } let presets = rustgis_engine::cmd::time::SHOWS; let shown = presets.iter().find(|(_, b, a, c)| *b == t.before && *a == t.after && *c == t.cumulative).map_or("custom", |p| p.0); - egui::ComboBox::from_id_salt("time_show").selected_text(show_label(shown)).width(130.0).show_ui(ui, |ui| { - for (code, ..) in presets { - if ui.button(show_label(code)).clicked() { - let _ = app.run("time.set", json!({"show": code})); + egui::ComboBox::from_id_salt("time_show") + .selected_text(show_label(shown)) + .width(130.0) + .show_ui(ui, |ui| { + for (code, ..) in presets { + if ui.button(show_label(code)).clicked() { + let _ = app.run("time.set", json!({"show": code})); + } } - } - }); - ui.label(egui::RichText::new(tl!("Show")).font(regular(11.5)).color(tk.text_dim)); + }) + .response + .on_hover_text(tl!("Show")); + let input_label = ui.label(egui::RichText::new(tl!("Show")).font(regular(11.5)).color(tk.text_dim)); // Applied when the drag or the typing ends: one undo step, not one a frame. let mut every = app.time.interval.unwrap_or(t.interval); - let r = ui.add(egui::DragValue::new(&mut every).range(1..=1000).speed(0.1)).on_hover_text(tl!("Units per step")); + let r = ui + .add(egui::DragValue::new(&mut every).range(1..=1000).speed(0.1)) + .labelled_by(input_label.id) + .on_hover_text(tl!("Units per step")); if r.changed() { app.time.interval = Some(every); } @@ -282,13 +295,18 @@ fn header(app: &mut RustGisApp, ui: &mut Ui, t: &TimeSlider, steps: &[f64], tk: if done && let Some(n) = app.time.interval.take() { let _ = app.run("time.set", json!({"interval": n})); } - egui::ComboBox::from_id_salt("time_unit").selected_text(unit_label(t.unit)).width(70.0).show_ui(ui, |ui| { - for u in Unit::ALL { - if ui.selectable_label(t.unit == u, unit_label(u)).clicked() { - let _ = app.run("time.set", json!({"unit": u.code()})); + egui::ComboBox::from_id_salt("time_unit") + .selected_text(unit_label(t.unit)) + .width(70.0) + .show_ui(ui, |ui| { + for u in Unit::ALL { + if ui.selectable_label(t.unit == u, unit_label(u)).clicked() { + let _ = app.run("time.set", json!({"unit": u.code()})); + } } - } - }); + }) + .response + .on_hover_text(tl!("Unit")); ui.label(egui::RichText::new(tl!("Step")).font(regular(11.5)).color(tk.text_dim)); }); } @@ -319,14 +337,19 @@ fn layers_menu(app: &mut RustGisApp, ui: &mut Ui) { let _ = if on { app.run("time.bind", json!({"layer": id})) } else { app.run("time.unbind", json!({"layer": id})) }; } let current = bound.clone().or_else(|| fields.first().map(|f| f.0.clone())).unwrap_or_default(); - egui::ComboBox::from_id_salt(("time_field", *id)).selected_text(current.as_str()).show_ui(ui, |ui| { - for (f, share) in fields { - let label = crate::i18n::fmt("{field} ({share}%)", &[("field", f), ("share", &format!("{:.0}", share * 100.0))]); - if ui.selectable_label(*f == current, label).clicked() { - let _ = app.run("time.bind", json!({"layer": id, "field": f})); + egui::ComboBox::from_id_salt(("time_field", *id)) + .selected_text(current.as_str()) + .show_ui(ui, |ui| { + for (f, share) in fields { + let label = + crate::i18n::fmt("{field} ({share}%)", &[("field", f), ("share", &format!("{:.0}", share * 100.0))]); + if ui.selectable_label(*f == current, label).clicked() { + let _ = app.run("time.bind", json!({"layer": id, "field": f})); + } } - } - }); + }) + .response + .on_hover_text(tl!("Field")); }); } if !dated_tiles.is_empty() { diff --git a/crates/ui-egui/src/webdataui.rs b/crates/ui-egui/src/webdataui.rs index 00bb3e267..c47ed4711 100644 --- a/crates/ui-egui/src/webdataui.rs +++ b/crates/ui-egui/src/webdataui.rs @@ -252,8 +252,9 @@ pub fn nasa_earthdata(app: &mut RustGisApp, ui: &mut egui::Ui) { ui.label(RichText::new(tl!("NASA's Global Imagery Browse Services (GIBS): over a thousand layers of satellite imagery and science data, most of them daily.")).weak()); let words = search_box(app, ui, "gibs", tl!("Search layers (e.g. temperature)")); ui.horizontal(|ui| { - ui.label(tl!("Date")); - ui.add(egui::TextEdit::singleline(&mut app.ui.web_services.date).hint_text(tl!("latest")).desired_width(110.0)); + let input_label = ui.label(tl!("Date")); + ui.add(egui::TextEdit::singleline(&mut app.ui.web_services.date).hint_text(tl!("latest")).desired_width(110.0)) + .labelled_by(input_label.id); ui.label(RichText::new(tl!("YYYY-MM-DD, for time layers")).small().weak()); }); let look = listing(app, NASA_GIBS, "wmts"); @@ -689,16 +690,20 @@ pub fn fields_of_the_world(app: &mut RustGisApp, ui: &mut egui::Ui) { let year = *app.ui.web_services.year.get_or_insert(*years.end()); let mut picked = year; ui.horizontal(|ui| { - ui.label(tl!("Year")); - egui::ComboBox::from_id_salt("ftw_year").selected_text(picked.to_string()).show_ui(ui, |ui| { - for y in years.clone().rev() { - ui.selectable_value(&mut picked, y, y.to_string()); - } - }); + let input_label = ui.label(tl!("Year")); + egui::ComboBox::from_id_salt("ftw_year") + .selected_text(picked.to_string()) + .show_ui(ui, |ui| { + for y in years.clone().rev() { + ui.selectable_value(&mut picked, y, y.to_string()); + } + }) + .response + .labelled_by(input_label.id); }); app.ui.web_services.year = Some(picked); - ui.label(tl!("Score threshold")); - ui.add(egui::Slider::new(&mut app.ui.web_services.min_score, 0.0..=100.0).step_by(1.0)); + let input_label = ui.label(tl!("Score threshold")); + ui.add(egui::Slider::new(&mut app.ui.web_services.min_score, 0.0..=100.0).step_by(1.0)).labelled_by(input_label.id); ui.label(RichText::new(tl!("Hides fields scoring below this. The score is the model's mean field probability × 100, a ranking rather than a calibrated probability.")).small().weak()); // The score's colours, as the fields are drawn. ui.horizontal_wrapped(|ui| { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ac9cf4d03..9d54ccd9b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -372,13 +372,18 @@ pub fn ribbon_combo(ui: &mut Ui, id: &str, label: &str, current: &str, options: if !label.is_empty() { ui.label(egui::RichText::new(tl!(label)).size(11.5)); } - egui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| { - for o in options { - if ui.selectable_label(o == current, o).clicked() { - out = Some(o.clone()); + egui::ComboBox::from_id_salt(id) + .selected_text(current) + .width(width) + .show_ui(ui, |ui| { + for o in options { + if ui.selectable_label(o == current, o).clicked() { + out = Some(o.clone()); + } } - } - }); + }) + .response + .on_hover_text(tl!(label)); }); out } @@ -510,7 +515,6 @@ mod tests { /// Opens a colour button's picker and says whether it offers additive blending. fn offers_additive(button: fn(&mut egui::Ui, &mut egui::Color32)) -> bool { let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) .build_ui_state(|ui, c: &mut egui::Color32| button(ui, c), egui::Color32::from_rgb(224, 105, 59)); h.get_by_role(egui::accesskit::Role::ColorWell).click(); h.run(); @@ -540,18 +544,18 @@ mod tests { whole: u64, settled: Vec<&'static str>, } - let mut h = egui_kittest::Harness::builder().with_accessibility_check(false).build_ui_state( + let mut h = egui_kittest::Harness::builder().build_ui_state( |ui, s: &mut Saved| { let mut real = s.real; let drag = super::Dragged::lend(ui, egui::Id::unique("real"), &mut real); - let r = ui.add(egui::DragValue::new(&mut real).speed(0.1)); + let r = ui.add(egui::DragValue::new(&mut real).speed(0.1)).on_hover_text("Real"); if drag.settled(ui, &r, &mut real) && real != s.real { s.real = real; s.settled.push("real"); } let mut whole = s.whole; let drag = super::Dragged::lend(ui, egui::Id::unique("whole"), &mut whole); - let r = ui.add(egui::DragValue::new(&mut whole).speed(0.1)); + let r = ui.add(egui::DragValue::new(&mut whole).speed(0.1)).on_hover_text("Whole"); if drag.settled(ui, &r, &mut whole) && whole != s.whole { s.whole = whole; s.settled.push("whole"); diff --git a/crates/ui-egui/src/workspacepanes.rs b/crates/ui-egui/src/workspacepanes.rs index bb0dcb889..7f2c6554c 100644 --- a/crates/ui-egui/src/workspacepanes.rs +++ b/crates/ui-egui/src/workspacepanes.rs @@ -131,8 +131,9 @@ fn record_row(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.horizontal_wrapped(|ui| match app.ws.recording.as_ref().map(Vec::len) { Some(n) => { ui.label(egui::RichText::new("●").color(t.red)); - ui.label(crate::i18n::fmt("Recording: {n} steps", &[("n", &n.to_string())])); - ui.add(egui::TextEdit::singleline(&mut app.ws.record_name).hint_text(tl!("Task name")).desired_width(150.0)); + let input_label = ui.label(crate::i18n::fmt("Recording: {n} steps", &[("n", &n.to_string())])); + ui.add(egui::TextEdit::singleline(&mut app.ws.record_name).hint_text(tl!("Task name")).desired_width(150.0)) + .labelled_by(input_label.id); if ui.button(tl!("Stop Recording")).on_hover_text(tl!("Make a task of the steps recorded")).clicked() { let name = app.ws.record_name.clone(); run(app, "ui.taskRecord", json!({"name": name})); @@ -181,7 +182,9 @@ pub fn tasks(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { app.ws.task_edit = None; } } - }); + }) + .response + .on_hover_text(tl!("Task")); // Leaving edit mode drops a step's unsaved edit, which would come back stale. if ui.checkbox(&mut app.ws.task_editing, tl!("Edit Steps")).changed() && !app.ws.task_editing { app.ws.task_edit = None; @@ -365,11 +368,15 @@ pub fn profile(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.label(egui::RichText::new(tl!("Add an elevation raster first.")).weak()); return; } - egui::ComboBox::from_id_salt("prof_layer").selected_text(app.ws.profile_layer.as_str()).show_ui(ui, |ui| { - for r in &rasters { - ui.selectable_value(&mut app.ws.profile_layer, r.clone(), r.as_str()); - } - }); + egui::ComboBox::from_id_salt("prof_layer") + .selected_text(app.ws.profile_layer.as_str()) + .show_ui(ui, |ui| { + for r in &rasters { + ui.selectable_value(&mut app.ws.profile_layer, r.clone(), r.as_str()); + } + }) + .response + .on_hover_text(tl!("Layer")); ui.horizontal_wrapped(|ui| { for (label, purpose, n) in [ ("Create (click points)", "p_create", 100), @@ -406,11 +413,16 @@ pub fn profile(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if !lines.contains(&app.ws.profile_lines) { app.ws.profile_lines = lines.first().cloned().unwrap_or_default(); } - egui::ComboBox::from_id_salt("prof_lines").selected_text(app.ws.profile_lines.as_str()).width(120.0).show_ui(ui, |ui| { - for l in &lines { - ui.selectable_value(&mut app.ws.profile_lines, l.clone(), l.as_str()); - } - }); + egui::ComboBox::from_id_salt("prof_lines") + .selected_text(app.ws.profile_lines.as_str()) + .width(120.0) + .show_ui(ui, |ui| { + for l in &lines { + ui.selectable_value(&mut app.ws.profile_lines, l.clone(), l.as_str()); + } + }) + .response + .on_hover_text(tl!("Layer")); if ui.button(tl!("Along Selected Line")).clicked() { app.ws.profile = run(app, "profile.alongLine", json!({"layer": layer, "lines": app.ws.profile_lines})); } @@ -547,26 +559,33 @@ fn summarise(app: &mut RustGisApp, ui: &mut Ui) { app.ws.reduce_stat = "mean".into(); } egui::Grid::new("reduce_grid").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Statistic")); - egui::ComboBox::from_id_salt("reduce_stat").selected_text(app.ws.reduce_stat.as_str()).show_ui(ui, |ui| { - for st in ["mean", "min", "max", "sum", "std", "count", "countAbove"] { - ui.selectable_value(&mut app.ws.reduce_stat, st.to_string(), st); - } - }); + let input_label = ui.label(tl!("Statistic")); + egui::ComboBox::from_id_salt("reduce_stat") + .selected_text(app.ws.reduce_stat.as_str()) + .show_ui(ui, |ui| { + for st in ["mean", "min", "max", "sum", "std", "count", "countAbove"] { + ui.selectable_value(&mut app.ws.reduce_stat, st.to_string(), st); + } + }) + .response + .labelled_by(input_label.id); ui.end_row(); if app.ws.reduce_stat == "countAbove" { - ui.label(tl!("Above")); - ui.add(egui::DragValue::new(&mut app.ws.reduce_above).speed(0.5)); + let input_label = ui.label(tl!("Above")); + ui.add(egui::DragValue::new(&mut app.ws.reduce_above).speed(0.5)).labelled_by(input_label.id); ui.end_row(); } - ui.label(tl!("From")); - ui.add(egui::TextEdit::singleline(&mut app.ws.reduce_start).hint_text("1991-01-01").desired_width(110.0)); + let input_label = ui.label(tl!("From")); + ui.add(egui::TextEdit::singleline(&mut app.ws.reduce_start).hint_text("1991-01-01").desired_width(110.0)) + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("To")); - ui.add(egui::TextEdit::singleline(&mut app.ws.reduce_end).hint_text("2020-12-31").desired_width(110.0)); + let input_label = ui.label(tl!("To")); + ui.add(egui::TextEdit::singleline(&mut app.ws.reduce_end).hint_text("2020-12-31").desired_width(110.0)) + .labelled_by(input_label.id); ui.end_row(); - ui.label(tl!("Months")); - ui.add(egui::TextEdit::singleline(&mut app.ws.reduce_months).hint_text("12, 1, 2").desired_width(110.0)); + let input_label = ui.label(tl!("Months")); + ui.add(egui::TextEdit::singleline(&mut app.ws.reduce_months).hint_text("12, 1, 2").desired_width(110.0)) + .labelled_by(input_label.id); ui.end_row(); }); if ui.button(tl!("Summarise")).clicked() { @@ -644,11 +663,15 @@ pub fn time_series(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if !layers.contains(&app.ws.series_layer) { app.ws.series_layer = layers.first().cloned().unwrap_or_default(); } - egui::ComboBox::from_id_salt("series_layer").selected_text(app.ws.series_layer.as_str()).show_ui(ui, |ui| { - for l in &layers { - ui.selectable_value(&mut app.ws.series_layer, l.clone(), l.as_str()); - } - }); + egui::ComboBox::from_id_salt("series_layer") + .selected_text(app.ws.series_layer.as_str()) + .show_ui(ui, |ui| { + for l in &layers { + ui.selectable_value(&mut app.ws.series_layer, l.clone(), l.as_str()); + } + }) + .response + .on_hover_text(tl!("Layer")); ui.horizontal_wrapped(|ui| { let active = app.workflow.pick.as_ref().is_some_and(|p| p.purpose == "ts_point"); if ui.selectable_label(active, tl!("Pick a Point")).clicked() { @@ -1053,17 +1076,13 @@ mod tests { app.workflow.pick = Some(crate::analysispane::Pick { purpose: "ts_point", needed: 1, points: vec![rustgis_engine::geom::Coord::new(1.0, 30.0)] }); // Without the interface fonts (`theme::install_fonts`) egui's faces have no "●". - let mut h = egui_kittest::Harness::builder() - .allow_missing_glyphs() - .with_accessibility_check(false) - .with_size(egui::vec2(420.0, 600.0)) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let t = crate::theme::Tokens::get(ui.ctx()); - time_series(app, ui, &t); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().allow_missing_glyphs().with_size(egui::vec2(420.0, 600.0)).build_ui_state( + |ui, app: &mut RustGisApp| { + let t = crate::theme::Tokens::get(ui.ctx()); + time_series(app, ui, &t); + }, + app, + ); h.run_steps(3); assert_eq!(h.state().ws.series_layer, "air"); assert_eq!(h.state().ws.series.len(), 1); diff --git a/crates/ui-egui/src/zarrui.rs b/crates/ui-egui/src/zarrui.rs index d3121fd4c..b6a8da3d7 100644 --- a/crates/ui-egui/src/zarrui.rs +++ b/crates/ui-egui/src/zarrui.rs @@ -292,14 +292,20 @@ pub fn picker(app: &mut RustGisApp, ui: &mut egui::Ui) { t }; egui::Grid::new("zarr").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - ui.label(crate::tl!("Variable")); - egui::ComboBox::from_id_salt("zarr_var").selected_text(title(&vars[st.variable])).width(wide).truncate().show_ui(ui, |ui| { - for (i, v) in vars.iter().enumerate() { - if ui.selectable_value(&mut st.variable, i, title(v)).changed() { - st.steps.clear(); + let input_label = ui.label(crate::tl!("Variable")); + egui::ComboBox::from_id_salt("zarr_var") + .selected_text(title(&vars[st.variable])) + .width(wide) + .truncate() + .show_ui(ui, |ui| { + for (i, v) in vars.iter().enumerate() { + if ui.selectable_value(&mut st.variable, i, title(v)).changed() { + st.steps.clear(); + } } - } - }); + }) + .response + .labelled_by(input_label.id); ui.end_row(); let var = &vars[st.variable]; for d in var["dims"].as_array().into_iter().flatten().filter(|d| d["map"] != json!(true)) { @@ -320,8 +326,9 @@ pub fn picker(app: &mut RustGisApp, ui: &mut egui::Ui) { }); ui.end_row(); } - ui.label(crate::tl!("Coordinate system")); - ui.add(egui::TextEdit::singleline(&mut st.crs).hint_text(crate::tl!("for a store that doesn't say")).desired_width(200.0)); + let input_label = ui.label(crate::tl!("Coordinate system")); + ui.add(egui::TextEdit::singleline(&mut st.crs).hint_text(crate::tl!("for a store that doesn't say")).desired_width(200.0)) + .labelled_by(input_label.id); ui.end_row(); }); if !vars[st.variable]["readable"].as_bool().unwrap_or(true) { diff --git a/crates/ui-egui/tests/define_projection.rs b/crates/ui-egui/tests/define_projection.rs index a4cb02dae..7391882d8 100644 --- a/crates/ui-egui/tests/define_projection.rs +++ b/crates/ui-egui/tests/define_projection.rs @@ -17,18 +17,14 @@ fn harness() -> egui_kittest::Harness<'static, RustGisApp> { } s.run("layer.select", &json!({"layer": "Parcels"})).unwrap(); let app = RustGisApp::new(s); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(egui::vec2(1400.0, 1000.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 1000.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.state_mut().ui.command_search = Some("layer.defineProjection".into()); h.run_steps(3); h.key_press(egui::Key::Enter); diff --git a/crates/ui-egui/tests/gpu_fastpath.rs b/crates/ui-egui/tests/gpu_fastpath.rs index 6130625fb..d82d8ffb5 100644 --- a/crates/ui-egui/tests/gpu_fastpath.rs +++ b/crates/ui-egui/tests/gpu_fastpath.rs @@ -70,7 +70,6 @@ fn harness() -> Option> { app.run("ui.map.gpu", json!({"on": true})).expect("gpu on"); Some( egui_kittest::Harness::builder() - .with_accessibility_check(false) .with_size(egui::vec2(900.0, 550.0)) .with_pixels_per_point(1.0) .with_max_steps(10_000) diff --git a/crates/ui-egui/tests/measurement_units.rs b/crates/ui-egui/tests/measurement_units.rs index 20c91c638..8bad0e6d6 100644 --- a/crates/ui-egui/tests/measurement_units.rs +++ b/crates/ui-egui/tests/measurement_units.rs @@ -13,18 +13,14 @@ fn harness_with_services(services: Services) -> egui_kittest::Harness<'static, R app.run("layer.template", json!({"template": "lines", "name": "Paths"})).unwrap(); app.run("edit.addFeature", json!({"geometry": {"type": "LineString", "coordinates": [[0, 0], [0, 0.001]]}})).unwrap(); app.run("select.rows", json!({"layer": "Paths", "rows": [0]})).unwrap(); - egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(egui::vec2(1800.0, 1000.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ) + egui_kittest::Harness::builder().with_size(egui::vec2(1800.0, 1000.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ) } fn first_x_metres(h: &egui_kittest::Harness<'_, RustGisApp>) -> f64 { diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index c64480996..aed864063 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -9,7 +9,7 @@ mod remembered_database; /// Builds a headless World Atlas UI for interaction tests. fn harness() -> egui_kittest::Harness<'static, RustGisApp> { - egui_kittest::Harness::builder().with_accessibility_check(false).with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( + egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( |ui, app: &mut RustGisApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -258,18 +258,14 @@ fn mcp_relay_early_start_wakes_queued_request_and_updates_ui() { writeln!(client, "{}", json!({"jsonrpc":"2.0","id":1,"method":"ping"})).expect("queue ping"); client.flush().expect("flush request"); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(egui::vec2(1400.0, 860.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); let mut input = BufReader::new(client.try_clone().expect("clone stream")); let deadline = Instant::now() + Duration::from_secs(3); let mut reply = String::new(); @@ -443,8 +439,7 @@ fn every_tab_pane_and_dialog_renders() { } for tab in ["Home", "Data", "Style", "Edit", "Analyze", "View", "Publish", "Plugins", "Settings", "Help"] { h.state_mut().ui.tab = tab.into(); - h.step(); - h.step(); + h.run(); } for (id, p) in [ ("ui.pane.symbology", json!(null)), @@ -466,15 +461,13 @@ fn every_tab_pane_and_dialog_renders() { ("ui.backstage", json!({"open": true})), ] { h.state_mut().run(id, p).unwrap_or_else(|e| panic!("{id}: {e}")); - h.step(); - h.step(); + h.run(); } h.state_mut().ui.backstage = false; // Every language, the collapsed and peeked ribbon. for l in rustgis_ui_egui::i18n::Lang::all() { h.state_mut().run("ui.language", json!({"code": l.code()})).unwrap(); - h.step(); - h.step(); + h.run(); } h.state_mut().run("ui.language", json!({"code": "auto"})).unwrap(); assert!(h.state_mut().run("ui.language", json!({"code": "xx"})).is_err()); @@ -1275,18 +1268,14 @@ fn delimited_text_options_go_with_the_picked_file() { fn delimited_text_dialog_lists_the_picked_files_columns() { let services = rustgis_ui_egui::Services { open_async: Some(Box::new(|_: &str| {})), ..Default::default() }; let app = RustGisApp::with_sample().with_services(services); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(egui::vec2(1400.0, 860.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); h.state_mut().run("ui.addDelimited", json!(null)).unwrap(); let picked = format!("{}parks.csv", rustgis_ui_egui::DELIMITED_PICK); h.state_mut().open_picked(&picked, b"name;east;north\nCentral Park;992000;220000\n").unwrap(); @@ -2036,19 +2025,14 @@ fn connections_tree_browses_and_adds() { } }); // Frames 0.05 s apart, so two clicks fall within egui's double-click delay. - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(egui::vec2(1400.0, 860.0)) - .with_max_steps(64) - .with_step_dt(0.05) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - RustGisApp::with_sample(), - ); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).with_step_dt(0.05).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + RustGisApp::with_sample(), + ); h.state_mut().run("connection.new", json!({"kind":"wms","address":format!("http://{addr}/wms"),"name":"Local"})).unwrap(); h.state_mut().run("ui.pane.sources", json!({})).unwrap(); h.step(); @@ -2118,18 +2102,14 @@ fn web_save_asks_for_the_file_name() { ..Default::default() }; let app = RustGisApp::with_sample().with_services(services); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(egui::vec2(1400.0, 860.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 860.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); let names = |got: &std::sync::Mutex)>>| got.lock().unwrap().iter().map(|(n, _)| n.clone()).collect::>(); // A project never saved: Save asks first, with the project's name. h.state_mut().run("layer.color", json!({"layer": "Countries", "color": "#336699"})).unwrap(); @@ -2300,18 +2280,14 @@ fn letting_agents_control_rustgis_starts_the_relay_and_shows_how_to_connect() { app.run("ui.pane.assistant", json!({})).expect("opens the assistant"); // The connection instructions expand below the checkbox: keep the settings above the // bottom composer so both pointer clicks reach the checkbox. - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(egui::vec2(1400.0, 2000.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 2000.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); // The first frame loads the assistant's settings and closes the form when they're complete (a // key in the environment): open it after that. h.step(); @@ -2364,18 +2340,14 @@ fn agents_on_this_computer_switches_tick_and_stick() { } })); app.run("ui.pane.assistant", json!({})).expect("opens the assistant"); - let mut h = egui_kittest::Harness::builder() - .with_accessibility_check(false) - .with_size(egui::vec2(1400.0, 2000.0)) - .with_max_steps(64) - .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 2000.0)).with_max_steps(64).build_ui_state( + |ui, app: &mut RustGisApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); // The first frame loads the assistant's settings and closes the form when they're complete (a // key in the environment): open it after that. h.step(); From e7dad1e24c41fe719a8b78fc8beb825090ec4ddf Mon Sep 17 00:00:00 2001 From: Khan Tahsin Abrar Date: Sat, 24 Oct 2026 19:40:14 +0600 Subject: [PATCH 101/297] Add Bengali localization (#803) --- crates/render/src/fonts.rs | 40 +- crates/ui-egui/src/i18n/bn.tsv | 3385 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 5 +- crates/ui-egui/src/i18n/tests.rs | 11 + crates/ui-egui/src/lib.rs | 12 +- 5 files changed, 3442 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/src/i18n/bn.tsv diff --git a/crates/render/src/fonts.rs b/crates/render/src/fonts.rs index 095ed543a..12c1e40b0 100644 --- a/crates/render/src/fonts.rs +++ b/crates/render/src/fonts.rs @@ -304,6 +304,23 @@ pub enum ScriptFont { /// on its own, so a line of Persian with a Latin word in it reads in order only when one face /// draws the whole line. Arabic, + /// Bengali glyph coverage only; the current text stack does not shape Indic conjuncts. + Bengali, +} + +const BENGALI_CANDIDATES: &[&str] = &[ + "C:\\Windows\\Fonts\\siyamrupali.ttf", + "C:\\Windows\\Fonts\\Nirmala.ttf", + "/System/Library/Fonts/Supplemental/NotoSansBengali-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansBengali-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansBengaliUI-Regular.ttf", + "/usr/share/fonts/opentype/noto/NotoSansBengali-Regular.ttf", + "/usr/share/fonts/google-noto/NotoSansBengali-Regular.ttf", +]; + +fn covers_bengali(bytes: &[u8]) -> bool { + use ab_glyph::Font; + ab_glyph::FontRef::try_from_slice(bytes).is_ok_and(|f| ['\u{0985}', '\u{09be}', 'A', 'z'].iter().all(|c| f.glyph_id(*c).0 != 0)) } /// Installed faces with both Arabic and Latin letters (checked by [`covers_arabic`]). @@ -336,6 +353,10 @@ pub fn script_font(script: ScriptFont) -> Option<&'static (Vec, u32)> { static FOUND: OnceLock, u32)>> = OnceLock::new(); FOUND.get_or_init(|| ARABIC_CANDIDATES.iter().find_map(|p| read(p).filter(|b| covers_arabic(b, 0)).map(|b| (b, 0)))).as_ref() } + ScriptFont::Bengali => { + static FOUND: OnceLock, u32)>> = OnceLock::new(); + FOUND.get_or_init(|| BENGALI_CANDIDATES.iter().find_map(|p| read(p).filter(|b| covers_bengali(b)).map(|b| (b, 0)))).as_ref() + } } } @@ -365,17 +386,26 @@ pub fn symbol_font() -> Option<&'static (Vec, u32)> { FOUND.get_or_init(|| SYMBOL_CANDIDATES.iter().find_map(|p| read(p).filter(|b| covers_symbols(b, 0)).map(|b| (b, 0)))).as_ref() } -/// Where the web app downloads a face for `script` from (SIL Open Font License): Noto Sans for -/// CJK, Vazirmatn for Arabic script. -pub fn script_font_url(script: ScriptFont) -> &'static str { +/// Where the web app downloads a verified face for `script` from. Bengali has no verified hosted +/// asset yet, so it is discovered only among installed desktop fonts. +pub fn script_font_url(script: ScriptFont) -> Option<&'static str> { match script { - ScriptFont::Cjk(region) => cjk_font_url(region), - ScriptFont::Arabic => "https://assets.rustgis.app/fonts/Vazirmatn-Regular.ttf", + ScriptFont::Cjk(region) => Some(cjk_font_url(region)), + ScriptFont::Arabic => Some("https://assets.rustgis.app/fonts/Vazirmatn-Regular.ttf"), + // No verified Bengali asset is hosted by RustGIS; don't guess a URL. + ScriptFont::Bengali => None, } } #[cfg(test)] mod tests { + #[test] + fn bengali_coverage_requires_bengali_and_latin_glyphs() { + assert!(!super::covers_bengali(epaint_default_fonts::UBUNTU_LIGHT)); + assert!(!super::covers_bengali(b"not a font")); + assert_eq!(super::script_font_url(super::ScriptFont::Bengali), None); + } + /// A variable Noto Serif (one file for every weight, its italic beside it) is found in a /// package folder by its name's start, whatever axes the name lists. #[test] diff --git a/crates/ui-egui/src/i18n/bn.tsv b/crates/ui-egui/src/i18n/bn.tsv new file mode 100644 index 000000000..ef7ed57c5 --- /dev/null +++ b/crates/ui-egui/src/i18n/bn.tsv @@ -0,0 +1,3385 @@ +# bn interface catalog: Englishtranslation + by দ্বারা  +'{b}' isn't a bucket name '{b}' কোনো bucket-এর নাম নয় +'{text}' isn't a scale: type one such as 1:12,345 '{text}' কোনো স্কেল নয়: 1:12,345-এর মতো একটি স্কেল লিখুন +(b4-b3)/(b4+b3) (b4-b3)/(b4+b3) +(filtered) (ফিল্টার করা) ++ Add Stop + স্টপ যোগ করুন ++ Input + ইনপুট ++ New expression + নতুন এক্সপ্রেশন ++ Output + আউটপুট +/F /F +/PID /PID +/T /T +00:00 UTC 00:00 UTC +06:00 UTC 06:00 UTC +12:00 UTC 12:00 UTC +18:00 UTC 18:00 UTC +3035, vn2000tm3 3035, vn2000tm3 +3D mesh 3D মেশ + কোনোটিই নয় +A 3D globe seen from space. মহাকাশ থেকে দেখা একটি 3D পৃথিবী-গোলক। +A blank map with a basemap বেসম্যাপসহ একটি ফাঁকা মানচিত্র +A call for the rustgis Python package rustgis Python প্যাকেজের জন্য একটি কল +A catalogue's (CSW 2.0.2) address: search its records by words and, if you like, the map's area, then add the services and files they link to. একটি ক্যাটালগের (CSW 2.0.2) ঠিকানা: শব্দ দিয়ে এর রেকর্ড খুঁজুন, চাইলে মানচিত্রের এলাকার ভিত্তিতেও খুঁজুন, তারপর লিঙ্ক করা সার্ভিস ও ফাইল যোগ করুন। +A ChatGPT plan or app usage limit was reached. Review Weekly usage before trying again. ChatGPT প্ল্যান বা অ্যাপ ব্যবহারের সীমা পূর্ণ হয়েছে। আবার চেষ্টা করার আগে Weekly usage দেখুন। +A ChatGPT sign-in can only be used with the OpenAI API. ChatGPT সাইন-ইন শুধু OpenAI API-এর সঙ্গে ব্যবহার করা যায়। +A country's administrative boundaries from geoBoundaries (CC BY 4.0): the country itself (ADM0), its first-level divisions (ADM1, such as states) and finer ones. geoBoundaries (CC BY 4.0) থেকে একটি দেশের প্রশাসনিক সীমানা: দেশটি নিজে (ADM0), এর প্রথম-স্তরের বিভাগ (ADM1, যেমন রাজ্য) এবং আরও সূক্ষ্ম বিভাগ। +A custom stretch is in this raster's own units: pick another stretch type to save it as the default. কাস্টম stretch এই raster-এর নিজস্ব এককে থাকে: ডিফল্ট হিসেবে সংরক্ষণ করতে অন্য stretch ধরন বেছে নিন। +A fast, free and open-source GIS written in pure Rust, for mapping, analyzing and sharing geospatial data on the desktop and in the browser. ডেস্কটপ ও ব্রাউজারে ভৌগোলিক ডেটা মানচিত্রে দেখানো, বিশ্লেষণ ও শেয়ার করার জন্য Rust-এ লেখা দ্রুত, মুক্ত ও open-source GIS। +A field (known once the model runs) একটি ফিল্ড (মডেল চালানোর পর জানা যাবে) +A flat scene centred on the view (azimuthal equidistant). দৃশ্যকে কেন্দ্র করে সমতল দৃশ্য (azimuthal equidistant)। +A GeoLens server's datasets, read as features (the first 1,000 of each). GeoLens সার্ভারের ডেটাসেট, feature হিসেবে পড়া হয় (প্রতিটির প্রথম ১,০০০টি)। +A globe scene. একটি গ্লোব দৃশ্য। +A latitude and longitude (40.7, -74.0 or 40°42'N 74°0'W), a layer, a bookmark or a value in a layer's table. Enter picks the highlighted row; Search places looks the text up as a place অক্ষাংশ ও দ্রাঘিমাংশ (40.7, -74.0 অথবা 40°42'N 74°0'W), একটি layer, bookmark অথবা layer-এর টেবিলের কোনো মান। Enter চাপলে হাইলাইট করা সারি বেছে নেওয়া হয়; Search places লেখাটিকে স্থানের নাম হিসেবে খোঁজে +A layer the model reads মডেল যে layer পড়ে +A line for the Script pane Script প্যানেলের জন্য একটি লাইন +A magnifying lens that follows the pointer. পয়েন্টারকে অনুসরণ করে এমন একটি বিবর্ধক কাচ। +A map, feature or coverage service's address: WMS, WMTS, WFS, WCS, OGC API Features or Tiles, or an ArcGIS FeatureServer, MapServer, ImageServer or VectorTileServer (or a services directory, to browse). মানচিত্র, feature বা coverage সার্ভিসের ঠিকানা: WMS, WMTS, WFS, WCS, OGC API Features বা Tiles; অথবা ArcGIS FeatureServer, MapServer, ImageServer কিংবা VectorTileServer (ব্রাউজ করার জন্য service directory-ও হতে পারে)। +a name, or owner/name such as giswqs/geospatial একটি নাম, অথবা giswqs/geospatial-এর মতো owner/name +A named endpoint you can reuse. Passwords and keys are kept until RustGIS closes, never saved. পুনরায় ব্যবহারযোগ্য একটি নামযুক্ত endpoint। RustGIS বন্ধ হওয়া পর্যন্ত পাসওয়ার্ড ও key রাখা হয়, সংরক্ষণ করা হয় না। +A node cannot connect to itself. একটি node নিজেই নিজের সঙ্গে সংযুক্ত হতে পারে না। +A notebook of text and command cells. টেক্সট ও কমান্ড সেলসহ একটি notebook। +A password or token in the address was left out: connections never keep secrets. ঠিকানায় থাকা পাসওয়ার্ড বা token বাদ দেওয়া হয়েছে: সংযোগে গোপন তথ্য রাখা হয় না। +A place or an address একটি স্থান বা ঠিকানা +A project keeps at least one map একটি প্রজেক্টে অন্তত একটি মানচিত্র থাকে +A projected coordinate system. একটি projected coordinate system। +A report listing the selected layer's records, page after page. নির্বাচিত layer-এর রেকর্ড পৃষ্ঠা ধরে তালিকাভুক্ত করা একটি প্রতিবেদন। +A result that only feeds other tools is removed once the model has run; add an output to keep it. শুধু অন্য টুলে ইনপুট হিসেবে ব্যবহৃত ফলাফল মডেল চালানোর পর সরিয়ে ফেলা হয়; রাখতে চাইলে একটি output যোগ করুন। +A result the model keeps, under a name একটি নামের অধীনে মডেল যে ফলাফল সংরক্ষণ করে +A rustgis-cli command, for a POSIX shell (macOS, Linux, WSL, Git Bash) POSIX shell-এর জন্য rustgis-cli কমান্ড (macOS, Linux, WSL, Git Bash) +A service import or refresh is already running. একটি service import বা refresh ইতিমধ্যেই চলছে। +A service's layers stay in their service group. একটি service-এর layer-গুলো সেই service-এর group-এই থাকে। +A task holds at most 200 steps: stop recording to make it, then record the rest as another task. একটি task-এ সর্বোচ্চ ২০০টি ধাপ থাকতে পারে: task তৈরি করতে রেকর্ডিং বন্ধ করুন, তারপর বাকি ধাপগুলো অন্য task হিসেবে রেকর্ড করুন। +A template with the first selected feature's values প্রথম নির্বাচিত feature-এর মানসহ একটি template +A tile layer per grid tile, each following the time slider through its dates. গ্রিডের প্রতিটি tile-এর জন্য একটি tile layer, প্রতিটি তারিখে time slider-কে অনুসরণ করে। +A tile service's address: raster tiles with {z}/{x}/{y} (or {-y}, {q}, {bbox-epsg-3857} for WMS), vector tiles (.pbf) or TileJSON, a MapLibre style, a PMTiles archive (a web address, or a file on this computer), or an MBTiles file on this computer. tile service-এর ঠিকানা: {z}/{x}/{y} (WMS-এর জন্য {-y}, {q}, {bbox-epsg-3857}) দিয়ে raster tile, vector tile (.pbf), TileJSON, MapLibre style, PMTiles archive (ওয়েব ঠিকানা বা এই কম্পিউটারের ফাইল), অথবা এই কম্পিউটারের MBTiles ফাইল। +A token is set. একটি token সেট করা আছে। +A value, a field, or two of them joined by + − × ÷. Applies to the selected rows, or every row when none is selected. একটি মান, একটি field, অথবা + − × ÷ দিয়ে যুক্ত দুটি মান। নির্বাচিত সারিতে প্রয়োগ হয়; কিছু নির্বাচিত না থাকলে সব সারিতে। +a vector a pixel প্রতি pixel-এ একটি vector +a vector an image patch একটি image patch-এর একটি vector +A Zarr store (format 2 or 3) by its web address, with consolidated metadata, or a folder on this computer. A variable's map is read for the steps picked of its other dimensions, such as time. ওয়েব ঠিকানা দিয়ে Zarr store (format 2 বা 3), consolidated metadata-সহ, অথবা এই কম্পিউটারের একটি folder। time-এর মতো অন্য dimension-এর নির্বাচিত ধাপগুলোর জন্য variable-এর মানচিত্র পড়া হয়। +A Zarr variable's values through time at points clicked on the map. মানচিত্রে ক্লিক করা বিন্দুগুলোতে সময়ের সঙ্গে Zarr variable-এর মান। +A2 landscape sheet A2 আড়াআড়ি পৃষ্ঠা +A2 portrait sheet A2 লম্বালম্বি পৃষ্ঠা +A3 landscape sheet A3 আড়াআড়ি পৃষ্ঠা +A3 portrait sheet A3 লম্বালম্বি পৃষ্ঠা +A4 landscape sheet A4 আড়াআড়ি পৃষ্ঠা +A4 portrait sheet A4 লম্বালম্বি পৃষ্ঠা +A5 Grid A5 গ্রিড +A5's equal-area pentagonal cells at a resolution, over the map's extent, a layer or the world, as polygons with their ids. নির্বাচিত resolution-এ A5-এর সম-ক্ষেত্রফলবিশিষ্ট পঞ্চভুজ কোষ, মানচিত্রের extent, একটি layer বা পুরো পৃথিবী জুড়ে polygon ও তাদের ID হিসেবে। +About সম্পর্কে +About plugins প্লাগইন সম্পর্কে +About RustGIS RustGIS সম্পর্কে +About RustGIS. RustGIS সম্পর্কে। +Above উপরে +Above Left ওপরে বামে +Above Right ওপরে ডানে +Access key ID অ্যাক্সেস কী ID +Accessibility অ্যাক্সেসিবিলিটি +Account অ্যাকাউন্ট +Activate Map মানচিত্র সক্রিয় করুন +Add যোগ করুন +Add\nData যোগ করুন\nডেটা +Add ({n}) যোগ করুন ({n}) +Add a bar chart of a layer's numeric field. একটি layer-এর সংখ্যাসূচক field-এর bar chart যোগ করুন। +Add a circle. একটি বৃত্ত যোগ করুন। +Add a colorbar of a raster layer's colour scale in the map frame. মানচিত্রের frame-এ raster layer-এর রঙের স্কেলের colorbar যোগ করুন। +Add a description. একটি বর্ণনা যোগ করুন। +Add a field. একটি field যোগ করুন। +Add a file path একটি ফাইলের path যোগ করুন +Add a legend of the map frame's layers. মানচিত্রের frame-এর layer-গুলোর legend যোগ করুন। +Add a map frame showing a map of the project. প্রজেক্টের একটি মানচিত্র দেখানো map frame যোগ করুন। +Add a new map to the project. প্রজেক্টে একটি নতুন মানচিত্র যোগ করুন। +Add a north arrow for the map frame. map frame-এর জন্য উত্তর-দিক নির্দেশক তীর যোগ করুন। +Add a page একটি পৃষ্ঠা যোগ করুন +Add a page layout for printing and exporting the map. মানচিত্র মুদ্রণ ও export করার জন্য page layout যোগ করুন। +Add a page to the presentation. প্রেজেন্টেশনে একটি পৃষ্ঠা যোগ করুন। +Add a PNG or JPEG picture to the page. পৃষ্ঠায় PNG বা JPEG ছবি যোগ করুন। +Add a presentation file. একটি প্রেজেন্টেশন ফাইল যোগ করুন। +Add a rectangle, to frame or highlight part of the page. পৃষ্ঠার কোনো অংশ ঘিরে দেখাতে বা হাইলাইট করতে একটি আয়তক্ষেত্র যোগ করুন। +Add a report file, or a report template for the selected layer. নির্বাচিত layer-এর জন্য report file বা report template যোগ করুন। +Add a scale bar for the map frame. map frame-এর জন্য scale bar যোগ করুন। +add a second map first (Data › New Map) প্রথমে দ্বিতীয় একটি মানচিত্র যোগ করুন +Add a second map frame first প্রথমে দ্বিতীয় একটি map frame যোগ করুন +Add a straight line. একটি সরলরেখা যোগ করুন। +Add a text element. একটি text element যোগ করুন। +Add a web basemap over the others instead of replacing them, to blend it with them. Click one that shows to take it off. উপরে আরেকটি web basemap যোগ করুন, আগেরটিকে প্রতিস্থাপন না করে একসঙ্গে দেখানোর জন্য। এটি সরাতে দৃশ্যমান basemap-এ ক্লিক করুন। +Add a Zarr variable first (Add Data › Zarr…). প্রথমে একটি Zarr variable যোগ করুন (Add Data › Zarr…)। +Add Administrative Boundaries প্রশাসনিক সীমানা যোগ করুন +Add an arrow. Turn it with the round handle above it, or set its angle in its properties. একটি তীর যোগ করুন। উপরের গোল হাতল দিয়ে এটি ঘোরান, অথবা properties-এ কোণ নির্ধারণ করুন। +Add an Earthdata Login token (Earthdata Login, above) to put NASA GeoTIFFs on the map. মানচিত্রে NASA GeoTIFF দেখাতে Earthdata Login token (উপরে Earthdata Login) যোগ করুন। +Add an elevation raster first. প্রথমে একটি elevation raster যোগ করুন। +Add an elevation raster to the map first প্রথমে মানচিত্রে একটি elevation raster যোগ করুন +Add an ellipse. একটি উপবৃত্ত যোগ করুন। +Add an empty layer with a ready-made schema, to draw into. আঁকার জন্য প্রস্তুত schema-সহ একটি খালি layer যোগ করুন। +Add an input for each layer the model reads. মডেল যে প্রতিটি layer পড়ে, তার জন্য একটি input যোগ করুন। +Add an output to keep a result under a name. একটি নামের অধীনে ফলাফল রাখতে output যোগ করুন। +Add and Open যোগ করে খুলুন +Add and Open Notebook যোগ করে Notebook খুলুন +Add Archive আর্কাইভ যোগ করুন +Add as Time Series Time Series হিসেবে যোগ করুন +add at least two keyframes অন্তত দুটি keyframe যোগ করুন +Add Cell সেল যোগ করুন +Add Color Ramp File… Color Ramp ফাইল যোগ করুন… +Add Color Ramps Color Ramp যোগ করুন +Add color ramps to the project from a .rgisramp file, a QGIS style, a GDAL color file or a GMT palette. .rgisramp ফাইল, QGIS style, GDAL color file অথবা GMT palette থেকে প্রজেক্টে color ramp যোগ করুন। +Add Commands কমান্ড যোগ করুন +Add Data ডেটা যোগ করুন +Add Data (from file contents) ডেটা যোগ করুন (ফাইলের বিষয়বস্তু থেকে) +Add Data (from text) ডেটা যোগ করুন (টেক্সট থেকে) +Add Data From Database ডেটাবেস থেকে ডেটা যোগ করুন +Add data from public web services and catalogs. সর্বজনীন web service ও catalog থেকে ডেটা যোগ করুন। +Add Data From SQL SQL থেকে ডেটা যোগ করুন +Add Data From URL URL থেকে ডেটা যোগ করুন +Add Data From URL… URL থেকে ডেটা যোগ করুন… +Add Data Store… Data Store যোগ করুন… +Add Data… ডেটা যোগ করুন… +Add Delimited Text Delimited Text যোগ করুন +Add Delimited Text… Delimited Text যোগ করুন… +Add Downloaded Data ডাউনলোড করা ডেটা যোগ করুন +Add Element এলিমেন্ট যোগ করুন +Add Encoded Polyline Encoded Polyline যোগ করুন +Add Field Field যোগ করুন +Add field field যোগ করুন +Add field boundaries কৃষিজমির সীমানা যোগ করুন +Add field density কৃষিজমির ঘনত্ব যোগ করুন +Add Fields of the World বিশ্বের কৃষিজমি যোগ করুন +Add Folder ফোল্ডার যোগ করুন +Add Folder… ফোল্ডার যোগ করুন… +Add folder… ফোল্ডার যোগ করুন… +Add From Database ডেটাবেস থেকে যোগ করুন +Add From Service Service থেকে যোগ করুন +Add From Service… Service থেকে যোগ করুন… +Add GeoJSON, CSV or Shapefile data to the map. মানচিত্রে GeoJSON, CSV বা Shapefile ডেটা যোগ করুন। +Add Graphic গ্রাফিক যোগ করুন +Add Graphics Layer Graphics layer যোগ করুন +Add Input Input যোগ করুন +Add Job Job যোগ করুন +Add Keyframe Keyframe যোগ করুন +Add Label Class Label class যোগ করুন +Add Layer File Layer file যোগ করুন +Add Open Data Open Data যোগ করুন +Add Output Output যোগ করুন +Add Overture Maps Data Overture Maps ডেটা যোগ করুন +Add Page After এর পরে পৃষ্ঠা যোগ করুন +Add Page Before এর আগে পৃষ্ঠা যোগ করুন +Add pages, layouts, related reports and related records to the report. report-এ পৃষ্ঠা, layout, সংশ্লিষ্ট report ও সংশ্লিষ্ট record যোগ করুন। +Add Photos ছবি যোগ করুন +Add Plugin Folder… Plugin folder যোগ করুন… +Add points বিন্দু যোগ করুন +Add raster tiles, vector tiles or a map style by address. ঠিকানা দিয়ে raster tile, vector tile বা map style যোগ করুন। +Add Rule নিয়ম যোগ করুন +Add sample countries নমুনা দেশ যোগ করুন +Add Sample Data নমুনা ডেটা যোগ করুন +Add Satellite Embeddings Satellite Embeddings যোগ করুন +Add Selected ({n}) নির্বাচিত যোগ করুন ({n}) +Add Service Layer Service layer যোগ করুন +Add skill… skill যোগ করুন… +Add STAC Asset STAC Asset যোগ করুন +Add STAC Data Cube STAC Data Cube যোগ করুন +Add STAC Footprints STAC Footprints যোগ করুন +Add Step ধাপ যোগ করুন +Add Stroke Stroke যোগ করুন +Add Style Style যোগ করুন +Add Style File… Style file যোগ করুন… +Add Task Step Task step যোগ করুন +Add Text টেক্সট যোগ করুন +Add text that updates: page, date, scale, map and more. পৃষ্ঠার নম্বর, তারিখ, স্কেল, মানচিত্র এবং আরও তথ্য আপডেট হয় এমন টেক্সট যোগ করুন। +Add the attribute table of a layer in the map frame. map frame-এ একটি layer-এর attribute table যোগ করুন। +Add the current view as a keyframe. বর্তমান view-কে keyframe হিসেবে যোগ করুন। +Add the maps of a map file, map package, project or project package. map file, map package, project বা project package-এর মানচিত্র যোগ করুন। +Add The National Map Service The National Map Service যোগ করুন +Add this GeoTIFF to the map এই GeoTIFF মানচিত্রে যোগ করুন +Add this MCP server to your agent: আপনার agent-এ এই MCP server যোগ করুন: +Add Tile Layer Tile layer যোগ করুন +Add Tile Layer… Tile layer যোগ করুন… +Add To Current Selection বর্তমান নির্বাচনে যোগ করুন +Add to Favourites প্রিয়তে যোগ করুন +Add to Map মানচিত্রে যোগ করুন +Add vector data. vector ডেটা যোগ করুন। +Add view view যোগ করুন +Add With Options… বিকল্পসহ যোগ করুন… +Add Zarr Variable Zarr variable যোগ করুন +Add Zarr Variable… Zarr variable যোগ করুন… +Add ▾ যোগ করুন ▾ +Added যোগ করা হয়েছে +Added Overture's {theme} as map tiles ({release}; some themes show only zoomed in): {attribution} Overture-এর {theme} map tile হিসেবে যোগ করা হয়েছে ({release}; কিছু theme শুধু zoom করলে দেখা যায়): {attribution} +Added {name} {name} যোগ করা হয়েছে +Added {p} points, {l} lines and {g} polygons {p}টি বিন্দু, {l}টি লাইন এবং {g}টি polygon যোগ করা হয়েছে +Added: {names} যোগ করা হয়েছে: {names} +Adding… যোগ করা হচ্ছে… +Additional surrounds. অতিরিক্ত পারিপার্শ্বিক উপাদান। +Address ঠিকানা +Address fields ঠিকানার field +Adds geodesic area (km²), length (km) or centroid coordinates as fields. geodesic area (km²), দৈর্ঘ্য (km) অথবা centroid-এর স্থানাঙ্ক field হিসেবে যোগ করে। +ADM0 ADM0 +ADM0 — the country ADM0 — দেশ +ADM1 ADM1 +ADM1 — states, provinces ADM1 — রাজ্য, প্রদেশ +ADM2 ADM2 +ADM2 — districts, counties ADM2 — জেলা, কাউন্টি +ADM3 ADM3 +ADM4 ADM4 +ADM5 ADM5 +Administrative Boundaries প্রশাসনিক সীমানা +After the event ঘটনার পরে +Agent input exceeds the protocol line limit. Agent-এর input প্রোটোকলের line limit অতিক্রম করেছে। +Agent input pipe closed. Agent-এর input pipe বন্ধ। +Agent input queue is full or disconnected; stop the agent and try again. Agent-এর input queue পূর্ণ বা সংযোগ বিচ্ছিন্ন; agent বন্ধ করে আবার চেষ্টা করুন। +Agent threads Agent thread-সমূহ +Agent work Agent-এর কাজ +Agents have full access: they may run scripts, tasks, models and plugins. Click to stop the MCP relay Agent-গুলোর পূর্ণ অ্যাক্সেস আছে: তারা script, task, model ও plugin চালাতে পারে। MCP relay বন্ধ করতে ক্লিক করুন +Agents on this computer that connect (Claude Code, Codex, Cursor and others) work on the map open here, with RustGIS's tools: they can open and save files and use the web. Scripts and plugin management stay off. এই কম্পিউটারে সংযুক্ত হওয়া agent-গুলো (Claude Code, Codex, Cursor এবং অন্যান্য) এখানে খোলা মানচিত্রে RustGIS-এর টুল ব্যবহার করে কাজ করে: তারা ফাইল খুলতে ও সংরক্ষণ করতে পারে এবং web ব্যবহার করতে পারে। Script ও plugin management বন্ধ থাকে। +Agents then run commands, change files and use the web on this computer without asking you. এরপর agent-গুলো আপনার অনুমতি না নিয়েই এই কম্পিউটারে command চালায়, ফাইল পরিবর্তন করে এবং web ব্যবহার করে। +Aggregate Points to A5 বিন্দু A5-এ aggregate করুন +Aggregation সমষ্টিকরণ +Agricultural field boundaries the Fields of the World model found in Sentinel-2 imagery, worldwide, a year at a time (2nd Edition, CC BY 4.0). These are model predictions, not surveyed parcels. বিশ্বজুড়ে Sentinel-2 ছবিতে Fields of the World মডেল যে কৃষিজমির সীমানা শনাক্ত করেছে, বছরভিত্তিক (২য় সংস্করণ, CC BY 4.0)। এগুলো মডেলের পূর্বাভাস, জরিপ করা জমির খণ্ড নয়। +AI AI +AI\nAssistant AI\nসহকারী +AI Assistant AI সহকারী +AI provider AI প্রদানকারী +Albers Equal Area Conic Albers সমক্ষেত্রফল শঙ্কু অভিক্ষেপ +Alignment সারিবদ্ধকরণ +All সব +All categories সব বিভাগ +All classes সব শ্রেণি +All files সব ফাইল +all of it সবকিছু +All Selected সব নির্বাচিত +All steps done. সব ধাপ সম্পন্ন। +All tables with geometry জ্যামিতিসহ সব টেবিল +Allow অনুমতি দিন +Allow ChatGPT plan use when signing in. If your workspace doesn't permit it, ask its admin or choose an API key provider. সাইন ইন করার সময় ChatGPT প্ল্যান ব্যবহার করতে দিন। আপনার workspace অনুমতি না দিলে প্রশাসকের সঙ্গে যোগাযোগ করুন অথবা API key-ভিত্তিক provider বেছে নিন। +Allow Feature Selection Feature নির্বাচন করার অনুমতি দিন +Allow only certain combinations of values across fields. field-গুলোর মানের মধ্যে শুধু নির্দিষ্ট সমন্বয় অনুমোদন করুন। +Allow such actions for this conversation এই কথোপকথনে এ ধরনের কাজের অনুমতি দিন +Allow this action? এই কাজের অনুমতি দেবেন? +Allow this agent's RustGIS action? এই agent-এর RustGIS action অনুমোদন করবেন? +Along Selected Line নির্বাচিত লাইন বরাবর +Alt+ Alt+ +Alternating bar বিকল্প বার +Altitude উচ্চতা +Always allow সব সময় অনুমতি দিন +Amazon S3 Amazon S3 +Amazon S3, or an S3-compatible service (MinIO, Cloudflare R2, Wasabi…) by its endpoint. Keys are kept until RustGIS closes; a saved connection keeps its name, endpoint and region, never its secret. endpoint-এর মাধ্যমে Amazon S3 বা S3-সামঞ্জস্যপূর্ণ সার্ভিস (MinIO, Cloudflare R2, Wasabi…)। RustGIS বন্ধ হওয়া পর্যন্ত key রাখা হয়; সংরক্ষিত connection-এ নাম, endpoint ও region থাকে, গোপন key নয়। +amenity=cafe, shop amenity=cafe, shop +An agent on this computer asks to: এই কম্পিউটারের একজন agent অনুমতি চাইছে: +An AWS profile একটি AWS profile +Analysis বিশ্লেষণ +Analyze বিশ্লেষণ করুন +Analyze › AI বিশ্লেষণ › AI +Analyze › Workflows বিশ্লেষণ › Workflows +AND AND +Angle কোণ +Animated GIF অ্যানিমেটেড GIF +Animation অ্যানিমেশন +Another run is under way. অন্য একটি কাজ ইতিমধ্যে চলছে। +Any যেকোনো +anywhere in the records রেকর্ডের যেকোনো স্থানে +API key API কী +Apply প্রয়োগ করুন +Apply a function to a raster raster-এ একটি function প্রয়োগ করুন +Apply Control Points Control Point প্রয়োগ করুন +Apply elevation উচ্চতা প্রয়োগ করুন +Apply Style Style প্রয়োগ করুন +Apply to layers layer-গুলোতে প্রয়োগ করুন +Apply to the selected layer নির্বাচিত layer-এ প্রয়োগ করুন +Approve agents' actions without asking না জিজ্ঞেস করে agent-এর কাজ অনুমোদন করুন +ArcGIS ArcGIS +ArcGIS Hub ArcGIS Hub +ArcGIS Portal ArcGIS পোর্টাল +ArcGIS Portal… ArcGIS পোর্টাল… +ArcGIS REST ArcGIS REST +Archive আর্কাইভ +Archive History আর্কাইভের ইতিহাস +Archiving আর্কাইভ করা হচ্ছে +Area ক্ষেত্রফল +Area of use: longitude {w}° to {e}°, latitude {s}° to {n}° ব্যবহারের এলাকা: দ্রাঘিমাংশ {w}° থেকে {e}°, অক্ষাংশ {s}° থেকে {n}° +Area of use: the whole world ব্যবহারের এলাকা: পুরো পৃথিবী +Area of use: within {r}° of the {lon}° meridian ব্যবহারের এলাকা: {lon}° মধ্যরেখার {r}°-এর মধ্যে +Area of use: within {r}° of {lon}°, {lat}° ব্যবহারের এলাকা: {lon}°, {lat}° বিন্দুর {r}°-এর মধ্যে +Area {area} · Perimeter {perimeter} ক্ষেত্রফল {area} · পরিসীমা {perimeter} +Areas (name, land use, area) এলাকা (নাম, ভূমি ব্যবহার, ক্ষেত্রফল) +Arrange সাজান +Arrow তীর +Arrowheads তীরের মাথা +Ask a question about your data, or describe what to do with the map. The assistant runs RustGIS commands for you; every change can be undone. আপনার ডেটা সম্পর্কে প্রশ্ন করুন, অথবা মানচিত্রে কী করতে হবে তা বলুন। Assistant আপনার হয়ে RustGIS কমান্ড চালায়; প্রতিটি পরিবর্তন ফিরিয়ে নেওয়া যায়। +Ask about the map, or tell it what to do… মানচিত্র সম্পর্কে জিজ্ঞেস করুন, অথবা কী করতে হবে বলুন… +Ask about your data, or tell the assistant what to do with the map. আপনার ডেটা সম্পর্কে জিজ্ঞেস করুন, অথবা মানচিত্রে কী করতে হবে assistant-কে বলুন। +Asking NASA's CMR… NASA CMR-কে জিজ্ঞেস করা হচ্ছে… +Asking the catalog… ক্যাটালগে খোঁজা হচ্ছে… +Asking the NLDI… NLDI-কে জিজ্ঞেস করা হচ্ছে… +Assistant settings Assistant-এর সেটিংস +At the end শেষে +At the start শুরুতে +Atlas অ্যাটলাস +Attachments সংযুক্তি +Attribute\nTable অ্যাট্রিবিউট\nটেবিল +Attribute Rules অ্যাট্রিবিউট নিয়মাবলি +Attribute Table অ্যাট্রিবিউট টেবিল +Attributes অ্যাট্রিবিউট +Attribution উৎস-স্বীকৃতি +Aurora অরোরা +Auto স্বয়ংক্রিয় +Auto Cache স্বয়ংক্রিয় cache +Auto-complete (2 clicks) স্বয়ংক্রিয় পূরণ (২ বার ক্লিক) +Auto-complete Profile স্বয়ংক্রিয় পূরণের profile +Autofill Fields Field স্বয়ংক্রিয়ভাবে পূরণ করুন +Automatic স্বয়ংক্রিয় +Automatic (m or km) স্বয়ংক্রিয় (m বা km) +Automatic (system language) স্বয়ংক্রিয় (সিস্টেমের ভাষা) +Automatic (system time zone and region) স্বয়ংক্রিয় (সিস্টেমের সময় অঞ্চল ও অঞ্চল) +Automatic ({kind}) স্বয়ংক্রিয় ({kind}) +Avoid এড়িয়ে যান +AWS, anonymous AWS, বেনামি +Azimuth আজিমুথ +Back পেছনে +Back to the Map মানচিত্রে ফিরে যান +Back to the map মানচিত্রে ফিরে যান +Back to tools টুলে ফিরে যান +Backdrop পটভূমি +Band ব্যান্ড +Band Combination ব্যান্ডের সমন্বয় +Band values at a point একটি বিন্দুতে ব্যান্ডের মান +Band {n} ব্যান্ড {n} +Bands ব্যান্ডসমূহ +Bands: blue green red NIR SWIR ব্যান্ড: নীল সবুজ লাল NIR SWIR +Basemap বেসম্যাপ +Basemaps বেসম্যাপসমূহ +Basin from hydrolocation হাইড্রোলোকেশন থেকে অববাহিকা +Bearing দিকনির্দেশ +Before the event ঘটনার আগে +Below নিচে +Below 1:5,000,000 show everything; beyond it only what matches: 1:5,000,000-এর নিচে সব দেখান; এর বাইরে শুধু মিলে যাওয়া ফল দেখান: +Below Left নিচে বামে +Below Right নিচে ডানে +Best সেরা +Bevel বেভেল +Bike সাইকেল +Bilinear দ্বিরৈখিক +Bind To Time Slider সময় স্লাইডারের সঙ্গে যুক্ত করুন +Bind to Time Slider সময় স্লাইডারের সঙ্গে যুক্ত করুন +Blank ফাঁকা +Blank for Amazon S3 Amazon S3-এর জন্য ফাঁকা +Blank Letter page ফাঁকা Letter পৃষ্ঠা +Blank Map ফাঁকা মানচিত্র +Blank Page ফাঁকা পৃষ্ঠা +Blend: {mode} মিশ্রণ: {mode} +Block Feature Selection Feature নির্বাচন আটকে দিন +Blue নীল +Bold গাঢ় +Bold label text. গাঢ় label text। +Bookmark Description Bookmark-এর বর্ণনা +Bookmarks Bookmark-সমূহ +Bookmarks Pane Bookmark প্যানেল +Boolean বুলিয়ান +Both ends দুই প্রান্ত +Bottom নিচে +Boundaries… সীমানা… +Bounding\nGeometry সীমানা নির্ধারণের\nজ্যামিতি +Brightness উজ্জ্বলতা +Brightness, contrast, saturation and sepia for the whole map. পুরো মানচিত্রের উজ্জ্বলতা, contrast, saturation ও sepia। +Bring this bookmark's map into view to update it এই bookmark-এর মানচিত্রটি দেখান, যাতে এটি আপডেট করা যায় +Browse ব্রাউজ করুন +Browse Connection Connection ব্রাউজ করুন +Browse it in the Browser Browser-এ খুলে দেখুন +Browse Plugins Plugin ব্রাউজ করুন +Browse the geoprocessing toolbox. Geoprocessing toolbox ব্রাউজ করুন। +Browse the project's maps, tables and folders. প্রজেক্টের মানচিত্র, টেবিল ও ফোল্ডার ব্রাউজ করুন। +Browse this folder below নিচে এই ফোল্ডার ব্রাউজ করুন +Browse with যে অ্যাপ দিয়ে ব্রাউজ করবেন +Browse, install, update and remove plugins. Plugin ব্রাউজ, ইনস্টল, আপডেট ও অপসারণ করুন। +Browser ব্রাউজার +Browser\nPane Browser\nপ্যানেল +Browser View ব্রাউজার ভিউ +Bucket বাকেট +Buckets Bucket-সমূহ +Buffer বাফার +Buffer A Point একটি বিন্দুর Buffer +Buffer the cities by 200 km শহরগুলোর চারপাশে ২০০ km Buffer তৈরি করুন +Buffer {distance} {distance} দূরত্বের বাফার +Buffer {distance} Here এখানে Buffer {distance} +Build তৈরি করুন +Build a surface model and mesh from elevation rasters elevation raster থেকে surface model ও mesh তৈরি করুন +Build Mosaic Mosaic তৈরি করুন +Built in অন্তর্নির্মিত +Bundle the active map and its data. সক্রিয় মানচিত্র ও এর ডেটা একসঙ্গে প্যাক করুন। +Bundle the geoprocessing history with the layers it made. Geoprocessing-এর ইতিহাস এবং এর তৈরি layer-গুলো একসঙ্গে প্যাক করুন। +Bundle the project and its data into one file to share. শেয়ার করার জন্য প্রজেক্ট ও এর ডেটা একটি ফাইলে প্যাক করুন। +Bundle the visible layers, with simplified geometry for phones and tablets. দৃশ্যমান layer-গুলো মোবাইল ও ট্যাবলেটে ব্যবহারের উপযোগী সরলীকৃত geometry-সহ প্যাক করুন। +By\nAttributes \nঅ্যাট্রিবিউট অনুসারে +By\nLocation \nঅবস্থান অনুসারে +by {author} {author}-এর +By {field} {field} অনুসারে +cached tiles cache করা tile +Calculate হিসাব করুন +Calculate\nGeometry \nজ্যামিতি হিসাব করুন +Calculate Field Field হিসাব করুন +Calculate field field হিসাব করুন +Calculate field values. field-এর মান হিসাব করুন। +Calculate fields and enforce conditions on every edit. প্রতিটি edit-এ field হিসাব করুন এবং শর্ত কার্যকর করুন। +Calculate Geometry জ্যামিতি হিসাব করুন +Calculate Geometry Attributes জ্যামিতির অ্যাট্রিবিউট হিসাব করুন +Calculated {rows} features {rows}টি feature হিসাব করা হয়েছে +Calculation হিসাব +Camera ক্যামেরা +Camera Effect ক্যামেরার প্রভাব +Can't read this folder এই ফোল্ডার পড়া যাচ্ছে না +Cancel বাতিল +Cancel Caching Caching বাতিল করুন +Cap প্রান্ত-সংযোগ +Capture To Clipboard Clipboard-এ ক্যাপচার করুন +CARGO_PKG_VERSION CARGO_PKG_VERSION +Cartography কার্টোগ্রাফি +Case Field Case field +Cast Shadow ছায়া প্রক্ষেপণ করুন +Catalog ক্যাটালগ +catalog is a static STAC catalog's catalog.json or collection.json catalog হলো static STAC catalog-এর catalog.json বা collection.json +Catalogue (CSW) ক্যাটালগ (CSW) +Catalogue search (CSW) ক্যাটালগ অনুসন্ধান (CSW) +Celestial Bodies মহাজাগতিক বস্তুসমূহ +Celestial Body মহাজাগতিক বস্তু +Celestial body: {body} মহাজাগতিক বস্তু: {body} +Cell size সেলের আকার +cell size ° সেলের আকার ° +Cells সেলসমূহ +Center কেন্দ্র +Center Map Here এখানে মানচিত্র কেন্দ্রীভূত করুন +Centered কেন্দ্রস্থিত +Central meridian কেন্দ্রীয় মধ্যরেখা +Chain functions Function chain করুন +Chain raster functions. Raster function-গুলো chain করুন। +Change dataset Dataset পরিবর্তন করুন +Change Detection পরিবর্তন শনাক্তকরণ +Change the map's backdrop. মানচিত্রের পটভূমি পরিবর্তন করুন। +Change the map's coordinate system মানচিত্রের coordinate system পরিবর্তন করুন +Change the page size and orientation. পৃষ্ঠার আকার ও অভিমুখ পরিবর্তন করুন। +Change the project (add layers, edit features, change symbology) প্রজেক্ট পরিবর্তন করুন (layer যোগ, feature সম্পাদনা, symbology পরিবর্তন) +Chart categories Chart-এর category +Chart Frame Chart frame +ChatGPT (sign in) ChatGPT (সাইন ইন) +ChatGPT plan use is unavailable for this account or workspace. এই account বা workspace-এ ChatGPT প্ল্যান ব্যবহার করা যায় না। +ChatGPT sign-in failed. Check the connection and local settings, then try again. ChatGPT-তে সাইন-ইন ব্যর্থ হয়েছে। সংযোগ ও স্থানীয় সেটিংস পরীক্ষা করে আবার চেষ্টা করুন। +ChatGPT usage is temporarily unavailable. Keep your sign-in and try again later. ChatGPT ব্যবহার সাময়িকভাবে অনুপলব্ধ। সাইন-ইন বজায় রেখে পরে আবার চেষ্টা করুন। +CHE CHE +Check পরীক্ষা করুন +Check Again আবার পরীক্ষা করুন +Check for\nUpdates \nআপডেট পরীক্ষা করুন +Check for Updates আপডেট পরীক্ষা করুন +Check for Updates… আপডেট পরীক্ষা করুন… +Check the address and the service type. ঠিকানা ও service-এর ধরন পরীক্ষা করুন। +Check the address and your connection. ঠিকানা ও সংযোগ পরীক্ষা করুন। +Check the address. ঠিকানা পরীক্ষা করুন। +Check the clause, and count the features it takes. clause পরীক্ষা করুন এবং এতে যত feature আছে তা গণনা করুন। +Check the integration's client, permissions, workspace policy and route configuration. integration-এর client, permission, workspace policy ও route configuration পরীক্ষা করুন। +Checking for updates… আপডেট পরীক্ষা করা হচ্ছে… +China চীন +Choice Test বিকল্প পরীক্ষা +Choose a band combination to display the raster layer. raster layer দেখাতে band combination বেছে নিন। +Choose a contrast stretch for the raster layer. raster layer-এর জন্য contrast stretch বেছে নিন। +Choose a country একটি দেশ বেছে নিন +Choose a delimited text file or a spreadsheet, then pick its columns. Delimited text file বা spreadsheet বেছে নিয়ে তার column নির্বাচন করুন। +Choose a destination একটি গন্তব্য বেছে নিন +choose a file first আগে একটি ফাইল বেছে নিন +Choose a layer… একটি layer বেছে নিন… +Choose a model. একটি model বেছে নিন। +Choose a resampling type for the raster layer. raster layer-এর জন্য resampling-এর ধরন বেছে নিন। +Choose a start শুরুর স্থান বেছে নিন +Choose a stop শেষের স্থান বেছে নিন +Choose an account একটি account বেছে নিন +Choose extra buttons for the Quick Access Toolbar. Quick Access Toolbar-এর জন্য অতিরিক্ত বোতাম বেছে নিন। +Choose File… ফাইল বেছে নিন… +Choose how the flat map lays out the Earth. সমতল মানচিত্রে পৃথিবী কীভাবে উপস্থাপিত হবে তা বেছে নিন। +Choose how the layer is drawn. layer কীভাবে আঁকা হবে তা বেছে নিন। +Choose how the raster is drawn. raster কীভাবে আঁকা হবে তা বেছে নিন। +Choose Project… প্রজেক্ট বেছে নিন… +Choose the interface language. ইন্টারফেসের ভাষা বেছে নিন। +Choose which layers a click identifies. ক্লিক করলে কোন layer শনাক্ত হবে তা বেছে নিন। +Choose which tabs the ribbon shows. Ribbon-এ কোন tab দেখানো হবে তা বেছে নিন। +Circle বৃত্ত +Circle Radius (km) বৃত্তের ব্যাসার্ধ (km) +Class শ্রেণি +Class color শ্রেণির রং +Class Style শ্রেণির style +Classes শ্রেণিসমূহ +Classes from training polygons. training polygon থেকে শ্রেণিসমূহ। +Classic arrow ক্লাসিক তীর +Classification\nTools শ্রেণিবিন্যাসের\nটুল +Classification\nWizard শ্রেণিবিন্যাসের\nউইজার্ড +Classification Tools শ্রেণিবিন্যাসের টুল +Classification Wizard শ্রেণিবিন্যাসের উইজার্ড +Classify শ্রেণিবদ্ধ করুন +Classify from training polygons training polygon থেকে শ্রেণিবদ্ধ করুন +Claude Code and Codex connect through an ACP adapter. On first use, npx downloads and runs its pinned version from npm. Claude Code ও Codex একটি ACP adapter-এর মাধ্যমে সংযুক্ত হয়। প্রথম ব্যবহারে npx npm থেকে নির্দিষ্ট version ডাউনলোড করে চালায়। +Clear মুছে ফেলুন +Clear\nLimits \nসীমা মুছুন +Clear Filters Filter মুছুন +Clear Guides Guide মুছুন +Clear History ইতিহাস মুছুন +Clear Limits সীমা মুছুন +Clear mask Mask মুছুন +Clear NLDI result NLDI ফলাফল মুছুন +Clear search অনুসন্ধান মুছুন +Clear Selection নির্বাচন মুছুন +Clear selection নির্বাচন মুছুন +Clear the filter to reorder পুনরায় সাজাতে filter মুছুন +Clear the selection in every layer. সব layer থেকে নির্বাচন মুছুন। +Clear the selection. নির্বাচন মুছুন। +Clear Visibility Range দৃশ্যমানতার সীমা মুছুন +Click a feature, then drag its vertices · click an edge to add one একটি feature-এ ক্লিক করুন, তারপর তার vertex টেনে সরান · নতুন vertex যোগ করতে edge-এ ক্লিক করুন +Click a pixel on the map মানচিত্রের একটি pixel-এ ক্লিক করুন +Click a point on the map মানচিত্রের একটি বিন্দুতে ক্লিক করুন +Click a source to add it, or to open its browser connected. Right-click for favourites. উৎস যোগ করতে বা সংযুক্ত browser খুলতে সেটিতে ক্লিক করুন। পছন্দের জন্য ডান-ক্লিক করুন। +Click or drag a box to select features. Shift adds, Ctrl removes. feature নির্বাচন করতে ক্লিক করুন বা একটি বাক্স টানুন। Shift যোগ করে, Ctrl সরিয়ে দেয়। +Click or drag a box · Shift adds · Ctrl removes ক্লিক করুন বা বাক্স টানুন · Shift যোগ করে · Ctrl সরিয়ে দেয় +Click points a smooth curve passes through; double-click to finish. মসৃণ curve যে বিন্দুগুলোর মধ্য দিয়ে যাবে সেগুলোতে ক্লিক করুন; শেষ করতে ডাবল-ক্লিক করুন। +Click the corners of a polygon; double-click to close it. একটি polygon-এর কোণগুলোতে ক্লিক করুন; বন্ধ করতে ডাবল-ক্লিক করুন। +Click the corners of the area · double-click to finish এলাকার কোণগুলোতে ক্লিক করুন · শেষ করতে ডাবল-ক্লিক করুন +Click the frame's corners · double-click to finish · Esc to stop frame-এর কোণগুলোতে ক্লিক করুন · শেষ করতে ডাবল-ক্লিক করুন · থামতে Esc +Click the map to add a point একটি বিন্দু যোগ করতে মানচিত্রে ক্লিক করুন +Click the map to place a point একটি বিন্দু বসাতে মানচিত্রে ক্লিক করুন +Click the map to place text টেক্সট বসাতে মানচিত্রে ক্লিক করুন +Click the map to place text. টেক্সট বসাতে মানচিত্রে ক্লিক করুন। +Click the map to trace a point একটি বিন্দু ট্রেস করতে মানচিত্রে ক্লিক করুন +Click the map: {n} of {total} মানচিত্রে ক্লিক করুন: {n} / {total} +Click the observer on the map মানচিত্রে পর্যবেক্ষককে ক্লিক করুন +Click the observer, then the target প্রথমে পর্যবেক্ষক, তারপর লক্ষ্যবস্তুতে ক্লিক করুন +Click the polygon's corners polygon-এর কোণগুলোতে ক্লিক করুন +Click the start, then the end প্রথমে শুরুতে, তারপর শেষে ক্লিক করুন +Click the vertices of a line; double-click to finish. একটি line-এর vertex-গুলোতে ক্লিক করুন; শেষ করতে ডাবল-ক্লিক করুন। +Click to add points · double-click to finish · Esc to stop বিন্দু যোগ করতে ক্লিক করুন · শেষ করতে ডাবল-ক্লিক করুন · থামতে Esc +Click to add vertices · double-click to close vertex যোগ করতে ক্লিক করুন · বন্ধ করতে ডাবল-ক্লিক করুন +Click to add vertices · double-click to close the polygon vertex যোগ করতে ক্লিক করুন · polygon বন্ধ করতে ডাবল-ক্লিক করুন +Click to add vertices · double-click to finish vertex যোগ করতে ক্লিক করুন · শেষ করতে ডাবল-ক্লিক করুন +Click to add vertices · double-click to finish the line vertex যোগ করতে ক্লিক করুন · line শেষ করতে ডাবল-ক্লিক করুন +Click to edit the description বর্ণনা সম্পাদনা করতে ক্লিক করুন +Click to place a point graphic. বিন্দু-গ্রাফিক বসাতে ক্লিক করুন। +Click to select the node node নির্বাচন করতে ক্লিক করুন +Click to select, double-click to add, right-click for more নির্বাচন করতে একবার ক্লিক, যোগ করতে ডাবল-ক্লিক, আরও বিকল্পের জন্য ডান-ক্লিক +Click to switch between decimal degrees and DMS দশমিক ডিগ্রি ও DMS-এর মধ্যে বদলাতে ক্লিক করুন +Clip ক্লিপ +Clip a raster to the view, or resample it. দৃশ্যমান অংশে raster clip করুন অথবা resample করুন। +Clip Features Feature clip করুন +Clip or resample Clip বা resample করুন +Clip To Map View মানচিত্রের দৃশ্যে clip করুন +Clipboard ক্লিপবোর্ড +Close বন্ধ করুন +Close (Esc) বন্ধ করুন (Esc) +Close All সব বন্ধ করুন +Close all panes সব pane বন্ধ করুন +Close ModelBuilder ModelBuilder বন্ধ করুন +Close Others অন্যগুলো বন্ধ করুন +Close Table টেবিল বন্ধ করুন +Close table টেবিল বন্ধ করুন +Close the layout and show the map. layout বন্ধ করে মানচিত্র দেখান। +Close the time slider (it stays on; Time Slider opens it again). time slider বন্ধ করুন (এটি চালু থাকে; Time Slider আবার খুলবে)। +Close thread Thread বন্ধ করুন +Close {name} {name} বন্ধ করুন +Closest সবচেয়ে কাছের +Coded values, one per line: code=description প্রতি লাইনে একটি করে coded value: code=description +Collapse or Expand the Ribbon Ribbon গুটিয়ে নিন বা প্রসারিত করুন +Collapse the ribbon (Ctrl+F1) Ribbon গুটিয়ে নিন (Ctrl+F1) +Collection সংগ্রহ +Collection {id}: choose dates and Find Items Collection {id}: তারিখ বেছে Find Items করুন +Collections Collection-সমূহ +Color রং +Color Infrared (4, 1, 2) রঙিন অবলোহিত (4, 1, 2) +Color Ramps Color Ramp-সমূহ +Color Scheme রঙের scheme +Color scheme রঙের scheme +Color Vision রঙ দেখার ক্ষমতা +Color Vision Simulator রঙ-দৃষ্টির simulator +Colorbar রঙের বার +Colormap রঙের মানচিত্র +Colors from the raster's own color table. raster-এর নিজস্ব color table থেকে রং। +Colour ramps (RustGIS, QGIS, GDAL, GMT) রঙের র‍্যাম্প (RustGIS, QGIS, GDAL, GMT) +Columns named like longitude/latitude, easting/northing or geometry are picked for you. longitude/latitude, easting/northing অথবা geometry-র মতো নামযুক্ত column-গুলো স্বয়ংক্রিয়ভাবে বেছে নেওয়া হয়। +Combinations from the box above, one per line উপরের বাক্সের সমন্বয়গুলো, প্রতি লাইনে একটি +COMID {comid} COMID {comid} +Comma কমা +Comma-separated (optional) কমা দিয়ে আলাদা করা (ঐচ্ছিক) +Command\nSearch কমান্ড\nঅনুসন্ধান +command is required command আবশ্যক +Command Search কমান্ড অনুসন্ধান +Commands কমান্ডসমূহ +Commands on the Plugins tab: {commands} · Tools in the Tools pane: {tools} Plugins tab-এর command: {commands} · Tools pane-এর tool: {tools} +Commands… কমান্ড… +Common scales সাধারণ স্কেল +Compact সংক্ষিপ্ত +Compacted the earlier conversation. আগের কথোপকথন সংক্ষিপ্ত করা হয়েছে। +Compare তুলনা করুন +Compare two rasters দুটি raster তুলনা করুন +Compass rose কম্পাসের গোলাপ +Completed সম্পন্ন +Confirm uninstall আনইনস্টল নিশ্চিত করুন +Conic, conformal: true shapes across a mid-latitude region; set its meridian and parallels to the region. Conic, conformal: মধ্য অক্ষাংশের অঞ্চলে আকৃতি ঠিক থাকে; অঞ্চল অনুযায়ী meridian ও parallel নির্ধারণ করুন। +Conic, equal-area: true sizes across a mid-latitude region; set its meridian and parallels to the region. Conic, equal-area: মধ্য অক্ষাংশের অঞ্চলে আকার ঠিক থাকে; অঞ্চল অনুযায়ী meridian ও parallel নির্ধারণ করুন। +Connect সংযুক্ত করুন +Connect a folder to the Browser. Browser-এর সঙ্গে একটি folder সংযুক্ত করুন। +Connect to S3 S3-তে সংযুক্ত করুন +Connect, or pick a layer সংযুক্ত করুন, অথবা একটি layer বেছে নিন +Connected to {kind} {title} {kind} {title}-এর সঙ্গে সংযুক্ত +Connecting to {url}… {url}-এ সংযোগ করা হচ্ছে… +Connection সংযোগ +connection.new returned no id connection.new কোনো ID ফেরত দেয়নি +Connections সংযোগসমূহ +Connections you add are listed here আপনি যে সংযোগ যোগ করেন সেগুলো এখানে তালিকাভুক্ত হবে +Connections… সংযোগ… +Constant ধ্রুবক +Constant height ধ্রুব উচ্চতা +Constant… ধ্রুবক… +Contingent Values শর্তাধীন মান +Continue চালিয়ে যান +Continue with ChatGPT ChatGPT দিয়ে চালিয়ে যান +Contrast কনট্রাস্ট +Control Point Fit কন্ট্রোল পয়েন্ট ফিট +Conversion রূপান্তর +Convert রূপান্তর করুন +Convert a layer to another format একটি layer-কে অন্য format-এ রূপান্তর করুন +Convert the selected layer's labels into an annotation layer. নির্বাচিত layer-এর label-গুলো annotation layer-এ রূপান্তর করুন। +Convert this layer's labels into an annotation layer. এই layer-এর label-গুলো annotation layer-এ রূপান্তর করুন। +Convert To Annotation Annotation-এ রূপান্তর করুন +Convert to Annotation Annotation-এ রূপান্তর করুন +Converted data রূপান্তরিত ডেটা +Convert… রূপান্তর করুন… +Coordinate স্থানাঙ্ক +Coordinate System স্থানাঙ্ক ব্যবস্থা +Coordinate system স্থানাঙ্ক ব্যবস্থা +Coordinate System… স্থানাঙ্ক ব্যবস্থা… +Copied the command. কমান্ড কপি করা হয়েছে। +Copied the map to the clipboard মানচিত্র clipboard-এ কপি করা হয়েছে +Copied {path} {path} কপি করা হয়েছে +Copies the (selected) features of a layer to a new layer. একটি layer-এর (নির্বাচিত) feature-গুলো নতুন layer-এ কপি করে। +Copy কপি +Copy / Cut / Paste features Feature কপি / কাট / পেস্ট +Copy a model.run command that runs this model, for the Script pane or Python Script pane বা Python-এর জন্য এই model চালানোর model.run command কপি করুন +Copy Address ঠিকানা কপি করুন +Copy address ঠিকানা কপি করুন +Copy an image of the map to the clipboard. মানচিত্রের ছবি clipboard-এ কপি করুন। +Copy as CLI Command CLI command হিসেবে কপি করুন +Copy as Command Command হিসেবে কপি করুন +Copy As GeoJSON GeoJSON হিসেবে কপি করুন +Copy as Python Python হিসেবে কপি করুন +Copy Command Command কপি করুন +Copy coordinates to the clipboard স্থানাঙ্ক clipboard-এ কপি করুন +Copy details বিবরণ কপি করুন +Copy Group Group কপি করুন +Copy Layer Layer কপি করুন +Copy Layers Layer-গুলো কপি করুন +Copy Path Path কপি করুন +Copy Run As Code Run as code কপি করুন +Copy sign-in link সাইন-ইন লিংক কপি করুন +Copy the selected features (also as GeoJSON text). নির্বাচিত feature-গুলো কপি করুন (GeoJSON text হিসেবেও)। +Copy the selected features and delete them. নির্বাচিত feature-গুলো কপি করে মুছে ফেলুন। +Copy the selected layer's data source path. নির্বাচিত layer-এর data source path কপি করুন। +Copy this element (Ctrl+D, ⌘D on a Mac) এই element কপি করুন (Ctrl+D, Mac-এ ⌘D) +Copy this run as code, or open it in its tool এই run-টি code হিসেবে কপি করুন, অথবা এর tool-এ খুলুন +Corrections সংশোধন +Cost খরচ +could not draw the map মানচিত্র আঁকা যায়নি +Couldn't check for updates: {error} আপডেট পরীক্ষা করা যায়নি: {error} +Couldn't download the font for {language}; pick the language again to retry. {language}-এর font ডাউনলোড করা যায়নি; আবার চেষ্টা করতে ভাষাটি পুনরায় বেছে নিন। +Couldn't load the plugin registry: {error} Plugin registry লোড করা যায়নি: {error} +Count, sum, mean, min, max and standard deviation of a field, optionally per case field. একটি field-এর count, sum, mean, min, max ও standard deviation; চাইলে case field অনুযায়ী। +Countries, cities, rivers and lakes দেশ, শহর, নদী ও হ্রদ +Country দেশ +Counts a layer's points by the A5 cell holding them (cells of equal area, so the count is a density), with a field's sum and mean. A5 cell-এ থাকা বিন্দুর সংখ্যা layer অনুযায়ী গণনা করে (সমান ক্ষেত্রফলের cell হওয়ায় এটি density), সঙ্গে একটি field-এর sum ও mean। +Counts the points that fall inside each polygon. প্রতিটি polygon-এর ভেতরে পড়া বিন্দু গণনা করে। +Counts, totals and top categories of what's in view. দৃশ্যমান অংশের সংখ্যা, মোট ও শীর্ষ category। +CPU CPU +Create তৈরি করুন +Create (click points) তৈরি করুন (বিন্দুতে ক্লিক) +Create a new project or start from the World Atlas sample. নতুন প্রজেক্ট তৈরি করুন অথবা World Atlas নমুনা দিয়ে শুরু করুন। +Create Feature Feature তৈরি করুন +Create Hosted\nImagery Hosted\nImagery তৈরি করুন +Create Hosted Imagery Hosted Imagery তৈরি করুন +Create Hosted Imagery… Hosted Imagery তৈরি করুন… +Create Line Line তৈরি করুন +Create Point Point তৈরি করুন +Create Polygon Polygon তৈরি করুন +Create Profile Profile তৈরি করুন +Create Thumbnail Thumbnail তৈরি করুন +Creates a point inside each input feature. প্রতিটি input feature-এর ভেতরে একটি বিন্দু তৈরি করে। +Creates points from a table's x and y coordinate fields. টেবিলের x ও y coordinate field থেকে বিন্দু তৈরি করে। +Creates polygons around input features at a given distance. নির্দিষ্ট দূরত্বে input feature-গুলোর চারপাশে polygon তৈরি করে। +Cross Section ক্রস-সেকশন +Cross Section (2 clicks) ক্রস-সেকশন (২টি ক্লিক) +CSV CSV +Ctrl+ Ctrl+ +Ctrl+K Ctrl+K +Ctrl+T Ctrl+T +Cumulative ক্রমযোজিত +Current date বর্তমান তারিখ +Current Project বর্তমান প্রজেক্ট +Current time বর্তমান সময় +Curve বক্ররেখা +Custom কাস্টম +Custom Min/Max কাস্টম Min/Max +Customize কাস্টমাইজ করুন +Cut কাটুন +Cut a raster into web map tiles raster-কে web map tile-এ কাটুন +Cut a raster into web map tiles (XYZ PNG) with a viewer page. viewer page-সহ raster-কে web map tile (XYZ PNG)-এ কাটুন। +Dark\nMode গাঢ়\nমোড +Data ডেটা +Data\nSources ডেটা\nউৎস +Data Design ডেটা ডিজাইন +Data Engineering ডেটা ইঞ্জিনিয়ারিং +Data Interop ডেটা আন্তঃকার্যক্ষমতা +Data Management ডেটা ব্যবস্থাপনা +Data privacy ডেটার গোপনীয়তা +Data Source Data source +Data Sources Data source-সমূহ +Data Sources Pane ডেটা সোর্স প্যান +Data Stores Data store-সমূহ +Data type ডেটার ধরন +Data you add is listed here আপনি যে ডেটা যোগ করেন তা এখানে তালিকাভুক্ত হবে +data.gov and CKAN catalogues data.gov ও CKAN catalog +Database ডেটাবেস +Database connections are used by the desktop app ডেটাবেস connection desktop app ব্যবহার করে +databases are read through DuckDB, a program the browser can't run DuckDB-এর মাধ্যমে database পড়া হয়, যা browser-এ চালানো যায় না +Database… ডেটাবেস… +Dataset ডেটাসেট +Datasets on Hugging Face by name or owner/name; browse a dataset's files and add GeoTIFFs, GeoParquet, PMTiles, LiDAR and other files. নাম বা owner/name দিয়ে Hugging Face-এ dataset খুঁজুন; dataset-এর file ব্রাউজ করে GeoTIFF, GeoParquet, PMTiles, LiDAR ও অন্যান্য file যোগ করুন। +Data… ডেটা… +Date তারিখ +Dates তারিখসমূহ +Dates a cell needs একটি cell-এর জন্য প্রয়োজনীয় তারিখ +Dates a cell needs is a whole number, or empty for half একটি cell-এর জন্য প্রয়োজনীয় তারিখ পূর্ণসংখ্যা হবে, অথবা অর্ধেকের জন্য ফাঁকা রাখুন +Day দিন +Day and night shading for a time. একটি সময়ের জন্য দিন-রাতের ছায়া +Decimal দশমিক +Decline প্রত্যাখ্যান করুন +Deep Learning ডিপ লার্নিং +Deep Learning\nTools ডিপ লার্নিং\nটুলস +Deep Learning Package Deep Learning প্যাকেজ +Deep Learning Tools Deep Learning টুল +Default ডিফল্ট +Definition\nQuery সংজ্ঞা\nQuery +Definition Query ডেফিনিশন কোয়েরি +Definition query ডেফিনিশন কোয়েরি +Definition Query… ডেফিনিশন কোয়েরি… +Delete মুছুন +Delete (or Backspace) মুছুন (Delete বা Backspace) +Delete Cell Cell মুছুন +Delete Color Ramp Color ramp মুছুন +Delete Element Element মুছুন +Delete Field Field মুছুন +Delete Job Job মুছুন +Delete Layout Layout মুছুন +Delete Map Map মুছুন +Delete Model Model মুছুন +Delete node Node মুছুন +Delete Notebook Notebook মুছুন +Delete Page পৃষ্ঠা মুছুন +Delete Presentation Presentation মুছুন +Delete Report Report মুছুন +Delete selected rows নির্বাচিত সারি মুছুন +Delete Style Style মুছুন +Delete Task Task মুছুন +Delete Task Step Task step মুছুন +Delete the GeoLibre runner that RustGIS installed. Install it again at any time. RustGIS যে GeoLibre runner ইনস্টল করেছে তা মুছে ফেলুন। যেকোনো সময় আবার ইনস্টল করা যাবে। +Delete the layout {name}? {name} layout মুছে ফেলবেন? +Delete the map {name}? {name} মানচিত্র মুছে ফেলবেন? +Delete the selected features. নির্বাচিত feature মুছুন। +Delete the step ধাপটি মুছুন +Delimited Text বিভাজক-চিহ্নিত টেক্সট +Delimited text and spreadsheets Delimited text ও spreadsheet +Delimited Text… বিভাজক-চিহ্নিত টেক্সট… +Depth Priority গভীরতার অগ্রাধিকার +Descending অবরোহী +Describing বর্ণনা করা হচ্ছে +Description বর্ণনা +Desktop and command line only শুধু desktop app ও command line +Details বিস্তারিত +Details of the selected layer. নির্বাচিত layer-এর বিবরণ। +Deviations বিচ্যুতি +DGGS DGGS +Diagnostic\nMonitor ডায়াগনস্টিক\nমনিটর +Diagnostic Monitor ডায়াগনস্টিক মনিটর +Diagnostics ডায়াগনস্টিক +Diagonal ↗ তির্যক ↗ +Diagonal ↘ তির্যক ↘ +Diamond হীরক +Difference, ratio or percent change between two rasters. দুটি raster-এর মধ্যকার পার্থক্য, অনুপাত বা শতকরা পরিবর্তন। +Digit অঙ্ক +Directions দিকনির্দেশ +Directions From Here এখান থেকে দিকনির্দেশ +Directions To Here এখানে আসার দিকনির্দেশ +Directions use an external routing server দিকনির্দেশে বাহ্যিক routing server ব্যবহার করা হয় +Discard বাতিল করুন +Discard unsaved changes to the current model? বর্তমান model-এর অসংরক্ষিত পরিবর্তন বাতিল করবেন? +Disconnect সংযোগ বিচ্ছিন্ন করুন +Disconnect From S3 S3 থেকে সংযোগ বিচ্ছিন্ন করুন +Display প্রদর্শন +Display Filters প্রদর্শনের filter +Display Filters ({n}) প্রদর্শনের filter ({n}) +Display Type প্রদর্শনের ধরন +Dissolve ডিসলভ +Dissolve Field ডিসলভ ফিল্ড +Distance দূরত্ব +Distance (km) দূরত্ব (km) +Distance Coefficient P দূরত্ব সহগ P +Distance units দূরত্বের একক +Distances along the surface, heights, areas and volumes. পৃষ্ঠ বরাবর দূরত্ব, উচ্চতা, ক্ষেত্রফল ও আয়তন। +DISTINCT DISTINCT +Distinct স্বতন্ত্র +Diverging বিচ্ছুরিত +Divisions বিভাগ +DMS DMS +Do the exercise once: each command that changes the project becomes a step একবার অনুশীলনটি করুন: প্রজেক্ট বদলায় এমন প্রতিটি command একটি ধাপে পরিণত হবে +Do the step for me আমার হয়ে ধাপটি করুন +Documents নথি +Domains ডোমেইন +Done সম্পন্ন +Double ডাবল +Double-click ডাবল-ক্লিক +Double-click to add যোগ করতে ডাবল-ক্লিক +Download ডাউনলোড +Download\nMap \nমানচিত্র ডাউনলোড +Download its features in the view (up to the server's limit) দৃশ্যের ভেতরের feature-গুলো ডাউনলোড করুন (server-এর সীমা পর্যন্ত) +Download Map মানচিত্র ডাউনলোড +Download OpenStreetMap Data OpenStreetMap ডেটা ডাউনলোড +Download the GeoLibre runner GeoLibre runner ডাউনলোড +Downloaded {name} {name} ডাউনলোড হয়েছে +Downloading {url}… {url} ডাউনলোড করা হচ্ছে… +Downloading… ডাউনলোড হচ্ছে… +Downloading… {mb} MB ডাউনলোড হচ্ছে… {mb} MB +DRA DRA +Drag / middle-drag টানুন / মাঝের বোতাম দিয়ে টানুন +Drag a box একটি বাক্স টানুন +Drag a box or click to zoom in বড় করতে বাক্স টানুন বা ক্লিক করুন +Drag a box to draw an ellipse. একটি ellipse আঁকতে বাক্স টানুন। +Drag a box to zoom into. যে অংশ বড় করবেন সেখানে বাক্স টানুন। +Drag a map frame to pan its map, wheel to zoom it · Esc to stop মানচিত্র সরাতে map frame টানুন, zoom করতে wheel ব্যবহার করুন · থামতে Esc +Drag a rectangle, then pick a PNG or JPEG image to place in it. একটি আয়তক্ষেত্র টানুন, তারপর তার মধ্যে বসানোর জন্য PNG বা JPEG ছবি বেছে নিন। +Drag a tool from the palette to start building, or add an input layer. তৈরি শুরু করতে palette থেকে tool টেনে আনুন, অথবা একটি input layer যোগ করুন। +Drag a vertex to move it · click an edge to add one · Delete removes the chosen vertex vertex সরাতে টানুন · যোগ করতে edge-এ ক্লিক করুন · Delete নির্বাচিত vertex সরিয়ে দেয় +Drag from a node's right port to another node's left port to connect them. সংযুক্ত করতে একটি node-এর ডান port থেকে অন্য node-এর বাম port-এ টানুন। +Drag from the centre out কেন্দ্র থেকে বাইরের দিকে টানুন +Drag from the centre to draw a circle. বৃত্ত আঁকতে কেন্দ্র থেকে টানুন। +Drag inside map frames to pan their maps and turn the wheel to zoom them, instead of moving them. frame সরানোর বদলে তার ভেতরে টেনে মানচিত্র pan করুন এবং wheel ঘুরিয়ে zoom করুন। +Drag inside map frames to pan their maps and turn the wheel to zoom them, instead of moving them. Esc stops. frame সরানোর বদলে তার ভেতরে টেনে মানচিত্র pan করুন এবং wheel ঘুরিয়ে zoom করুন। Esc থামায়। +Drag the frame's outline · Esc to stop frame-এর সীমানা টানুন · থামতে Esc +drag the picture's rectangle on the map first আগে মানচিত্রে ছবির আয়তক্ষেত্রটি টেনে আঁকুন +Drag the selected features' vertices to move them, click an edge to add one, Delete to remove one. নির্বাচিত feature সরাতে তাদের vertex টানুন, vertex যোগ করতে edge-এ ক্লিক করুন, সরাতে Delete চাপুন। +Drag to draw আঁকতে টানুন +Drag to draw a freehand line. মুক্তহাতে line আঁকতে টানুন। +Drag to draw a freehand polygon. মুক্তহাতে polygon আঁকতে টানুন। +Drag to draw a rectangle. আয়তক্ষেত্র আঁকতে টানুন। +Drag to resize the message box message box-এর আকার বদলাতে টানুন +Drag tools from the palette onto the canvas. palette থেকে canvas-এ tool টেনে আনুন। +Drag vertices on the map; click an edge to add one মানচিত্রে vertex টানুন; যোগ করতে edge-এ ক্লিক করুন +Draw a halo around label text. label text-এর চারপাশে halo আঁকুন। +Draw a labelled latitude/longitude grid over the map frame. map frame-এর ওপর অক্ষাংশ/দ্রাঘিমাংশের label-সহ grid আঁকুন। +Draw a model: chain tools from input layers to the results to keep একটি model আঁকুন: input layer থেকে সংরক্ষণযোগ্য ফলাফল পর্যন্ত tool-গুলো chain করুন +Draw a model: chain tools from input layers to the results to keep. একটি model আঁকুন: input layer থেকে সংরক্ষণযোগ্য ফলাফল পর্যন্ত tool-গুলো chain করুন। +Draw at its place in the stack, the bottom stroke first stack-এ নির্ধারিত অবস্থানে আঁকুন, প্রথমে নিচের stroke +Draw every feature's strokes of one level before the next, so casings join under the cores where lines meet পরের স্তরে যাওয়ার আগে এক স্তরের সব feature-এর stroke আঁকুন, যাতে line মেলায় outline-এর নিচে core-গুলো যুক্ত হয় +Draw labels at every scale and distance. প্রতিটি scale ও দূরত্বে label আঁকুন। +Draw new features in the selected layer. Double-click to finish lines and polygons. নির্বাচিত layer-এ নতুন feature আঁকুন। line ও polygon শেষ করতে ডাবল-ক্লিক করুন। +Draw new single-band rasters with the inverted grey stretch again. নতুন single-band raster-এ আবার উল্টানো ধূসর stretch ব্যবহার করুন। +Draw only polygon outlines, keeping their colors and widths. রং ও প্রস্থ অপরিবর্তিত রেখে শুধু polygon-এর outline আঁকুন। +Draw points as bins coloured by how many fall in each. প্রতিটি bin-এ পড়া বিন্দুর সংখ্যা অনুযায়ী রঙিন bin হিসেবে বিন্দু আঁকুন। +Draw polygons as outlines only. শুধু outline হিসেবে polygon আঁকুন। +Draw single-band rasters added from now on with this color scheme, stretch and resampling. এখন থেকে যোগ করা single-band raster-কে এই color scheme, stretch ও resampling দিয়ে আঁকুন। +Draw strokes in levels স্তর অনুযায়ী stroke আঁকুন +Draw the layer at every scale. সব scale-এ layer আঁকুন। +Draw the map on the GPU (beta; the globe, view effects and colour-vision simulation stay on the CPU renderer). GPU-তে মানচিত্র আঁকুন (beta; globe, view effect ও color-vision simulation CPU renderer-এই থাকবে)। +Draw the selected layer above others. নির্বাচিত layer অন্যগুলোর ওপরে আঁকুন। +Draw the selected layer below others. নির্বাচিত layer অন্যগুলোর নিচে আঁকুন। +Drawing আঁকা +Drawing Effects আঁকার প্রভাব +Drawing Mode আঁকার মোড +Drawing Order আঁকার ক্রম +drawn by the server server দ্বারা আঁকা +Drive গাড়ি চালিয়ে +Drone and aerial imagery shared on OpenAerialMap, newest first, added as cloud-optimised GeoTIFFs. OpenAerialMap-এ শেয়ার করা drone ও aerial ছবি, নতুনগুলো আগে, cloud-optimized GeoTIFF হিসেবে যোগ করা হয়েছে। +Duplicate নকল করুন +Duplicate Connection Connection নকল করুন +Duplicate Element Element নকল করুন +Duplicate Layout Layout নকল করুন +Duplicate Map Map নকল করুন +Dusk গোধূলি +Dynamic\nText গতিশীল\nটেক্সট +Dynamic Picture গতিশীল ছবি +Dynamic picture গতিশীল ছবি +Dynamic range adjustment (stretch the pixels in view) গতিশীল range adjustment (দৃশ্যমান pixel stretch করুন) +Dynamic range adjustment: stretch using the pixels in view. গতিশীল range adjustment: দৃশ্যমান pixel ব্যবহার করে stretch করুন। +Dynamical গতিশীল +Dynamical… গতিশীল… +e.g. pop_est / 1000000 or 'Africa' or name যেমন pop_est / 1000000 অথবা 'Africa' অথবা name +e.g. 0, 255, -9999 যেমন 0, 255, -9999 +e.g. continent = 'Europe' যেমন continent = 'Europe' +e.g. pop_est > 100000000 AND continent = 'Asia' যেমন pop_est > 100000000 AND continent = 'Asia' +e.g. pop_max > 1000000 যেমন pop_max > 1000000 +Each geoprocessing run becomes a step, chained by its outputs প্রতিটি geoprocessing run একটি ধাপে পরিণত হয়, output দিয়ে ধাপগুলো যুক্ত থাকে +Earth Index has no points in the view দৃশ্যে Earth Index-এর কোনো বিন্দু নেই +Earth Search Earth Search +Earthdata Cloud Earthdata Cloud +Earthdata Cloud only শুধু Earthdata Cloud +Earthdata footprints Earthdata ফুটপ্রিন্ট +Earthdata GIS Earthdata GIS +Earthdata GIS… Earthdata GIS… +Earthdata Login Status Earthdata Login-এর অবস্থা +Earthdata Login Token Earthdata লগইন টোকেন +Earthdata Login: no token Earthdata Login: token নেই +Earthdata Login: token expired Earthdata Login: token-এর মেয়াদ শেষ +Earthdata Login: token set Earthdata Login: token সেট করা হয়েছে +East পূর্ব +Eckert IV (equal-area world) Eckert IV (সমক্ষেত্রফলবিশিষ্ট বিশ্ব) +Edge ধার +Edges ধারসমূহ +Edit সম্পাদনা +Edit\nVertices \nVertex সম্পাদনা +Edit and run the notebook. Notebook সম্পাদনা ও চালান। +Edit Attribute অ্যাট্রিবিউট সম্পাদনা +Edit Cell Cell সম্পাদনা +Edit Connection Connection সম্পাদনা +Edit Description বর্ণনা সম্পাদনা +Edit Graphic Graphic সম্পাদনা +Edit Status সম্পাদনার অবস্থা +Edit Steps ধাপ সম্পাদনা +Edit Task Step Task step সম্পাদনা +Edit Text… টেক্সট সম্পাদনা… +Edit the title and instructions শিরোনাম ও নির্দেশনা সম্পাদনা করুন +Edit Vertex Vertex সম্পাদনা +Edit Vertices Vertex-গুলো সম্পাদনা +Editing সম্পাদনা +Editing on সম্পাদনা চালু +Editing Settings সম্পাদনার সেটিংস +Editing settings. সম্পাদনার সেটিংস। +Editor\nTools সম্পাদনা\nটুল +Editor Tools সম্পাদনার টুল +Edits since the project was saved. প্রজেক্ট সংরক্ষণের পর থেকে করা পরিবর্তন। +Edits the project প্রজেক্ট সম্পাদনা করে +Edit… সম্পাদনা… +Effects প্রভাব +Element উপাদান +Element Properties উপাদানের বৈশিষ্ট্য +Elevation উচ্চতা +Elevation model উচ্চতা মডেল +Elevation profiles along lines, cross sections and more. লাইন বরাবর elevation profile, cross-section এবং আরও অনেক কিছু। +Eligible requests in RustGIS use your ChatGPT plan. You can manage usage in ChatGPT settings. RustGIS-এ যোগ্য request-গুলো আপনার ChatGPT প্ল্যান ব্যবহার করে। ChatGPT সেটিংসে ব্যবহার পরিচালনা করতে পারেন। +Ellipse উপবৃত্ত +Embeddings এমবেডিং +Ember অঙ্গার +Empty ফাঁকা +Empty Cache Cache খালি করুন +Enable Lighting আলো সক্রিয় করুন +Enable Plugin Plugin সক্রিয় করুন +Enabled সক্রিয় +Encloses each feature, or all features, in its convex hull. প্রতিটি feature অথবা সব feature-কে তার convex hull-এর মধ্যে আবদ্ধ করে। +Encoded polyline এনকোড করা পলিলাইন +End শেষ +End date শেষের তারিখ +Endpoint এন্ডপয়েন্ট +Enhancement উন্নতি +Enter Enter +Enter an API key. একটি API key লিখুন। +Enter an EPSG code as a number, such as 32650 সংখ্যা হিসেবে EPSG code লিখুন, যেমন 32650 +Enter the provider's address. Provider-এর ঠিকানা লিখুন। +Enter to send, Shift+Enter for a new line পাঠাতে Enter, নতুন লাইন দিতে Shift+Enter +EnviroAtlas EnviroAtlas +EnviroAtlas… EnviroAtlas… +Environments পরিবেশ +EPSG EPSG +EPSG code EPSG কোড +EPSG or ESRI code EPSG বা ESRI code +EPSG: EPSG: +EPSG:4326 EPSG:4326 +Equal সমান +Equal Earth Equal Earth +Equal Interval সমান interval +Equirectangular (Plate Carrée) সমদূরত্বীয় (Plate Carrée) +Erase মুছে দিন +Erase Features Feature থেকে মুছে দিন +Error ত্রুটি +Error Inspector ত্রুটি পরিদর্শক +Error Inspector (validate) ত্রুটি পরিদর্শক (যাচাই) +Esc Esc +Escape Escape +ESRI ESRI +ESRI: ESRI: +Europe Equal Area (ETRS89 LAEA) Europe Equal Area (ETRS89 LAEA) +Every band's value at a pixel. একটি pixel-এ প্রতিটি band-এর মান। +Every editing tool. প্রতিটি editing tool। +Every label setting of this layer, in the Labeling pane. Labeling pane-এ এই layer-এর সব label setting। +Every layer, as FEMA draws them FEMA যেভাবে আঁকে, সেভাবে প্রতিটি layer +Every run, in order, as a script that opens the project প্রজেক্ট খোলে এমন script হিসেবে প্রতিটি run, ক্রমানুসারে +every Socrata site, or one such as data.cityofnewyork.us সব Socrata site, অথবা data.cityofnewyork.us-এর মতো একটি site +Everywhere সব জায়গায় +Existing field বিদ্যমান field +Existing Template বিদ্যমান template +exit rotary রোটারি থেকে বের হন +exit roundabout রাউন্ডঅ্যাবাউট থেকে বের হন +Expand Group Group প্রসারিত করুন +Expand Legend Legend প্রসারিত করুন +Expand the ribbon (Ctrl+F1) Ribbon প্রসারিত করুন (Ctrl+F1) +Explode বিস্ফোরিত করুন +Exploratory 3D Analysis অনুসন্ধানমূলক 3D বিশ্লেষণ +Exploratory Analysis অনুসন্ধানমূলক বিশ্লেষণ +Explore অন্বেষণ +Export রপ্তানি +Export\nFeatures ফিচার\nরপ্তানি +Export\nLayout লে-আউট\nরপ্তানি +Export\nMap মানচিত্র\nরপ্তানি +Export\nRaster রাস্টার\nরপ্তানি +Export Animation অ্যানিমেশন রপ্তানি +Export Bookmarks বুকমার্ক রপ্তানি +Export Bookmarks… বুকমার্ক রপ্তানি… +Export CSV… CSV export… +Export Features ফিচার রপ্তানি +Export Features (tool) ফিচার রপ্তানি (টুল) +Export Features needs a vector layer (Data › Export Raster writes a raster) Feature export-এর জন্য vector layer দরকার (Data › Export Raster raster লেখে) +Export Features… ফিচার রপ্তানি… +Export Layout লেআউট রপ্তানি +Export Map Map export +Export Map failed unexpectedly Map export অপ্রত্যাশিতভাবে ব্যর্থ হয়েছে +Export Map… Map export… +Export Mesh Mesh export +Export Mesh (OBJ)… মেশ রপ্তানি (OBJ)… +Export Model মডেল রপ্তানি +Export Presentation প্রেজেন্টেশন রপ্তানি +Export Presentation… প্রেজেন্টেশন রপ্তানি… +Export Raster রাস্টার রপ্তানি +Export Raster… রাস্টার রপ্তানি… +Export Report প্রতিবেদন রপ্তানি +Export Report… প্রতিবেদন রপ্তানি… +Export Table টেবিল রপ্তানি +Export the animation as PNG frames (zip). Animation-কে PNG frame (zip) হিসেবে export করুন। +Export the layer. Layer export করুন। +Export the layout as PDF or PNG. Layout PDF বা PNG হিসেবে export করুন। +Export the map view as a PNG image (with labels and a scale bar). মানচিত্রের view PNG ছবি হিসেবে export করুন (label ও scale bar-সহ)। +Export the open layout as PDF or PNG. খোলা layout PDF বা PNG হিসেবে export করুন। +Export the presentation as a slideshow or PDF, or save it as a file. Presentation slideshow বা PDF হিসেবে export করুন, অথবা ফাইল হিসেবে সংরক্ষণ করুন। +Export the report, or save it as a report file or template. Report export করুন, অথবা report file বা template হিসেবে সংরক্ষণ করুন। +Export the selected layer as a 3D model (OBJ), polygons extruded. নির্বাচিত layer 3D model (OBJ) হিসেবে export করুন, polygon extrude করা হবে। +Export the selected layer to GeoJSON or CSV. নির্বাচিত layer GeoJSON বা CSV-তে export করুন। +Export the steps as an animated GIF. ধাপগুলো animated GIF হিসেবে export করুন। +Export Time Animation সময়ভিত্তিক animation export +Export To 3D 3D-তে export +Export To 3D File 3D file-এ export +Exported to {path}. {path}-এ export করা হয়েছে। +exporting animations needs the desktop app animation export করতে desktop app দরকার +exporting time animations needs the desktop app সময়ভিত্তিক animation export করতে desktop app দরকার +Export… রপ্তানি… +Expression এক্সপ্রেশন +Extent ভৌগোলিক বিস্তার +Extent Indicator বিস্তারের নির্দেশক +extent is [minLon, minLat, maxLon, maxLat], "view" or null extent হলো [minLon, minLat, maxLon, maxLat], "view" অথবা null +Extent: {extent} ভৌগোলিক বিস্তার: {extent} +Extract এক্সট্র্যাক্ট +Extracts the parts of input features that fall inside clip polygons. clip polygon-এর ভেতরে পড়া input feature-এর অংশগুলো বের করে। +Extruded shapes cast shadows on the map. Extrude করা আকার মানচিত্রে ছায়া ফেলে। +Extrusion এক্সট্রুশন +Face Culling ফেস কালিং +Faces Face +Factor গুণক +Failed ব্যর্থ +FALSE FALSE +Farthest সবচেয়ে দূরের +Faster drawing: no antialiasing. দ্রুত আঁকা: antialiasing নেই। +Favourite Data Source পছন্দের data source +Favourites পছন্দের +Feature ফিচার +Feature\nAnalysis Feature\nবিশ্লেষণ +Feature\nTo Point Feature থেকে\nPoint +Feature Cache ফিচার ক্যাশ +Feature Layer ফিচার লেয়ার +Feature Templates ফিচার টেমপ্লেট +Feature templates: default values for new features. Feature template: নতুন feature-এর ডিফল্ট মান। +Feature To Point Feature থেকে Point +featured products বিশেষভাবে প্রদর্শিত পণ্য +Features Feature-সমূহ +Features most like the selected one নির্বাচিতটির সঙ্গে সবচেয়ে মিল আছে এমন feature +Feet ফুট +FEMA NFHL FEMA NFHL +FEMA NFHL… FEMA NFHL… +FEMA's National Flood Hazard Layer: effective flood zones, floodways, base flood elevations, levees and map panels. Its flood layers are drawn only from zoom 15 (closer than about 1:36,000), its availability map only farther out. FEMA-এর National Flood Hazard Layer: কার্যকর বন্যা অঞ্চল, বন্যার প্রবাহপথ, base flood elevation, বাঁধ ও map panel। এর flood layer শুধু zoom 15 থেকে আঁকা হয় (প্রায় 1:36,000-এর চেয়ে কাছাকাছি); availability map শুধু আরও দূরের zoom-এ। +Ferries ফেরি +Fetch map tiles, data and web services from addresses it chooses নিজে বেছে নেওয়া ঠিকানা থেকে map tile, ডেটা ও web service সংগ্রহ করুন +Fetch the tiles and styles of the web layers again. web layer-এর tile ও style আবার আনুন। +Field ফিল্ড +field * 2, 'text', a + b field * 2, 'text', a + b +Field boundaries কৃষিজমির সীমানা +Field density কৃষিজমির ঘনত্ব +Field name Field-এর নাম +Field Prefix ফিল্ড প্রিফিক্স +Field to Sum and Average যোগ ও গড় করার field +Fields Field-সমূহ +Fields are drawn from zoom 9: zoom in to see them. কৃষিজমির সীমানা zoom 9 থেকে আঁকা হয়: দেখতে zoom in করুন। +Fields colored by score, drawn from zoom 9 স্কোর অনুসারে রঙ করা কৃষিজমি; zoom 9 থেকে আঁকা হয় +Fields of the World Fields of the World +Fields of the World… Fields of the World… +fields, comma separated field, কমা দিয়ে আলাদা +Fields, statistics, groups, pictures, attachments and charts in the report. report-এ field, পরিসংখ্যান, group, ছবি, সংযুক্তি ও chart। +Fields: Field: +File ফাইল +File bridge only শুধু file bridge +File bridge: the script ran File bridge: script চালানো হয়েছে +File bridge: {error} ফাইল ব্রিজ: {error} +File name ফাইলের নাম +file path ফাইলের path +Files ({n}) ফাইল ({n}) +Fill পূরণ +Fill Cache Cache পূরণ করুন +Fill the cache whenever the projection changes. projection বদলালেই cache পূরণ করুন। +Film grain ফিল্ম গ্রেইন +Filter ফিল্টার +Filter bookmarks Bookmark filter করুন +Filter layers Layer filter করুন +Filter rows… সারি filter করুন… +Find a place by coordinates. স্থানাঙ্ক দিয়ে কোনো স্থান খুঁজুন। +Find a place or an address by name (OpenStreetMap). নাম দিয়ে কোনো স্থান বা ঠিকানা খুঁজুন (OpenStreetMap)। +Find and run geoprocessing tools. Geoprocessing tool খুঁজুন ও চালান। +Find any command by name (Ctrl+K). নাম দিয়ে যেকোনো command খুঁজুন (Ctrl+K)। +Find Items Item খুঁজুন +Find Place স্থান খুঁজুন +Find plugins in the RustGIS plugin registry. RustGIS plugin registry-তে plugin খুঁজুন। +Find Similar সদৃশ খুঁজুন +Find Tools Tool খুঁজুন +Finding routes… Route খোঁজা হচ্ছে… +Finish Polygon Polygon শেষ করুন +Finish Profile Profile শেষ করুন +Finish Tool Run Tool চালানো শেষ করুন +Finished: {tool} সম্পন্ন: {tool} +Firewall ফায়ারওয়াল +First standard parallel প্রথম standard parallel +First-Person প্রথম-ব্যক্তি +First-Person Navigation প্রথম-ব্যক্তি নেভিগেশন +Fit মাপের সঙ্গে মানানসই করুন +Fit To Map View Map view-তে মানানসই করুন +Fix the problems listed below first আগে নিচের তালিকাভুক্ত সমস্যাগুলো ঠিক করুন +Fixed Zoom In নির্দিষ্ট zoom in +Fixed Zoom Out নির্দিষ্ট zoom out +Flat সমতল +FlatGeobuf FlatGeobuf +Flip Line Line উল্টে দিন +Flowline প্রবাহরেখা +Fog কুয়াশা +Folder ফোল্ডার +Folder Connections ফোল্ডার সংযোগ +folder connections need the desktop app ফোল্ডার সংযোগের জন্য desktop app দরকার +Folders ফোল্ডার +Folder… ফোল্ডার… +Follow the time slider Time slider অনুসরণ করুন +Font ফন্ট +for a store that doesn't say যে store এটি উল্লেখ করে না তার জন্য +Forest বন +Forget Earthdata Login Token Earthdata Login token ভুলে যান +Format ফরম্যাট +Found: {names}. An agent works on this map through RustGIS's tools and with its own (web, scripts, files) in a folder of its own, kept in Documents › RustGIS › Agent work. It asks you before each action. পাওয়া গেছে: {names}। একটি agent RustGIS-এর tool ব্যবহার করে এই মানচিত্রে কাজ করে এবং নিজের web, script ও file নিজস্ব folder-এ রাখে, যা Documents › RustGIS › Agent work-এ থাকে। প্রতিটি action-এর আগে এটি আপনাকে জিজ্ঞেস করে। +Frame rate ফ্রেম রেট +Frame rate, draw time, data size and memory. ফ্রেম রেট, আঁকার সময়, ডেটার আকার ও মেমরি। +France ফ্রান্স +Free the prepared geometry. প্রস্তুত geometry মুক্ত করুন। +Freehand মুক্তহাতে +FROM FROM +From থেকে +From Selection নির্বাচন থেকে +From Service… Service থেকে… +From symbol প্রতীক থেকে +From the address ঠিকানা থেকে +from the address ঠিকানা থেকে +from the service service থেকে +From URL… URL থেকে… +Full Extent সম্পূর্ণ extent +Full extent সম্পূর্ণ extent +Full lines সম্পূর্ণ line +Full size পূর্ণ আকার +Function Editor ফাংশন এডিটর +Gamma Gamma +Gemini stopped the reply ({reason}). Gemini উত্তর থামিয়েছে ({reason})। +Generalization সাধারণীকরণ +Generate a token Token তৈরি করুন +geoBoundaries listed no countries: type a country's code geoBoundaries কোনো দেশ দেখায়নি: একটি দেশের code লিখুন +Geocode জিওকোড +Geocode Addresses ঠিকানা geocode করুন +Geocode Addresses… ঠিকানা geocode করুন… +Geocoder জিওকোডার +Geocoding {n} addresses… {n}টি ঠিকানা geocode করা হচ্ছে… +Geodata Service জিওডেটা সার্ভিস +Geodesic circles on a grid that show how the map's projection distorts scale, area and angles. গ্রিডের ওপর geodesic বৃত্ত দেখায়, মানচিত্রের projection কীভাবে scale, area ও angle বিকৃত করে। +GeoJSON GeoJSON +GeoJSON, CSV, GeoParquet, FlatGeobuf, GeoPackage, zipped Shapefiles, KML, GPX or GeoTIFF, up to 256 MB; the server must allow requests from web pages (CORS). GeoJSON, CSV, GeoParquet, FlatGeobuf, GeoPackage, zip করা Shapefile, KML, GPX বা GeoTIFF, সর্বোচ্চ 256 MB; server-কে web page থেকে request অনুমোদন করতে হবে (CORS)। +GeoJSON, CSV, GeoParquet, FlatGeobuf, GeoPackage, zipped Shapefiles, KML, GPX or GeoTIFF, up to 512 MB. GeoJSON, CSV, GeoParquet, FlatGeobuf, GeoPackage, zip করা Shapefile, KML, GPX বা GeoTIFF, সর্বোচ্চ 512 MB। +GeoLens GeoLens +GeoLens… GeoLens… +GeoLibre GeoLibre +GeoLibre Tools GeoLibre টুলস +Geometry জ্যামিতি +GeoPackage GeoPackage +GeoParquet GeoParquet +Geoprocessing জিওপ্রসেসিং +Geoprocessing history. Geoprocessing-এর ইতিহাস। +Georeference জিওরেফারেন্স +Geostatistical Wizard জিওস্ট্যাটিস্টিক্যাল উইজার্ড +GeoTIFF GeoTIFF +Get a key Key সংগ্রহ করুন +Get it এটি সংগ্রহ করুন +GitHub GitHub +GitHub's latest release has no version number GitHub-এর সর্বশেষ release-এ version number নেই +GitHub's reply isn't a release GitHub-এর উত্তর কোনো release নয় +GitHub's reply names no release GitHub-এর উত্তর কোনো release-এর নাম উল্লেখ করে না +Give a Zarr store's address or folder Zarr store-এর address বা folder দিন +Give the catalogue's web address catalog-এর web address দিন +Give the file's web address ফাইলের web address দিন +Give the service's web address service-এর web address দিন +give the text's layer and row, or the lon and lat to place one টেক্সটের layer ও row দিন, অথবা বসানোর জন্য lon ও lat দিন +Global বৈশ্বিক +Globe গ্লোব +Go যান +Go back a step. এক ধাপ পেছনে যান। +Go back to the previous extent. আগের extent-এ ফিরে যান। +Go forward a step. এক ধাপ সামনে যান। +Go forward to the next extent. পরের extent-এ এগিয়ে যান। +Go to a saved extent or save the current one. সংরক্ষিত extent-এ যান অথবা বর্তমানটি সংরক্ষণ করুন। +Go To Bookmark Bookmark-এ যান +Go to Date তারিখে যান +Go To Map Link Map link-এ যান +Go to the first step. প্রথম ধাপে যান। +Go to the last step. শেষ ধাপে যান। +Go To XY XY-তে যান +Go to {coordinate} {coordinate}-এ যান +Google Gemini Google Gemini +Got it বুঝেছি +GPU GPU +GPU\nRendering GPU\nরেন্ডারিং +GPX GPX +Graduated Colors ধাপভিত্তিক রং +Granules দানা +Graphic Style গ্রাফিক স্টাইল +Graphics গ্রাফিক +Graphics and Text গ্রাফিক ও টেক্সট +Graphics Layer গ্রাফিক্স লেয়ার +Graticule গ্র্যাটিকিউল +Green সবুজ +Greys ধূসর +Grid গ্রিড +Grid Spacing (degrees) গ্রিডের ব্যবধান (ডিগ্রি) +Ground\nTo Grid মাটি থেকে\nগ্রিড +Ground To Grid মাটি থেকে গ্রিড +Group গ্রুপ +GROUP BY GROUP BY +Group features by an integer code, each with default values. পূর্ণসংখ্যার code অনুযায়ী feature-গুলো group করুন, প্রতিটির ডিফল্ট মানসহ। +Group Option গ্রুপ অপশন +Group Properties গ্রুপের বৈশিষ্ট্য +Group Visibility Range Group-এর দৃশ্যমানতার সীমা +Groups Group-সমূহ +Guided analysis: suitability, visibility, networks, interpolation and more. নির্দেশিত বিশ্লেষণ: উপযোগিতা, দৃশ্যমানতা, নেটওয়ার্ক, interpolation এবং আরও অনেক কিছু। +Guides গাইডসমূহ +Halo হ্যালো +Harbour (qualitative) বন্দর (গুণগত) +Hazards বিপদ +Haze ধোঁয়াশা +Haze, fog, rain or snow over the map. মানচিত্রের ওপর ধোঁয়াশা, কুয়াশা, বৃষ্টি বা তুষার। +Height উচ্চতা +Height (mm) উচ্চতা (mm) +Help সহায়তা +Help › Help সহায়তা › সহায়তা +Hexagons ষড়ভুজ +Hide লুকান +Hide features far from the view's centre. দৃশ্যের কেন্দ্র থেকে দূরের feature লুকান। +Hide Group Group লুকান +Hide labels when the viewer is closer than this. দর্শক এর চেয়ে কাছে এলে label লুকান। +Hide labels when the viewer is farther than this. দর্শক এর চেয়ে দূরে গেলে label লুকান। +Hide Layers Layer লুকান +Hide other values অন্য মান লুকান +Hide Steps ধাপ লুকান +Hide the description বর্ণনা লুকান +Hide values (no data) মান লুকান (NoData) +Hide values inside a range একটি range-এর ভেতরের মান লুকান +Hide values outside a range একটি range-এর বাইরের মান লুকান +Hide walls facing away from you. আপনার বিপরীত দিকে মুখ করা দেয়াল লুকান। +Hides fields scoring below this. The score is the model's mean field probability × 100, a ranking rather than a calibrated probability. এর চেয়ে কম স্কোরের কৃষিজমি লুকায়। স্কোর হলো model-এর গড় field probability × 100; এটি calibrated probability নয়, বরং ranking। +High values of the first raster and low values of the second score best. প্রথম raster-এর বেশি মান ও দ্বিতীয়টির কম মান সর্বোচ্চ স্কোর পায়। +Highways মহাসড়ক +Histogram Equalize হিস্টোগ্রাম ইকুয়ালাইজ +Histograms and correlations Histogram ও correlation +History ইতিহাস +Hold স্থগিত করুন +HOME হোম +Home হোম +Home › Add হোম › যোগ করুন +Home › Add Data হোম › ডেটা যোগ করুন +Home › Inspect › Measure হোম › পরিদর্শন › পরিমাপ +Home › Navigate হোম › নেভিগেট +Home › Navigate › Bookmarks হোম › নেভিগেট › বুকমার্ক +Home › Web Data হোম › ওয়েব ডেটা +Home › Web Data › United States হোম › ওয়েব ডেটা › যুক্তরাষ্ট্র +Homepage হোমপেজ +Horizontal অনুভূমিক +host=localhost dbname=gis user=postgres host=localhost dbname=gis user=postgres +host=… dbname=… user=… host=… dbname=… user=… +Hosted Imagery হোস্টেড ইমেজারি +Hour ঘণ্টা +How the layer's colours combine with the tile layers and basemap beneath. layer-এর রং নিচের tile layer ও basemap-এর সঙ্গে কীভাবে মিশবে। +How the layer's colours combine with what's beneath. layer-এর রং নিচের বিষয়বস্তুর সঙ্গে কীভাবে মিশবে। +HUB Ocean's Ocean Data Platform: marine datasets, read as features (up to 10,000 each). HUB Ocean-এর Ocean Data Platform: সামুদ্রিক dataset feature হিসেবে পড়া হয় (প্রতিটিতে সর্বোচ্চ ১০,০০০টি)। +hub.arcgis.com, or an organisation's Hub site hub.arcgis.com, অথবা কোনো সংস্থার Hub site +Hugging Face Hugging Face +Hugging Face… Hugging Face… +Hydrography হাইড্রোগ্রাফি +Icechunk only, not read yet শুধু Icechunk, এখনো পড়া হয়নি +Id: {id} ID: {id} +Identify শনাক্ত করুন +Idle নিষ্ক্রিয় +IDW\nInterpolation IDW\nইন্টারপোলেশন +IDW Interpolation IDW ইন্টারপোলেশন +IGN LiDAR HD IGN LiDAR HD +IGN LiDAR HD… IGN LiDAR HD… +IGN's LiDAR HD point clouds of France: 1 km tiles where the map is, added as COPC point clouds. ফ্রান্সের IGN-এর LiDAR HD point cloud: মানচিত্রের এলাকার জন্য ১ km tile, COPC point cloud হিসেবে যোগ করা হয়। +Illumination আলোকায়ন +Image Analysis ছবি বিশ্লেষণ +Image Classification ছবি শ্রেণিবিন্যাস +Image Page ছবির পৃষ্ঠা +Imagery ইমেজারি +Imagery Sharing Imagery শেয়ারিং +Imagery Tools ইমেজারি টুলস +ImageServer ImageServer +Image… ছবি… +Imperial ইম্পেরিয়াল +Import ইমপোর্ট +Import\nProject \nপ্রজেক্ট ইমপোর্ট +Import All সব ইমপোর্ট করুন +Import Bookmarks বুকমার্ক ইমপোর্ট +Import Bookmarks… বুকমার্ক ইমপোর্ট… +Import Deep Learning Package Deep Learning package ইমপোর্ট +Import Layout Layout ইমপোর্ট +Import Layout… Layout ইমপোর্ট… +Import Model Model ইমপোর্ট +Import Package… Package ইমপোর্ট… +Import Presentation Presentation ইমপোর্ট +Import Report Report ইমপোর্ট +Import Service Service ইমপোর্ট +Import Styles Style ইমপোর্ট +Import Styles From… এখান থেকে style ইমপোর্ট করুন… +Import Task Task ইমপোর্ট +Import Task… Task ইমপোর্ট… +Import Thumbnail Thumbnail ইমপোর্ট +Import… ইমপোর্ট… +IN IN +In Beyond নির্ধারিত সীমার বাইরে +In memory মেমরিতে +In Progress চলমান +in progress চলমান +In project প্রজেক্টে +In RustGIS, it can: RustGIS-এ এটি পারে: +In use ব্যবহৃত হচ্ছে +Indexes Index +Indices সূচক +Infographics ইনফোগ্রাফিক +Information তথ্য +INPUT ইনপুট +Input ইনপুট +Input Features ইনপুট feature +Input Layer ইনপুট layer +Input Points ইনপুট point +Input Polygons ইনপুট polygon +Input Rows ইনপুট row +Input Summary Features ইনপুট summary feature +Input Table ইনপুট table +Insert সন্নিবেশ করুন +Insert Dynamic Text গতিশীল টেক্সট সন্নিবেশ করুন +Inspect পরিদর্শন করুন +Install ইনস্টল করুন +Install a plugin একটি plugin ইনস্টল করুন +Install a plugin from a .rgplugin file. .rgplugin ফাইল থেকে plugin ইনস্টল করুন। +Install from a file ফাইল থেকে ইনস্টল করুন +Install From File ফাইল থেকে ইনস্টল করুন +Install From File… ফাইল থেকে ইনস্টল করুন… +Install GeoLibre Tools GeoLibre tool ইনস্টল করুন +Install Plugin Plugin ইনস্টল করুন +Install Plugin From Registry Registry থেকে plugin ইনস্টল করুন +Install the Basemaps plugin Basemaps plugin ইনস্টল করুন +Install Whitebox and GeoLibre tools Whitebox ও GeoLibre tool ইনস্টল করুন +Install {name} {version}? {name} {version} ইনস্টল করবেন? +Installed ইনস্টল করা +Installed plugins are kept in {folder} ইনস্টল করা plugin-গুলো {folder}-এ রাখা হয় +Installed with Homebrew, winget, the AUR or COPR? Update it there. Homebrew, winget, AUR বা COPR দিয়ে ইনস্টল করেছেন? সেখান থেকেই আপডেট করুন। +Installed {name} {version}. Find it on the Plugins tab; its tools are also in the Tools pane. {name} {version} ইনস্টল হয়েছে। Plugins tab-এ এটি খুঁজুন; এর tool-গুলো Tools pane-এও আছে। +Installing it downloads Python {version} and the packages it needs from the internet. ইনস্টল করলে ইন্টারনেট থেকে Python {version} ও এর প্রয়োজনীয় package ডাউনলোড হবে। +Integer field: values are rounded. পূর্ণসংখ্যার field: মানগুলো round করা হয়। +Interface font: {font} ইন্টারফেসের font: {font} +Interpolate a surface from points (IDW) বিন্দু থেকে পৃষ্ঠ interpolate করুন (IDW) +Interpolates a 2-band raster surface (Prediction and Variance) from point features using Ordinary Kriging. Ordinary Kriging ব্যবহার করে point feature থেকে ২-ব্যান্ড raster surface (Prediction ও Variance) interpolate করে। +Interpolates a raster surface from feature values by inverse distance weighting, as QGIS does. QGIS-এর মতো inverse distance weighting ব্যবহার করে feature-এর মান থেকে raster surface interpolate করে। +Interpolation ইন্টারপোলেশন +Interpolation Attribute ইন্টারপোলেশন অ্যাট্রিবিউট +Interval ব্যবধান +invalid child id child process-এর ID অবৈধ +Invalid: {error} অবৈধ: {error} +Invert উল্টান +Invert color scheme রঙের scheme উল্টান +Invert the selection. নির্বাচন উল্টে দিন। +IS IS +IS NULL IS NULL +it connects to an address on the internet ইন্টারনেটের কোনো ঠিকানায় সংযোগ করে +It has no code: nothing in it runs. এতে কোনো code নেই: এর ভেতরে কিছুই চলে না। +It is locked, or one of its surrounds is: unlock it first এটি অথবা এর কোনো পারিপার্শ্বিক উপাদান locked; আগে unlock করুন +it is reserved to you (plugins, scripts, tasks, models and the GeoLibre runner) এটি আপনার জন্য সংরক্ষিত (plugin, script, task, model ও GeoLibre runner) +it needs input: open it from its pane or button, or run it from a script or the assistant এটির input দরকার: pane বা button থেকে খুলুন, অথবা script বা assistant থেকে চালান +it reads or writes files on this computer এটি এই কম্পিউটারে ফাইল পড়ে বা লেখে +it replaces the open project এটি খোলা প্রজেক্ট প্রতিস্থাপন করে +it runs a plugin's command এটি plugin-এর command চালায় +It runs in a sandbox with no other access to your computer or the network. এটি sandbox-এ চলে; আপনার কম্পিউটার বা নেটওয়ার্কে অন্য কোনো অ্যাক্সেস নেই। +It runs: {program} এটি চালায়: {program} +It's in the Symbology pane, open on the step's layer. এটি Symbology pane-এ, ধাপটির layer-এ খোলা। +It's installed but turned off: turn it on in the Plugin Manager. এটি ইনস্টল আছে কিন্তু বন্ধ: Plugin Manager-এ চালু করুন। +It's too large to keep in the browser: install it again on your next visit. এটি browser-এ রাখার জন্য অনেক বড়: পরেরবার আসলে আবার ইনস্টল করুন। +Italic ইটালিক +Item Pop-ups Item pop-up +Item {id} আইটেম {id} +Its firewall ({firewall}) blocked RustGIS. The service may only allow some applications: contact its administrator and send them the details below. এর firewall ({firewall}) RustGIS-কে আটকেছে। service হয়তো শুধু কিছু application অনুমোদন করে: administrator-এর সঙ্গে যোগাযোগ করে নিচের বিবরণ পাঠান। +Its tool isn't available now (a plugin's, not loaded, or a suite's, not installed) এর tool এখন পাওয়া যাচ্ছে না (plugin-এরটি লোড হয়নি অথবা suite ইনস্টল নেই) +Jobs Job-সমূহ +JOIN JOIN +Join জয়েন +Join Features ফিচার জয়েন +Join Operation জয়েন অপারেশন +Joins attributes from a join layer to a target layer based on their spatial relationship. স্থানিক সম্পর্কের ভিত্তিতে join layer-এর attribute target layer-এ যুক্ত করে। +Journal figure, 1 column (90 mm) জার্নাল চিত্র, ১ কলাম (৯০ mm) +Journal figure, 1.5 columns (140 mm) জার্নাল চিত্র, ১.৫ কলাম (১৪০ mm) +Journal figure, 2 columns (190 mm) জার্নাল চিত্র, ২ কলাম (১৯০ mm) +Keep a history of every edit to the layer, to look back or restore. পরে পর্যালোচনা বা পুনরুদ্ধারের জন্য layer-এর প্রতিটি edit-এর ইতিহাস রাখুন। +Keep an archive of edits Edit-এর archive রাখুন +keep current বর্তমানটি রাখুন +Keep it from being moved, resized, deleted or edited by mistake ভুল করে সরানো, আকার বদলানো, মোছা বা সম্পাদনা করা থেকে আটকান +Keep one value of each point's vector as its value field প্রতিটি বিন্দুর vector-এর একটি মানকে তার value field হিসেবে রাখুন +Keep Result ফলাফল রাখুন +Keep the labelled features fixed as the map moves. মানচিত্র সরলেও label দেওয়া feature-গুলো স্থির রাখুন। +Keep the last drawing tool active after each graphic. প্রতিটি graphic-এর পর সর্বশেষ drawing tool সক্রিয় রাখুন। +Keep the last tool active সর্বশেষ tool সক্রিয় রাখুন +Keep this endpoint under Data Sources › Connections, without its password or keys এই endpoint-টি Data Sources › Connections-এ রাখুন, পাসওয়ার্ড বা key ছাড়া +Keep this result এই ফলাফল রাখুন +Keep Tool Tool রাখুন +Keep value মান রাখুন +Key কী +Keyboard Shortcuts কীবোর্ড শর্টকাট +Keyboard shortcuts. কীবোর্ড শর্টকাট। +Keys from যেখান থেকে key +Kilometers কিলোমিটার +kind is required kind আবশ্যক +KML KML +KMZ KMZ +Label লেবেল +Label Class Label class +Label Distance Range Label-এর দূরত্বের range +Label features in this layer এই layer-এর feature-এ label দিন +Label Field Label field +Label only the features that match. শুধু মিলে যাওয়া feature-এ label দিন। +Label Placement Label-এর অবস্থান +Label Range Label range +Label the cities with their names শহরগুলোর নাম দিয়ে label দিন +Label Visibility Range Label-এর দৃশ্যমানতার range +Labeling লেবেলিং +Labeling Pane লেবেলিং প্যান +Labeling Properties… লেবেলিং বৈশিষ্ট্য… +Labels Label +labels are drawn for feature layers, not rasters label raster-এ নয়, feature layer-এ আঁকা হয় +Labels are drawn for feature layers, not rasters. Label raster-এ নয়, feature layer-এ আঁকা হয়। +Labels are off for this layer: the clause applies once they are on. এই layer-এর label বন্ধ আছে: চালু করলে clause প্রয়োগ হবে। +Lagoon লেগুন +Lambert Conformal Conic Lambert Conformal Conic +Lambert Cylindrical Equal Area Lambert Cylindrical Equal Area +Landscape আড়াআড়ি +Language ভাষা +Lasso ল্যাসো +Lasso (draw) ল্যাসো (আঁকা) +Last agent call failed: {call} ({secs} s ago) সর্বশেষ agent call ব্যর্থ: {call} ({secs} সেকেন্ড আগে) +Last agent call: {call} ({secs} s ago) সর্বশেষ agent call: {call} ({secs} সেকেন্ড আগে) +Last map render time মানচিত্র render-এর সর্বশেষ সময় +Last Project সর্বশেষ প্রজেক্ট +Latitude অক্ষাংশ +Latitude (Y) অক্ষাংশ (Y) +Lay the nodes out along the flow প্রবাহ বরাবর node-গুলো সাজান +Layer লেয়ার +Layer\nTemplates লেয়ার\nটেমপ্লেট +Layer Blend লেয়ার ব্লেন্ডিং +Layer Extrusion লেয়ার এক্সট্রুশন +Layer File Layer file +Layer From\nSelection নির্বাচন থেকে\nLayer +Layer not found Layer পাওয়া যায়নি +Layer Template লেয়ার টেমপ্লেট +Layer Templates Layer template-সমূহ +layer {id} লেয়ার {id} +Layers Layer-সমূহ +Layers ({n}) লেয়ারসমূহ ({n}) +Layers and Table Views Layer ও table view +Layers, a date each Layer, প্রতিটির জন্য একটি তারিখ +Layout লেআউট +Layout\nFile লেআউট\nফাইল +Layout after: {name} Layout-এর পরে: {name} +Layout before: {name} Layout-এর আগে: {name} +Layout File… লেআউট ফাইল… +Layout name Layout-এর নাম +Layout: {kind} লেআউট: {kind} +Layouts Layout-সমূহ +Learn শিখুন +Learning\nResources শেখার\nউপকরণ +Left বামে +Legend লেজেন্ড +Legend label. Clear it to show the value again. Legend label। মানটি আবার দেখাতে এটি মুছুন। +Length {length} · gain {gain} · loss {loss} দৈর্ঘ্য {length} · উত্থান {gain} · পতন {loss} +Let the assistant hand tasks to agents on this computer এই কম্পিউটারের agent-দের assistant দিয়ে task দেওয়ার অনুমতি দিন +Let your Agents Control RustGIS আপনার agent-দের RustGIS নিয়ন্ত্রণ করতে দিন +Letter spacing অক্ষরের মধ্যবর্তী ফাঁক +Level স্তর +Library লাইব্রেরি +Light and Shadow আলো ও ছায়া +Light the extruded shapes from the north-west. উত্তর-পশ্চিম থেকে extrude করা আকারগুলো আলোকিত করুন। +Lighting আলোকসজ্জা +LIKE LIKE +LIMIT LIMIT +Limit a field to coded values or a range. একটি field-কে coded value বা range-এ সীমাবদ্ধ করুন। +Limit to map view মানচিত্রের view-তে সীমাবদ্ধ করুন +Limit where tools work, and the cell size of new rasters. tool কোথায় কাজ করবে এবং নতুন raster-এর cell size সীমিত করুন। +Limit which features draw with a SQL expression. SQL expression দিয়ে আঁকা feature সীমিত করুন। +Limits সীমা +Line রেখা +Line color Line-এর রং +Line Of Sight দৃষ্টিরেখা +Line of sight between two points দুটি বিন্দুর মধ্যকার দৃষ্টিরেখা +Line Of Sight From Here এখান থেকে দৃষ্টিরেখা +Line Strokes Line stroke +Line width Line-এর প্রস্থ +Line {n} isn't four numbers (column row lon lat) Line {n}-এ চারটি সংখ্যা নেই (column row lon lat) +Lines Line-সমূহ +Lines and ticks Line ও tick +LineString LineString +Link লিংক +Link Cursors কার্সর লিংক করুন +Link Views View লিংক করুন +List তালিকা করুন +List a Dataset's Files একটি dataset-এর file তালিকাভুক্ত করুন +List by data source data source অনুযায়ী তালিকা +List by drawing order আঁকার ক্রম অনুযায়ী তালিকা +List by selection নির্বাচন অনুযায়ী তালিকা +List Columns in File ফাইলে column তালিকাভুক্ত করুন +List Commands Command তালিকাভুক্ত করুন +List Connections Connection তালিকাভুক্ত করুন +List Data Sources Data source তালিকাভুক্ত করুন +List Database Columns Database-এর column তালিকাভুক্ত করুন +List Database Tables Database table তালিকাভুক্ত করুন +List geoBoundaries Countries geoBoundaries-এর দেশ তালিকাভুক্ত করুন +List Layers in File ফাইলে থাকা layer তালিকাভুক্ত করুন +List Products পণ্য তালিকাভুক্ত করুন +List S3 Bucket S3 bucket তালিকাভুক্ত করুন +List Source Cooperative Files Source Cooperative-এর file তালিকাভুক্ত করুন +List Source Cooperative Products Source Cooperative-এর product তালিকাভুক্ত করুন +List STAC Collections STAC collection তালিকাভুক্ত করুন +List Tables Table তালিকাভুক্ত করুন +List The National Map Services The National Map service তালিকাভুক্ত করুন +List the provider's models Provider-এর model তালিকাভুক্ত করুন +List this layer in the legend Legend-এ এই layer দেখান +List what it holds এর ভেতরের বিষয়বস্তু তালিকাভুক্ত করুন +Listed for discovery: RustGIS doesn't read this dataset yet. Its data and paper are linked above. আবিষ্কারের জন্য তালিকাভুক্ত: RustGIS এখনো এই dataset পড়ে না। এর data ও গবেষণাপত্রের লিংক উপরে আছে। +Listing… তালিকা তৈরি হচ্ছে… +Lit আলোকিত +Live summary of the features in view. দৃশ্যমান feature-গুলোর সরাসরি সারাংশ। +Load All Features সব feature লোড করুন +Load an unpacked plugin from its folder while you develop it. উন্নয়নের সময় folder থেকে unpack করা plugin লোড করুন। +Load more আরও লোড করুন +Load sources & plot source লোড ও plot করুন +Load Whitebox and GeoLibre tools Whitebox ও GeoLibre tool লোড করুন +Loaded {name} from its folder. নিজের folder থেকে {name} লোড হয়েছে। +Loading the GeoLibre tools… GeoLibre tool লোড হচ্ছে… +Loading… লোড হচ্ছে… +Loads map tiles from the web ওয়েব থেকে map tile লোড করে +Local স্থানীয় +Locate অবস্থান নির্ণয় +Locate GeoLibre Runner GeoLibre Runner খুঁজুন +Locate the GeoLibre runner GeoLibre runner খুঁজুন +Locate… খুঁজুন… +Location অবস্থান +Locator লোকেটর +Lock লক করুন +Locked লক করা +Locked: unlock it in the Format pane to move or change it লক করা: সরাতে বা বদলাতে Format pane-এ unlock করুন +Log In to Connection Connection-এ লগ ইন +Log In to {name}… {name}-এ লগ ইন… +Log In… লগ ইন… +Long লং (দীর্ঘ পূর্ণসংখ্যা) +Longitude দ্রাঘিমাংশ +Longitude (X) দ্রাঘিমাংশ (X) +Look for {name}, outlined on the ribbon. Ribbon-এ চিহ্নিত {name} খুঁজুন। +Looking up the place… স্থান খোঁজা হচ্ছে… +Loop লুপ +Lower Priority অগ্রাধিকার কমান +Made in RustGIS: samples, drawings and tool outputs RustGIS-এ তৈরি: নমুনা, অঙ্কন ও tool-এর output +Magnifier বিবর্ধক +Major Lakes প্রধান হ্রদ +Major Rivers প্রধান নদী +Make a new layer of the selected features. নির্বাচিত feature থেকে নতুন layer তৈরি করুন। +Make a task of the steps recorded রেকর্ড করা ধাপগুলো নিয়ে task তৈরি করুন +Make Layer From Selected Features নির্বাচিত feature থেকে layer তৈরি করুন +Manage পরিচালনা +Manage Bookmarks Bookmark পরিচালনা +Manage Edits Edit পরিচালনা +Manage Templates Template পরিচালনা +Mann–Kendall Mann–Kendall +Map মানচিত্র +Map\nFrame মানচিত্রের\nFrame +Map (the current view) মানচিত্র (বর্তমান view) +map by date তারিখ অনুযায়ী মানচিত্র +Map centre মানচিত্রের কেন্দ্র +Map draw মানচিত্র আঁকা +Map Extent মানচিত্রের extent +Map File Map file +Map Frame Map frame +Map Frames Map frame-সমূহ +Map frames per second (live while the map moves) মানচিত্রের প্রতি সেকেন্ডে frame (মানচিত্র নড়ার সময় সরাসরি) +Map Labeling মানচিত্রের label +Map Link Map link +Map name মানচিত্রের নাম +Map Page মানচিত্রের পৃষ্ঠা +Map scale মানচিত্রের scale +Map Series Map series +Map Series Page Map series-এর পৃষ্ঠা +Map series page name Map series পৃষ্ঠার নাম +Map style (JSON) মানচিত্রের স্টাইল (JSON) +Map Surrounds মানচিত্রের পারিপার্শ্বিক উপাদান +Map Thumbnail মানচিত্রের thumbnail +Map tiles of the whole world পুরো পৃথিবীর map tile +Map Tool: {tool} মানচিত্রের টুল: {tool} +Map View মানচিত্রের view +map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} map.goToXY {"lon": 2.35, "lat": 48.85, "scale": 5000000}\nselect.byAttributes {"layer": "Cities", "where": "pop_max > 1000000"} +Map: {kind} মানচিত্র: {kind} +Mapping মানচিত্রায়ন +Maps মানচিত্রসমূহ +Maps move together. মানচিত্রগুলো একসঙ্গে নড়ে। +MapServer MapServer +Marker মার্কার +Mask Values Mask করার মান +Masking মাস্কিং +Match Option মিলের বিকল্প +Matching places মিলে যাওয়া স্থান +Max সর্বোচ্চ +Max % সর্বোচ্চ % +Max Neighbors সর্বোচ্চ প্রতিবেশী +Maximum zoom সর্বোচ্চ zoom +MCP Relay MCP relay +MCP relay is already listening on 127.0.0.1:{port}; stop it first to use port {asked} MCP relay ইতিমধ্যে 127.0.0.1:{port}-এ listen করছে; {asked} port ব্যবহার করতে আগে এটি বন্ধ করুন +MCP relay listening on 127.0.0.1:{port} MCP relay 127.0.0.1:{port}-এ listen করছে +MCP relay stopped MCP relay বন্ধ হয়েছে +MCP relay · full access · {n} attached MCP relay · পূর্ণ অ্যাক্সেস · {n} সংযুক্ত +MCP relay · {n} attached MCP relay · {n} সংযুক্ত +Mean গড় +Measure পরিমাপ +Measure Area ক্ষেত্রফল পরিমাপ +Measure Distance দূরত্ব পরিমাপ +Measure geodesic distances and areas on the map. মানচিত্রে geodesic দূরত্ব ও ক্ষেত্রফল পরিমাপ করুন। +Measure on this body, with one of its basemaps. এই জ্যোতিষ্কে, এর একটি basemap ব্যবহার করে পরিমাপ করুন। +Measure with an elevation raster elevation raster দিয়ে পরিমাপ করুন +Measurement units পরিমাপের একক +Measurement units must be metric or imperial পরিমাপের একক metric বা imperial হতে হবে +Measurements so far. এ পর্যন্ত পরিমাপ। +Mensuration মাপজোক +Mensuration Tools মাপজোকের tool +Merge একত্র করুন +Merge elevation rasters into a surface model and export a 3D mesh. elevation raster-গুলো একত্র করে surface model তৈরি করুন এবং 3D mesh export করুন। +Merges polygons that share a field value into one feature per value. একই field value-যুক্ত polygon একত্র করে প্রতিটি value-র জন্য একটি feature তৈরি করে। +Messages appear here. বার্তাগুলো এখানে দেখা যাবে। +Meters মিটার +Method পদ্ধতি +Metric মেট্রিক +Midnight মধ্যরাত +Miles মাইল +Miller Cylindrical Miller Cylindrical +Min সর্বনিম্ন +Min % সর্বনিম্ন % +Min must be less than Max Min অবশ্যই Max-এর চেয়ে কম হতে হবে +Min must not be more than Max Min, Max-এর চেয়ে বেশি হতে পারবে না +Minimum Bounding Geometry সর্বনিম্ন bounding geometry +Minimum Maximum সর্বনিম্ন সর্বোচ্চ +Minus বিয়োগ +Minutes মিনিট +minutes ago মিনিট আগে +Miter মাইটার +Mobile Map মোবাইল মানচিত্র +Mod+ Mod+ +Mod+C Mod+C +Mod+D Mod+D +Mod+Equals Mod+Equals +Mod+F1 Mod+F1 +Mod+K Mod+K +Mod+Minus Mod+Minus +Mod+N Mod+N +Mod+O Mod+O +Mod+Plus Mod+Plus +Mod+S Mod+S +Mod+Shift+S Mod+Shift+S +Mod+Shift+Z Mod+Shift+Z +Mod+T Mod+T +Mod+V Mod+V +Mod+X Mod+X +Mod+Y Mod+Y +Mod+Z Mod+Z +Model মডেল +Model\nBuilder মডেল\nবিল্ডার +model id model ID +Model output মডেল আউটপুট +ModelBuilder মডেল বিল্ডার +Models Model-সমূহ +Modified Mann–Kendall (Hamed & Rao) পরিবর্তিত Mann–Kendall (Hamed & Rao) +Modified Mann–Kendall (Yue & Wang) পরিবর্তিত Mann–Kendall (Yue & Wang) +Modify পরিবর্তন +Modify Selected Features নির্বাচিত feature পরিবর্তন করুন +Mollweide (equal-area world) Mollweide (সমক্ষেত্রফলবিশিষ্ট বিশ্ব) +Month মাস +Months মাসসমূহ +Months are numbers from 1 to 12 (12, 1, 2 for December to February) মাস ১ থেকে ১২ পর্যন্ত সংখ্যা (ডিসেম্বর থেকে ফেব্রুয়ারি হলে 12, 1, 2) +More আরও +More basemaps: আরও basemap: +Mosaic images into an orthomosaic ছবি একত্র করে orthomosaic তৈরি করুন +Mosaic Rasters রাস্টার মোজাইক +Mosaic the visible rasters into an orthomosaic. দৃশ্যমান raster-গুলো একত্র করে orthomosaic তৈরি করুন। +Most cloud cover (%) সর্বোচ্চ মেঘের আচ্ছাদন (%) +Mouse wheel মাউসের wheel +Move সরান +Move Bookmark Bookmark সরান +Move Down নিচে সরান +Move down নিচে সরান +Move Group Group সরান +Move Label Class Label class সরান +Move Task Step Task step সরান +Move To Bottom একেবারে নিচে সরান +Move To Group Group-এ সরান +Move To Top একেবারে ওপরে সরান +Move To {group} {group}-এ সরান +Move Up ওপরে সরান +Move up ওপরে সরান +Move, rotate, scale, merge, explode, split or flip the selected features. নির্বাচিত feature সরান, ঘোরান, scale করুন, একত্র করুন, explode, ভাগ বা উল্টে দিন। +Move, Rotate, Scale… সরান, ঘোরান, scale করুন… +MultiLineString MultiLineString +MultiPolygon MultiPolygon +My Buckets আমার Bucket-সমূহ +My OGC Layer আমার OGC layer +MySQL/MariaDB MySQL/MariaDB +N/S/E/W N/S/E/W +Name নাম +name the plugin whose panel to open: {"id": plugin id} কোন plugin-এর panel খুলতে হবে তা লিখুন: {"id": plugin id} +NASA Earthaccess NASA Earthaccess +NASA Earthaccess… NASA Earthaccess… +NASA Earthdata NASA Earthdata +NASA Earthdata through Earthaccess's search: find a dataset, then its granules where the map is, drawn as footprints. GeoTIFFs are added as COGs, read in place. Searching needs no account. Earthaccess search দিয়ে NASA Earthdata: একটি dataset, তারপর মানচিত্রের এলাকার granule খুঁজুন; footprint হিসেবে আঁকা হবে। GeoTIFF COG হিসেবে যোগ হয় এবং একই জায়গায় পড়া হয়। খুঁজতে account লাগে না। +NASA Earthdata… NASA Earthdata… +NASA's CMR has no dataset named {name} NASA CMR-এ {name} নামে কোনো dataset নেই +NASA's Earthdata GIS: imagery, maps, features and web maps of Earth science data. NASA Earthdata GIS: পৃথিবীবিজ্ঞানের ডেটার ছবি, মানচিত্র, feature ও web map। +NASA's Global Imagery Browse Services (GIBS): over a thousand layers of satellite imagery and science data, most of them daily. NASA-র Global Imagery Browse Services (GIBS): এক হাজারেরও বেশি satellite imagery ও বৈজ্ঞানিক ডেটার layer, অধিকাংশই দৈনিক। +Natural Breaks (Jenks) প্রাকৃতিক বিরতি (Jenks) +Natural Color (1, 2, 3) প্রাকৃতিক রং (1, 2, 3) +Natural Earth Natural Earth +Natural Earth's public domain map data (countries, states, coastlines, rivers, lakes, roads and more) on Source Cooperative. PMTiles are added as tile layers, other files as layers. Source Cooperative-এ Natural Earth-এর public-domain মানচিত্রের ডেটা (দেশ, রাজ্য, উপকূলরেখা, নদী, হ্রদ, রাস্তা ও আরও অনেক কিছু)। PMTiles tile layer হিসেবে যোগ হয়, অন্য file layer হিসেবে। +Natural Earth… Natural Earth… +Navigate নেভিগেট +Navigate with the mouse (planar) or walk with W A S D, Q and E (first-person). মাউস দিয়ে নেভিগেট করুন (সমতল) অথবা W A S D, Q ও E দিয়ে হাঁটুন (প্রথম-ব্যক্তি)। +Navigation নেভিগেশন +Navigation Mode নেভিগেশন মোড +Navigator নেভিগেটর +NDVI, NDWI, SAVI, NBR, NDBI, EVI. NDVI, NDWI, SAVI, NBR, NDBI, EVI। +Nearest Neighbor নিকটতম প্রতিবেশী +Neatline মানচিত্রের সীমানার রেখা +Needs three or more bands. তিন বা তার বেশি band দরকার। +Neighborhood Explorer পাড়া অন্বেষক +Network Analysis নেটওয়ার্ক বিশ্লেষণ +New নতুন +New\nLayout নতুন\nLayout +New\nMap নতুন\nমানচিত্র +New\nNotebook নতুন\nNotebook +New\nPresentation নতুন\nPresentation +New\nReport নতুন\nReport +New\nWorkspace নতুন\nWorkspace +New Bookmark নতুন Bookmark +New Bookmark… নতুন Bookmark… +New Connection নতুন Connection +New Connection ▾ নতুন Connection ▾ +New conversation নতুন কথোপকথন +New Custom Template নতুন কাস্টম Template +New Custom Template… নতুন কাস্টম Template… +New features use this template's values নতুন feature এই template-এর মান ব্যবহার করবে +New field নতুন field +New Group নতুন Group +New job name নতুন job-এর নাম +New Layout নতুন Layout +New Map নতুন মানচিত্র +New Model From History ইতিহাস থেকে নতুন Model +New Notebook নতুন Notebook +New Presentation নতুন Presentation +New Project নতুন Project +New project নতুন project +New Publication Layout নতুন প্রকাশনা layout +New Relationship… নতুন Relationship… +New Report নতুন Report +New Selection নতুন Selection +New Style নতুন Style +New Style From Layer Layer থেকে নতুন Style +New Task নতুন Task +New Task From History ইতিহাস থেকে নতুন Task +New Template নতুন Template +New Template (a layout page) নতুন Template (একটি layout পৃষ্ঠা) +New Workspace নতুন Workspace +New Workspace From Visible Rasters দৃশ্যমান raster থেকে নতুন workspace +Next পরবর্তী +Next Extent পরবর্তী extent +Next Page পরবর্তী পৃষ্ঠা +NLDI result cleared. NLDI ফলাফল মুছে ফেলা হয়েছে। +NLDI returned no plottable navigation source. NLDI কোনো plot করা যায় এমন navigation source ফেরত দেয়নি। +No না +No agents that speak ACP (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Cursor, OpenCode, goose) were found on this computer. এই কম্পিউটারে ACP-সমর্থিত কোনো agent (Claude Code, Codex, Gemini CLI, GitHub Copilot CLI, Cursor, OpenCode, goose) পাওয়া যায়নি। +No AlphaEarth layer on the map মানচিত্রে কোনো AlphaEarth layer নেই +No bookmark matches the filter. কোনো bookmark filter-এর সঙ্গে মেলে না। +No bookmarks yet এখনও কোনো bookmark নেই +No bookmarks yet. New Bookmark saves the current view of the map. এখনও কোনো bookmark নেই। New Bookmark বর্তমান map view সংরক্ষণ করে। +No catalogs below this one: Find Items lists its items এর নিচে কোনো catalog নেই: Find Items এর item-গুলো তালিকাভুক্ত করে +No COMID was returned for this point. এই বিন্দুর জন্য কোনো COMID ফেরত আসেনি। +no command কোনো command নেই +No data at the points picked. নির্বাচিত বিন্দুগুলোতে কোনো ডেটা নেই। +No datasets match. কোনো dataset মেলেনি। +No datasets with data RustGIS can add RustGIS যোগ করতে পারে এমন ডেটাসহ কোনো dataset নেই +no date in the name নামে কোনো তারিখ নেই +No edits since the project was saved প্রজেক্ট সংরক্ষণের পর কোনো পরিবর্তন নেই +No field of this layer holds dates. এই layer-এর কোনো field-এ তারিখ নেই। +No Fill কোনো fill নেই +No geometry to calculate হিসাব করার মতো geometry নেই +No granules in this view and date range. এই view ও তারিখের range-এ কোনো granule নেই। +no home folder কোনো home folder নেই +no layer কোনো layer নেই +No layer has a field of dates. কোনো layer-এ তারিখের field নেই। +no layer has a field of dates: add one that has, or a tile layer whose address has a date (map.addTiles) কোনো layer-এ তারিখের field নেই: এমন একটি layer যোগ করুন, অথবা তারিখসহ address-যুক্ত tile layer যোগ করুন (map.addTiles) +No layers match কোনো layer মেলে না +No layers match your search. আপনার অনুসন্ধানের সঙ্গে কোনো layer মেলেনি। +No mask কোনো mask নেই +No models are available for this account. এই account-এর জন্য কোনো model পাওয়া যায় না। +No notebooks yet. Analyze › New Notebook starts one, or Add and Open Notebook opens one. এখনও কোনো notebook নেই। Analyze › New Notebook দিয়ে শুরু করুন, অথবা Add and Open Notebook দিয়ে একটি খুলুন। +No offline areas কোনো offline area নেই +No other reports অন্য কোনো report নেই +No place found কোনো স্থান পাওয়া যায়নি +No place matches that address. ওই ঠিকানার সঙ্গে মেলে এমন কোনো স্থান নেই। +No plugin panel is open. কোনো plugin panel খোলা নেই। +No plugins are installed. Browse the plugin registry, or install a .rgplugin file. কোনো plugin ইনস্টল করা নেই। Plugin registry ব্রাউজ করুন, অথবা .rgplugin file ইনস্টল করুন। +No plugins match. কোনো plugin মেলেনি। +No Pop-ups কোনো pop-up নেই +No presentations yet. Publish › New Presentation starts one. এখনও কোনো presentation নেই। Publish › New Presentation দিয়ে একটি তৈরি করুন। +No products কোনো পণ্য নেই +No project opened or saved yet এখনও কোনো project খোলা বা সংরক্ষিত হয়নি +No properties কোনো property নেই +No relationships (Data Design › Relationships) কোনো relationship নেই (Data Design › Relationships) +No relationships yet এখনও কোনো relationship নেই +No reports yet. Publish › New Report lists the selected layer's records. এখনও কোনো report নেই। Publish › New Report নির্বাচিত layer-এর record-গুলো তালিকাভুক্ত করে। +no row কোনো row নেই +No serif font is installed, so this text is drawn in the sans-serif কোনো serif font ইনস্টল নেই, তাই এই টেক্সট sans-serif font-এ আঁকা হচ্ছে +no service or file links service বা file-এর কোনো link নেই +No services match your search. আপনার অনুসন্ধানের সঙ্গে কোনো service মেলেনি। +No standalone tables কোনো স্বতন্ত্র table নেই +No styles yet এখনও কোনো style নেই +No tasks yet. Record one, or make one from your geoprocessing history with Analyze › Task. এখনও কোনো task নেই। একটি রেকর্ড করুন, অথবা Analyze › Task দিয়ে geoprocessing history থেকে তৈরি করুন। +no text graphic there সেখানে কোনো text graphic নেই +No tools match your search. আপনার অনুসন্ধানের সঙ্গে কোনো tool মেলেনি। +no yearly series কোনো বার্ষিক series নেই +NoData NoData +Nominatim Nominatim +None কোনোটিই নয় +None (public buckets) কোনোটিই নয় (public bucket) +none, a .duckdb file, or host=… dbname=… user=… কিছুই নয়, একটি .duckdb file, অথবা host=… dbname=… user=… +none, or from PGPASSWORD, MYSQL_PWD or SQLCMDPASSWORD কিছুই নয়, অথবা PGPASSWORD, MYSQL_PWD বা SQLCMDPASSWORD থেকে +None. Add one by address, or from a basemap plugin. কিছুই নয়। ঠিকানা দিয়ে অথবা basemap plugin থেকে একটি যোগ করুন। +North উত্তর +North\nArrow উত্তর\nতীর +North Polar Stereographic উত্তর মেরু স্টেরিওগ্রাফিক +NOT NOT +not a picture ছবি নয় +not a RustGIS layout RustGIS layout নয় +not a RustGIS project RustGIS project নয় +Not applied yet. এখনও প্রয়োগ করা হয়নি। +Not for texts: a text is edited in the Format pane. টেক্সটের জন্য নয়: Format pane-এ টেক্সট সম্পাদনা করা হয়। +not needed প্রয়োজন নেই +Not sandboxed Sandbox-এর বাইরে +Not saved yet এখনও সংরক্ষিত নয় +Not searched, as they didn't load: {folders} লোড হয়নি বলে অনুসন্ধান করা হয়নি: {folders} +Not Selectable নির্বাচনযোগ্য নয় +Notebook নোটবুক +Notebook Pane নোটবুক প্যান +Nothing found কিছু পাওয়া যায়নি +Nothing found where the map is মানচিত্র যেখানে আছে, সেখানে কিছু পাওয়া যায়নি +Nothing here এখানে কিছু নেই +Nothing named {name} to rename পুনঃনামকরণের জন্য {name} নামে কিছু নেই +Nothing shared yet. Publish › Share As makes web maps, layers, styles, scenes and more. এখনও কিছু শেয়ার করা হয়নি। Publish › Share As দিয়ে web map, layer, style, scene এবং আরও অনেক কিছু তৈরি হয়। +Nothing to add here এখানে যোগ করার কিছু নেই +Nothing to test পরীক্ষা করার কিছু নেই +Nothing was found at this address. এই ঠিকানায় কিছু পাওয়া যায়নি। +Nothing was recorded, so no task was made. কিছুই রেকর্ড হয়নি, তাই কোনো task তৈরি হয়নি। +Now এখন +Nugget Nugget +NULL NULL +Nulls Null মান +Observations (observer, date, species, count) পর্যবেক্ষণ (পর্যবেক্ষক, তারিখ, প্রজাতি, সংখ্যা) +observer height m পর্যবেক্ষকের উচ্চতা m +Ocean মহাসাগর +Ocean Data Platform Ocean Data Platform +Ocean Data Platform… Ocean Data Platform… +of a geometry that doesn't say যে geometry এটি জানায় না তার +Off বন্ধ +Off, west and south are negative: -112.5°. Off অবস্থায় পশ্চিম ও দক্ষিণ ঋণাত্মক: -112.5°। +Offline অফলাইন +Offline area অফলাইন এলাকা +Offset অফসেট +OGC API OGC API +OGC API Features and Tiles OGC API Features ও Tiles +OID OID +Ollama (local) Ollama (স্থানীয়) +On চালু +On Hold স্থগিত +on hold স্থগিত +On the desktop, the key can also come from {vars}. Desktop-এ key {vars} থেকেও আসতে পারে। +On-screen compass, zoom slider and pan pad. পর্দায় কম্পাস, zoom slider ও pan pad। +One command per line: id {json}. Lines starting with # are comments. প্রতি লাইনে একটি command: id {json}। # দিয়ে শুরু হওয়া লাইন comment। +One DuckDB SELECT: DuckDB Spatial's ST_ functions, files and web addresses (read_parquet('…')), and the tables of the database below. Ctrl+Enter runs it. DuckDB-এর একটি SELECT: DuckDB Spatial-এর ST_ function, file ও web address (read_parquet(‘…’)), এবং নিচের database-এর table। চালাতে Ctrl+Enter চাপুন। +One layer: a Zarr variable or a raster's bands একটি layer: Zarr variable অথবা raster-এর band +One page per feature of an index layer. একটি index layer-এর প্রতিটি feature-এর জন্য একটি পৃষ্ঠা। +One per line: code=name প্রতি লাইনে একটি: code=name +One stroke, from the symbol. Add strokes for casings, parallel lines and offsets. প্রতীক থেকে একটি stroke। casing, parallel line ও offset-এর জন্য stroke যোগ করুন। +Only features matching this expression draw in {layer}. শুধু এই expression-এর সঙ্গে মেলা feature {layer}-এ আঁকা হবে। +Only where the map is শুধু মানচিত্র যেখানে আছে +Opacity অস্বচ্ছতা +Open খুলুন +Open a .rgis project একটি .rgis project খুলুন +open a layout first আগে একটি layout খুলুন +Open a model file (*.model.json) একটি model file (*.model.json) খুলুন +Open a model of this project এই project-এর একটি model খুলুন +Open a RustGIS or Jupyter notebook. RustGIS বা Jupyter notebook খুলুন। +Open a RustGIS project. RustGIS project খুলুন। +Open an offline area to edit. সম্পাদনার জন্য offline area খুলুন। +Open browser again Browser আবার খুলুন +Open Custom Template Custom template খুলুন +Open Custom Template… Custom template খুলুন… +Open data catalogs registered with Portolan: pick a publisher's catalog, then Find Items for its collections. Portolan-এ নিবন্ধিত open-data catalog খুলুন: publisher-এর catalog বেছে নিয়ে collection-এর জন্য Find Items ব্যবহার করুন। +Open data catalogues searched by text. Files are added by their address (read in place when they can be); a service opens Add From Service to pick its layer. টেক্সট দিয়ে খোঁজা open-data catalog। File তাদের address দিয়ে যোগ হয় (সম্ভব হলে একই জায়গায় পড়া হয়); service তার layer বেছে নিতে Add From Service খোলে। +Open data products on Source Cooperative. GeoParquet, FlatGeobuf, cloud-optimised GeoTIFFs and PMTiles are read in place; other files are downloaded. Source Cooperative-এর open-data product। GeoParquet, FlatGeobuf, cloud-optimized GeoTIFF ও PMTiles একই জায়গায় পড়া হয়; অন্য file ডাউনলোড হয়। +Open folder ফোল্ডার খুলুন +Open In Geoprocessing… Geoprocessing-এ খুলুন… +Open in ModelBuilder ModelBuilder-এ খুলুন +Open in Tool Tool-এ খুলুন +Open Layout Layout খুলুন +Open ModelBuilder ModelBuilder খুলুন +Open Notebook Notebook খুলুন +Open Offline Map Offline map খুলুন +Open Presentation Presentation খুলুন +Open Project Project খুলুন +Open project Project খুলুন +Open Project (from text) Project খুলুন (টেক্সট থেকে) +Open Project… Project খুলুন… +Open Report Report খুলুন +Open the attribute table of the selected layer. নির্বাচিত layer-এর attribute table খুলুন। +Open the Basemaps tab Basemaps tab খুলুন +Open the layer's attribute table. Layer-এর attribute table খুলুন। +Open the layer's table. Layer-এর table খুলুন। +Open the plugin's panel. Plugin-এর panel খুলুন। +Open the Release Page Release page খুলুন +Open the RustGIS user guide. RustGIS ব্যবহারকারী নির্দেশিকা খুলুন। +Open the selected layer's table. নির্বাচিত layer-এর table খুলুন। +Open the Symbology pane. Symbology pane খুলুন। +Open the task's steps Task-এর ধাপগুলো খুলুন +Open this granule in Earthdata Search Earthdata Search-এ এই granule খুলুন +Open {code} on epsg.io epsg.io-তে {code} খুলুন +Open, save and create projects Project খুলুন, সংরক্ষণ করুন ও তৈরি করুন +OpenAerialMap OpenAerialMap +OpenAerialMap… OpenAerialMap… +OpenAI-compatible OpenAI-সামঞ্জস্যপূর্ণ +Opened {name} for printing প্রিন্ট করার জন্য {name} খোলা হয়েছে +OpenFreeMap Bright OpenFreeMap Bright +OpenFreeMap Dark OpenFreeMap Dark +OpenFreeMap Fiord OpenFreeMap Fiord +OpenFreeMap Liberty OpenFreeMap Liberty +OpenFreeMap Positron OpenFreeMap Positron +OpenFreeMap © OpenMapTiles Data from OpenStreetMap OpenFreeMap © OpenMapTiles; ডেটা OpenStreetMap থেকে +Opening {url}… {url} খোলা হচ্ছে… +OpenStreetMap OpenStreetMap +OpenStreetMap data where the map is, from an Overpass server: a preset, or tag filters such as amenity=cafe, shop. The area can be up to about 50 × 50 km. © OpenStreetMap contributors (ODbL). Overpass server থেকে মানচিত্র যেখানে আছে সেখানে OpenStreetMap ডেটা: একটি preset অথবা amenity=cafe, shop-এর মতো tag filter। এলাকা প্রায় ৫০ × ৫০ km পর্যন্ত হতে পারে। © OpenStreetMap contributors (ODbL)। +OpenStreetMap's public server OpenStreetMap-এর public server +OpenStreetMap, Esri, USGS, NASA and more, from the plugin registry. Plugin registry থেকে OpenStreetMap, Esri, USGS, NASA এবং আরও অনেক কিছু। +OpenStreetMap… OpenStreetMap… +Open… খুলুন… +Optional. Shift+Enter starts a new line. ঐচ্ছিক। নতুন লাইন শুরু করতে Shift+Enter চাপুন। +Options বিকল্প +OR OR +Or অথবা +Or run in a POSIX shell, for Claude Code or Codex: অথবা Claude Code বা Codex-এর জন্য POSIX shell-এ চালান: +Or run in PowerShell, for Claude Code or Codex: অথবা Claude Code বা Codex-এর জন্য PowerShell-এ চালান: +Or run, for Claude Code or Codex: অথবা Claude Code বা Codex-এর জন্য চালান: +ORDER BY ORDER BY +Ordinary Kriging অর্ডিনারি ক্রিগিং +Orientation অভিমুখ +Ortho Mapping অর্থো ম্যাপিং +Other অন্যান্য +Other Data অন্যান্য ডেটা +Other label classes on this layer (made with commands): {n}. Each labels the features its own clause matches, so a feature can get a label from more than one. এই layer-এ আরও label class আছে (command দিয়ে তৈরি): {n}। প্রতিটি নিজের clause-এর সঙ্গে মেলা feature-এ label দেয়, তাই একটি feature একাধিক label পেতে পারে। +Out Beyond বাইরের দিকে +Outline আউটলাইন +Outline another frame's extent in this map frame. এই map frame-এ অন্য frame-এর extent-এর outline দেখান। +Outline color Outline-এর রং +Outline width Outline-এর প্রস্থ +Outlines {frame} {frame}-এর outline +OUTPUT আউটপুট +Output আউটপুট +Output Name Output-এর নাম +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools that run in the browser, from a one-time download of about 25 MB. প্রায় 25 MB-এর একবারের ডাউনলোডে browser-এ চলে এমন ১,০০০-এর বেশি raster, vector, LiDAR, hydrology ও terrain tool। +Over 1,000 raster, vector, LiDAR, hydrology and terrain tools, from an optional download of about 20 MB. প্রায় 20 MB-এর ঐচ্ছিক ডাউনলোডে ১,০০০-এর বেশি raster, vector, LiDAR, hydrology ও terrain tool। +Overlapping একটির ওপর আরেকটি পড়া +Overlay ওভারলে +overlay ওভারলে +Overlay (a transparent layer kept on top, such as labels or roads) Overlay (একটি স্বচ্ছ layer, যা উপরে থাকে; যেমন label বা রাস্তা) +Overture has no {ty} data where the map is মানচিত্র যেখানে আছে, সেখানে Overture-এর {ty} ডেটা নেই +Overture Maps Overture Maps +Overture Maps' open data: buildings, places, transportation, administrative divisions, base layers and addresses. Where the map is, its features are read in place from Overture's GeoParquet files (zoom to a city or smaller); the whole world comes as map tiles. Overture Maps-এর open data: ভবন, স্থান, পরিবহন, প্রশাসনিক বিভাগ, base layer ও ঠিকানা। মানচিত্র যেখানে আছে, সেখানে Overture-এর GeoParquet file থেকে feature সরাসরি পড়া হয় (শহর বা তার চেয়ে ছোট এলাকায় zoom করুন); পুরো পৃথিবী map tile হিসেবে আসে। +Overture Maps… Overture Maps… +Overwrite existing values বিদ্যমান মান overwrite করুন +Package প্যাকেজ +Package a trained deep learning model. প্রশিক্ষিত deep learning model package করুন। +Package Map Map package করুন +Package Project Project package করুন +Package Results ফলাফল package করুন +Page পৃষ্ঠা +Page\nSize পৃষ্ঠা\nআকার +Page after: {name} {name}-এর পরে পৃষ্ঠা +Page before: {name} {name}-এর আগে পৃষ্ঠা +Page number পৃষ্ঠা নম্বর +Page Properties পৃষ্ঠার বৈশিষ্ট্য +Page Setup পৃষ্ঠা সেটআপ +Page Size পৃষ্ঠার আকার +Page text পৃষ্ঠার টেক্সট +Page title পৃষ্ঠার শিরোনাম +Page {n} of {total} মোট {total}-এর মধ্যে পৃষ্ঠা {n} +Page {n} of {total}: {name} মোট {total}-এর মধ্যে পৃষ্ঠা {n}: {name} +Pages of maps, text, images and videos, shown one after another. একটির পর একটি দেখানো মানচিত্র, টেক্সট, ছবি ও ভিডিওর পৃষ্ঠা। +Pan প্যান +Pan (Explore) · any tool Pan (Explore) · যেকোনো tool +Pan and Zoom Map মানচিত্র সরান ও zoom করুন +Pan To Pan করে যান +Pan to a longitude/latitude. একটি দ্রাঘিমাংশ/অক্ষাংশে view সরান। +Pan with the mouse, zoom with the wheel and click features to see their attributes. মাউস দিয়ে pan, wheel দিয়ে zoom, আর attribute দেখতে feature-এ ক্লিক করুন। +Pan/Zoom\nMap মানচিত্র\nপ্যান/জুম +Pane Sets Pane set +Paper কাগজ +Parent folder উপরের folder +parks, bike lanes, flood zones… পার্ক, বাইক লেন, বন্যা অঞ্চল… +Password পাসওয়ার্ড +Paste পেস্ট +Paste a token Token পেস্ট করুন +Paste copied features into the selected layer, or as a new layer. কপি করা feature নির্বাচিত layer-এ বা নতুন layer হিসেবে paste করুন। +Paste Group Group পেস্ট করুন +Paste Groups Group-গুলো পেস্ট করুন +Paste Layer Layer পেস্ট করুন +Paste Layers Layer-গুলো পেস্ট করুন +Paste Special বিশেষভাবে পেস্ট +Paste Special (as a new layer) বিশেষভাবে পেস্ট (নতুন layer হিসেবে) +paste your key আপনার key পেস্ট করুন +Pause বিরতি +Pause. বিরতি। +PDF or PNG PDF বা PNG +Percent Clip শতাংশ clip +Perceptually Uniform অনুভূতিগতভাবে সমান +Performance কর্মক্ষমতা +Phase পর্যায় +Photon Photon +Pick a live value, such as the scale or date, then click the map to place it. স্কেল বা তারিখের মতো একটি চলমান মান বেছে নিন, তারপর মানচিত্রে ক্লিক করে সেটি বসান। +Pick a Point একটি বিন্দু বেছে নিন +Pick a point on the map to see the variable's values through time there. মানচিত্রে একটি বিন্দু বেছে নিন, সেখানে সময়ের সঙ্গে variable-এর মান দেখতে। +Pick a route in the list, or click a grey route on the map. তালিকা থেকে একটি route বেছে নিন, অথবা মানচিত্রে ধূসর route-এ ক্লিক করুন। +Pick a single layer. একটি মাত্র layer বেছে নিন। +Pick it on the map মানচিত্রে এটি বেছে নিন +Pick one… একটি বেছে নিন… +Picture ছবি +Pictures ছবি +Pictures, attachments and chart ছবি, সংযুক্তি ও chart +Pipe পাইপ +Pitch ঢাল +Pixel পিক্সেল +Pixel Editor পিক্সেল এডিটর +Pixel Size (°) পিক্সেলের আকার (°) +Pixel type Pixel-এর ধরন +Place a raster on the map মানচিত্রে একটি raster বসান +Place a raster on the map from control points or the view. control point বা view থেকে মানচিত্রে একটি raster বসান। +Placement অবস্থান নির্ধারণ +Places স্থানসমূহ +Places (name, category, address, rating) স্থান (নাম, category, ঠিকানা, rating) +Plan a route between places and follow it turn by turn (Valhalla, on OpenStreetMap data). স্থানগুলোর মধ্যে route পরিকল্পনা করুন এবং মোড়ে মোড়ে অনুসরণ করুন (OpenStreetMap ডেটায় Valhalla)। +Planar সমতল +Planar Navigation সমতল নেভিগেশন +Planet Open Data Planet Open Data +Planet Open Data… Planet Open Data… +Planet's imagery of disasters (CC BY-NC), a static STAC catalog on Source Cooperative: pick an event, then Find Items. Planet-এর দুর্যোগের ছবি (CC BY-NC), Source Cooperative-এ static STAC catalog: একটি ঘটনা বেছে নিন, তারপর Find Items। +Planetary Computer Planetary Computer +Planning a route sends its start, stops and destination to a routing server, and looking up a place by name sends the name to OpenStreetMap's Nominatim. Route পরিকল্পনা করলে শুরু, থামার স্থান ও গন্তব্য routing server-এ পাঠানো হয়; নাম দিয়ে স্থান খুঁজলে নাম OpenStreetMap-এর Nominatim-এ পাঠানো হয়। +Play চালান +Play the animation. Animation চালান। +Play through the steps. ধাপগুলো চালিয়ে দেখুন। +Plot another navigation layer আরেকটি navigation layer plot করুন +Plotted {n} {source} features. {n}টি {source} feature plot করা হয়েছে। +Plugin প্লাগইন +Plugin\nManager প্লাগইন\nম্যানেজার +Plugin folder প্লাগইন ফোল্ডার +Plugin Manager প্লাগইন ম্যানেজার +Plugin Manager… প্লাগইন ম্যানেজার… +Plugin Panel State Plugin panel-এর অবস্থা +Plugin registry প্লাগইন রেজিস্ট্রি +Plugins Plugin-সমূহ +Plugins are .rgplugin files. Install only plugins from people you trust. Plugin হলো .rgplugin file। শুধু বিশ্বাসযোগ্য ব্যক্তির plugin ইনস্টল করুন। +Plugins marked Not sandboxed run as programs on your computer instead, and can do anything you can. Not sandboxed হিসেবে চিহ্নিত plugin আপনার কম্পিউটারে সাধারণ program হিসেবে চলে এবং আপনার মতোই সবকিছু করতে পারে। +Plugins run in a sandbox: they can't reach your files or the network, and they change the project only through RustGIS's own commands, within the permissions you accept. Plugin sandbox-এ চলে: তারা আপনার file বা network-এ পৌঁছাতে পারে না এবং আপনার অনুমোদিত সীমার মধ্যে শুধু RustGIS-এর নিজস্ব command দিয়েই project বদলায়। +PMTiles archive PMTiles আর্কাইভ +PNG frames (zip) PNG ফ্রেম (zip) +PNG image PNG ছবি +Point বিন্দু +Points বিন্দুসমূহ +points each প্রতিটিতে বিন্দু +Polar, conformal: Antarctica and the Southern Ocean. মেরু অঞ্চল, conformal: অ্যান্টার্কটিকা ও দক্ষিণ মহাসাগর। +Polar, conformal: the Arctic and the northern lands. মেরু অঞ্চল, conformal: আর্কটিক ও উত্তরাঞ্চল। +Polygon বহুভুজ +Polygon (draw) Polygon (আঁকা) +Polygons Polygon-সমূহ +Polyline পলিলাইন +Pop-up Mode Pop-up মোড +pop_max > 1000000 pop_max > 1000000 +Populated Places জনবসতিপূর্ণ স্থান +Portal পোর্টাল +Portolan Portolan +Portolan… Portolan… +Portrait লম্বালম্বি +Position অবস্থান +PostgreSQL/PostGIS PostgreSQL/PostGIS +Postprocessing পরবর্তী প্রক্রিয়াকরণ +power 2 ঘাত 2 +Precision 6 (OSRM, Valhalla) নির্ভুলতা 6 (OSRM, Valhalla) +Prepare প্রস্তুত করুন +Prepare every layer's geometry for this projection. এই projection-এর জন্য প্রতিটি layer-এর geometry প্রস্তুত করুন। +Prepare Plugin Environment Plugin environment প্রস্তুত করুন +Preparing its Python environment… এর Python environment প্রস্তুত করা হচ্ছে… +Presentation প্রেজেন্টেশন +Presentation File প্রেজেন্টেশন ফাইল +Presentation Pane প্রেজেন্টেশন প্যান +Preset প্রিসেট +Press Load sources & plot first আগে Load sources & plot চাপুন +Preview and set up the report. Report-এর preview দেখুন ও সেটআপ করুন। +Preview, edit and play the presentation. Presentation-এর preview দেখুন, সম্পাদনা করুন ও চালান। +Previous আগের +Previous Extent আগের extent +Previous Page আগের পৃষ্ঠা +Primary\nSymbology প্রাথমিক\nSymbology +Primary symbology প্রাথমিক symbology +Print মুদ্রণ +Print\nLayout \nLayout মুদ্রণ +Print Map মানচিত্র মুদ্রণ +Print the layout. Layout মুদ্রণ করুন। +Print the map view (through the system's PDF viewer). সিস্টেমের PDF viewer দিয়ে map view মুদ্রণ করুন। +Print the open layout (through the system's PDF viewer). সিস্টেমের PDF viewer দিয়ে খোলা layout মুদ্রণ করুন। +printing failed unexpectedly অপ্রত্যাশিতভাবে মুদ্রণ ব্যর্থ হয়েছে +printing needs the desktop app মুদ্রণের জন্য desktop app দরকার +Process প্রক্রিয়াকরণ +Products পণ্য +Profile প্রোফাইল +Profile Along a Line একটি line বরাবর profile +Profile Along Selected Lines নির্বাচিত line-গুলো বরাবর profile +Profile every field প্রতিটি field-এর profile তৈরি করুন +Profile Viewing Profile দেখা +PROJ definition PROJ সংজ্ঞা +Project প্রজেক্ট +Project\nPackage প্রজেক্ট\nপ্যাকেজ +Project Info প্রজেক্টের তথ্য +project is "sample", "blank", "last" or {"path": a project file} project হলো "sample", "blank", "last" অথবা {"path": a project file} +Project Template প্রজেক্ট টেমপ্লেট +Project templates: start projects from a saved setup. Project template: সংরক্ষিত সেটআপ থেকে project শুরু করুন। +project's path is required project-এর path আবশ্যক +Projection অভিক্ষেপ +Projections Projection-সমূহ +Properties বৈশিষ্ট্য +Property বৈশিষ্ট্য +Proportional Symbols আনুপাতিক প্রতীক +Provider প্রদানকারী +Provinces: প্রদেশ: +Proximity নিকটতা +Public GIS portals of US counties and cities, by state. যুক্তরাষ্ট্রের কাউন্টি ও শহরের public GIS portal, রাজ্য অনুযায়ী। +Public GIS portals of US federal agencies, by department. যুক্তরাষ্ট্রের ফেডারেল সংস্থার public GIS portal, বিভাগ অনুযায়ী। +Public GIS portals of US states and the District of Columbia. যুক্তরাষ্ট্রের রাজ্য ও District of Columbia-এর public GIS portal। +Public layers on ArcGIS Online or another ArcGIS portal: feature, map, image and vector tile services, web maps and GeoJSON files. ArcGIS Online বা অন্য ArcGIS portal-এর public layer: feature, map, image ও vector tile service, web map এবং GeoJSON file। +Publication Layout প্রকাশনা layout +Publish প্রকাশ করুন +Publish › Output প্রকাশ করুন › Output +Put the layer back as it was then Layer-কে তখনকার অবস্থায় ফিরিয়ে আনুন +Python environment failed: {error} Python environment ব্যর্থ: {error} +Python environment not prepared Python environment প্রস্তুত নয় +Python environment ready Python environment প্রস্তুত +Python script Python স্ক্রিপ্ট +Qualitative গুণগত +Quantile কোয়ান্টাইল +Query files, web addresses and databases with DuckDB Spatial; add results to the map. DuckDB Spatial দিয়ে file, web address ও database query করুন; ফলাফল মানচিত্রে যোগ করুন। +Quick\nAccess দ্রুত\nঅ্যাক্সেস +Quick Access Toolbar দ্রুত অ্যাক্সেস টুলবার +Quick actions দ্রুত কাজ +Quick Analysis দ্রুত বিশ্লেষণ +radius km ব্যাসার্ধ km +radius m ব্যাসার্ধ m +Rain বৃষ্টি +Raise polygons by a height or a field. উচ্চতা বা field value দিয়ে polygon উঁচু করুন। +Raise Priority অগ্রাধিকার বাড়ান +Random points in polygons polygon-এর মধ্যে এলোমেলো বিন্দু +Range পরিসর +Range (Degrees) Range (ডিগ্রি) +range: 0, 0.9 পরিসর: 0, 0.9 +Raster রাস্টার +Raster\nAnalysis Raster\nবিশ্লেষণ +Raster\nInformation Raster\nতথ্য +Raster Data Raster ডেটা +Raster Effects রাস্টার ইফেক্ট +Raster Functions রাস্টার ফাংশন +Raster Information Raster তথ্য +Raster Layer রাস্টার লেয়ার +Raster tiles (XYZ) রাস্টার টাইল (XYZ) +Raster · {w} × {h} · {n} bands · {renderer} রাস্টার · {w} × {h} · {n} ব্যান্ড · {renderer} +Read and write files on this computer এই কম্পিউটারে file পড়ুন ও লিখুন +Read the bands picked for the view (at most about 6 km a side) view-এর জন্য নির্বাচিত band পড়ুন (প্রতি পাশে সর্বোচ্চ প্রায় ৬ km) +Read the map's layers and features মানচিত্রের layer ও feature পড়ুন +Read the points in the view, coloured by their principal components view-এর বিন্দুগুলো তাদের principal component অনুযায়ী রঙ করে পড়ুন +Reading a granule's files needs an Earthdata Login token, which works for every NASA DAAC. It is kept until RustGIS closes, never in the project, and sent only to NASA's Earthdata data servers. granule-এর file পড়তে Earthdata Login token দরকার, যা NASA-র সব DAAC-এ কাজ করে। RustGIS বন্ধ হওয়া পর্যন্ত এটি রাখা হয়, project-এ নয়, এবং শুধু NASA Earthdata data server-এ পাঠানো হয়। +Reading the catalog: {n} documents read, {m} items found… Catalog পড়া হচ্ছে: {n}টি নথি পড়া হয়েছে, {m}টি item পাওয়া গেছে… +Reading the catalog… Catalog পড়া হচ্ছে… +Reading the embeddings… Embedding পড়া হচ্ছে… +Reading the store… ({n} files to go) Store পড়া হচ্ছে… (আরও {n}টি file বাকি) +Reading the tileset of এর tileset পড়া হচ্ছে +Reading {n} of Overture's {ty} files where the map is: {attribution} মানচিত্র যেখানে আছে, সেখানে Overture-এর {ty} file-এর {n}টি পড়া হচ্ছে: {attribution} +Reading {url}… {url} পড়া হচ্ছে… +Reads and writes files File পড়ে ও লেখে +Reads the map মানচিত্র পড়ে +Reality Mapping রিয়ালিটি ম্যাপিং +Recent সাম্প্রতিক +Record Task Task রেকর্ড করুন +Recorded Task রেকর্ড করা Task +Recording stopped: another project opened, and its {n} steps were for the previous one. রেকর্ডিং থেমেছে: অন্য project খোলা হয়েছে, আর এর {n}টি ধাপ আগের project-এর জন্য ছিল। +Recording: {n} steps রেকর্ড করা হচ্ছে: {n}টি ধাপ +Rectangle আয়তক্ষেত্র +Red লাল +Redo আবার করুন +Redo (Ctrl+Y) আবার করুন (Ctrl+Y) +Redo the change you undid. যে পরিবর্তনটি undo করেছেন সেটি আবার করুন। +Redo {action} {action} আবার করুন +Red–Blue লাল–নীল +Refine the search to see {n} more আরও {n}টি দেখতে অনুসন্ধানটি পরিমার্জন করুন +Refresh Refresh করুন +Refresh every connection's lists প্রতিটি connection-এর তালিকা refresh করুন +Refresh Map Series Map series refresh করুন +Refresh the map series from its index layer. index layer থেকে map series refresh করুন। +Region অঞ্চল +Regional, equal-area: the contiguous United States. আঞ্চলিক, সমক্ষেত্রফল: সংলগ্ন যুক্তরাষ্ট্র। +Regional, equal-area: the European standard for statistics. আঞ্চলিক, সমক্ষেত্রফল: পরিসংখ্যানের জন্য ইউরোপীয় মান। +Relate সম্পর্ক স্থাপন +Related\nData সম্পর্কিত\nডেটা +Related Data সম্পর্কিত ডেটা +Related Report সম্পর্কিত report +Related report: {name} সম্পর্কিত report: {name} +Relationship সম্পর্ক +Relationship name সম্পর্কের নাম +Relationships সম্পর্কসমূহ +Release রিলিজ +Reload পুনরায় লোড করুন +Reload Web Layers Web layer পুনরায় লোড করুন +Remember the key on this computer এই কম্পিউটারে key মনে রাখুন +Remove সরান +Remove All সব সরান +Remove All Label Classes সব label class সরান +Remove an offline area. একটি offline area সরান। +Remove Bookmark Bookmark সরান +Remove Connection Connection সরান +Remove Data Source Data source সরান +Remove every guide from this layout. এই layout থেকে সব guide সরান। +Remove Extent Indicator Extent indicator সরান +Remove Folder Folder সরান +Remove From Current Selection বর্তমান selection থেকে সরান +Remove from Favourites প্রিয় থেকে সরান +Remove From Group Group থেকে সরান +Remove Group Group সরান +Remove Keyframe Keyframe সরান +Remove Label Class Label class সরান +Remove Section Section সরান +Remove Table Table সরান +Remove the clause. Clause সরান। +Remove the last keyframe. শেষ keyframe সরান। +Remove the layer from the map. মানচিত্র থেকে layer সরান। +Remove the strokes and draw the symbol's line alone Stroke সরিয়ে শুধু symbol-এর line আঁকুন +Remove this class's own strokes and draw it with the layer's এই class-এর নিজস্ব stroke সরিয়ে layer-এর stroke দিয়ে আঁকুন +Remove this stop এই stop সরান +Remove Tile Layer Tile layer সরান +Remove token Token সরান +Remove {name} {name} সরান +Removes the parts of polygon features that overlap erase polygons. erase polygon-এর সঙ্গে overlap করা polygon feature-এর অংশ সরিয়ে দেয়। +Removes vertices that deviate less than a tolerance (Douglas–Peucker). tolerance-এর চেয়ে কম বিচ্যুত vertex সরিয়ে দেয় (Douglas–Peucker)। +Rename নাম পরিবর্তন +Rename Bookmark Bookmark-এর নাম পরিবর্তন +Rename Group Group-এর নাম পরিবর্তন +Rename it to save it: a connection has this name সংরক্ষণ করতে এর নাম বদলান: এই নামে connection ইতিমধ্যে আছে +Rename Label Class Label class-এর নাম পরিবর্তন +Rename Layer Layer-এর নাম পরিবর্তন +Rename Layout Layout-এর নাম পরিবর্তন +Rename Map Map-এর নাম পরিবর্তন +Rename Project Project-এর নাম পরিবর্তন +Rename Service Group Service group-এর নাম পরিবর্তন +Rename… নাম পরিবর্তন… +Rendering রেন্ডারিং +Replace Web Layer Web layer প্রতিস্থাপন +Replay the route as a drive the map follows ম্যাপ অনুসরণ করে এমন drive হিসেবে route পুনরায় চালান +Report প্রতিবেদন +Report File Report file +Report Pane Report pane +Report Properties Report-এর বৈশিষ্ট্য +Report Section প্রতিবেদনের অংশ +Report Template প্রতিবেদন টেমপ্লেট +Request ID অনুরোধ ID +Requests go to the OpenAI API and use your ChatGPT plan. Request OpenAI API-তে যায় এবং আপনার ChatGPT প্ল্যান ব্যবহার করে। +Requests send your message and the map's layer names, fields and the rows the assistant reads to the provider. Request-এ আপনার বার্তা, মানচিত্রের layer-এর নাম, field এবং assistant যে row পড়ে সেগুলো provider-কে পাঠানো হয়। +Resample রিস্যাম্পল +Resampling রিস্যাম্পলিং +Resampling Type Resampling-এর ধরন +Reset রিসেট করুন +Reset brightness, contrast and gamma. উজ্জ্বলতা, contrast ও gamma রিসেট করুন। +Reset Default ডিফল্টে রিসেট করুন +Reset Effects Effect রিসেট করুন +Reset North উত্তর দিক রিসেট করুন +Reset north উত্তর দিক রিসেট করুন +Resolution (0–30) রেজোলিউশন (0–30) +Response উত্তর +Restore পুনরুদ্ধার করুন +Restore From Archive আর্কাইভ থেকে পুনরুদ্ধার +Result name ফলাফলের নাম +Results ফলাফল +Resume আবার শুরু করুন +Retry আবার চেষ্টা করুন +Retry after পরে আবার চেষ্টা করুন +Reverse উল্টে দিন +RGB RGB +Ribbon রিবন +Right ডানে +Right-click a source to add it here এখানে যোগ করতে কোনো উৎসে ডান-ক্লিক করুন +Right-click for more আরও বিকল্পের জন্য ডান-ক্লিক করুন +Robinson Robinson +Rotate ঘোরান +Rotate (°) ঘোরান (°) +Rotate 90° ৯০° ঘোরান +Rotate Map মানচিত্র ঘোরান +Rotation ঘূর্ণন +Rotation Type ঘূর্ণনের ধরন +rotation ° ঘূর্ণন ° +Round গোলাকার +roundabout turn roundabout-এ মোড় নিন +Routes (name, surface, length) Route (নাম, পৃষ্ঠ, দৈর্ঘ্য) +Routes by Valhalla (FOSSGIS) on OpenStreetMap data. Travel times don't include live traffic. OpenStreetMap ডেটায় Valhalla (FOSSGIS)-এর route। যাত্রার সময়ে সরাসরি ট্র্যাফিক অন্তর্ভুক্ত নয়। +Rows সারি +Rule name নিয়মের নাম +Rulers Ruler +Run চালান +Run a query whose result has a geometry column যে query-এর ফলাফলে geometry column আছে সেটি চালান +Run All সব চালান +Run All Steps সব ধাপ চালান +Run Cells Cell চালান +Run Chain Chain চালান +Run commands from text, one per line. টেক্সট থেকে প্রতি লাইনে একটি করে command চালান। +Run failed: {error} চালানো ব্যর্থ: {error} +Run finished. চালানো সম্পন্ন। +Run finished. Layers kept: {names}. চালানো সম্পন্ন। রাখা layer: {names}. +Run Model Model চালান +Run Script Script চালান +Run SQL Query SQL query চালান +Run Task Task চালান +Run the model Model চালান +Run Tool Tool চালান +Run {name} {name} চালান +Running the query for the map… মানচিত্রের জন্য query চালানো হচ্ছে… +Running… চলছে… +RustGIS RustGIS +RustGIS bookmarks RustGIS বুকমার্ক +RustGIS couldn't reach the server. RustGIS server-এ পৌঁছাতে পারেনি। +RustGIS deep learning package RustGIS ডিপ লার্নিং প্যাকেজ +RustGIS didn't close properly last time, so it started with the World Atlas instead of your start-up project. Open the project from the Project menu when you're ready. গতবার RustGIS সঠিকভাবে বন্ধ হয়নি, তাই আপনার startup project-এর বদলে World Atlas দিয়ে শুরু হয়েছে। প্রস্তুত হলে Project menu থেকে project খুলুন। +RustGIS doesn't have this tool. Install the tools it comes from, or delete the node. RustGIS-এর কাছে এই tool নেই। যে package থেকে এটি এসেছে তার tool ইনস্টল করুন, অথবা node মুছুন। +RustGIS is a free and open-source GIS written in pure Rust. It runs on the desktop and in the browser, and every action is a command you can also run from the command line. RustGIS হলো Rust-এ লেখা একটি মুক্ত ও open-source GIS। এটি desktop ও browser-এ চলে, এবং প্রতিটি action এমন command যা command line থেকেও চালানো যায়। +RustGIS layer file RustGIS লেয়ার ফাইল +RustGIS layers, maps and projects RustGIS layer, মানচিত্র ও project +RustGIS layout RustGIS লেআউট +RustGIS maps and projects RustGIS মানচিত্র ও project +RustGIS model RustGIS মডেল +RustGIS or Jupyter notebook RustGIS বা Jupyter notebook +RustGIS plugin RustGIS প্লাগইন +RustGIS presentation RustGIS প্রেজেন্টেশন +RustGIS project RustGIS প্রকল্প +RustGIS project template RustGIS প্রকল্প টেমপ্লেট +RustGIS ran into an internal error last time. The details are in {path}; please attach them to a bug report. গতবার RustGIS একটি অভ্যন্তরীণ ত্রুটিতে পড়েছিল। বিবরণ {path}-এ আছে; bug report-এ যুক্ত করুন। +RustGIS report or report template RustGIS report বা report template +RustGIS styles RustGIS স্টাইল +RustGIS task RustGIS টাস্ক +RustGIS {latest} is available. RustGIS {latest} পাওয়া যাচ্ছে। +RustGIS {version} is the latest version. RustGIS {version} সর্বশেষ version। +RUSTGIS_ASSISTANT_MODEL RUSTGIS_ASSISTANT_MODEL +RUSTGIS_ASSISTANT_PROVIDER RUSTGIS_ASSISTANT_PROVIDER +RUSTGIS_GPU RUSTGIS_GPU +S3 S3 +S3 Buckets S3 bucket +S3 Connections S3 সংযোগ +S3… S3… +Sample data (Natural Earth) নমুনা ডেটা (Natural Earth) +Sample data © Natural Earth (public domain). RustGIS is licensed under Apache-2.0. নমুনা ডেটা © Natural Earth (public domain)। RustGIS Apache-2.0 লাইসেন্সে প্রকাশিত। +Sample data: Natural Earth (public domain). Licensed under Apache-2.0. নমুনা ডেটা: Natural Earth (public domain)। Apache-2.0 লাইসেন্সপ্রাপ্ত। +Sans-serif স্যান্স-সেরিফ +Satellite Embedding Datasets স্যাটেলাইট এমবেডিং ডেটাসেট +Satellite Embeddings স্যাটেলাইট এমবেডিং +Satellite Embeddings… স্যাটেলাইট এমবেডিং… +Saturation স্যাচুরেশন +Save সংরক্ষণ করুন +Save As নাম দিয়ে সংরক্ষণ করুন +Save as Connection Connection হিসেবে সংরক্ষণ করুন +Save as Default ডিফল্ট হিসেবে সংরক্ষণ করুন +Save as Default Raster Style ডিফল্ট raster style হিসেবে সংরক্ষণ করুন +Save as Layer Layer হিসেবে সংরক্ষণ করুন +Save As Presentation File… Presentation file হিসেবে সংরক্ষণ করুন… +Save as Python Script… Python script হিসেবে সংরক্ষণ করুন… +Save As Report File… Report file হিসেবে সংরক্ষণ করুন… +Save As Report Template… Report template হিসেবে সংরক্ষণ করুন… +Save as {name} {name} হিসেবে সংরক্ষণ করুন +Save As… নাম দিয়ে সংরক্ষণ করুন… +Save Color Ramps Color ramp সংরক্ষণ করুন +Save Data Source Data source সংরক্ষণ করুন +Save History As Python Script History Python script হিসেবে সংরক্ষণ করুন +Save Layer File Layer file সংরক্ষণ করুন +Save Layout File Layout file সংরক্ষণ করুন +Save Map File Map file সংরক্ষণ করুন +Save Model Model সংরক্ষণ করুন +Save Notebook Notebook সংরক্ষণ করুন +save or load a pane set by name নাম দিয়ে pane set সংরক্ষণ বা লোড করুন +Save Project Project সংরক্ষণ করুন +Save project Project সংরক্ষণ করুন +Save Project As নতুন নামে project সংরক্ষণ করুন +Save Project As… নতুন নামে project সংরক্ষণ করুন… +Save Project Template Project template সংরক্ষণ করুন +Save Style Items Style item সংরক্ষণ করুন +Save Task Item Task item সংরক্ষণ করুন +Save the active map (with its data) as a map file. সক্রিয় মানচিত্র (এর ডেটাসহ) map file হিসেবে সংরক্ষণ করুন। +Save the last task as a task file. সর্বশেষ task task file হিসেবে সংরক্ষণ করুন। +Save the layout as a layout file. Layout layout file হিসেবে সংরক্ষণ করুন। +Save the model as a file (*.model.json) Model file (*.model.json) হিসেবে সংরক্ষণ করুন +Save the model in the project Project-এ model সংরক্ষণ করুন +Save the open layout as a layout file. খোলা layout layout file হিসেবে সংরক্ষণ করুন। +Save the panes you have open, and switch back to them. খোলা pane-গুলো সংরক্ষণ করুন এবং পরে সেগুলোতে ফিরে যান। +Save the project too: the script opens its saved file, without changes made since. Project-ও সংরক্ষণ করুন: script-টি সংরক্ষিত file খুলবে, পরবর্তী পরিবর্তনগুলোসহ নয়। +Save the project under a new name. নতুন নামে project সংরক্ষণ করুন। +Save the project with your edits. আপনার edit-সহ project সংরক্ষণ করুন। +Save the project's custom color ramps to a color ramp file. Project-এর custom color ramp color ramp file-এ সংরক্ষণ করুন। +Save the project's setup, without its features, as a template. Project-এর সেটআপ, feature ছাড়া, template হিসেবে সংরক্ষণ করুন। +Save the project's styles to a style file. Project-এর style style file-এ সংরক্ষণ করুন। +Save the raster as a GeoTIFF. Raster GeoTIFF হিসেবে সংরক্ষণ করুন। +Save the selected layer with its symbology as a layer file. নির্বাচিত layer-এর symbology-সহ layer file হিসেবে সংরক্ষণ করুন। +Save Web Link Chart Web Link Chart সংরক্ষণ করুন +Save Web Map Web map সংরক্ষণ করুন +Save Web Scene Web scene সংরক্ষণ করুন +Saved সংরক্ষিত +Saved as the connection {name} {name} connection হিসেবে সংরক্ষিত +Saved as the default style for new single-band rasters. নতুন single-band raster-এর জন্য ডিফল্ট style হিসেবে সংরক্ষিত। +Saved connection সংরক্ষিত connection +Saved keys are kept unencrypted in the app's preferences (in the browser's storage on the web). সংরক্ষিত key app-এর preference-এ (web-এ browser storage-এ) এনক্রিপশন ছাড়া রাখা হয়। +Saved models সংরক্ষিত model +Saved symbology: apply it to the selected layer, or save the layer's. সংরক্ষিত symbology: নির্বাচিত layer-এ প্রয়োগ করুন অথবা layer-এর symbology সংরক্ষণ করুন। +Saved View সংরক্ষিত view +Saved {name} {name} সংরক্ষিত +Saved {name}. {name} সংরক্ষণ করা হয়েছে। +Saved {path} {path} সংরক্ষিত +saving failed unexpectedly সংরক্ষণ অপ্রত্যাশিতভাবে ব্যর্থ হয়েছে +Scale স্কেল +Scale\nBar Scale\nbar +Scale (×) স্কেল (×) +scale 0.9996 স্কেল 0.9996 +Scale 1: স্কেল 1: +Scale and rotate entered distances and directions to the grid. গ্রিডের তুলনায় দেওয়া দূরত্ব ও দিক scale ও rotate করুন। +Scale ×2 স্কেল ×2 +Scale ×½ স্কেল ×½ +Scene দৃশ্য +Score threshold স্কোর থ্রেশহোল্ড +Score two rasters 1–10 and weight them দুটি raster-কে 1–10 স্কোর দিয়ে weight দিন +Script স্ক্রিপ্ট +Scroll the ribbon left Ribbon বামে scroll করুন +Scroll the ribbon right Ribbon ডানে scroll করুন +Scroll the tabs left Tab বামে scroll করুন +Scroll the tabs right Tab ডানে scroll করুন +Search অনুসন্ধান +Search (this portal needs words)… অনুসন্ধান (এই portal-এ শব্দ দরকার)… +Search Catalogue Catalog অনুসন্ধান +Search commands — e.g. buffer, basemap, labels Command খুঁজুন — যেমন buffer, basemap, label +Search datasets, e.g. GEDI biomass Dataset খুঁজুন, যেমন GEDI biomass +Search Distance অনুসন্ধানের দূরত্ব +Search every command and its parameters প্রতিটি command ও এর parameter খুঁজুন +Search layers (e.g. temperature) Layer খুঁজুন (যেমন temperature) +Search layers, places, lat/lon… Layer, স্থান, lat/lon খুঁজুন… +Search layers… Layer খুঁজুন… +Search NASA Earthdata Collections NASA Earthdata collection খুঁজুন +Search NASA Earthdata Granules NASA Earthdata granule খুঁজুন +Search Open Data Open Data অনুসন্ধান +Search Open Data… Open Data অনুসন্ধান… +Search places for “{query}” “{query}”-এর জন্য স্থান খুঁজুন +Search plugins Plugin খুঁজুন +Search results © OpenStreetMap contributors অনুসন্ধানের ফলাফল © OpenStreetMap contributors +Search Satellite Embeddings Satellite embedding খুঁজুন +Search services and layers… Service ও layer খুঁজুন… +Search services… Service খুঁজুন… +Search the map মানচিত্রে অনুসন্ধান +Search this view এই view-তে অনুসন্ধান +Search tools Tool খুঁজুন +Search where the map is মানচিত্র যেখানে আছে সেখানে অনুসন্ধান করুন +Search {name}… {name}-এ অনুসন্ধান… +Searching places… স্থান খোঁজা হচ্ছে… +Searching the folders… Folder-এ অনুসন্ধান করা হচ্ছে… +Searching works here, but NASA's data servers don't let web pages read their files (no CORS), so GeoTIFFs and downloads of protected data need the desktop app. Download opens a file in a new tab, where Earthdata Login asks you to sign in. এখানে অনুসন্ধান কাজ করে, কিন্তু NASA-র data server web page-কে তাদের file পড়তে দেয় না (CORS নেই), তাই GeoTIFF ও সুরক্ষিত ডেটার download-এর জন্য desktop app দরকার। Download নতুন tab-এ file খোলে, যেখানে Earthdata Login আপনাকে সাইন ইন করতে বলে। +Searching… অনুসন্ধান চলছে… +Searching… try again in a moment. অনুসন্ধান চলছে… কিছুক্ষণ পর আবার চেষ্টা করুন। +Seasonal Mann–Kendall ঋতুভিত্তিক Mann–Kendall +Seasons a year বছরে ঋতুর সংখ্যা +Second standard parallel দ্বিতীয় standard parallel +Seconds সেকেন্ড +Secret access key গোপন access key +Sections Section +See and set the camera's position and scale. ক্যামেরার অবস্থান ও scale দেখুন ও নির্ধারণ করুন। +See the map as people with colour vision deficiencies do. রঙ-দৃষ্টির ঘাটতি থাকা ব্যক্তি যেভাবে দেখেন, সেভাবে মানচিত্র দেখুন। +See the tools you've run. আপনি যে tool চালিয়েছেন সেগুলো দেখুন। +See whether a newer RustGIS has been released. RustGIS-এর নতুন version প্রকাশিত হয়েছে কি না দেখুন। +SELECT SELECT +Select নির্বাচন +Select (click) নির্বাচন (ক্লিক) +Select (rectangle) নির্বাচন (আয়তক্ষেত্র) +Select a direction… একটি দিক নির্বাচন করুন… +Select a feature layer in the Layers pane Layer pane-এ একটি feature layer নির্বাচন করুন +Select a layer in Layers to style it. Style দিতে Layer-এ একটি layer নির্বাচন করুন। +Select a layer in the Layers pane Layer pane-এ একটি layer নির্বাচন করুন +select a layer in the Layers pane first আগে Layer pane-এ একটি layer নির্বাচন করুন +Select a layer in the Layers pane. Layer pane-এ একটি layer নির্বাচন করুন। +Select a navigation method after tracing a point. একটি বিন্দু trace করার পরে navigation পদ্ধতি নির্বাচন করুন। +Select All সব নির্বাচন করুন +Select an element to format it. Format করতে একটি element নির্বাচন করুন। +Select By\nAttributes অ্যাট্রিবিউট অনুসারে\nনির্বাচন +Select By\nLocation অবস্থান অনুসারে\nনির্বাচন +Select By Attributes Attribute অনুসারে নির্বাচন +Select every feature. প্রতিটি feature নির্বাচন করুন। +Select features by their spatial relationship. স্থানিক সম্পর্ক অনুযায়ী feature নির্বাচন করুন। +Select features that intersect another layer. অন্য layer-কে intersect করে এমন feature নির্বাচন করুন। +Select features with a SQL expression. SQL expression দিয়ে feature নির্বাচন করুন। +Select Layer Layer নির্বাচন করুন +Select Layer By Attribute Attribute অনুযায়ী layer নির্বাচন +Select Layer By Location অবস্থান অনুযায়ী layer নির্বাচন +Select one text on the map, or a layout element, to format it. Format করতে মানচিত্রের একটি text বা layout element নির্বাচন করুন। +Select Related সম্পর্কিত নির্বাচন করুন +Select Rows সারি নির্বাচন করুন +Select rows with a SQL expression. SQL expression দিয়ে সারি নির্বাচন করুন। +Select Subset From Current Selection বর্তমান নির্বাচনের মধ্যে subset নির্বাচন করুন +Select the rows related to the selection. নির্বাচনের সঙ্গে সম্পর্কিত সারি নির্বাচন করুন। +Selectable নির্বাচনযোগ্য +Selectable Layers নির্বাচনযোগ্য layer +Selectable on the map: the Select tool picks its features মানচিত্রে নির্বাচনযোগ্য: Select tool এর feature বেছে নেয় +Selected নির্বাচিত +Selected Features: {n} নির্বাচিত feature: {n} +Selected in Layers Layer-এ নির্বাচিত +Selected only শুধু নির্বাচিত +Selected records only শুধু নির্বাচিত record +Selecting Features Feature নির্বাচন +Selection নির্বাচন +Selection Type নির্বাচনের ধরন +Selects features that intersect features in another layer. অন্য layer-এর feature-কে intersect করে এমন feature নির্বাচন করে। +Selects features with a SQL where clause, e.g. pop_est > 1000000. SQL where clause দিয়ে feature নির্বাচন করে, যেমন pop_est > 1000000। +Semicolon সেমিকোলন +Send পাঠান +Send offline edits back and refresh. অফলাইন edit পাঠিয়ে refresh করুন। +Sent as যে হিসাবে পাঠানো হয়েছে +Sentinel-2 Explorer Sentinel-2 Explorer +Sentinel-2 Explorer… Sentinel-2 Explorer… +Sentinel-2 scenes (Collection 1, Level-2A) from Earth Search, clearest first: add the true colour image (visual) or a band. Earth Search-এর Sentinel-2 scene (Collection 1, Level-2A), সবচেয়ে পরিষ্কার আগে: natural colour image (visual) অথবা একটি band যোগ করুন। +Separator বিভাজক +Sepia সেপিয়া +Sequential ক্রমিক +Serif সেরিফ +Server সার্ভার +Service Group সার্ভিস গ্রুপ +Service group data exceeds 64 MB. Service group-এর ডেটা 64 MB ছাড়িয়ে গেছে। +Service Import Requests সার্ভিস ইমপোর্ট অনুরোধ +Service Layers সার্ভিস লেয়ার +Session token সেশন টোকেন +Set সেট করুন +Set Cell Size Cell size সেট করুন +Set Domain Domain সেট করুন +Set Filter Filter সেট করুন +Set Geometry Geometry সেট করুন +Set Map Tool Map tool সেট করুন +Set Scale Scale সেট করুন +Set Subtypes Subtype সেট করুন +Set the pixels inside a polygon একটি polygon-এর ভেতরের pixel নির্ধারণ করুন +Set the pixels inside a polygon. একটি polygon-এর ভেতরের pixel নির্ধারণ করুন। +Settings সেটিংস +Shade walls by the way they face the light. আলোর দিকে মুখ করার ভিত্তিতে দেয়াল ছায়ায় দেখান। +Shaded ছায়াযুক্ত +Shaded Relief ছায়াযুক্ত relief +Shape আকৃতি +Shapefile Shapefile +Shapes আকৃতি +Share শেয়ার করুন +Share As এভাবে শেয়ার করুন +Share every layer and table as OGC API Features collections. প্রতিটি layer ও table OGC API Features collection হিসেবে শেয়ার করুন। +Share the last model as a tool file. সর্বশেষ model tool file হিসেবে শেয়ার করুন। +Share the map as a web page that needs no server. server ছাড়া চলবে এমন web page হিসেবে মানচিত্র শেয়ার করুন। +Share the map in 3D (glTF), polygons extruded. polygon extrude করে মানচিত্র 3D (glTF)-এ শেয়ার করুন। +Share the map's relationships as a link chart page. মানচিত্রের relationship link-chart page হিসেবে শেয়ার করুন। +Share the selected layer as an OGC API Features collection with its style. নির্বাচিত layer-কে তার style-সহ OGC API Features collection হিসেবে শেয়ার করুন। +Share the selected layer's names as a place-search locator. নির্বাচিত layer-এর নাম place-search locator হিসেবে শেয়ার করুন। +Share the selected layer's symbology as a MapLibre style. নির্বাচিত layer-এর symbology MapLibre style হিসেবে শেয়ার করুন। +Shared শেয়ার করা +Shared Items শেয়ার করা item +Shares made, with Manage tools to update them. তৈরি করা share, আপডেট করার জন্য Manage tool-সহ। +sharp left তীব্র বাঁক বামে +sharp right তীব্র বাঁক ডানে +Shift+ Shift+ +Shift+drag Shift+টানুন +Shift+Enter starts a new line. Shift+Enter নতুন লাইন শুরু করে। +Shifts the stroke sideways: positive to the right of the line's direction, negative to the left stroke পাশের দিকে সরায়: ধনাত্মক মান line-এর দিকের ডানে, ঋণাত্মক মান বামে +Shortcuts শর্টকাট +Shortest route over a line network line network-এ সবচেয়ে ছোট route +Show দেখান +Show a second map beside this one. এর পাশে দ্বিতীয় একটি মানচিত্র দেখান। +Show all values সব মান দেখান +Show details when hovering items in the Browser view. Browser view-এর item-এর ওপর mouse রাখলে বিবরণ দেখান। +Show different features at different scales. বিভিন্ন scale-এ বিভিন্ন feature দেখান। +Show grid Grid দেখান +Show Group Group দেখান +Show labels Label দেখান +Show latitude/longitude lines on the basemap. Basemap-এ অক্ষাংশ/দ্রাঘিমাংশের রেখা দেখান। +Show Layers Layer দেখান +Show layers through time: only the features whose date falls in the slider's window, and dated imagery for its date. সময়ের সঙ্গে layer দেখান: শুধু slider-এর window-এ পড়ে এমন তারিখের feature এবং slider-এর তারিখের imagery। +Show Map মানচিত্র দেখান +Show Me আমাকে দেখান +Show or hide দেখান বা লুকান +Show or hide the AI Assistant. AI Assistant দেখান বা লুকান। +Show or hide the Bookmarks pane: every map's bookmarks. Bookmark pane দেখান বা লুকান: প্রতিটি মানচিত্রের bookmark। +Show or hide the Browser pane. Browser pane দেখান বা লুকান। +Show or hide the Data Sources pane: favourites, recent data, saved services, databases and STAC APIs, and a library of public sources. Data Sources pane দেখান বা লুকান: পছন্দের উৎস, সাম্প্রতিক ডেটা, সংরক্ষিত service, database, STAC API এবং public source-এর library। +Show or hide the group's layers Group-এর layer দেখান বা লুকান +Show or hide the layer. Layer দেখান বা লুকান। +Show or hide the Layers pane. Layer pane দেখান বা লুকান। +Show or hide the Tools pane. Tools pane দেখান বা লুকান। +Show rulers. Drag from a ruler to add a guide; drag a guide back onto it to remove it. Ruler দেখান। guide যোগ করতে ruler থেকে টানুন; সরাতে guide-টিকে ruler-এর ওপর ফিরিয়ে টানুন। +Show the bands picked, read in place as the map needs them নির্বাচিত band দেখান; map-এর প্রয়োজন অনুযায়ী একই জায়গায় পড়া হবে +Show the description বর্ণনা দেখান +Show the Labeling pane for the selected layer. নির্বাচিত layer-এর জন্য Labeling pane দেখান। +Show the labels that don't fit, in red. জায়গা না পাওয়া label লাল রঙে দেখান। +Show the layer only inside another layer's polygons. অন্য layer-এর polygon-এর ভেতরেই শুধু layer দেখান। +Show the layer only left of a divider, to compare it with what's beneath. নিচের বিষয়বস্তুর সঙ্গে তুলনা করতে divider-এর বাম দিকে শুধু layer দেখান। +Show the map as a 3D globe, or as a flat map again. মানচিত্রকে 3D globe হিসেবে দেখান, অথবা আবার সমতল মানচিত্রে ফিরুন। +Show the next map series page. Map series-এর পরের পৃষ্ঠা দেখান। +Show the pointer's place on the other map. অন্য মানচিত্রে pointer-এর অবস্থান দেখান। +Show the previous map series page. Map series-এর আগের পৃষ্ঠা দেখান। +Show the Symbology pane for the selected layer. নির্বাচিত layer-এর Symbology pane দেখান। +Show the whole model সম্পূর্ণ model দেখান +Show these bands and stretch on the AlphaEarth layers on the map মানচিত্রের AlphaEarth layer-এ এই band ও stretch দেখান +Show vertices Vertex দেখান +Show where this step's command is, to do it yourself এই ধাপের command কোথায় আছে দেখান, যাতে আপনি নিজে করতে পারেন +Show {n} Steps {n}টি ধাপ দেখান +showing everything through {to} {to} পর্যন্ত সব দেখানো হচ্ছে +showing {date} {date} দেখানো হচ্ছে +showing {from} to {to} {from} থেকে {to} পর্যন্ত দেখানো হচ্ছে +Showing {n} datasets. {n}টি dataset দেখানো হচ্ছে। +Showing {n} granules, newest first. {n}টি granule দেখানো হচ্ছে, নতুনগুলো আগে। +Showing {n} items {n}টি item দেখানো হচ্ছে +Showing {n} items (more match: narrow the search) {n}টি item দেখানো হচ্ছে (আরও মিল আছে: অনুসন্ধান সীমিত করুন) +Showing {n} items (some catalogs were too deep or too large to read: pick a catalog further down) {n}টি item দেখানো হচ্ছে (কিছু catalog পড়ার জন্য অতিরিক্ত গভীর বা বড় ছিল: নিচের দিকে থাকা catalog বেছে নিন) +Showing {n} items (stopped after reading {r} documents: pick a catalog further down) {n}টি item দেখানো হচ্ছে ({r}টি নথি পড়ার পর থেমেছে: নিচের দিকে থাকা catalog বেছে নিন) +Showing {n} items ({f} documents couldn't be read) {n}টি item দেখানো হচ্ছে ({f}টি নথি পড়া যায়নি) +Showing {n} of {m} {m}-এর মধ্যে {n} দেখানো হচ্ছে +Showing {n} of {m} items (narrow the search for others) {m}-এর মধ্যে {n}টি item দেখানো হচ্ছে (অন্যগুলো পেতে অনুসন্ধান সীমিত করুন) +Showing {n} of {total} on {portal} {portal}-এ মোট {total}-এর মধ্যে {n}টি দেখানো হচ্ছে +Side by Side পাশাপাশি +Side labels পাশের label +Sign in to choose a model. Model বেছে নিতে সাইন ইন করুন। +Sign in with your ChatGPT account. আপনার ChatGPT account দিয়ে সাইন ইন করুন। +Sign out সাইন আউট +Sign-in সাইন ইন +Sign-in cancelled. সাইন-ইন বাতিল হয়েছে। +Sign-in timed out. Start again and allow the browser to reach this computer's loopback address. সাইন-ইনের সময় শেষ। আবার শুরু করুন এবং browser-কে এই কম্পিউটারের loopback address-এ পৌঁছাতে দিন। +signed in সাইন ইন করা +Signed out on this computer, but OpenAI didn't confirm it. You can disconnect RustGIS in ChatGPT's settings. এই কম্পিউটারে সাইন আউট হয়েছে, কিন্তু OpenAI নিশ্চিত করেনি। ChatGPT-এর setting থেকে RustGIS সংযোগ বিচ্ছিন্ন করতে পারেন। +Significance level তাৎপর্য স্তর +Signing in with ChatGPT works in the desktop app. ChatGPT দিয়ে সাইন ইন desktop app-এ কাজ করে। +Signing the asset (Planetary Computer)… Asset-এ স্বাক্ষর করা হচ্ছে (Planetary Computer)… +Sill সিল +Simplify সরল করুন +Simulate the drive, or save the route as a layer. যাত্রা simulate করুন, অথবা route layer হিসেবে সংরক্ষণ করুন। +Simulation সিমুলেশন +Single line একক লাইন +Single Symbol একক প্রতীক +Size আকার +Size, bands, statistics and coordinate system. আকার, band, পরিসংখ্যান ও coordinate system। +Skill (SKILL.md or .zip) Skill (SKILL.md বা .zip) +Skills স্কিলসমূহ +Skills for the AI Assistant: {names} AI Assistant-এর জন্য skill: {names} +Slideshow, PDF or PNG Slideshow, PDF বা PNG +slight left সামান্য বাঁক বামে +slight right সামান্য বাঁক ডানে +Slope per প্রতি-তে ঢাল +Slope, aspect, hillshade, NDVI, band math, thresholds. ঢাল, aspect, hillshade, NDVI, band math, threshold। +Slope, hillshade, NDVI and more. ঢাল, hillshade, NDVI এবং আরও অনেক কিছু। +Small scales ছোট scale +Snap স্ন্যাপ +Snap elements to guides, the page and each other as you move or resize them. Hold Alt to place one freely. সরানো বা আকার বদলানোর সময় element-গুলোকে guide, page ও পরস্পরের সঙ্গে snap করুন। অবাধে বসাতে Alt ধরে রাখুন। +Snap new vertices to nearby vertices, edges and line ends. নতুন vertex-কে কাছের vertex, edge ও line-এর শেষ প্রান্তে snap করুন। +Snap Point বিন্দু snap করুন +Snapping স্ন্যাপিং +Snow তুষার +Socrata Socrata +Source উৎস +Source Cooperative Source Cooperative +Source Cooperative… Source Cooperative… +South দক্ষিণ +South Polar Stereographic দক্ষিণ মেরু স্টেরিওগ্রাফিক +Spatial Join স্থানিক Join +Speaker notes বক্তার নোট +Spectral Analysis বর্ণালি বিশ্লেষণ +Spectral indices বর্ণালি সূচক +Speed Display গতি প্রদর্শন +Split ভাগ করুন +Split (click the line) ভাগ করুন (line-এ ক্লিক) +SQL SQL +SQL Pane SQL pane +SQL queries run through DuckDB, a program the desktop app calls: open this project in the desktop app to query it. SQL query DuckDB-এর মাধ্যমে চলে, যা desktop app চালায়: query করার জন্য এই project desktop app-এ খুলুন। +SQL Server SQL Server +SQL that must be true, e.g. pop > 0 সত্য হতে হবে এমন SQL, যেমন pop > 0 +Square বর্গ +Squares বর্গসমূহ +ST_Area ST_Area +ST_AsGeoJSON ST_AsGeoJSON +ST_AsText ST_AsText +ST_Buffer ST_Buffer +ST_Centroid ST_Centroid +ST_Contains ST_Contains +ST_ConvexHull ST_ConvexHull +ST_Difference ST_Difference +ST_Distance ST_Distance +ST_Envelope ST_Envelope +ST_GeometryType ST_GeometryType +ST_GeomFromGeoJSON ST_GeomFromGeoJSON +ST_GeomFromText ST_GeomFromText +ST_Intersection ST_Intersection +ST_Intersects ST_Intersects +ST_IsValid ST_IsValid +ST_Length ST_Length +ST_MakeLine ST_MakeLine +ST_MakePolygon ST_MakePolygon +ST_NPoints ST_NPoints +ST_Perimeter ST_Perimeter +ST_Point ST_Point +ST_Read ST_Read +ST_SetCRS ST_SetCRS +ST_Simplify ST_Simplify +ST_Touches ST_Touches +ST_Transform ST_Transform +ST_Union ST_Union +ST_Union_Agg ST_Union_Agg +ST_Within ST_Within +ST_X ST_X +ST_XMax ST_XMax +ST_XMin ST_XMin +ST_Y ST_Y +ST_YMax ST_YMax +ST_YMin ST_YMin +STAC API STAC API +STAC APIs STAC APIসমূহ +STAC catalog STAC ক্যাটালগ +STAC Search STAC অনুসন্ধান +STAC Search… STAC অনুসন্ধান… +Stack another stroke on top উপরে আরেকটি stroke রাখুন +Stack on the current basemaps বর্তমান basemap-এর ওপর স্তূপ করুন +Standalone Tables স্বতন্ত্র table +Standard Deviation প্রমিত বিচ্যুতি +Star তারা +Stars তারা +Start শুরু +Start an empty model খালি model শুরু করুন +Start date শুরুর তারিখ +Start Over আবার শুরু করুন +Start over at the end while playing. চালানোর সময় শেষে পৌঁছে আবার শুরু করুন। +Start With দিয়ে শুরু করুন +Start-up শুরু +starting with a project file needs the desktop app project file দিয়ে শুরু করতে desktop app দরকার +Statistic পরিসংখ্যান +Statistics পরিসংখ্যান +Statistics Field পরিসংখ্যানের field +Status অবস্থা +Status Bar Status bar +Std. Dev. প্রমিত বিচ্যুতি +Step ধাপ +Step back এক ধাপ পেছনে +Step forward এক ধাপ সামনে +Step through a task. একটি task ধাপে ধাপে অনুসরণ করুন। +Step Time ধাপের সময় +Step {n} done ধাপ {n} সম্পন্ন +Stepped line সিঁড়ি-আকৃতির line +Stop থামুন +Stop drawing labels, for faster navigation. দ্রুত নেভিগেশনের জন্য label আঁকা বন্ধ করুন। +Stop filling the cache. Cache পূরণ বন্ধ করুন। +Stop MCP relay MCP relay বন্ধ করুন +Stop Recording রেকর্ডিং বন্ধ করুন +Stopped after {steps} steps; say "continue" to go on. {steps} ধাপ পরে থেমেছে; চালিয়ে যেতে "continue" বলুন। +Stopped. থেমেছে। +street, city, country রাস্তা, শহর, দেশ +Stretch স্ট্রেচ +Stretch\nType Stretch-এর\nধরন +Stretch it over the map view, or give control points (one per line: column row lon lat). মানচিত্রের view জুড়ে stretch করুন অথবা control point দিন (প্রতি লাইনে একটি: column row lon lat)। +Stretch Type Stretch-এর ধরন +Stretch type Stretch-এর ধরন +Stroke Levels Stroke স্তর +Stroke {n} স্ট্রোক {n} +Strokes Stroke +Strokes for এর জন্য stroke +Style স্টাইল +Style Items Style item +Style › Drawing স্টাইল › অঙ্কন +Styles Style +Subreport সাবরিপোর্ট +Subreport: {name} সাবরিপোর্ট: {name} +Subtypes Subtype +Subtypes, one per line: code=name প্রতি লাইনে একটি subtype: code=name +Suitability Modeler উপযোগিতা modeler +Summarise সারসংক্ষেপ করুন +Summarise over time সময়ের সঙ্গে সারসংক্ষেপ +Summarise the conversation so far, so a long chat stays within the model's context দীর্ঘ chat-কে model-এর context-এর মধ্যে রাখতে এ পর্যন্ত কথোপকথন সারসংক্ষেপ করুন +Summarise Zarr Over Time সময়ের সঙ্গে Zarr-এর সারসংক্ষেপ +Summarising the earlier conversation to keep it within the model's context… model-এর context-এর মধ্যে রাখতে আগের কথোপকথন সারসংক্ষেপ করা হচ্ছে… +Summarize সারসংক্ষেপ +Summarize\nWithin \nভেতরে সারসংক্ষেপ +Summarize Within ভেতরে সারসংক্ষেপ +Summary\nStatistics সারসংক্ষেপ\nপরিসংখ্যান +Summary Statistics সারসংক্ষেপ পরিসংখ্যান +Supplemental Page অতিরিক্ত পৃষ্ঠা +Supplemental Page After এর পরে অতিরিক্ত পৃষ্ঠা +Surface পৃষ্ঠ +Surface: {layer} পৃষ্ঠ: {layer} +Swap বদল করুন +Swap the start and the destination শুরু ও গন্তব্য বদল করুন +Swipe সোয়াইপ +Switch বদল করুন +Switch between GPU and CPU map rendering GPU ও CPU map rendering-এর মধ্যে বদল করুন +Switch between light and dark themes. হালকা ও গাঢ় theme-এর মধ্যে বদল করুন। +Switch Selection নির্বাচন বদল করুন +Switch selection নির্বাচন বদল করুন +Switch to a dark basemap গাঢ় basemap-এ বদল করুন +Symbol প্রতীক +Symbol Color প্রতীকের রং +Symbology প্রতীকায়ন +Symbology Pane প্রতীকায়ন প্যান +Symbology Pane… প্রতীকায়ন প্যান… +Sync সিঙ্ক করুন +Tab ট্যাব +tab is required tab আবশ্যক +Table টেবিল +Table Frame Table frame +Table or layer Table বা layer +Tables Table-সমূহ +Tags ট্যাগসমূহ +Take the data in view offline, to edit and sync back later. দৃশ্যমান ডেটা offline-এ নিন, পরে সম্পাদনা ও sync back করার জন্য। +Target Features গন্তব্য feature +Target Records গন্তব্য record +Task কাজ +Task Item Task item +Task name Task-এর নাম +Tasks Task-সমূহ +Tasks: guided steps that each run a command. Task: নির্দেশিত ধাপ, প্রতিটি একটি command চালায়। +Template name Template-এর নাম +Templates Template +Tennessee GIS Tennessee GIS +Tennessee GIS… Tennessee GIS… +Tennessee's public GIS data (Tennessee GeoData, geodata.tn.gov): property boundaries, transportation, health, hydrology and more. Tennessee-এর public GIS data (Tennessee GeoData, geodata.tn.gov): সম্পত্তির সীমানা, পরিবহন, স্বাস্থ্য, জলবিদ্যা এবং আরও অনেক কিছু। +Terrain ভূপ্রকৃতি +Test পরীক্ষা +Test Connection Connection পরীক্ষা করুন +Testing… পরীক্ষা করা হচ্ছে… +Text টেক্সট +Text Symbol Text symbol +That connection would create a loop. এই সংযোগ একটি loop তৈরি করবে। +The address fields are empty in every row. প্রতিটি সারিতে ঠিকানার field ফাঁকা। +The answer isn't UTF-8 text উত্তরটি UTF-8 text নয় +the answer isn't UTF-8 text উত্তরটি UTF-8 text নয় +The assistant follows a skill's instructions when it uses it. Whatever they say, its commands that need your approval still ask. Assistant কোনো skill ব্যবহার করলে তার নির্দেশনা অনুসরণ করে। নির্দেশনায় যা-ই থাকুক, অনুমোদন দরকার এমন command তবুও অনুমতি চাইবে। +The assistant is busy; stop it or wait for it to finish. Assistant ব্যস্ত; থামান অথবা শেষ হওয়া পর্যন্ত অপেক্ষা করুন। +The assistant wants to run this, and {why}: Assistant এটি চালাতে চায়, এবং {why}: +The Basemaps plugin's tab of public basemaps. Basemaps plugin-এর public basemap tab। +the capture failed unexpectedly ক্যাপচার অপ্রত্যাশিতভাবে ব্যর্থ হয়েছে +The connection failed; trying again in {seconds} s. সংযোগ ব্যর্থ; {seconds} সেকেন্ড পরে আবার চেষ্টা করা হবে। +The country list couldn't be read ({e}): type a country's code দেশের তালিকা পড়া যায়নি ({e}): একটি দেশের code লিখুন +The current elevation stays in effect while you edit inputs. Click Apply elevation to apply the value and units. input সম্পাদনা করার সময় বর্তমান elevation কার্যকর থাকে। মান ও একক প্রয়োগ করতে Apply elevation-এ ক্লিক করুন। +The dataset has nothing RustGIS can add Dataset-এ RustGIS যোগ করতে পারে এমন কিছু নেই +The environment (AWS_ACCESS_KEY_ID…) এনভায়রনমেন্ট (AWS_ACCESS_KEY_ID…) +The features where the map is মানচিত্র যেখানে আছে, সেখানকার feature +The first 1,000 entries; narrow it down a folder at a time. প্রথম ১,০০০টি entry; একবারে একটি folder ধরে অনুসন্ধান সীমিত করুন। +The first {n} are listed. প্রথম {n}টি তালিকাভুক্ত। +The first {n} are shown: search to narrow them. প্রথম {n}টি দেখানো হয়েছে: তালিকা সীমিত করতে অনুসন্ধান করুন। +The first {n} rows প্রথম {n}টি সারি +The flowtrace didn't answer: the whole flowline nearest the point is shown. flowtrace উত্তর দেয়নি: বিন্দুর সবচেয়ে কাছের সম্পূর্ণ flowline দেখানো হচ্ছে। +The installed GeoLibre runner lists no tools. ইনস্টল করা GeoLibre runner কোনো tool তালিকাভুক্ত করেনি। +The job covers the map view Job map view জুড়ে রয়েছে +The key is kept for this session only. key শুধু এই session-এর জন্য রাখা হয়। +The layer shows the step the time slider is at, and turns the slider on over the variable's dates. layer সময়ের slider যে ধাপে আছে তা দেখায় এবং variable-এর তারিখজুড়ে slider চালু করে। +The layer's file couldn't be read: {error} layer-এর file পড়া যায়নি: {error} +The layer's strokes Layer-এর stroke +the layout export failed unexpectedly layout export অপ্রত্যাশিতভাবে ব্যর্থ হয়েছে +The link doesn't say its STAC API: Find Items searches {api} link-এ STAC API উল্লেখ নেই: Find Items {api}-তে অনুসন্ধান করে +The list of plugins to browse. Leave it empty for the RustGIS plugin registry. ব্রাউজ করার plugin-এর তালিকা। RustGIS plugin registry ব্যবহার করতে ফাঁকা রাখুন। +the map has no layers মানচিত্রে কোনো layer নেই +The map is drawn in this coordinate system; layers are projected on the fly. মানচিত্র এই coordinate system-এ আঁকা হয়; layer-গুলো তাৎক্ষণিকভাবে project করা হয়। +the map isn't on screen in RustGIS (a layout or the catalog is shown), so it was drawn offscreen মানচিত্রটি RustGIS-এর পর্দায় নেই (layout বা catalog দেখা যাচ্ছে), তাই এটি পর্দার বাইরে আঁকা হয়েছে +The map view as it is now এখনকার মানচিত্রের view +The map's extent isn't known yet মানচিত্রের extent এখনও জানা যায়নি +The map's view takes {n} of Overture's {ty} files (up to {max}): zoom in, or add the theme as map tiles মানচিত্রের view-তে Overture-এর {ty} file-এর {n}টি আছে (সর্বোচ্চ {max}): zoom in করুন অথবা theme-টি map tile হিসেবে যোগ করুন +the MCP relay couldn't start MCP relay শুরু করা যায়নি +The MCP relay couldn't start; agents can still use the file bridge. Click to turn agents off MCP relay শুরু করা যায়নি; agent-গুলো তবু file bridge ব্যবহার করতে পারে। Agent বন্ধ করতে ক্লিক করুন +The model answering; click to change it উত্তর দেওয়া model; বদলাতে ক্লিক করুন +The model declined this request. Model এই অনুরোধ প্রত্যাখ্যান করেছে। +The model declined this request: {why} Model এই অনুরোধ প্রত্যাখ্যান করেছে: {why} +The model is ready to run. Model চালানোর জন্য প্রস্তুত। +The model sent an empty reply; ask again, or try another model. Model ফাঁকা উত্তর দিয়েছে; আবার জিজ্ঞেস করুন বা অন্য model চেষ্টা করুন। +The National Map The National Map +The National Map… The National Map… +the NLDI's answer isn't UTF-8 text NLDI-এর উত্তর UTF-8 text নয় +the page is too large to draw পৃষ্ঠা আঁকার জন্য খুব বড় +the picture is too large to show ছবিটি দেখানোর জন্য খুব বড় +the picture is too large to show, or isn't one ছবিটি দেখানোর জন্য খুব বড়, অথবা এটি ছবি নয় +The places leave this device for the public Valhalla server at {server}, run by FOSSGIS. Don't continue if your project is bound by data privacy or data sovereignty rules. স্থানগুলো এই ডিভাইস ছেড়ে FOSSGIS-চালিত {server} public Valhalla server-এ যাবে। আপনার project-এ data privacy বা sovereignty-র নিয়ম থাকলে এগোবেন না। +the presentation export failed unexpectedly presentation export অপ্রত্যাশিতভাবে ব্যর্থ হয়েছে +the preview failed unexpectedly preview অপ্রত্যাশিতভাবে ব্যর্থ হয়েছে +The project downloads as this file, to the folder your browser saves downloads in. Project এই file হিসেবে download হবে, আপনার browser-এর download folder-এ। +The project's items in place of the map. মানচিত্রের বদলে project-এর item-গুলো। +The project's thumbnail, from an image. ছবি থেকে নেওয়া project-এর thumbnail। +The project's thumbnail, from the map view. মানচিত্রের view থেকে নেওয়া project-এর thumbnail। +The projection the map opens in. মানচিত্র যে projection-এ খুলবে। +The provider's content filter stopped the reply. Provider-এর content filter উত্তর থামিয়েছে। +The public server is free and shared by many: it answers light use, and may ask you to wait. Public server বিনামূল্যে এবং অনেকের সঙ্গে ভাগ করা: এটি হালকা ব্যবহারের উত্তর দেয়, অপেক্ষা করতেও বলতে পারে। +The range is two numbers, low and high (0, 0.9) Range-এ দুটি সংখ্যা থাকে, নিম্ন ও উচ্চ (0, 0.9) +The raster's data couldn't be read. Raster-এর data পড়া যায়নি। +The raster's data couldn't be read: {path} Raster-এর data পড়া যায়নি: {path} +the release isn't known Release জানা নেই +The reply reached its length limit. উত্তরটি দৈর্ঘ্যের সীমায় পৌঁছেছে। +The reply stopped before completion; no actions were run. উত্তর সম্পূর্ণ হওয়ার আগে থেমেছে; কোনো action চালানো হয়নি। +The reply stopped early ({reason}). উত্তরটি আগেই থেমেছে ({reason})। +the report export failed unexpectedly Report export অপ্রত্যাশিতভাবে ব্যর্থ হয়েছে +The request failed. অনুরোধ ব্যর্থ হয়েছে। +The result wired into this output is added to the map under this name. এই output-এ সংযুক্ত ফলাফলটি এই নামে মানচিত্রে যোগ হবে। +The saved sign-in changed. Select your account again. সংরক্ষিত সাইন-ইন বদলেছে। আবার account বেছে নিন। +The server had a problem answering. উত্তর দিতে server-এর সমস্যা হয়েছে। +The server is limiting requests. Server request সীমিত করছে। +The server refused the request. Server অনুরোধ প্রত্যাখ্যান করেছে। +The server took too long to answer. উত্তর দিতে server-এর খুব বেশি সময় লেগেছে। +The service may be private or need a token. Service-টি private হতে পারে অথবা token লাগতে পারে। +The service needs a sign-in or a token. Service-এর জন্য সাইন-ইন বা token দরকার। +The service's answer isn't UTF-8 text Service-এর উত্তর UTF-8 text নয় +The Shapefile's files download as one zip. Shapefile-এর file-গুলো একটি zip হিসেবে download হয়। +The share of each cell covered by fields, for the zoomed-out view ছোট করে দেখা view-এর জন্য প্রতিটি cell-এর যে অংশ কৃষিজমি দিয়ে ঢাকা +The sign-in expired or was revoked. Sign in again. সাইন-ইনের মেয়াদ শেষ হয়েছে বা প্রত্যাহার করা হয়েছে। আবার সাইন ইন করুন। +The sign-in is kept in the app's settings folder, for your user account only, and never in a project file. সাইন-ইন app-এর settings folder-এ শুধু আপনার user account-এর জন্য রাখা হয়, কখনো project file-এ নয়। +The significance level is a number, such as 0.05 Significance level একটি সংখ্যা, যেমন 0.05 +The start-up project {path} didn't open: {error} Startup project {path} খোলা যায়নি: {error} +The start-up project {path} isn't a RustGIS project file. Startup project {path} RustGIS project file নয়। +The step's layer {name} isn't in the map. ধাপের layer {name} মানচিত্রে নেই। +The store has no variables with two or more dimensions. Store-এ দুই বা তার বেশি dimension-যুক্ত কোনো variable নেই। +the store is gone Store আর নেই +The summary couldn't start: {error} Summary শুরু করা যায়নি: {error} +The summary failed unexpectedly Summary অপ্রত্যাশিতভাবে ব্যর্থ হয়েছে +The summary finished after another project was opened, so it wasn't added অন্য project খোলার পর summary শেষ হয়েছে, তাই এটি যোগ করা হয়নি +The task's steps changed, so the step being edited was closed: edit it again. Task-এর ধাপ বদলেছে, তাই সম্পাদিত ধাপটি বন্ধ হয়েছে: আবার সম্পাদনা করুন। +the time slider is off time slider বন্ধ +The token expires on {date}. Token-এর মেয়াদ শেষ হবে {date}-এ। +The trend couldn't start: {error} Trend শুরু করা যায়নি: {error} +The trend failed unexpectedly Trend অপ্রত্যাশিতভাবে ব্যর্থ হয়েছে +The trend finished after another project was opened, so it wasn't added অন্য project খোলার পর trend শেষ হয়েছে, তাই এটি যোগ করা হয়নি +The values shown from black to white, the same for every date and tile (the band's own units). Empty: each file's own stretch. কালো থেকে সাদা পর্যন্ত প্রদর্শিত মান, সব তারিখ ও tile-এর জন্য একই (band-এর নিজস্ব একক)। ফাঁকা: প্রতিটি file-এর নিজস্ব stretch। +the view has no extent view-এর extent নেই +The view is larger than one read: its centre was read (zoom in for the rest) View একটি read-এর চেয়ে বড়: এর কেন্দ্র পড়া হয়েছে (বাকিটা দেখতে zoom in করুন) +The web app always runs the latest release: reload the page to get it. Web app সবসময় সর্বশেষ release চালায়: এটি পেতে page reload করুন। +the window's screenshot didn't come back in time, so the map was drawn offscreen window-এর screenshot সময়মতো আসেনি, তাই মানচিত্র পর্দার বাইরে আঁকা হয়েছে +the window's screenshot didn't hold the map, so it was drawn offscreen window-এর screenshot-এ মানচিত্র ছিল না, তাই এটি পর্দার বাইরে আঁকা হয়েছে +Theme থিম +There is no expression defined. কোনো expression নির্ধারিত নেই। +Thermal তাপীয় +Thesis figure, full page (160 × 225 mm) থিসিসের চিত্র, পুরো পৃষ্ঠা (160 × 225 mm) +Thesis figure, half page (160 × 110 mm) থিসিসের চিত্র, অর্ধেক পৃষ্ঠা (160 × 110 mm) +Thinking… ভাবছে… +Third-party licences তৃতীয় পক্ষের লাইসেন্স +THIRD-PARTY-LICENSES.txt THIRD-PARTY-LICENSES.txt +this build can't download plugins এই build plugin download করতে পারে না +this build can't reach AI providers এই build AI provider-এ পৌঁছাতে পারে না +this build saves projects with Save As এই build Save As দিয়ে project সংরক্ষণ করে +This ChatGPT account was already saved. Next time, choose it in the list instead of adding it again. এই ChatGPT account আগে থেকেই সংরক্ষিত। পরেরবার আবার যোগ না করে তালিকা থেকে বেছে নিন। +This class is drawn with the layer's strokes. Changing them here gives it its own. এই class layer-এর stroke দিয়ে আঁকা হয়। এখানে বদলালে এর নিজস্ব stroke হবে। +This class is hidden, so it labels nothing. এই class লুকানো, তাই কোনো label দেয় না। +This clause no longer reads, so no feature is labelled: {error} এই clause আর পড়া যায় না, তাই কোনো feature-এ label নেই: {error} +This map has no layers yet. এই মানচিত্রে এখনো কোনো layer নেই। +This map's thumbnail, from an image. ছবি থেকে নেওয়া এই মানচিত্রের thumbnail। +This map's thumbnail, from the view. view থেকে নেওয়া এই মানচিত্রের thumbnail। +This plugin can: এই plugin পারে: +This plugin runs as a program on your computer, outside RustGIS's sandbox. It can do anything you can: read and change your files, use the network and run other programs. Install it only if you trust its author. এই plugin RustGIS sandbox-এর বাইরে আপনার কম্পিউটারে program হিসেবে চলে। এটি আপনার মতোই সব করতে পারে: file পড়া ও বদলানো, network ব্যবহার এবং অন্য program চালানো। শুধু লেখককে বিশ্বাস করলেই ইনস্টল করুন। +This request has no one-time allow option. এই অনুরোধে একবারের জন্য অনুমতি দেওয়ার option নেই। +This request uses an unsupported capability. Check the parameter and change the request. এই অনুরোধ অসমর্থিত capability ব্যবহার করে। Parameter পরীক্ষা করে অনুরোধ বদলান। +This result is kept এই ফলাফল রাখা হয়েছে +This run has no tool form to open এই run-এ খোলার মতো tool form নেই +This session only (optional) শুধু এই session-এর জন্য (ঐচ্ছিক) +This step এই ধাপ +This step names no tool to show. এই ধাপে দেখানোর জন্য কোনো tool-এর নাম নেই। +Thumbnail থাম্বনেইল +Tick length Tick-এর দৈর্ঘ্য +Ticks Tick +Ticks only শুধু tick +Tile archive টাইল আর্কাইভ +Tile Archive File… টাইল আর্কাইভ ফাইল… +Tile Layer Properties Tile layer-এর বৈশিষ্ট্য +Tile Layers Tile layer +Tilt Map মানচিত্র কাত করুন +Tilt-shift Tilt-shift +Tilt-shift blur and film grain. Tilt-shift blur ও film grain। +Time সময় +Time\nSeries টাইম\nসিরিজ +Time\nSlider টাইম\nস্লাইডার +Time Fields সময়ের field +Time Series Time series +Time series টাইম সিরিজ +Time Series at a Point একটি বিন্দুতে time series +Time Slider Time slider +Time Slider Settings Time slider-এর সেটিংস +Tint by elevation উচ্চতা অনুযায়ী রঙ করুন +Tissot's Indicatrix Tissot-এর indicatrix +Title শিরোনাম +To প্রতি +Toggle Dark Mode Dark mode চালু/বন্ধ করুন +Toggle GPU Rendering GPU rendering চালু/বন্ধ করুন +Toggle Imperial Measurements Imperial measurement চালু/বন্ধ করুন +Tolerance (km) সহনসীমা (কিমি) +Tolls টোল +Tool Gallery টুল গ্যালারি +Toolbox টুলবক্স +Toolboxes Toolbox-সমূহ +Tools Tool +Tools\nPane টুলস\nপ্যান +Tools Pane Tools pane +Tools that analyse features. Feature বিশ্লেষণের tool। +Tools that analyse rasters. Raster বিশ্লেষণের tool। +Tools you run appear here. আপনি যে tool চালান, তা এখানে দেখা যাবে। +Top শীর্ষে +Top visible raster সবচেয়ে ওপরের দৃশ্যমান raster +Topmost Layer সবার ওপরের layer +Topographic and Bathymetric ভূপ্রাকৃতিক ও বাথিমেট্রিক +Topology টপোলজি +Topology Options টপোলজি অপশন +Topology Options… টপোলজি অপশন… +Topology rules and the errors they find. Topology rule এবং সেগুলো যে error খুঁজে পায়। +Total {total} · Segment {segment} মোট {total} · Segment {segment} +Trace ট্রেস করুন +Trace a point first: click the map. আগে একটি বিন্দু trace করুন: মানচিত্রে ক্লিক করুন। +Track jobs and their status. Job ও তার অবস্থা ট্র্যাক করুন। +Train a neural network on training polygons Training polygon দিয়ে neural network train করুন +Train a neural network on training polygons and classify every pixel. Training polygon দিয়ে neural network train করে প্রতিটি pixel শ্রেণিবদ্ধ করুন। +Train And Classify Train ও classify +Train Deep Learning Model Deep learning model train করুন +Trained models প্রশিক্ষিত model +Transparency স্বচ্ছতা +Traverse ট্রাভার্স +Trend Analysis প্রবণতা বিশ্লেষণ +Trend over a series of rasters Raster series জুড়ে trend +Triangle ত্রিভুজ +TRUE TRUE +Try again in a moment. একটু পরে আবার চেষ্টা করুন। +Try again later. পরে আবার চেষ্টা করুন। +Turn editing on or off for the map. মানচিত্রের জন্য editing চালু বা বন্ধ করুন। +Turn Labels Off Label বন্ধ করুন +Turn labels on or off for the selected layer. নির্বাচিত layer-এর label চালু বা বন্ধ করুন। +Turn labels on or off. Label চালু বা বন্ধ করুন। +Turn Off বন্ধ করুন +Turn on the Basemaps plugin Basemaps plugin চালু করুন +Turn the time slider off: every feature shows again. Time slider বন্ধ করুন: সব feature আবার দেখা যাবে। +Turns a table's addresses into points: each row's address fields are joined with commas (such as street, city, country) and looked up. The public servers take up to 100 addresses a run, one a second; a server of your own takes more. একটি table-এর address-কে point-এ রূপান্তর করে: প্রতিটি row-এর address field কমা দিয়ে যুক্ত হয় (যেমন রাস্তা, শহর, দেশ) এবং খোঁজা হয়। Public server প্রতি run-এ সর্বোচ্চ ১০০টি address, সেকেন্ডে একটি, নেয়; নিজের server আরও নিতে পারে। +Two layers are named {name}: the step names it, so it replays on the first of them. Rename one to tell them apart. দুটি layer-এর নাম {name}: ধাপটি নাম ব্যবহার করে, তাই দুটির প্রথমটিতে replay হয়। আলাদা করতে একটির নাম বদলান। +Type ধরন +type a file name ফাইলের নাম লিখুন +type a longitude and latitude, or click the map দ্রাঘিমাংশ ও অক্ষাংশ লিখুন, অথবা মানচিত্রে ক্লিক করুন +Type a scale, such as 1:12,345, 12345 or 25k, and press Enter 1:12,345, 12345 বা 25k-এর মতো scale লিখে Enter চাপুন +Type an address or coordinates and press Enter, or click the map. ঠিকানা বা coordinate লিখে Enter চাপুন, অথবা মানচিত্রে ক্লিক করুন। +Type numbers separated by commas কমা দিয়ে আলাদা করা সংখ্যা লিখুন +Type the keys to log in for this session, then Save. এই session-এ লগ ইন করতে key লিখে Save চাপুন। +Type the password or keys to log in for this session, then Save. এই session-এ লগ ইন করতে password বা key লিখে Save চাপুন। +Typed below নিচে টাইপ করা হয়েছে +UM UM +Unavailable: {reason} অনুপলব্ধ: {reason} +Unbind From Time Slider Time slider থেকে বিচ্ছিন্ন করুন +Unbind from Time Slider Time slider থেকে বিচ্ছিন্ন করুন +Undo পূর্বাবস্থায় ফেরান +Undo (Ctrl+Z) পূর্বাবস্থায় ফেরান (Ctrl+Z) +Undo doesn't remove the recorded step: delete it in Edit Steps once you stop recording. Undo রেকর্ড করা ধাপ সরায় না: রেকর্ডিং বন্ধ করার পর Edit Steps-এ সেটি মুছুন। +Undo the last change. সর্বশেষ পরিবর্তন undo করুন। +Undo {action} {action} undo করুন +Ungroup Group আলাদা করুন +Uninstall আনইনস্টল করুন +Uninstall GeoLibre Tools GeoLibre tool আনইনস্টল করুন +Uninstall Plugin Plugin আনইনস্টল করুন +Uninstalled {id}. {id} আনইনস্টল করা হয়েছে। +Unique Values অনন্য মান +Unit একক +United States যুক্তরাষ্ট্র +Units একক +Units per step প্রতি ধাপে একক +UNKNOWN TOOL অজানা টুল +Unload the GeoLibre tools and stop loading them when RustGIS starts. GeoLibre tool memory থেকে সরিয়ে দিন এবং RustGIS চালুর সময় সেগুলো লোড হওয়া বন্ধ করুন। +Unsaved changes অসংরক্ষিত পরিবর্তন +Unsupervised classes (k-means) অসুপারভাইজড class (k-means) +Unsupervised classes (k-means). অসুপারভাইজড class (k-means)। +Untitled model শিরোনামহীন model +Up ওপরে +Up a level এক স্তর ওপরে +Update আপডেট করুন +Update Bookmark Bookmark আপডেট করুন +Update Job Job আপডেট করুন +Update Label Class Label class আপডেট করুন +Update to {version} {version}-এ আপডেট করুন +Upload File ফাইল আপলোড করুন +Upload File… ফাইল আপলোড করুন… +url is required url আবশ্যক +US EPA EnviroAtlas: maps of ecosystem services, land cover, people and the built environment across the United States. US EPA EnviroAtlas: যুক্তরাষ্ট্রজুড়ে ecosystem service, land cover, মানুষ ও নির্মিত পরিবেশের মানচিত্র। +US Federal GIS যুক্তরাষ্ট্রের ফেডারেল GIS +US Federal GIS… যুক্তরাষ্ট্রের ফেডারেল GIS… +US Local GIS যুক্তরাষ্ট্রের স্থানীয় GIS +US Local GIS… যুক্তরাষ্ট্রের স্থানীয় GIS… +US State GIS যুক্তরাষ্ট্রের রাজ্য GIS +US State GIS… যুক্তরাষ্ট্রের রাজ্য GIS… +USA Contiguous Albers Equal Area সংলগ্ন যুক্তরাষ্ট্রের Albers Equal Area +Use ব্যবহার করুন +Use a Single Stroke একটি stroke ব্যবহার করুন +Use another account অন্য account ব্যবহার করুন +Use Current View বর্তমান view ব্যবহার করুন +Use each feature's line color প্রতিটি feature-এর line-এর রং ব্যবহার করুন +Use each feature's line width প্রতিটি feature-এর line-এর প্রস্থ ব্যবহার করুন +Use the Layer's Strokes Layer-এর stroke ব্যবহার করুন +USERPROFILE USERPROFILE +USGS 3DEP DEM USGS 3DEP DEM +USGS 3DEP DEM… USGS 3DEP DEM… +USGS 3DEP elevation models (GeoTIFF tiles from The National Map) where the map is. মানচিত্র যেখানে আছে, সেখানে USGS 3DEP elevation model (The National Map থেকে GeoTIFF tile)। +USGS 3DEP LiDAR point clouds (COPC) from the Planetary Computer, where the map is: add a tile's data as a point cloud. মানচিত্র যেখানে আছে, সেখানে Planetary Computer থেকে USGS 3DEP LiDAR point cloud (COPC): একটি tile-এর data point cloud হিসেবে যোগ করুন। +USGS Imagery USGS ইমেজারি +USGS LiDAR USGS LiDAR +USGS LiDAR… USGS LiDAR… +USGS NLDI USGS NLDI +USGS NLDI network tools: choose a flowline direction and click the map (or trace the point below). Then get the basin, or choose a navigation method and press Load sources & plot to plot its flowlines, or pick a catalog such as stream gages or wells and plot it too. The NLDI covers the United States' NHDPlus network. USGS NLDI network tool: flowline-এর দিক বেছে মানচিত্রে ক্লিক করুন (অথবা নিচে বিন্দুটি trace করুন)। এরপর অববাহিকা নিন, অথবা navigation পদ্ধতি বেছে Load sources & plot চাপুন flowline আঁকতে; কিংবা stream gage বা well-এর মতো catalog বেছে সেটিও plot করুন। NLDI যুক্তরাষ্ট্রের NHDPlus network কভার করে। +USGS NLDI… USGS NLDI… +USGS The National Map USGS The National Map +USGS The National Map's map services (topographic maps, imagery, elevation, hydrography, boundaries and more) and a few partners'. Each is added as a tile layer. USGS The National Map-এর map service (topographic map, imagery, elevation, hydrography, boundary ও আরও অনেক কিছু) এবং কয়েকটি partner-এর service। প্রতিটি tile layer হিসেবে যোগ হয়। +Using ChatGPT plan ChatGPT প্ল্যান ব্যবহার হচ্ছে +Utilities উপযোগিতা +Valid combinations, one per line: a, b প্রতি লাইনে একটি করে বৈধ সমন্বয়: a, b +Valid expression · {n} matching rows বৈধ expression · {n}টি মেলা সারি +Valid: {n} of {of} features match বৈধ: {of}টি feature-এর মধ্যে {n}টি মিলে গেছে +Validate যাচাই করুন +Validate Model Model যাচাই করুন +value (empty: no-data) মান (ফাঁকা: no-data) +Values from −stretch to +stretch are shown from black to full colour −stretch থেকে +stretch পর্যন্ত মান কালো থেকে পূর্ণ রঙে দেখানো হয় +Vantor Open Data Vantor Open Data +Vantor's open imagery of disasters: pick an event and a phase (before or after it), then Find Items. Vantor-এর উন্মুক্ত দুর্যোগ-ছবি: ঘটনা ও পর্যায় (আগে বা পরে) বেছে নিন, তারপর Find Items। +Vantor… Vantor… +Variable ভেরিয়েবল +Variogram Model ভ্যারিওগ্রাম মডেল +Vector and raster data Vector ও raster data +Vector Layer ভেক্টর লেয়ার +vector tiles Vector tile +Vector tiles (MVT) ভেক্টর টাইল (MVT) +Vectors a geospatial foundation model made for every pixel or image patch, found where the map is and shown as colours. প্রতিটি pixel বা image patch-এর জন্য তৈরি geospatial foundation model-এর vector-গুলো মানচিত্র যেখানে আছে সেখানে পাওয়া যায় এবং রং হিসেবে দেখানো হয়। +Version {version} সংস্করণ {version} +Vertex ভার্টেক্স +Vertical উল্লম্ব +Via {roads} {roads} দিয়ে +Video ভিডিও +Video file or URL ভিডিও file বা URL +Video Page ভিডিও পৃষ্ঠা +Videos ভিডিও +Video… ভিডিও… +Vietnam national coordinate system (VN-2000) using 3-degree Transverse Mercator with a 7-parameter Helmert shift to WGS 84. WGS 84-এ ৭-parameter Helmert shift-সহ ৩-ডিগ্রি Transverse Mercator ব্যবহার করা ভিয়েতনামের জাতীয় coordinate system (VN-2000)। +Vietnam national coordinate system (VN-2000) UTM zone 48N (102°E–108°E, central meridian 105°E). ভিয়েতনামের জাতীয় coordinate system (VN-2000), UTM zone 48N (102°E–108°E, central meridian 105°E)। +Vietnam national coordinate system (VN-2000) UTM zone 49N (108°E–114°E, central meridian 111°E). ভিয়েতনামের জাতীয় coordinate system (VN-2000), UTM zone 49N (108°E–114°E, central meridian 111°E)। +View দেখুন +View Clipping ভিউ ক্লিপিং +View Effects View effect +View Extent View extent +View In Google Earth Google Earth-এ দেখুন +View In Google Maps Google Maps-এ দেখুন +View Unplaced অবস্থানহীনগুলো দেখুন +View › Windows দেখুন › উইন্ডোসমূহ +Viewshed from a point একটি বিন্দু থেকে viewshed +Viewshed {radius} দৃশ্যক্ষেত্র {radius} +Viewshed {radius} Here এখানে viewshed {radius} +Vignette ভিনিয়েট +Vignette, edge outlines and the stars around the globe. Vignette, প্রান্তের outline এবং globe-কে ঘিরে তারা। +Visibility দৃশ্যমানতা +Visibility Analysis দৃশ্যমানতা বিশ্লেষণ +Visibility Range দৃশ্যমানতার range +Visible দৃশ্যমান +Visible Layers দৃশ্যমান layer +Visual Effect দৃশ্যগত প্রভাব +VN-2000 / TM-3 (Vietnam) VN-2000 / TM-3 (Vietnam) +VN-2000 / UTM zone 48N VN-2000 / UTM zone 48N +VN-2000 / UTM zone 49N VN-2000 / UTM zone 49N +Volume আয়তন +Waiting for the browser… Browser-এর জন্য অপেক্ষা করা হচ্ছে… +Waiting for your answer আপনার উত্তরের অপেক্ষায় +Walk হাঁটুন +Want more basemaps? আরও basemap চান? +WCS WCS +WCS Coverage Request WCS কভারেজ অনুরোধ +Weather আবহাওয়া +Weather and climate data from dynamical.org (NOAA GFS, GEFS and HRRR, ECMWF, DWD ICON…) as Zarr stores: open a dataset, pick a variable and its time steps, and add it. dynamical.org (NOAA GFS, GEFS ও HRRR, ECMWF, DWD ICON…) থেকে Zarr store আকারে আবহাওয়া ও জলবায়ুর ডেটা: dataset খুলুন, variable ও তার time step বেছে যোগ করুন। +Web\nData ওয়েব\nডেটা +Web\nMap ওয়েব\nমানচিত্র +Web Basemaps ওয়েব basemap +Web Data ওয়েব ডেটা +Web Layer ওয়েব layer +Web Link Chart ওয়েব link chart +Web maps ওয়েব মানচিত্র +Web Mercator Web Mercator +Web Scene ওয়েব scene +Web service (detected) ওয়েব service (শনাক্ত করা হয়েছে) +Web Style ওয়েব style +Web Tile Layers ওয়েব tile layer +Web Tool ওয়েব tool +Website ওয়েবসাইট +Weekly usage সাপ্তাহিক ব্যবহার +Weights ওজন +Welcome to RustGIS RustGIS-এ স্বাগতম +West পশ্চিম +WFS WFS +WGS 84 / Globe WGS 84 / গ্লোব +WGS 84 / Web Mercator WGS 84 / Web Mercator +What RustGIS opens with: the World Atlas sample or a blank map. RustGIS কী দিয়ে খুলবে: World Atlas sample অথবা ফাঁকা মানচিত্র। +What RustGIS opens with: the World Atlas sample, a blank map, a project you choose, or the last project opened or saved. RustGIS কী দিয়ে খুলবে: World Atlas sample, ফাঁকা মানচিত্র, আপনার বেছে নেওয়া project, অথবা সর্বশেষ খোলা বা সংরক্ষিত project। +What to do, where to find it and why কী করতে হবে, কোথায় পাবেন এবং কেন +What's near a point একটি বিন্দুর কাছে কী আছে +WHERE WHERE +Where যেখানে +Where Clause WHERE ক্লজ +Where labels sit relative to their features. feature-এর তুলনায় label কোথায় থাকবে। +Where new features get their elevation (the z field). নতুন feature কোথা থেকে elevation পাবে (z field)। +Where the map opens: the full extent or a view you saved. মানচিত্র কোথায় খুলবে: সম্পূর্ণ extent বা আপনার সংরক্ষিত view। +Where tools work Tool কোথায় কাজ করবে +Whether the Select tool picks these layers' features on the map Select tool মানচিত্রে এই layer-এর feature বেছে নেবে কি না +Which 5 countries have the largest GDP? কোন ৫টি দেশের GDP সবচেয়ে বড়? +Whitebox and GeoLibre tools Whitebox ও GeoLibre tool +Whole map সম্পূর্ণ মানচিত্র +Whose borders the sample countries and the basemap outline show. নমুনা দেশ ও basemap outline-এ কোন দেশের সীমানা দেখানো হবে। +Wide (±1 step) প্রশস্ত (±১ ধাপ) +Wider (±3 steps) আরও প্রশস্ত (±৩ ধাপ) +Width প্রস্থ +Width (mm) প্রস্থ (mm) +Windows উইন্ডোসমূহ +Winkel Tripel Winkel Tripel +Wireframe ওয়্যারফ্রেম +Withdrawn প্রত্যাহৃত +WKT column WKT কলাম +WMS WMS +WMTS WMTS +Words শব্দ +words in the name নামের শব্দ +words to keep to (optional) যে শব্দগুলোর মধ্যে সীমাবদ্ধ রাখতে হবে (ঐচ্ছিক) +Workflow Manager কর্মপ্রবাহ ব্যবস্থাপক +Workflows কর্মপ্রবাহ +Working কাজ চলছে +Workspace ওয়ার্কস্পেস +Workspace cell size and blending. Workspace-এর cell size ও blending। +Workspace Options Workspace-এর বিকল্প +World পৃথিবী +World Atlas বিশ্ব অ্যাটলাস +World Atlas Sample World Atlas নমুনা +World Countries বিশ্বের দেশসমূহ +World, compromise: low overall distortion, used for world atlases. বিশ্ব, সমঝোতামূলক: সামগ্রিক বিকৃতি কম, বিশ্ব অ্যাটলাসে ব্যবহৃত। +World, compromise: neither equal-area nor conformal, balanced for wall maps. বিশ্ব, সমঝোতামূলক: সমক্ষেত্রফলও নয়, conformal-ও নয়; দেয়াল মানচিত্রের জন্য ভারসাম্যপূর্ণ। +World, compromise: rounded corners, gentle shapes near the poles. বিশ্ব, সমঝোতামূলক: গোলাকার কোণ, মেরুর কাছে কোমল আকৃতি। +World, conformal: the usual web map; areas grow towards the poles. বিশ্ব, conformal: প্রচলিত web map; মেরুর দিকে ক্ষেত্রফল বড় হয়। +World, cylindrical: a compromise between Mercator and equal steps. বিশ্ব, cylindrical: Mercator ও সমান ধাপের মধ্যে সমঝোতা। +World, cylindrical: equal steps of longitude and latitude. বিশ্ব, cylindrical: দ্রাঘিমাংশ ও অক্ষাংশের সমান ধাপ। +World, equal-area, cylindrical: true sizes, flattened near the poles. বিশ্ব, cylindrical equal-area: প্রকৃত আকার, মেরুর কাছে চাপা। +World, equal-area: true sizes with a Robinson-like look. বিশ্ব, সমক্ষেত্রফল: প্রকৃত আকার, Robinson-এর মতো চেহারা। +World, equal-area: true sizes with rounded poles. বিশ্ব, সমক্ষেত্রফল: গোলাকার মেরুসহ প্রকৃত আকার। +World, equal-area: true sizes, shapes bend towards the edges. বিশ্ব, সমক্ষেত্রফল: প্রকৃত আকার, প্রান্তের দিকে আকৃতি বাঁকে। +Worldview বিশ্বদৃশ্য +Write a plugin Plugin লিখুন +Writes Sen's slope, the test's Z and p-value, and the slope where the trend is significant. Sen-এর ঢাল, পরীক্ষার Z ও p-value, এবং trend তাৎপর্যপূর্ণ হলে সেই ঢাল লেখে। +X (mm) X (mm) +X column X কলাম +X Field X field +XY Table To Point XY table থেকে point +XY Table To Point… XY table থেকে point… +Y (mm) Y (mm) +Y column Y কলাম +Y Field Y field +Year বছর +Yes হ্যাঁ +You can undo this with {shortcut}. {shortcut} দিয়ে এটি undo করতে পারেন। +You have version {version}. আপনার version {version}। +You're using your ChatGPT plan আপনি আপনার ChatGPT প্ল্যান ব্যবহার করছেন +Your skills are kept in the skills folder of the app's settings folder. Shared skills (~/.agents/skills) are off until you turn them on. আপনার skill-গুলো app-এর settings folder-এর skills folder-এ রাখা হয়। শেয়ার করা skill (~/.agents/skills) চালু না করা পর্যন্ত বন্ধ থাকে। +Yours আপনার +YYYY-MM-DD YYYY-MM-DD +YYYY-MM-DD, for the layers that change with time YYYY-MM-DD, সময়ের সঙ্গে বদলায় এমন layer-এর জন্য +YYYY-MM-DD, for time layers YYYY-MM-DD, time layer-এর জন্য +Z factor Z ফ্যাক্টর +Zarr Zarr +Zarr Variables Zarr ভেরিয়েবল +Zarr… Zarr… +Zoom জুম +Zoom about the pointer pointer-কে কেন্দ্র করে zoom +Zoom Box Zoom box +Zoom Frame Frame-এ zoom +Zoom In Zoom in +Zoom in জুম ইন +Zoom In Here এখানে zoom in করুন +Zoom in on the centre of the map. মানচিত্রের কেন্দ্রে zoom in করুন। +Zoom in · finish a sketch Zoom in · sketch শেষ করুন +Zoom Out Zoom out +Zoom out জুম আউট +Zoom out from the centre of the map. মানচিত্রের কেন্দ্র থেকে zoom out করুন। +Zoom To এখানে zoom করুন +Zoom To\nLayer Zoom করুন\nLayer-এ +Zoom to a box (Explore) একটি বাক্সে zoom করুন (Explore) +Zoom To Extent Extent-এ zoom করুন +Zoom To Layer Layer-এ zoom করুন +Zoom To Layers Layer-গুলোতে zoom করুন +Zoom To Map View Map view-তে zoom করুন +Zoom To Selection নির্বাচনে zoom করুন +Zoom to selection নির্বাচনে zoom করুন +Zoom to the error ত্রুটির স্থানে zoom করুন +Zoom to the extent of all layers. সব layer-এর extent-এ zoom করুন। +Zoom to the layer's extent. Layer-এর extent-এ zoom করুন। +Zoom to the selected features. নির্বাচিত feature-গুলোর extent-এ zoom করুন। +Zoom to the whole world. পুরো পৃথিবীতে zoom করুন। +Zoom to this bookmark এই bookmark-এ zoom করুন +Zoom To Tile Layer Tile layer-এ zoom করুন +Zoom to Tokyo Tokyo-তে zoom করুন +Zoom To World বিশ্বে zoom করুন +Zoom {z} · Bearing {b}° · Pitch {p}° Zoom {z} · দিক {b}° · ঢাল {p}° +Zoom: the map's zoom level, as web maps count it. Bearing: the direction the top of the map faces, clockwise from north. Pitch: the tilt from looking straight down. Click to reset bearing and pitch. Zoom: মানচিত্রের zoom level, web map যেভাবে গণনা করে। দিক: উত্তর থেকে ঘড়ির কাঁটার দিকে মানচিত্রের ওপরের অংশ কোন দিকে মুখ করে। ঢাল: সোজা উপর থেকে দেখার তুলনায় কাত। দিক ও ঢাল reset করতে ক্লিক করুন। +{agent} was stopped by you. আপনি {agent}-কে থামিয়েছেন। +{id} changed since it was installed; install it again from the Plugin Manager. ইনস্টল করার পর থেকে {id} বদলেছে; Plugin Manager থেকে আবার ইনস্টল করুন। +{names}: needs Node.js (npx) for its ACP adapter. {names}: এর ACP adapter-এর জন্য Node.js (npx) দরকার। +{name} is larger than {mb} MB {name} {mb} MB-এর চেয়ে বড় +{name} runs as a program on your computer: it needs the desktop app or rustgis-cli. {name} আপনার কম্পিউটারে program হিসেবে চলে: এর জন্য desktop app বা rustgis-cli দরকার। +{name} was withdrawn from the plugin registry and has been turned off. {name} plugin registry থেকে প্রত্যাহার করা হয়েছে এবং বন্ধ করা হয়েছে। +{name} was withdrawn from the plugin registry: {reason} {name} plugin registry থেকে প্রত্যাহার করা হয়েছে: {reason} +{name}: the download doesn't match the registry; it wasn't installed. {name}: download registry-র সঙ্গে মেলে না; ইনস্টল করা হয়নি। +{name}: the download is a different plugin; it wasn't installed. {name}: download-টি অন্য plugin; ইনস্টল করা হয়নি। +{shown} of {total} plugins মোট {total} plugin-এর মধ্যে {shown}টি +{total} plugins {total}টি plugin +{type} · {format}, read in place · a sample of {n} of the {matched} features in view (zoom in to see them all) · {renderer} {type} · {format}, একই স্থানে পড়া · view-তে থাকা {matched}টি feature-এর মধ্যে {n}টির নমুনা (সব দেখতে zoom in করুন) · {renderer} +{type} · {format}, read in place · {n} of {total} features in view · {renderer} {type} · {format}, একই স্থানে পড়া · view-তে থাকা {total}টির মধ্যে {n}টি feature · {renderer} +{url}: downloaded after another project was opened, so it wasn't opened {url}: অন্য project খোলার পর ডাউনলোড হয়েছে, তাই এটি খোলা হয়নি +{url}: only HTTPS addresses, or plain HTTP on this computer or network, are allowed {url}: শুধু HTTPS ঠিকানা, অথবা এই কম্পিউটার বা নেটওয়ার্কে সাধারণ HTTP অনুমোদিত +© OpenStreetMap contributors © OpenStreetMap অবদানকারীরা +±, dequantised ±, ডিকোয়ান্টাইজড +— fps — fps +⌘K ⌘K +▶ Simulate ▶ সিমুলেট করুন diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 719096d13..ffdbed7c4 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -42,6 +42,8 @@ pub enum Script { /// Arabic script (Persian): a face with Arabic and Latin letters, drawn ahead of the /// interface font. Arabic, + /// Bengali: an Indic script with a face discovered on the desktop where available. + Bengali, } /// Which CJK face reads best for a language (the glyph shapes differ by region). @@ -81,7 +83,7 @@ macro_rules! lang { } /// The registry. English first: it is the source language and the fallback. -pub static LANGUAGES: [LangInfo; 17] = [ +pub static LANGUAGES: [LangInfo; 18] = [ LangInfo { code: "en", name: "English", @@ -107,6 +109,7 @@ pub static LANGUAGES: [LangInfo; 17] = [ lang!("ja", "日本語", "Japanese", Script::Cjk(CjkFont::Japanese), "ja.tsv"), lang!("ko", "한국어", "Korean", Script::Cjk(CjkFont::Korean), "ko.tsv"), lang!("ka", "ქართული", "Georgian", Script::Alphabetic, "ka.tsv"), + lang!("bn", "বাংলা", "Bengali", Script::Bengali, "bn.tsv"), ]; fn unescape(s: &str) -> String { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index efa46b98b..d2bec96be 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -30,6 +30,7 @@ fn lookup_fallback_and_tags() { assert_eq!(tr(Lang::EN, "no such string"), "no such string"); assert_eq!(lang_from_tag("de_DE.UTF-8").map(Lang::code), Some("de")); assert_eq!(lang_from_tag("pt-BR").map(Lang::code), Some("pt")); + assert_eq!(lang_from_tag("bn-BD").map(Lang::code), Some("bn")); assert_eq!(lang_from_tag("zh-Hans-CN").map(Lang::code), Some("zh")); assert_eq!(lang_from_tag("zh_CN.UTF-8").map(Lang::code), Some("zh")); assert_eq!(lang_from_tag("zh-TW"), None); @@ -40,6 +41,16 @@ fn lookup_fallback_and_tags() { assert_eq!(fmt("{n} of {total} selected", &[("n", "3"), ("total", "{n}")]), "3 of {n} selected"); } +#[test] +fn bengali_registration_and_fallback() { + let bn = Lang::from_code("bn").unwrap_or(Lang::EN); + assert_eq!(bn.name(), "বাংলা"); + assert_eq!(bn.english(), "Bengali"); + assert_eq!(bn.script(), Script::Bengali); + assert_eq!(tr(bn, "Open"), "খুলুন"); + assert_eq!(tr(bn, "A string not in the Bengali catalog"), "A string not in the Bengali catalog"); +} + #[test] fn plugin_strings_use_the_plugins_translations() { use rustgis_engine::plugins; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f98631042..becdadbd5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1001,7 +1001,8 @@ impl RustGisApp { fn can_draw(&self, lang: i18n::Lang) -> bool { let Some(region) = Self::script_region(lang.script()) else { return true }; let have = self.extra_fonts.iter().any(|(_, _, r)| *r == Some(region)); - (have || self.services.fetch_font.is_none()) && !self.fonts_pending && self.installed_region == Some(region) + let can_fetch = self.services.fetch_font.is_some() && rustgis_engine::render::fonts::script_font_url(region).is_some(); + (have || !can_fetch) && !self.fonts_pending && self.installed_region == Some(region) } /// On the desktop, an installed CJK face as a last-resort fallback, so CJK text (language names @@ -1047,6 +1048,7 @@ impl RustGisApp { i18n::Script::Cjk(i18n::CjkFont::Japanese) => Some(ScriptFont::Cjk(CjkRegion::Japanese)), i18n::Script::Cjk(i18n::CjkFont::Korean) => Some(ScriptFont::Cjk(CjkRegion::Korean)), i18n::Script::Arabic => Some(ScriptFont::Arabic), + i18n::Script::Bengali => Some(ScriptFont::Bengali), i18n::Script::Alphabetic => None, } } @@ -1071,8 +1073,8 @@ impl RustGisApp { face.index = *index; self.extra_fonts.push((name, std::sync::Arc::new(face), Some(region))); self.fonts_dirty = true; - } else if let Some(fetch) = &self.services.fetch_font { - fetch(script_font_url(region)); + } else if let (Some(fetch), Some(url)) = (&self.services.fetch_font, script_font_url(region)) { + fetch(url); } else { log::warn!("no {region:?} font installed; text in that script may not display"); } @@ -3355,8 +3357,8 @@ impl RustGisApp { let region = [CjkRegion::SimplifiedChinese, CjkRegion::Japanese, CjkRegion::Korean] .map(ScriptFont::Cjk) .into_iter() - .chain([ScriptFont::Arabic]) - .find(|r| script_font_url(*r) == url); + .chain([ScriptFont::Arabic, ScriptFont::Bengali]) + .find(|r| script_font_url(*r) == Some(url.as_str())); if bytes.is_empty() || !valid_font(&bytes, 0) { // A failed download: say so; picking the language again retries. log::error!("{url}: no usable font downloaded"); From 8538be2d3f67d4bed9e9cea9a6b48aa9e21e6726 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sat, 24 Oct 2026 15:50:46 +0200 Subject: [PATCH 102/297] feat(labels): label lines along them, straight or curved, and italic labels (#802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(labels): label lines along them, straight or curved, and italic labels Two label placements for lines: Parallel sets the text straight, turned to the line's direction across it; Curved turns each character with the line where it bends. Both sit at the middle of the line's longest part on the screen, centred on the line and kept upright, and may run past its ends by about a letter. A line too short for its text, or one bending more than 25 degrees from one character to the next (QGIS's default), is left unlabelled. Points and polygons take them as Automatic. Label classes can also be italic. - map: `LabelPlacement::Parallel`/`Curved`, `LabelClass::italic`. - render: `line_labels` places the text along a screen line; `PlacedLabel::glyphs` carries a curved label's characters (each placed and turned about the label's centre), drawn one by one, colliding as a square each, and turned with the view (`turn_with`, read from the other end when upside down). - ui: each glyph turned on screen; Italic and Position › Along lines in the Labeling pane. - engine: `labels.placement` "parallel"/"curved", `labels.style` italic. - docs: cartography.md › Labeling. * fix(labels): label a line's stretch in view, keep accents on their letters Review follow-ups for labels along lines: - A line labelled along it is no longer skipped when its label point is out of view: the label sits on the longest stretch in view, each part cut at the view's edges and where it goes out of sight. - Curved labels are cut into grapheme clusters (unicode-segmentation), so a combining accent stays on its letter. - A text over 200 characters is set straight rather than curved, so drawing and collision boxes stay bounded. - A curved label turned upside down places each character by its own width. - turn_with gets its doc comment back. * fix(labels): label a line in view whose label point has no screen position A line labelled along it is no longer skipped when its label point is behind the globe or past the projection's limits: only the classes that need the point wait for it, and those along the line take its stretch in view. The check for a line in view uses the layer's cached extents. * fix(labels): a curved label's halos go under all its letters; lines' parts out of view aren't projected for labels * fix(labels): a vertical line's label reads downward whichever way it was drawn; curved labels don't copy their characters per character --- Cargo.lock | 1 + Cargo.toml | 3 + apps/rustgis-python/THIRD-PARTY-LICENSES.txt | 8 +- crates/engine/src/cmd/labels.rs | 2 +- crates/engine/src/cmd/layer.rs | 7 +- crates/engine/src/tests.rs | 30 ++ crates/map/src/symbology.rs | 28 +- crates/render/Cargo.toml | 1 + crates/render/src/lib.rs | 296 ++++++++++++++++++- crates/render/src/line_labels.rs | 255 ++++++++++++++++ crates/render/src/stream_tests.rs | 1 + crates/render/src/text.rs | 38 ++- crates/render/src/tiles.rs | 1 + crates/ui-egui/src/labelpane.rs | 13 + crates/ui-egui/src/mapview.rs | 31 +- docs/user-guide/cartography.md | 11 +- 16 files changed, 697 insertions(+), 29 deletions(-) create mode 100644 crates/render/src/line_labels.rs diff --git a/Cargo.lock b/Cargo.lock index 5cf6769f1..1c77ef40e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4822,6 +4822,7 @@ dependencies = [ "serde_json", "tempfile", "tiny-skia 0.12.0", + "unicode-segmentation", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index 6e41ce5a3..403d8a60a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ bytemuck = { version = "1", features = ["derive"] } lyon = "1" image = { version = "0.25", default-features = false, features = ["png"] } ab_glyph = "0.2" +# A label curved along a line: its text cut into the characters drawn as one (a letter with its +# accents). +unicode-segmentation = "1.13" # Rasters: GeoTIFF decoding/encoding, and coordinate systems by EPSG code (pure Rust, except the # Zstandard decoder `rustgis-data` turns on, C that clang compiles for the browser build too). tiff = "0.11" diff --git a/apps/rustgis-python/THIRD-PARTY-LICENSES.txt b/apps/rustgis-python/THIRD-PARTY-LICENSES.txt index ef3070bc8..b1b9a6411 100644 --- a/apps/rustgis-python/THIRD-PARTY-LICENSES.txt +++ b/apps/rustgis-python/THIRD-PARTY-LICENSES.txt @@ -29,10 +29,10 @@ semver, seq-macro, serde, serde_core, serde_derive, serde_json, sha1, sha2, shle simdutf8, siphasher, smallvec, socket2, spade, spki, stable_deref_trait, syn, sys-locale, tar, tempfile, thiserror, thiserror-impl, tiberius-macros, time, time-core, tinyvec, tokio-postgres, ttf-parser, typed-path, unicode-bidi, unicode-ident, unicode-normalization, unicode-properties, -ureq, ureq-proto, url, utf8-zero, utf8_iter, uuid, vcpkg, version_check, wasm-bindgen, -wasm-bindgen-macro, wasm-bindgen-macro-support, wasm-bindgen-shared, wasmi, wasmi_collections, -wasmi_core, wasmi_ir, wasmparser, web-sys, weezl, whoami, winauth, wkt, x509-cert, xattr, zeroize, -zune-core, zune-jpeg +unicode-segmentation, ureq, ureq-proto, url, utf8-zero, utf8_iter, uuid, vcpkg, version_check, +wasm-bindgen, wasm-bindgen-macro, wasm-bindgen-macro-support, wasm-bindgen-shared, wasmi, +wasmi_collections, wasmi_core, wasmi_ir, wasmparser, web-sys, weezl, whoami, winauth, wkt, +x509-cert, xattr, zeroize, zune-core, zune-jpeg ==================================================================================================== Apache License diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index f0a1cfd0b..6e3ace0bb 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -49,7 +49,7 @@ pub fn commands() -> Vec { }) .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"}"#, + 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)}"#, ), // Label classes: each labels every feature its clause takes, as in ArcGIS Pro; the classes // listed first, Default last, are placed first (`Labeling`, #732). diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 7d63561ac..e09bed58c 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -287,7 +287,7 @@ pub fn commands() -> Vec { C::new("labels.style", "Text Symbol", "Style › Text Symbol", |s, p| { let size = f(p, "size").map(|v| v.clamp(*LABEL_SIZE.start(), *LABEL_SIZE.end()) as f32); let color = s.get_color(p, "color")?; - let (halo, bold) = (b(p, "halo"), b(p, "bold")); + let (halo, bold, italic) = (b(p, "halo"), b(p, "bold"), b(p, "italic")); super::labels::with_class(s, p, |c| { if let Some(v) = size { c.size = v; @@ -301,10 +301,13 @@ pub fn commands() -> Vec { if let Some(bb) = bold { c.bold = bb; } + if let Some(it) = italic { + c.italic = it; + } }) }) .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, "bold"?: bool}"##), + .params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "bold"?: bool, "italic"?: bool}"##), 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)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c7a0fc8cc..033cefa8c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -595,6 +595,34 @@ fn a_reference_scale_is_set_kept_and_undone() { check(&s); } +#[test] +fn rivers_are_labelled_along_their_lines_in_italics() { + let mut s = world(); + s.run("labels.field", &json!({"layer": "Rivers", "field": "name"})).unwrap(); + s.run("labels.placement", &json!({"layer": "Rivers", "position": "curved"})).unwrap(); + s.run("labels.style", &json!({"layer": "Rivers", "italic": true})).unwrap(); + let check = |s: &Session| { + let c = &s.map().layer_by_name("Rivers").unwrap().labels.default; + assert_eq!((c.placement, c.italic), (rustgis_map::LabelPlacement::Curved, true)); + }; + check(&s); + let dir = std::env::temp_dir().join(format!("rustgis-line-labels-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("labels.rgis"); + s.run("project.saveAs", &json!({"path": path.display().to_string()})).unwrap(); + let mut t = Session::new(Project::default()); + t.run("project.open", &json!({"path": path.display().to_string()})).unwrap(); + check(&t); + t.run("share.exportMap", &json!({"path": dir.join("map.png").display().to_string(), "width": 320, "height": 200})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + s.run("labels.placement", &json!({"layer": "Rivers", "position": "parallel"})).unwrap(); + assert_eq!(s.map().layer_by_name("Rivers").unwrap().labels.default.placement, rustgis_map::LabelPlacement::Parallel); + // Not slanted unless asked: a project from before reads as upright. + s.run("labels.style", &json!({"layer": "Rivers", "italic": false})).unwrap(); + let text = serde_json::to_string(&s.map().layer_by_name("Rivers").unwrap().labels).unwrap(); + assert!(!text.contains("italic"), "{text}"); +} + #[test] fn project_save_and_open_roundtrip() { let mut s = world(); @@ -1330,6 +1358,8 @@ fn hostile_params_never_panic() { json!({"type": "albers", "lon_0": f64::MAX, "lat_1": "x", "lat_2": -1e308}), json!({"kind": "legend", "element": 1, "exclude": [null, 7, "", {"x": 1}]}), json!({"kind": "text", "element": 1, "font": {"family": "serif"}, "italic": [], "align": 7}), + // A label class placed along lines, with junk the placement and style read after it. + json!({"layer": "Countries", "position": "curved", "italic": "maybe", "size": -1e308}), // A publication sheet's style, accent and orientation, and a shape's rounded corners. // (The profile left out or valid, so the style and accent are reached.) json!({"style": 7, "accent": "#XYZ", "kind": "ellipse", "element": 1, "radius": "x"}), diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index c5cb4dc6a..2629d2efb 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1213,6 +1213,9 @@ pub struct LabelClass { /// Where a label sits relative to its feature. #[serde(default)] pub placement: LabelPlacement, + /// Slanted (an italic face where the font has one). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub italic: bool, } fn default_class_name() -> String { @@ -1246,10 +1249,22 @@ pub enum LabelPlacement { AboveLeft, BelowRight, BelowLeft, + /// Along a line, straight, turned to its direction across the text, at its middle; points + /// and polygons are labelled as `Auto`. + Parallel, + /// Along a line, each character turned with it where it bends, at its middle; points and + /// polygons are labelled as `Auto`. + Curved, } impl LabelPlacement { - pub const ALL: [LabelPlacement; 10] = [ + /// Whether a line is labelled along it ([`Parallel`](Self::Parallel) or + /// [`Curved`](Self::Curved)). + pub fn along_line(self) -> bool { + matches!(self, LabelPlacement::Parallel | LabelPlacement::Curved) + } + + pub const ALL: [LabelPlacement; 12] = [ LabelPlacement::Auto, LabelPlacement::Center, LabelPlacement::Above, @@ -1260,6 +1275,8 @@ impl LabelPlacement { LabelPlacement::AboveLeft, LabelPlacement::BelowRight, LabelPlacement::BelowLeft, + LabelPlacement::Parallel, + LabelPlacement::Curved, ]; pub fn code(self) -> &'static str { match self { @@ -1273,6 +1290,8 @@ impl LabelPlacement { LabelPlacement::AboveLeft => "aboveLeft", LabelPlacement::BelowRight => "belowRight", LabelPlacement::BelowLeft => "belowLeft", + LabelPlacement::Parallel => "parallel", + LabelPlacement::Curved => "curved", } } pub fn label(self) -> &'static str { @@ -1287,6 +1306,8 @@ impl LabelPlacement { LabelPlacement::AboveLeft => "Above Left", LabelPlacement::BelowRight => "Below Right", LabelPlacement::BelowLeft => "Below Left", + LabelPlacement::Parallel => "Parallel", + LabelPlacement::Curved => "Curved", } } pub fn from_code(s: &str) -> Option { @@ -1298,8 +1319,8 @@ impl LabelPlacement { let gap = marker / 2.0 + 2.0; let (dx, dy) = (w / 2.0 + gap, h / 2.0 + gap); match self { - LabelPlacement::Auto if is_point => (0.0, -(marker / 2.0 + h * 0.6)), - LabelPlacement::Auto | LabelPlacement::Center => (0.0, 0.0), + LabelPlacement::Auto | LabelPlacement::Parallel | LabelPlacement::Curved if is_point => (0.0, -(marker / 2.0 + h * 0.6)), + LabelPlacement::Auto | LabelPlacement::Center | LabelPlacement::Parallel | LabelPlacement::Curved => (0.0, 0.0), LabelPlacement::Above => (0.0, -dy), LabelPlacement::Below => (0.0, dy), LabelPlacement::Left => (-dx, 0.0), @@ -1337,6 +1358,7 @@ impl LabelClass { closest: None, farthest: None, placement: LabelPlacement::Auto, + italic: false, } } diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index db22b9358..7ad7a3ee3 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -16,6 +16,7 @@ tiny-skia = { workspace = true } bytemuck = { workspace = true } lyon = { workspace = true } ab_glyph = { workspace = true } +unicode-segmentation = { workspace = true } epaint_default_fonts = { workspace = true } log = { workspace = true } image = { workspace = true, features = ["jpeg"] } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 5cf9c9693..9f378a9ed 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ mod effects; pub mod fonts; pub mod gpu_scene; pub mod layout; +mod line_labels; pub mod logo; mod mesh; pub mod pdf; @@ -109,12 +110,13 @@ use rustgis_data::{Dataset, Geometry, GeometryType}; use rustgis_geom::{Coord, Extent, extent_to_merc, lonlat_to_merc}; use rustgis_map::globe::{V3, densify}; use rustgis_map::layout::TextAlign; -use rustgis_map::{Camera, Globe, LineStroke, Map, Marker, Rgba, Symbol}; +use rustgis_map::{Camera, Globe, LabelPlacement, LineStroke, Map, Marker, Rgba, Symbol}; /// The image type pages and maps are drawn into. pub use tiny_skia::Pixmap; use tiny_skia::{ FillRule, GradientStop, LineCap, LineJoin, Paint, PathBuilder, PixmapPaint, Point, RadialGradient, SpreadMode, Stroke, Transform, }; +use unicode_segmentation::UnicodeSegmentation; /// Selection highlight (cyan, as desktop GIS users expect). pub const SELECTION: Rgba = Rgba::rgb(0x00, 0xE5, 0xFF); @@ -194,6 +196,19 @@ pub struct PlacedLabel { pub angle: Option, /// How it collides with other labels (padding, optional text or icon, overlaps). pub collide: rustgis_tiles::Collide, + /// Curved along a line: its characters one by one, each placed and turned where the line + /// takes it (`text` is all of them). Empty for a label drawn as one block. + pub glyphs: Vec, +} + +/// One character of a label curved along a line: its text, and its centre and angle (radians, +/// clockwise, kept upright) relative to the label's `x`, `y`. +#[derive(Clone, Debug, PartialEq)] +pub struct LabelGlyph { + pub text: String, + pub dx: f32, + pub dy: f32, + pub angle: f32, } impl PlacedLabel { @@ -3074,6 +3089,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac line_align: rustgis_map::graphics::align(ds_ptr, row), angle: None, collide: Default::default(), + glyphs: Vec::new(), }); } } @@ -3089,17 +3105,28 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let label_factor = layer.size_factor(label_scale); let classes = rustgis_map::LabelMatcher::new(labels, ds_ptr); let lid = layer.id; + // Whether a class labels lines along them: those take the stretch of a line in + // view, wherever its label point is. + let along_line = |placement: LabelPlacement, g: &Geometry| placement.along_line() && matches!(g, Geometry::Line(_)); + let any_along = labels.in_order().zip(&label_fields).any(|(c, f)| f.is_some() && c.placement.along_line()); for (row, g) in geoms.iter().enumerate() { let Some(g) = g else { continue }; if rows.get(row) != Some(&true) { continue; } let Some(c) = g.label_point() else { continue }; - if globe.is_none() && !ctx.cull.contains(c) { + // Where the label point is on the screen, `None` when it is out of view (or + // behind the globe): then only a line labelled along it can be, on its + // stretch in view. + let point = if globe.is_some() || ctx.cull.contains(c) { ctx.screen(c) } else { 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()) { continue; } - let Some((x, y)) = ctx.screen(c) else { continue }; let is_point = g.kind() == GeometryType::Point; + // A line's longest part on the screen, for the classes placed along it: worked + // out once, for the first that asks. + let mut screen_line: Option>> = None; let marker = if is_point { layer.symbol(row).size } else { 0.0 }; // Bigger polygons get priority. let area = match g { @@ -3117,6 +3144,11 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac if lock.as_ref().is_some_and(|k| !k.contains(&(lid, label_row(row), ci))) { continue; } + if point.is_none() && !along_line(class.placement, g) { + continue; + } + // (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 }; let v = ds_ptr.value(row, fi); if v.is_null() { @@ -3133,6 +3165,29 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac is_point, ); let (dx, dy) = (dx * label_factor, dy * label_factor); + let (mut lx, mut ly, mut angle, mut glyphs) = (x as f32 + dx, y as f32 + dy, None, Vec::new()); + if let (LabelPlacement::Parallel | LabelPlacement::Curved, Geometry::Line(parts)) = (class.placement, g) { + let line = screen_line.get_or_insert_with(|| longest_on_screen(ctx, parts)); + let Some(line) = line.as_deref() else { continue }; + // It may run past the line's ends by about a letter, as QGIS's overrun. + let overrun = size; + // A text too long to curve character by character goes straight. + let curved = class.placement == LabelPlacement::Curved + && text.graphemes(true).nth(line_labels::MAX_CURVED_CHARS).is_none(); + if !curved { + let width = text::block_width(&text, size, class.bold); + let Some((cx, cy, a)) = line_labels::parallel(line, width, overrun) else { continue }; + (lx, ly, angle) = (cx, cy, Some(a)); + } else { + // Each character with its accents, drawn as one. + let chars: Vec = text.graphemes(true).map(String::from).collect(); + let advances: Vec = chars.iter().map(|c| text::measure(c, size, class.bold)).collect(); + let Some(((cx, cy), at)) = line_labels::curved(line, &advances, overrun) else { continue }; + (lx, ly) = (cx, cy); + glyphs = + chars.into_iter().zip(at).map(|(text, (dx, dy, angle))| LabelGlyph { text, dx, dy, angle }).collect(); + } + } // 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. @@ -3140,8 +3195,8 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac label_candidates.push(( PlacedLabel { text, - x: x as f32 + dx, - y: y as f32 + dy, + x: lx, + y: ly, size, color: class.color, halo: class.halo.then_some(halo_for(class.color)), @@ -3149,11 +3204,12 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac icon: None, align: 0.0, framed: false, - italic: false, + italic: class.italic, spacing: 0.0, line_align: TextAlign::Left, - angle: None, + angle, collide: Default::default(), + glyphs, }, pri, (lid, label_row(row), ci), @@ -3793,6 +3849,27 @@ pub fn render_tilt_drape(map: &mut Map, view: &View, margin: f64, max_side: u32) out } +/// A line's longest stretch in view, in points (`None` when none has two points in sight): the +/// one a label along the line follows. Each part is cut at the view's edges and where it goes +/// out of sight (behind the globe), so a long part mostly off screen counts by what is on it. +fn longest_on_screen(ctx: &Ctx<'_>, parts: &[Vec]) -> Option> { + let (w, h) = (ctx.view.width as f32, ctx.view.height as f32); + let length = |pts: &[(f32, f32)]| pts.windows(2).map(|w| (w[1].0 - w[0].0).hypot(w[1].1 - w[0].1)).sum::(); + // A part whose extent is out of view has no stretch in it: not projected point by point. + let near = |part: &[Coord]| { + ctx.globe.is_some() || { + let mut e = Extent::EMPTY; + part.iter().for_each(|c| e.expand(*c)); + ctx.cull.intersects(&e) + } + }; + parts + .iter() + .filter(|part| near(part)) + .flat_map(|part| line_labels::in_view(part.iter().map(|c| ctx.screen(*c).map(|(x, y)| (x as f32, y as f32))), w, h)) + .max_by(|a, b| length(a).total_cmp(&length(b))) +} + /// Turns a label turned with the map (and its icon) as `at` moves it into another view (a /// turned or tilted one): its angle there is that of a short step along it, the text kept /// upright. Called before the label itself is moved. @@ -3806,6 +3883,23 @@ fn turn_with(l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) let x = l.center_x(); l.angle = Some(turned(x, l.y, a).map_or(a, rustgis_tiles::symbols::upright)); } + // A curved label's characters: each moved and turned, about where its centre goes. + if !l.glyphs.is_empty() + && let Some((cx, cy)) = at(l.x, l.y) + { + for g in &mut l.glyphs { + let (gx, gy) = (l.x + g.dx, l.y + g.dy); + if let Some((x, y)) = at(gx, gy) { + (g.dx, g.dy) = (x - cx, y - cy); + } + g.angle = turned(gx, gy, g.angle).unwrap_or(g.angle); + } + // Turned upside down (a map turned past a quarter): read from the other end. + let back = l.glyphs.iter().map(|g| g.angle.cos()).sum::() < 0.0; + if back { + read_back(l); + } + } if let Some(i) = &mut l.icon && let Some(a) = i.angle { @@ -3813,6 +3907,31 @@ fn turn_with(l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) } } +/// A curved label's characters read from the line's other end, turned half round: each placed +/// as far from that end as it was from the start, by its own width rather than that of the +/// character whose place it takes, a step along the nearest one's angle from it. +fn read_back(l: &mut PlacedLabel) { + let widths: Vec = l.glyphs.iter().map(|g| text::measure(&g.text, l.size, l.bold)).collect(); + let total: f32 = widths.iter().sum(); + // How far along the text each character's centre was. + let mut centres = Vec::with_capacity(widths.len()); + let mut d = 0.0; + for w in &widths { + centres.push(d + w / 2.0); + d += w; + } + let before = l.glyphs.clone(); + for (g, c) in l.glyphs.iter_mut().zip(¢res) { + let to = total - c; + let nearest = centres.iter().zip(&before).min_by(|a, b| (a.0 - to).abs().total_cmp(&(b.0 - to).abs())); + let Some((at, slot)) = nearest else { continue }; + let step = to - at; + g.dx = slot.dx + step * slot.angle.cos(); + g.dy = slot.dy + step * slot.angle.sin(); + g.angle = slot.angle + std::f32::consts::PI; + } +} + /// A halo that contrasts with the text colour. fn halo_for(text: Rgba) -> Rgba { let lum = 0.299 * text.r() as f32 + 0.587 * text.g() as f32 + 0.114 * text.b() as f32; @@ -3901,6 +4020,15 @@ fn label_boxes(l: &PlacedLabel) -> (Vec<[f32; 4]>, Vec<[f32; 4]>) { let th = text::block_height(&l.text, l.size); let p = l.collide.text_padding.clamp(0.0, 100.0); let cx = l.center_x(); + if !l.glyphs.is_empty() { + // A curved label: a square round each character, as tall as the text. + let r = th / 2.0 + p; + for g in l.glyphs.iter().take(500) { + let (x, y) = (l.x + g.dx, l.y + g.dy); + text.push([x - r, y - r, x + r, y + r]); + } + return (text, Vec::new()); + } match l.angle.filter(|a| a.is_finite() && a.abs() > 1e-3) { Some(a) => { // Squares as tall as the text, end to end along it. @@ -4046,6 +4174,7 @@ pub(crate) fn draw_attribution(pm: &mut Pixmap, attribution: &[String], ppp: f32 line_align: TextAlign::Left, angle: None, collide: Default::default(), + glyphs: Vec::new(), }; text::draw(pm, &label, ppp); } @@ -4070,6 +4199,7 @@ mod tests { line_align: TextAlign::Left, angle: None, collide: Default::default(), + glyphs: Vec::new(), } } @@ -5774,6 +5904,158 @@ mod tests { } } + #[test] + fn labels_run_along_lines_straight_or_curved() { + // A road rising to the right, labelled along it: straight and turned to it, or a + // character at a time; drawn either way, and a too sharp bend leaves a curved label out. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("roads", GeometryType::Polyline, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.0, -1.0), Coord::new(1.0, 1.0)]])), vec![Value::Text("Seestrasse".into())]); + // A right angle at its middle. + d.push( + Some(Geometry::Line(vec![vec![Coord::new(-1.0, -1.6), Coord::new(0.6, -1.6), Coord::new(0.6, 0.0)]])), + vec![Value::Text("Bahnhofstrasse".into())], + ); + let id = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, -0.3)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 400.0, height: 400.0, pixels_per_point: 1.0, camera, sky: false }; + let l = m.layer_mut(id).unwrap(); + l.labels.enabled = true; + l.labels.default.field = "name".into(); + l.labels.default.size = 12.0; + l.labels.default.placement = rustgis_map::LabelPlacement::Parallel; + let r = render(&mut m, &view).unwrap(); + let along = r.labels.iter().find(|l| l.text == "Seestrasse").expect("placed"); + // Up and to the right on the screen: −45°, about the middle of the line. + assert!(along.angle.is_some_and(|a| (a + std::f32::consts::FRAC_PI_4).abs() < 0.02), "{:?}", along.angle); + assert!(along.glyphs.is_empty()); + m.layer_mut(id).unwrap().labels.default.placement = rustgis_map::LabelPlacement::Curved; + let r = render(&mut m, &view).unwrap(); + let curved = r.labels.iter().find(|l| l.text == "Seestrasse").expect("placed"); + assert_eq!(curved.glyphs.iter().map(|g| g.text.as_str()).collect::(), "Seestrasse"); + assert!(curved.glyphs.windows(2).all(|w| w[1].dx > w[0].dx && w[1].dy < w[0].dy), "left to right, rising"); + assert!(r.labels.iter().all(|l| l.text != "Bahnhofstrasse"), "the bend is too sharp to curve round"); + // Drawn: dark text along the line, and a square to collide with per character. + let mut pm = Pixmap::new(400, 400).unwrap(); + text::draw(&mut pm, curved, 1.0); + let dark = pm.pixels().iter().filter(|p| p.alpha() > 200 && p.red() < 120).count(); + assert!(dark > 30, "{dark} text pixels"); + assert_eq!(label_boxes(curved).0.len(), 10); + } + + #[test] + fn a_line_mostly_off_screen_is_labelled_along_its_stretch_in_view() { + // A river reaching far off to the east, its middle well out of view, and a road whose + // longer part is out of view too: both labelled on what is on the screen, and the + // river's accent (a combining one) kept on its letter. + let mut m = Map::new("Map"); + m.basemap = rustgis_map::Basemap::Blank; + let mut d = Dataset::new("rivers", GeometryType::Polyline, vec![Field::new("name", FieldType::Text)]); + d.push(Some(Geometry::Line(vec![vec![Coord::new(-1.5, 0.5), Coord::new(30.0, 0.5)]])), vec![Value::Text("Rho\u{302}ne".into())]); + d.push( + Some(Geometry::Line(vec![ + vec![Coord::new(-1.5, -1.0), Coord::new(1.5, -1.0)], + vec![Coord::new(10.0, -1.0), Coord::new(20.0, -1.0)], + ])), + vec![Value::Text("Seestrasse".into())], + ); + let id = m.add_dataset(d); + let camera = Camera { center: lonlat_to_merc(Coord::new(0.0, -0.3)), res: 1500.0, bearing: 0.0, pitch: 0.0 }; + let view = View { width: 400.0, height: 400.0, pixels_per_point: 1.0, camera, sky: false }; + let l = m.layer_mut(id).unwrap(); + l.labels.enabled = true; + l.labels.default.field = "name".into(); + l.labels.default.size = 12.0; + l.labels.default.placement = rustgis_map::LabelPlacement::Curved; + let r = render(&mut m, &view).unwrap(); + for name in ["Rho\u{302}ne", "Seestrasse"] { + let l = r.labels.iter().find(|l| l.text == name).unwrap_or_else(|| panic!("{name} placed")); + assert!((0.0..400.0).contains(&l.x) && (0.0..400.0).contains(&l.y), "{name} in view: {} {}", l.x, l.y); + } + let river = r.labels.iter().find(|l| l.text.starts_with("Rho")).unwrap(); + let glyphs: Vec<&str> = river.glyphs.iter().map(|g| g.text.as_str()).collect(); + assert_eq!(glyphs, ["R", "h", "o\u{302}", "n", "e"]); + } + + #[test] + fn a_curved_label_turned_upside_down_reads_from_its_other_end() { + // "AB" along a level line, then the map turned half round: the characters swap ends and + // stay upright. + let glyph = |text: &str, dx: f32| LabelGlyph { text: text.into(), dx, dy: 0.0, angle: 0.0 }; + let mut l = PlacedLabel { + text: "AB".into(), + x: 100.0, + y: 100.0, + size: 10.0, + color: Rgba::rgb(0, 0, 0), + halo: None, + bold: false, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + glyphs: vec![glyph("A", -5.0), glyph("B", 5.0)], + }; + turn_with(&mut l, &|x, y| Some((200.0 - x, 200.0 - y))); + let order: Vec<(&str, f32)> = l.glyphs.iter().map(|g| (g.text.as_str(), g.dx)).collect(); + assert_eq!(order.iter().map(|g| g.0).collect::>(), ["A", "B"]); + assert!(order[0].1 < order[1].1, "A on the left, read first: {order:?}"); + assert!(l.glyphs.iter().all(|g| g.angle.cos() > 0.99), "upright"); + // Each placed by its own width: "Wi" turned round keeps "i" beside "W", not a "W" apart. + let (w, i) = (text::measure("W", 10.0, false), text::measure("i", 10.0, false)); + l.glyphs = vec![glyph("W", -i / 2.0), glyph("i", w / 2.0)]; + turn_with(&mut l, &|x, y| Some((200.0 - x, 200.0 - y))); + let (gw, gi) = (&l.glyphs[0], &l.glyphs[1]); + assert_eq!((gw.text.as_str(), gi.text.as_str()), ("W", "i")); + assert!(((gi.dx - gw.dx) - (w + i) / 2.0).abs() < 1e-3, "{gw:?} {gi:?}"); + assert!((gw.dx + i / 2.0).abs() < 1e-3, "W first, at the left end: {gw:?}"); + } + + #[test] + fn a_curved_labels_halos_go_under_all_its_letters() { + // Two letters close together along a line, with a halo: the second one's halo does not + // cover the first one's strokes, so the letters are as dark as drawn without halos. + let glyph = |dx: f32| LabelGlyph { text: "l".into(), dx, dy: 0.0, angle: 0.0 }; + let label = |halo: Option| PlacedLabel { + text: "ll".into(), + x: 20.0, + y: 20.0, + size: 12.0, + color: Rgba::rgb(0, 0, 0), + halo, + bold: false, + icon: None, + align: 0.0, + framed: false, + italic: false, + spacing: 0.0, + line_align: TextAlign::Left, + angle: None, + collide: Default::default(), + glyphs: vec![glyph(-0.75), glyph(0.75)], + }; + let drawn = |halo| { + let mut pm = Pixmap::new(40, 40).unwrap(); + pm.fill(tiny_skia::Color::from_rgba8(128, 128, 128, 255)); + text::draw(&mut pm, &label(halo), 1.0); + pm + }; + let (plain, haloed) = (drawn(None), drawn(Some(Rgba::rgb(255, 255, 255)))); + let mut letters = 0; + for (a, b) in plain.pixels().iter().zip(haloed.pixels()) { + if a.red() < 40 { + letters += 1; + assert!(b.red() < 80, "a letter's pixel covered by a halo: {} → {}", a.red(), b.red()); + } + } + assert!(letters > 5, "the letters are drawn: {letters}"); + } + #[test] fn a_reference_scale_scales_labels_and_their_gap() { // A point labelled to its right: at twice the size, the label is twice as large and twice diff --git a/crates/render/src/line_labels.rs b/crates/render/src/line_labels.rs new file mode 100644 index 000000000..a4ae18640 --- /dev/null +++ b/crates/render/src/line_labels.rs @@ -0,0 +1,255 @@ +//! Labels along lines, in screen points: straight and turned to the line ([`parallel`]), or each +//! character turned with it where it bends ([`curved`]), at the line's middle and kept upright, +//! as QGIS's Parallel and Curved line placements put text on the line. + +/// The most a curved label's text turns from one character to the next (radians): QGIS's +/// default limit, 25°. Past it, where a line bends sharply, the label is left out. +pub(super) const MAX_TURN: f32 = 0.436; + +/// The most characters a label curves along a line: a longer one is placed straight, so a long +/// attribute value on many lines doesn't lay out, and draw, character by character. +pub(super) const MAX_CURVED_CHARS: usize = 200; + +/// A line in screen points, with how far along it each vertex is. +struct Path { + pts: Vec<(f32, f32)>, + along: Vec, +} + +impl Path { + /// The finite vertices of `line` (repeats dropped), reversed when `reversed`; `None` for + /// one with fewer than two. + fn new(line: &[(f32, f32)], reversed: bool) -> Option { + let mut pts: Vec<(f32, f32)> = Vec::with_capacity(line.len()); + for p in line.iter().copied().filter(|p| p.0.is_finite() && p.1.is_finite()) { + if pts.last() != Some(&p) { + pts.push(p); + } + } + if reversed { + pts.reverse(); + } + let mut along = Vec::with_capacity(pts.len()); + let mut d = 0.0f32; + for (i, p) in pts.iter().enumerate() { + if let Some(q) = i.checked_sub(1).and_then(|j| pts.get(j)) { + d += (p.0 - q.0).hypot(p.1 - q.1); + } + along.push(d); + } + (pts.len() >= 2 && d.is_finite() && d > 0.0).then_some(Path { pts, along }) + } + + fn length(&self) -> f32 { + self.along.last().copied().unwrap_or(0.0) + } + + /// The point `d` along the line; before its start or past its end, on the first or last + /// segment carried on. + fn at(&self, d: f32) -> Option<(f32, f32)> { + let n = self.pts.len(); + // The segment holding `d`: the first or last one for a place off the line's ends. + let i = self.along.partition_point(|a| *a < d).clamp(1, n - 1); + let (a, b) = (self.pts.get(i - 1)?, self.pts.get(i)?); + let (da, db) = (self.along.get(i - 1)?, self.along.get(i)?); + let t = if db > da { (d - da) / (db - da) } else { 0.0 }; + Some((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)) + } +} + +/// The stretches of a line in screen points that are in a view `w` × `h` points: cut where it +/// crosses the view's edges and at points out of sight (`None`, or of no number), each with two +/// points or more. +pub(super) fn in_view(line: impl Iterator>, w: f32, h: f32) -> Vec> { + let mut runs: Vec> = Vec::new(); + let mut run: Vec<(f32, f32)> = Vec::new(); + let mut prev: Option<(f32, f32)> = None; + let end = |run: &mut Vec<(f32, f32)>, runs: &mut Vec>| { + let run = std::mem::take(run); + if run.len() >= 2 { + runs.push(run); + } + }; + for p in line { + let p = p.filter(|p| p.0.is_finite() && p.1.is_finite()); + match (prev, p) { + (Some(a), Some(b)) => match clip(a, b, w, h) { + Some((a, b, cut_end)) => { + if run.last() != Some(&a) { + end(&mut run, &mut runs); + run.push(a); + } + run.push(b); + // Left the view: what comes back in is another stretch. + if cut_end { + end(&mut run, &mut runs); + } + } + None => end(&mut run, &mut runs), + }, + _ => end(&mut run, &mut runs), + } + prev = p; + } + end(&mut run, &mut runs); + runs +} + +/// The part of the segment `a`–`b` inside a view `w` × `h` points (Liang–Barsky), and whether it +/// stops short of `b`; `None` when none of it is. +fn clip(a: (f32, f32), b: (f32, f32), w: f32, h: f32) -> Option<((f32, f32), (f32, f32), bool)> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f32, 1.0f32); + for (p, q) in [(-dx, a.0), (dx, w - a.0), (-dy, a.1), (dy, h - a.1)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + t0 = t0.max(r); + } else { + t1 = t1.min(r); + } + } + } + if t0.is_nan() || t1.is_nan() || t0 > t1 { + return None; + } + let at = |t: f32| (a.0 + dx * t, a.1 + dy * t); + Some((if t0 > 0.0 { at(t0) } else { a }, if t1 < 1.0 { at(t1) } else { b }, t1 < 1.0)) +} + +/// The angle (radians, clockwise) from `a` to `b`. +fn angle(a: (f32, f32), b: (f32, f32)) -> f32 { + (b.1 - a.1).atan2(b.0 - a.0) +} + +/// The line as text `len` points long reads along it, left to right: reversed when its middle +/// stretch runs leftward, so the text is never upside down. A vertical stretch reads downward, +/// as street labels turned with the map do ([`rustgis_tiles::symbols::upright`]), whichever way +/// the line was drawn. `None` when the line, with `overrun` past each end, is shorter than the +/// text. +fn upright(line: &[(f32, f32)], len: f32, overrun: f32) -> Option { + let path = Path::new(line, false)?; + let total = path.length(); + if !(len.is_finite() && len > 0.0) || len > total + 2.0 * overrun.max(0.0) { + return None; + } + let (a, b) = (path.at((total - len) / 2.0)?, path.at((total + len) / 2.0)?); + let a_to_b = angle(a, b); + // Turned half round to stand upright: read from the other end. + let flipped = (rustgis_tiles::symbols::upright(a_to_b) - a_to_b).abs() > 1.0; + if flipped { Path::new(line, true) } else { Some(path) } +} + +/// Text `len` points long straight along `line` at its middle: its centre and angle (radians, +/// clockwise, upright). `None` when the line (with `overrun` past each end) is shorter. +pub(super) fn parallel(line: &[(f32, f32)], len: f32, overrun: f32) -> Option<(f32, f32, f32)> { + let path = upright(line, len, overrun)?; + let total = path.length(); + let (a, b) = (path.at((total - len) / 2.0)?, path.at((total + len) / 2.0)?); + let a_to_b = angle(a, b); + a_to_b.is_finite().then_some(((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0, a_to_b)) +} + +/// Characters `advances` wide (points), in turn along `line` at its middle: the label's centre, +/// and each character's centre relative to it and angle (radians, clockwise, upright). `None` +/// when the line (with `overrun` past each end) is shorter than the text, or turns more than +/// [`MAX_TURN`] from one character to the next. +pub(super) fn curved(line: &[(f32, f32)], advances: &[f32], overrun: f32) -> Option<((f32, f32), Vec<(f32, f32, f32)>)> { + let len: f32 = advances.iter().sum(); + let path = upright(line, len, overrun)?; + let total = path.length(); + let centre = path.at(total / 2.0)?; + let mut d = (total - len) / 2.0; + let mut out = Vec::with_capacity(advances.len()); + let mut last: Option = None; + for adv in advances { + let mid = d + adv / 2.0; + // A character's angle: across its own width, so it sits on the bend rather than a vertex. + let (a, b) = (path.at(mid - adv.max(0.5) / 2.0)?, path.at(mid + adv.max(0.5) / 2.0)?); + let (p, turn) = (path.at(mid)?, angle(a, b)); + if !turn.is_finite() { + return None; + } + if let Some(prev) = last { + let mut step = turn - prev; + // Wrapped to within half a turn either way. + step = (step + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI; + if step.abs() > MAX_TURN { + return None; + } + } + last = Some(turn); + out.push((p.0 - centre.0, p.1 - centre.1, turn)); + d += adv; + } + Some((centre, out)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parallel_text_sits_at_the_middle_turned_to_the_line_and_upright() { + // Along a line rising to the right at 45° (screen y down: up-right is −45°). + let (x, y, a) = parallel(&[(0.0, 100.0), (100.0, 0.0)], 40.0, 0.0).unwrap(); + assert!((x - 50.0).abs() < 1e-3 && (y - 50.0).abs() < 1e-3); + assert!((a + std::f32::consts::FRAC_PI_4).abs() < 1e-4, "{a}"); + // The same line drawn right to left reads the same way. + let (_, _, b) = parallel(&[(100.0, 0.0), (0.0, 100.0)], 40.0, 0.0).unwrap(); + assert!((a - b).abs() < 1e-4, "{a} {b}"); + // Too short for the text, unless it may run past the ends. + assert!(parallel(&[(0.0, 0.0), (30.0, 0.0)], 40.0, 0.0).is_none()); + assert!(parallel(&[(0.0, 0.0), (30.0, 0.0)], 40.0, 5.0).is_some()); + assert!(parallel(&[(0.0, 0.0), (0.0, 0.0)], 1.0, 5.0).is_none(), "no length"); + } + + #[test] + fn a_vertical_line_reads_downward_whichever_way_it_was_drawn() { + // Screen y down: downward is +90°. Drawn up or down, exactly vertical or a hair off it. + let down = std::f32::consts::FRAC_PI_2; + for line in + [[(50.0, 0.0), (50.0, 100.0)], [(50.0, 100.0), (50.0, 0.0)], [(50.0, 100.0), (50.001, 0.0)], [(50.001, 100.0), (50.0, 0.0)]] + { + let (_, _, a) = parallel(&line, 40.0, 0.0).unwrap(); + assert!((a - down).abs() < 1e-3, "{line:?}: {a}"); + let (_, glyphs) = curved(&line, &[10.0; 4], 0.0).unwrap(); + assert!(glyphs.iter().all(|g| (g.2 - down).abs() < 1e-3), "{line:?}: {glyphs:?}"); + assert!(glyphs.windows(2).all(|w| w[1].1 > w[0].1), "first character at the top: {glyphs:?}"); + } + } + + #[test] + fn a_line_in_view_is_cut_at_its_edges_and_where_it_is_out_of_sight() { + // Across the view and out, back in and out again: two stretches, cut at the edges. + let line = [(-50.0, 50.0), (50.0, 50.0), (50.0, 150.0), (80.0, 150.0), (80.0, 50.0), (150.0, 50.0)]; + let runs = in_view(line.iter().copied().map(Some), 100.0, 100.0); + assert_eq!(runs, [vec![(0.0, 50.0), (50.0, 50.0), (50.0, 100.0)], vec![(80.0, 100.0), (80.0, 50.0), (100.0, 50.0)]]); + // A point out of sight splits the line; a stretch of one point is none. + let runs = + in_view([Some((10.0, 10.0)), Some((20.0, 10.0)), None, Some((30.0, 10.0)), Some((f32::NAN, 0.0))].into_iter(), 100.0, 100.0); + assert_eq!(runs, [vec![(10.0, 10.0), (20.0, 10.0)]]); + assert!(in_view([Some((-10.0, -10.0)), Some((-20.0, 200.0))].into_iter(), 100.0, 100.0).is_empty()); + } + + #[test] + fn curved_text_follows_a_gentle_bend_and_leaves_a_sharp_one() { + // A shallow arc: each character a little more turned than the one before. + let arc: Vec<(f32, f32)> = (0..=40) + .map(|i| { + let t = (i as f32 / 40.0 - 0.5) * 1.2; + (200.0 * t.sin() + 200.0, 200.0 - 200.0 * t.cos()) + }) + .collect(); + let (_, glyphs) = curved(&arc, &[10.0; 8], 0.0).unwrap(); + assert_eq!(glyphs.len(), 8); + assert!(glyphs.windows(2).all(|w| w[1].2 > w[0].2), "turning steadily clockwise"); + assert!(glyphs.windows(2).all(|w| w[1].0 > w[0].0), "left to right"); + // A right angle at the middle: too sharp for the text. + assert!(curved(&[(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)], &[10.0; 6], 0.0).is_none()); + } +} diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index bebda5a69..2b5d26176 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -549,6 +549,7 @@ fn chunked_label_compaction_keeps_late_priorities_stable_ties_and_nan_order() { line_align: TextAlign::Left, angle: None, collide: Default::default(), + glyphs: Vec::new(), }, if i % 17 == 0 { f64::NAN } else { (i % 7) as f64 }, (1, i, 0), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 1433dce62..ddd500856 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -431,14 +431,36 @@ pub fn styled_at(pm: &mut Pixmap, text: &str, left: f32, cy: f32, px: 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 Pixmap, l: &PlacedLabel, ppp: f32) { + // 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() { + let inks: &[Ink] = if l.halo.is_some() { &[Ink::Halo, Ink::Text] } else { &[Ink::Text] }; + // (The label without its characters, copied for each: not the characters each time.) + let base = PlacedLabel { text: String::new(), align: 0.0, angle: None, glyphs: Vec::new(), ..l.clone() }; + for &ink in inks { + for g in &l.glyphs { + let one = PlacedLabel { text: g.text.clone(), x: l.x + g.dx, y: l.y + g.dy, ..base.clone() }; + draw_turned(pm, &one, g.angle, ppp, ink); + } + } + return; + } match l.angle { - Some(a) if a.is_finite() && a.abs() > 1e-3 => draw_turned(pm, l, a, ppp), - _ => draw_upright(pm, l, ppp), + Some(a) if a.is_finite() && a.abs() > 1e-3 => draw_turned(pm, l, a, ppp, Ink::Both), + _ => draw_upright(pm, l, ppp, Ink::Both), } } +/// What of a label to draw: its halo, its text, or both (the halo under the text). +#[derive(Clone, Copy, PartialEq)] +enum Ink { + Halo, + Text, + Both, +} + /// Draws `l` turned by `angle` (radians, clockwise) about its centre. -fn draw_turned(pm: &mut Pixmap, l: &PlacedLabel, angle: f32, ppp: f32) { +fn draw_turned(pm: &mut Pixmap, l: &PlacedLabel, angle: f32, ppp: f32, ink: Ink) { let (w, h) = (label_width(l), block_height(&l.text, l.size).max(line_pitch(l.size))); // Room for the halo, and for the glyphs reaching past the line. let pad = 4.0 + l.size * 0.5; @@ -449,14 +471,14 @@ fn draw_turned(pm: &mut Pixmap, l: &PlacedLabel, angle: f32, ppp: f32) { let Some(mut pic) = Pixmap::new(pw.max(1.0) as u32, ph.max(1.0) as u32) else { return }; let (cx, cy) = (pw / ppp / 2.0, ph / ppp / 2.0); let upright = PlacedLabel { x: cx + l.align * w, y: cy, angle: None, framed: false, icon: None, ..l.clone() }; - draw_upright(&mut pic, &upright, ppp); + draw_upright(&mut pic, &upright, ppp, ink); let (tx, ty) = (l.center_x() * ppp, l.y * ppp); let t = Transform::from_translate(-pw / 2.0, -ph / 2.0).post_rotate(angle.to_degrees()).post_translate(tx, ty); let paint = tiny_skia::PixmapPaint { quality: tiny_skia::FilterQuality::Bilinear, ..tiny_skia::PixmapPaint::default() }; pm.draw_pixmap(0, 0, pic.as_ref(), &paint, t, None); } -fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { +fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32, ink: Ink) { let px = l.size * 1.33 * ppp; let (cx, cy) = (l.center_x() * ppp, l.y * ppp); // Several lines (#577) as the map view draws them: each aligned in a block as wide as the @@ -494,7 +516,7 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { } }; // Every line's halo first, so none covers the line above it. - if let Some(h) = l.halo { + if let Some(h) = l.halo.filter(|_| ink != Ink::Text) { let r = 1.2 * ppp; let ring = [(-r, 0.0), (r, 0.0), (0.0, -r), (0.0, r), (-r * 0.7, -r * 0.7), (r * 0.7, r * 0.7), (-r * 0.7, r * 0.7), (r * 0.7, -r * 0.7)]; @@ -504,6 +526,9 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32) { } } } + if ink == Ink::Halo { + return; + } for line in &placed { put(pm, line, l.color, (0.0, 0.0)); } @@ -560,6 +585,7 @@ pub fn scale_bar(pm: &mut Pixmap, view: &View, ground_per_pt: f64) { line_align: TextAlign::Left, angle: None, collide: Default::default(), + glyphs: Vec::new(), }; draw(pm, &label, ppp); } diff --git a/crates/render/src/tiles.rs b/crates/render/src/tiles.rs index bc698ae91..66e2d7755 100644 --- a/crates/render/src/tiles.rs +++ b/crates/render/src/tiles.rs @@ -81,6 +81,7 @@ pub(crate) fn tile_label(l: rustgis_tiles::Label) -> Option<(PlacedLabel, f64)> line_align, angle: l.angle, collide: l.collide, + glyphs: Vec::new(), }, l.priority, )) diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index ed88f82f5..ea82c1bf6 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -194,6 +194,10 @@ fn text_symbol(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { if ui.checkbox(&mut bold, tl!("Bold")).changed() { run(app, "labels.style", json!({"bold": bold})); } + let mut italic = l.labels.default.italic; + if ui.checkbox(&mut italic, tl!("Italic")).changed() { + run(app, "labels.style", json!({"italic": italic})); + } let mut halo = l.labels.default.halo; if ui.checkbox(&mut halo, tl!("Halo")).changed() { run(app, "labels.style", json!({"halo": halo})); @@ -222,6 +226,15 @@ fn position(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { ui.end_row(); } }); + // Lines: the text along them, straight or bending with them. + if l.geometry_type() == rustgis_engine::data::GeometryType::Polyline { + ui.add_space(4.0); + ui.label(RichText::new(tl!("Along lines")).size(11.5)); + ui.horizontal(|ui| { + pick(app, ui, LabelPlacement::Parallel); + pick(app, ui, LabelPlacement::Curved); + }); + } }); } diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index e4e4fd26d..4cf746a4a 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1197,6 +1197,25 @@ fn place_galley(anchor: Align2, at: Pos2, g: &egui::Galley) -> (Pos2, Rect) { } fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { + // 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() { + let inks: &[(bool, bool)] = if l.halo.is_some() { &[(true, false), (false, true)] } else { &[(false, true)] }; + // (The label without its characters, copied for each: not the characters each time.) + let base = PlacedLabel { text: String::new(), align: 0.0, glyphs: Vec::new(), ..l.clone() }; + for &(halo, text) in inks { + for g in &l.glyphs { + let one = PlacedLabel { text: g.text.clone(), angle: Some(g.angle), ..base.clone() }; + draw_label_ink(p, &one, at + vec2(g.dx, g.dy), halo, text); + } + } + return; + } + draw_label_ink(p, l, at, true, true); +} + +/// Draws a label (not a curved one) at `at`: its halo when `halo`, its text when `text`. +fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text: bool) { let galley = p.layout_job(label_job(&l.text, l.size, l.bold, l.italic, l.spacing, l.line_align)); // Text set beside an icon is lined up by its edge, with egui's own measure of its width. let anchor = if l.align < -0.25 { @@ -1214,13 +1233,15 @@ fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { .with_override_text_color(color) .with_angle_and_anchor(angle, Align2::CENTER_CENTER) }; - if let Some(h) = l.halo { + if let Some(h) = l.halo.filter(|_| halo) { let hc = c32(h); for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-0.8, -0.8), (0.8, 0.8), (-0.8, 0.8), (0.8, -0.8)] { p.add(shape(vec2(dx, dy), hc)); } } - p.add(shape(egui::Vec2::ZERO, c32(l.color))); + if text { + p.add(shape(egui::Vec2::ZERO, c32(l.color))); + } return; } let (pos, r) = place_galley(anchor, at, &galley); @@ -1229,13 +1250,15 @@ fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { let cyan = c32(rustgis_engine::render::SELECTION); p.rect(r.expand(3.0), 0.0, cyan.gamma_multiply(40.0 / 255.0), Stroke::new(2.0, cyan), egui::StrokeKind::Outside); } - if let Some(h) = l.halo { + if let Some(h) = l.halo.filter(|_| halo) { let hc = c32(h); for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0), (-0.8, -0.8), (0.8, 0.8), (-0.8, 0.8), (0.8, -0.8)] { p.galley_with_override_text_color(pos + vec2(dx, dy), galley.clone(), hc); } } - p.galley_with_override_text_color(pos, galley, c32(l.color)); + if text { + p.galley_with_override_text_color(pos, galley, c32(l.color)); + } } /// The globe as seen in this view, when the map is a globe. diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index b2388998b..55dada6d2 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -239,7 +239,7 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: - **Label field and text**: the field the labels show (`labels.field={"layer": "Cities", "field": "name"}`), and the text symbol's size (4–72 points), - colour, halo and bold (`labels.style={"layer": "Cities", "size": 12, "color": "#5A1E0A", "halo": true}`). + colour, halo, bold and italic (`labels.style={"layer": "Cities", "size": 12, "color": "#5A1E0A", "halo": true}`). - **SQL**: label only the features that match a SQL clause (the language of definition queries), such as `pop_max > 5000000`. In the Labeling pane, Class › **SQL** › **New expression**: write the clause (the field and operator buttons add to it), **Check** says @@ -251,7 +251,14 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: two viewing distances (Closest / Farthest). Clear Limits removes all four. - **Placement**: automatic (above points, centred on lines and polygons), centred, or one of eight positions around the feature (`labels.placement={"layer": "Cities", "position": - "aboveRight"}`). + "aboveRight"}`). Lines can be labelled **along** them (Position › Along lines): **Parallel** + sets the text straight, turned to the line's direction across it; **Curved** turns each + character with the line where it bends. Both sit at the middle of the line's longest stretch + in view, centred on the line and kept upright, and may run past its ends by about a letter; a + line too short for its text, or (curved) one bending more than 25° from one character to the + next, is left unlabelled. A text over 200 characters long is set straight rather than + curved. Points and polygons with these placements are labelled as automatic + (`labels.placement={"layer": "Rivers", "position": "curved"}`). - **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. - **Convert** turns a layer's labels into an annotation layer: one point per label, carrying its From 9df790cbe27b88d97f64add049b5448737ed4000 Mon Sep 17 00:00:00 2001 From: liyongyin Date: Sat, 24 Oct 2026 21:51:40 +0800 Subject: [PATCH 103/297] perf(render): cache repeated text width measurements (#804) Co-authored-by: yongyin-leon <269641072+yongyin-leon@users.noreply.github.com> --- crates/render/src/text.rs | 11 ++ crates/render/src/text/widths.rs | 84 ++++++++++++ crates/render/src/text/widths/tests.rs | 176 +++++++++++++++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 crates/render/src/text/widths.rs create mode 100644 crates/render/src/text/widths/tests.rs diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index ddd500856..810667cd4 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -10,6 +10,8 @@ use crate::{PlacedLabel, View}; use rustgis_map::Rgba; use rustgis_map::layout::TextAlign; +mod widths; + fn font(bold: bool) -> Option<&'static FontRef<'static>> { static R: OnceLock>> = OnceLock::new(); static B: OnceLock>> = OnceLock::new(); @@ -202,7 +204,16 @@ pub fn line_count(text: &str) -> usize { } /// Text width in points at `size` (font px ≈ size × 1.33 for point sizes), drawn on one line. +/// Repeated measurements share a bounded retention cache; fonts are discovered once per process. +/// Long strings and non-finite sizes keep the uncached behavior, including its glyph limit. pub fn measure(text: &str, size: f32, bold: bool) -> f32 { + static WIDTHS: OnceLock = OnceLock::new(); + WIDTHS.get_or_init(widths::Cache::default).measure(text, size, bold, || measure_uncached(text, size, bold)) +} + +/// The original measurement, including em scaling, font fallbacks and the missing-font estimate. +/// Cache misses call this outside the cache lock; no glyphs or font faces are retained by the cache. +fn measure_uncached(text: &str, size: f32, bold: bool) -> f32 { let Some(f) = font(bold) else { return text.chars().count() as f32 * size * 0.6; }; diff --git a/crates/render/src/text/widths.rs b/crates/render/src/text/widths.rs new file mode 100644 index 000000000..8b92172f8 --- /dev/null +++ b/crates/render/src/text/widths.rs @@ -0,0 +1,84 @@ +//! Retains repeated label-width results without changing the font measurement itself. + +use std::collections::{HashMap, hash_map::DefaultHasher}; +use std::hash::{Hash, Hasher}; +use std::sync::Mutex; + +/// Each shard releases its table and strings when their estimated retained size exceeds this. +/// Allocator overhead, measurement workspaces and simultaneous misses are outside this estimate. +const SHARD_BYTES: usize = 128 * 1024; +/// Longer inputs use the caller's unchanged measurement path and are never copied into the cache. +const MAX_TEXT_BYTES: usize = 4096; + +/// The full key accompanies its hash so a collision can only cause a miss, never a wrong width. +struct Entry { + text: String, + size: u32, + bold: bool, + width: f32, +} + +/// One shard's entries and owned string capacities; an eviction drops both allocations. +#[derive(Default)] +struct Widths { + entries: HashMap, + strings: usize, +} + +impl Widths { + /// Checks the complete measurement key, keeping signed zero and distinct finite sizes apart. + fn get(&self, hash: u64, text: &str, size: u32, bold: bool) -> Option { + self.entries.get(&hash).filter(|e| e.text == text && e.size == size && e.bold == bold).map(|e| e.width) + } + + /// Estimates retained strings and table capacity with slack for buckets, using saturation. + /// This is a retention policy, not a strict allocator or process-memory bound. + fn retained_bytes(&self) -> usize { + self.strings.saturating_add(self.entries.capacity().saturating_mul(2 * std::mem::size_of::<(u64, Entry)>())) + } + + /// Replaces a colliding entry and accounts for its old string before checking the budget. + /// Over-budget shards release their capacity rather than retaining an empty large table. + fn insert(&mut self, hash: u64, text: &str, size: u32, bold: bool, width: f32) { + let text = text.to_owned(); + self.strings = self.strings.saturating_add(text.capacity()); + if let Some(old) = self.entries.insert(hash, Entry { text, size, bold, width }) { + self.strings = self.strings.saturating_sub(old.text.capacity()); + } + if self.retained_bytes() > SHARD_BYTES { + *self = Self::default(); + } + } +} + +/// Sixteen independent shards retain at most 16 × the per-shard estimate after insertion. +/// Misses measure without a lock, so concurrent misses may repeat work but cannot mix results. +#[derive(Default)] +pub(super) struct Cache { + shards: [Mutex; 16], +} + +impl Cache { + /// Returns a matching width or calls `uncached` once, preserving its exact floating-point result. + /// Oversized text and non-finite sizes bypass hashing, locking and retention. Poisoned locks + /// are recovered: entries are only installed after the caller has measured successfully. + pub(super) fn measure(&self, text: &str, size: f32, bold: bool, uncached: impl FnOnce() -> f32) -> f32 { + if text.len() > MAX_TEXT_BYTES || !size.is_finite() { + return uncached(); + } + let bits = size.to_bits(); + let mut hasher = DefaultHasher::new(); + (text, bits, bold).hash(&mut hasher); + let hash = hasher.finish(); + let Some(shard) = self.shards.get(hash as usize % self.shards.len()) else { return uncached() }; + if let Some(width) = shard.lock().unwrap_or_else(|e| e.into_inner()).get(hash, text, bits, bold) { + return width; + } + let width = uncached(); + shard.lock().unwrap_or_else(|e| e.into_inner()).insert(hash, text, bits, bold, width); + width + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/render/src/text/widths/tests.rs b/crates/render/src/text/widths/tests.rs new file mode 100644 index 000000000..5c3a94927 --- /dev/null +++ b/crates/render/src/text/widths/tests.rs @@ -0,0 +1,176 @@ +use super::{Cache, MAX_TEXT_BYTES, SHARD_BYTES, Widths}; +use crate::text::{font, measure, shape}; + +/// Frozen pre-cache measurement: independent of `measure_uncached`, so changes to the miss path +/// cannot make the expected and actual results drift together. Font/shaping behavior stays shared. +fn original_measure(text: &str, size: f32, bold: bool) -> f32 { + let Some(f) = font(bold) else { + return text.chars().count() as f32 * size * 0.6; + }; + shape(text, size * 1.33, f, None).1 +} + +/// Compares cold and warm widths bit for bit across scripts, missing glyphs, kerning and sizes. +/// Extreme inputs keep the original NaN/infinity results instead of being clamped by the cache. +#[test] +fn measured_widths_match_the_original_bits() { + let long = "Long label 中 ".repeat(500); + let at_limit = "x".repeat(MAX_TEXT_BYTES); + for text in ["", "AV WA", "Mixed 中英 日本語 한글", "Ελλάδα Беларусь العربية", "a\u{301}\u{200d}", "\u{10FFFD}", &long, &at_limit] + { + for size in + [0.0, -0.0, -8.0, f32::MIN_POSITIVE, f32::from_bits(1), 4.0, 10.5, 72.0, f32::MAX, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] + { + for bold in [false, true] { + let expected = original_measure(text, size, bold).to_bits(); + for _ in 0..2 { + assert_eq!(measure(text, size, bold).to_bits(), expected, "{text:?}, {size:?}, bold={bold}"); + } + } + } + } + // The cache must preserve shaping's 500-character cutoff, not remeasure a shortened key. + assert_eq!(measure(&"A".repeat(500), 10.0, false).to_bits(), measure(&"A".repeat(501), 10.0, false).to_bits()); +} + +/// A warm key skips measurement, while text, exact size bits and weight select separate entries. +/// The no-font estimate is just another callback result and retains its original arithmetic. +#[test] +fn warm_keys_skip_work_and_distinguish_all_inputs() { + let cache = Cache::default(); + let calls = std::cell::Cell::new(0); + for (text, size, bold) in [ + ("label", 0.0_f32, false), + ("label", -0.0, false), + ("label", 10.0, false), + ("label", f32::from_bits(10.0_f32.to_bits() + 1), false), + ("label", 10.0, true), + ("Label", 10.0, false), + ] { + let expected = text.chars().count() as f32 * size * 0.6; + let actual = cache.measure(text, size, bold, || { + calls.set(calls.get() + 1); + expected + }); + assert_eq!(actual.to_bits(), expected.to_bits()); + assert_eq!(cache.measure(text, size, bold, || panic!("a matching key must hit")).to_bits(), expected.to_bits()); + } + assert_eq!(calls.get(), 6); +} + +/// Non-finite sizes and oversized UTF-8 strings are measured every time without retained keys. +/// The exact byte threshold still admits a cache hit, independent of the glyph count. +#[test] +fn bypassed_inputs_do_not_allocate_entries() { + let cache = Cache::default(); + let long = "中".repeat(MAX_TEXT_BYTES / 3 + 1); + let calls = std::cell::Cell::new(0); + for (text, size) in [("label", f32::NAN), ("label", f32::INFINITY), ("label", f32::NEG_INFINITY), (&long, 10.0)] { + for _ in 0..2 { + let width = cache.measure(text, size, false, || { + calls.set(calls.get() + 1); + f32::from_bits(0x7fc0_1234) + }); + assert_eq!(width.to_bits(), 0x7fc0_1234); + } + } + assert_eq!(calls.get(), 8); + assert!(cache.shards.iter().all(|s| s.lock().unwrap().entries.capacity() == 0)); + let text = "x".repeat(MAX_TEXT_BYTES); + assert_eq!(cache.measure(&text, 10.0, false, || 123.0), 123.0); + assert_eq!(cache.measure(&text, 10.0, false, || panic!("threshold input must hit")), 123.0); +} + +/// Forced hash collisions cannot return another string, size or weight's width; replacement +/// also removes the old string capacity from the retained-size estimate. +#[test] +fn collisions_are_misses_and_replacements_are_accounted() { + let mut widths = Widths::default(); + widths.insert(7, "a longer label", 0, false, 9.0); + assert_eq!(widths.get(7, "a longer label", 0, false), Some(9.0)); + assert_eq!(widths.get(7, "another label", 0, false), None); + assert_eq!(widths.get(7, "a longer label", (-0.0_f32).to_bits(), false), None); + assert_eq!(widths.get(7, "a longer label", 0, true), None); + widths.insert(7, "b", 0, true, 2.0); + assert_eq!(widths.entries.len(), 1); + assert_eq!(widths.strings, widths.entries.get(&7).unwrap().text.capacity()); + assert_eq!(widths.get(7, "a longer label", 0, false), None); + assert_eq!(widths.get(7, "b", 0, true), Some(2.0)); +} + +/// Budget pressure releases both strings and hash-table capacity, then accepts fresh entries. +/// Each post-insert estimate is checked; the policy does not claim a transient allocation cap. +#[test] +fn budget_eviction_releases_capacity_and_allows_reuse() { + // Empty strings force the table capacity alone to cross the budget; long ones charge strings too. + for len in [0, MAX_TEXT_BYTES] { + let mut widths = Widths::default(); + let text = "x".repeat(len); + let mut evicted = false; + for key in 0..4096_u32 { + widths.insert(u64::from(key), &text, key, false, 1.0); + assert!(widths.retained_bytes() <= SHARD_BYTES); + if widths.entries.is_empty() { + evicted = true; + assert_eq!(widths.strings, 0); + assert_eq!(widths.entries.capacity(), 0); + break; + } + } + assert!(evicted, "the fixture must cross the retention budget"); + widths.insert(1, "fresh", 0, false, 42.0); + assert_eq!(widths.get(1, "fresh", 0, false), Some(42.0)); + } +} + +/// A measurement may reenter the cache because the expensive callback runs without its lock. +/// A failed callback publishes no entry and leaves the same key usable on a later attempt. +#[test] +fn reentrant_and_failed_measurements_leave_the_cache_usable() { + let cache = Cache::default(); + assert_eq!(cache.measure("same", 10.0, false, || cache.measure("same", 10.0, false, || 17.0)), 17.0); + let failed = std::panic::catch_unwind(|| cache.measure("failed", 10.0, false, || panic!("measurement failed"))); + assert!(failed.is_err()); + assert_eq!(cache.measure("failed", 10.0, false, || 23.0), 23.0); + assert_eq!(cache.measure("failed", 10.0, false, || panic!("retry must have been cached")), 23.0); +} + +/// Poisoning a cache lock cannot make later map measurements panic or discard valid widths. +/// All shards are poisoned to exercise the public lookup path without depending on its hash. +#[test] +fn poisoned_shards_keep_measurements_available() { + let cache = Cache::default(); + assert_eq!(cache.measure("retained", 10.0, false, || 19.0), 19.0); + for shard in &cache.shards { + let result = std::panic::catch_unwind(|| { + let _guard = shard.lock().unwrap(); + panic!("poison the test cache"); + }); + assert!(result.is_err() && shard.is_poisoned()); + } + assert_eq!(cache.measure("retained", 10.0, false, || panic!("the retained entry must hit")), 19.0); + assert_eq!(cache.measure("new", 10.0, false, || 29.0), 29.0); + assert_eq!(cache.measure("new", 10.0, false, || panic!("the new entry must hit")), 29.0); +} + +/// Independent sizes and weights stay correct under concurrent cold misses and warm reads. +/// Local cache state avoids dependence on parallel tests' global-cache evictions. +#[test] +fn concurrent_measurements_preserve_results() { + let cache = Cache::default(); + std::thread::scope(|scope| { + for worker in 0..8 { + let cache = &cache; + scope.spawn(move || { + for n in 0..64 { + let text = format!("AV road 中 {}", n % 4); + let size = 8.0 + worker as f32; + let bold = worker % 2 == 0; + let expected = original_measure(&text, size, bold).to_bits(); + assert_eq!(cache.measure(&text, size, bold, || original_measure(&text, size, bold)).to_bits(), expected); + assert_eq!(cache.measure(&text, size, bold, || panic!("warm concurrent key must hit")).to_bits(), expected); + } + }); + } + }); +} From 881db84e8ee7034deafa987e5ba8b56fa6cb26f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Sat, 24 Oct 2026 09:25:11 -0500 Subject: [PATCH 104/297] fix(app): copy and paste through the system clipboard again on egui 0.37 (#815) * fix(app): copy and paste through the system clipboard again on egui 0.37 * chore: refresh the third-party notices for the desktop clipboard crates --- Cargo.lock | 101 +++++++++++++++++++++-- THIRD-PARTY-LICENSES.txt | 170 ++++++++++++++++++++++++++++++++++----- apps/rustgis/Cargo.toml | 2 +- 3 files changed, 248 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c77ef40e..1a4d73d1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -866,18 +866,42 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", +] + [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop", + "calloop 0.13.0", "rustix 0.38.44", "wayland-backend", "wayland-client", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cbc" version = "0.2.1" @@ -1562,6 +1586,7 @@ dependencies = [ "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", + "smithay-clipboard", "web-time", "webbrowser", "windows-sys 0.61.2", @@ -5042,7 +5067,7 @@ dependencies = [ "ab_glyph", "log", "memmap2", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "tiny-skia 0.11.4", ] @@ -5315,8 +5340,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop", - "calloop-wayland-source", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", "cursor-icon", "libc", "log", @@ -5333,6 +5358,44 @@ dependencies = [ "xkeysym", ] +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + [[package]] name = "smol_str" version = "0.2.2" @@ -6402,6 +6465,32 @@ dependencies = [ "wayland-scanner", ] +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -7053,7 +7142,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "bytemuck", - "calloop", + "calloop 0.13.0", "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", @@ -7075,7 +7164,7 @@ dependencies = [ "redox_syscall 0.4.1", "rustix 0.38.44", "sctk-adwaita", - "smithay-client-toolkit", + "smithay-client-toolkit 0.19.2", "smol_str", "tracing", "unicode-segmentation", diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index 3f9e5c3b5..3fc4cc01f 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -9,7 +9,7 @@ by `cargo xtask notices`. Apache License 2.0 Used by: a5, ab_glyph, ab_glyph_rasterizer, accesskit, accesskit_atspi_common, accesskit_consumer, accesskit_macos, accesskit_unix, accesskit_windows, accesskit_winit, aes, ahash, allocator-api2, -approx, arc-swap, arrayvec, as-raw-xcb-connection, async-channel, async-executor, async-io, +approx, arboard, arc-swap, arrayvec, as-raw-xcb-connection, async-channel, async-executor, async-io, async-lock, async-process, async-recursion, async-signal, async-task, async-trait, atomic-waker, atspi, atspi-common, atspi-proxies, autocfg, aws-lc-sys, base64, bit-set, bit-vec, bitflags, block-buffer, block-padding, blocking, btoi, bufstream, bumpalo, bytemuck, bytemuck_derive, cbc, cc, @@ -20,20 +20,20 @@ crossbeam-epoch, crossbeam-queue, crossbeam-utils, crypto-common, ctutils, der, derive_utils, digest, dispatch2, displaydoc, document-features, downcast-rs, dunce, ecolor, eframe, egui, egui-wgpu, egui-winit, egui_extras, either, emath, encoding_rs, enum-map, enum-map-derive, epaint, equivalent, errno, euclid, event-listener, event-listener-strategy, fastrand, fdeflate, -fearless_simd, filetime, find-msvc-tools, flagset, flatbuffers, flate2, form_urlencoded, -futures-lite, futures-timer, geo, geo-traits, geo-types, geozero, gethostname, gif, gl_generator, -glow, guillotiere, half, hash32, hashbrown, hashlink, heapless, heck, hkdf, hmac, httparse, -hybrid-array, i_overlay, idna, idna_adapter, image, image-webp, indexmap, inout, io-enum, itertools, -itoa, jobserver, js-sys, khronos-egl, khronos_api, kurbo, laz, lazy_static, leb128fmt, libc, -linebender_resource_handle, linux-raw-sys, litrs, lock_api, log, lyon, lyon_algorithms, lyon_geom, -lyon_path, lyon_tessellation, md-5, md5, miniz_oxide, multiversion_no_op, mysql, mysql_common, naga, -naga-types, named_pipe, nohash-hasher, num, num-bigint, num-complex, num-conv, num-integer, -num-iter, num-rational, num-traits, objc2-app-kit, objc2-core-foundation, objc2-core-graphics, -objc2-metal, objc2-quartz-core, objc2-system-configuration, once_cell, openssl-probe, -ordered-stream, owned_ttf_parser, parking, parking_lot, parking_lot_core, parquet, peniko, -percent-encoding, pin-project, pin-project-internal, pin-project-lite, piper, pkg-config, png, -polling, pollster, polycool, postgres, postgres-protocol, postgres-types, presser, proc-macro2, -profiling, proj4rs, proj4rs-geodesic, pyo3, pyo3-build-config, pyo3-ffi, pyo3-macros, +fearless_simd, filetime, find-msvc-tools, fixedbitset, flagset, flatbuffers, flate2, +form_urlencoded, futures-lite, futures-timer, geo, geo-traits, geo-types, geozero, gethostname, gif, +gl_generator, glow, guillotiere, half, hash32, hashbrown, hashlink, heapless, heck, hkdf, hmac, +httparse, hybrid-array, i_overlay, idna, idna_adapter, image, image-webp, indexmap, inout, io-enum, +itertools, itoa, jobserver, js-sys, khronos-egl, khronos_api, kurbo, laz, lazy_static, leb128fmt, +libc, linebender_resource_handle, linux-raw-sys, litrs, lock_api, log, lyon, lyon_algorithms, +lyon_geom, lyon_path, lyon_tessellation, md-5, md5, miniz_oxide, multiversion_no_op, mysql, +mysql_common, naga, naga-types, named_pipe, nohash-hasher, num, num-bigint, num-complex, num-conv, +num-integer, num-iter, num-rational, num-traits, objc2-app-kit, objc2-core-foundation, +objc2-core-graphics, objc2-metal, objc2-quartz-core, objc2-system-configuration, once_cell, +openssl-probe, ordered-stream, owned_ttf_parser, parking, parking_lot, parking_lot_core, parquet, +peniko, percent-encoding, petgraph, pin-project, pin-project-internal, pin-project-lite, piper, +pkg-config, png, polling, pollster, polycool, postgres, postgres-protocol, postgres-types, presser, +proc-macro2, profiling, proj4rs, proj4rs-geodesic, pyo3, pyo3-build-config, pyo3-ffi, pyo3-macros, pyo3-macros-backend, quote, rand, range-alloc, raw-window-handle, raw-window-metal, rayon, rayon-core, regex, regex-automata, regex-syntax, renderdoc-sys, ring, robust, roxmltree, rstar, rustc-hash, rustc_version, rustix, rustls, rustls-native-certs, rustls-pemfile, rustversion, ryu, @@ -48,7 +48,8 @@ vello_common, vello_cpu, version_check, wasm-bindgen, wasm-bindgen-futures, wasm wasm-bindgen-macro-support, wasm-bindgen-shared, wasm-encoder, wasmi, wasmi_collections, wasmi_core, wasmi_ir, wasmparser, wast, wat, web-sys, weezl, wgpu, wgpu-core, wgpu-core-deps-apple, wgpu-core-deps-wasm, wgpu-core-deps-windows-linux-android, wgpu-hal, wgpu-naga-bridge, wgpu-types, -whoami, winauth, wkt, x11rb, x11rb-protocol, x509-cert, xattr, zeroize, zune-core, zune-jpeg +whoami, winauth, wkt, wl-clipboard-rs, x11rb, x11rb-protocol, x509-cert, xattr, zeroize, zune-core, +zune-jpeg ==================================================================================================== Apache License @@ -8083,6 +8084,35 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +==================================================================================================== +Boost Software License 1.0 +Used by: clipboard-win, error-code +==================================================================================================== + +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + ==================================================================================================== Community Data License Agreement Permissive 2.0 Used by: webpki-roots @@ -9675,6 +9705,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +==================================================================================================== +MIT License +Used by: nom +==================================================================================================== + +Copyright (c) 2014-2019 Geoffroy Couprie + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ==================================================================================================== MIT License Used by: ordered-float @@ -9706,6 +9762,31 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +==================================================================================================== +MIT License +Used by: os_pipe +==================================================================================================== + +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ==================================================================================================== MIT License Used by: pem @@ -9992,6 +10073,31 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +==================================================================================================== +MIT License +Used by: smithay-clipboard +==================================================================================================== + +Copyright (c) 2018 Lucas Timmins & Victor Berger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + ==================================================================================================== MIT License Used by: spin, tracing-core, zip @@ -10196,6 +10302,33 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +==================================================================================================== +MIT License +Used by: tree_magic_mini +==================================================================================================== + +MIT License + +Copyright (c) 2017 Aaron Hancock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ==================================================================================================== MIT License Used by: twox-hash @@ -10279,8 +10412,9 @@ SOFTWARE. ==================================================================================================== MIT License -Used by: wayland-backend, wayland-client, wayland-cursor, wayland-protocols, wayland-protocols-plasma, -wayland-protocols-wlr, wayland-scanner, wayland-sys +Used by: wayland-backend, wayland-client, wayland-cursor, wayland-protocols, wayland-protocols-experimental, +wayland-protocols-misc, wayland-protocols-plasma, wayland-protocols-wlr, wayland-scanner, +wayland-sys ==================================================================================================== Copyright (c) 2015 Elinor Berger diff --git a/apps/rustgis/Cargo.toml b/apps/rustgis/Cargo.toml index 3d506cccb..118511541 100644 --- a/apps/rustgis/Cargo.toml +++ b/apps/rustgis/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [dependencies] rustgis-engine = { workspace = true, features = ["geolibre"] } rustgis-ui-egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "links", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["clipboard", "default_fonts", "links", "wgpu", "accesskit", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } From 45b28ec72d1c238884bb22f4b331e51c8406846a Mon Sep 17 00:00:00 2001 From: Alexey Tkachenko <182909819+alteralto@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:32:44 +0300 Subject: [PATCH 105/297] feat(symbology): geometric interval and standard deviation classes (#805) * feat(symbology): geometric interval and standard deviation classes * fix(symbology): geometric bounds far from zero; no empty standard deviation class * fix(symbology): no overflow in geometric and standard deviation bounds near the float limits * docs(cartography): how geometric interval and standard deviation treat edge cases * fix(symbology): dedupe class bounds relative to their size, so tiny values keep their classes * fix(symbology): dedupe only equal class bounds --------- Co-authored-by: Qiusheng Wu --- ROADMAP.md | 5 +- .../rustgis-python/python/rustgis/__init__.py | 3 +- crates/assistant/skills/thematic-map/SKILL.md | 2 +- crates/engine/src/cmd/layer.rs | 2 +- crates/map/src/symbology.rs | 140 +++++++++++++++++- docs/user-guide/cartography.md | 9 +- 6 files changed, 152 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e7291bf16..483933bd6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,7 +57,7 @@ The bugs users hit, the docs and a few quick wins: good first pull requests. - A pass over the user guide, the comparison pages and the README against what the app does today (vertex editing, spatial join and kriging are listed as missing or undocumented; some pages still say 16 languages). -- Styling: standard deviation, geometric interval and manual breaks _(open)_. +- Styling: manual breaks _(open)_. - Selection by polygon and lasso, and a radius tool on the map (Neighborhood Explorer already selects by radius) _(open)_. @@ -187,7 +187,8 @@ Open questions rather than plans; discuss before building. projections), a search box for layers, places and coordinates, bookmarks, web tile layers (XYZ/TMS/WMS, Mapbox Vector Tiles, MapLibre styles, PMTiles and MBTiles archives), a basemap gallery (OpenFreeMap, USGS Imagery), worldviews. -- Cartography: single, unique values, graduated colours and proportional symbols; layer groups; +- Cartography: single, unique values, graduated colours (natural breaks, equal interval, quantile, + geometric interval, standard deviation) and proportional symbols; layer groups; label classes with SQL queries, placement and annotation; custom colour ramps (and QGIS, GDAL and GMT palette files); masking, display filters, blend modes, aggregation, extrusion; graphics. - Editing: create, move, rotate, scale, merge, split, explode, vertex editing; snapping, templates, elevation, diff --git a/apps/rustgis-python/python/rustgis/__init__.py b/apps/rustgis-python/python/rustgis/__init__.py index f0b4796a3..4e68bfe08 100644 --- a/apps/rustgis-python/python/rustgis/__init__.py +++ b/apps/rustgis-python/python/rustgis/__init__.py @@ -157,7 +157,8 @@ def style(self, type: str = "single", field: Optional[str] = None, **params: Any (colours by class) or ``"proportional"`` (symbol sizes by value). field: The field the colours or sizes come from (not for ``"single"``). **params: More ``layer.symbology`` parameters: ``classes`` (1 to 12), ``method`` - (``"naturalBreaks"``, ``"equalInterval"``, ``"quantile"``), ``ramp``. + (``"naturalBreaks"``, ``"equalInterval"``, ``"quantile"``, + ``"geometricInterval"``, ``"standardDeviation"``), ``ramp``. Returns: The layer, so calls can be chained. diff --git a/crates/assistant/skills/thematic-map/SKILL.md b/crates/assistant/skills/thematic-map/SKILL.md index 71ab8f218..8ffd91f3c 100644 --- a/crates/assistant/skills/thematic-map/SKILL.md +++ b/crates/assistant/skills/thematic-map/SKILL.md @@ -9,7 +9,7 @@ description: Style a layer by its data (graduated colours, categories, proportio field suits graduated or proportional symbols; text or a few distinct values suit unique values. 2. Style it with `run_command` `layer.symbology`: - Numbers: `{"layer": L, "type": "graduated", "field": F, "classes": 5, "method": "naturalBreaks"}` (or - `quantile`, `equalInterval`), with an optional `"ramp"`. + `quantile`, `equalInterval`, `geometricInterval`, `standardDeviation`), with an optional `"ramp"`. For counts or sizes, `"type": "proportional"` usually reads better than colour. - Categories: `{"layer": L, "type": "unique", "field": F}`. More than about 12 categories is hard to read, so suggest grouping first. diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index e09bed58c..034e0f2a5 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -109,7 +109,7 @@ pub fn commands() -> Vec { .when(need_layer), C::new("layer.symbology", "Symbology", "Style › Drawing", symbology) .when(need_feature_layer) - .params(r#"{"layer"?, "type": "single"|"unique"|"graduated"|"proportional", "field"?, "classes"?: 1..12, "method"?: "naturalBreaks"|"equalInterval"|"quantile", "ramp"?: string}"#), + .params(r#"{"layer"?, "type": "single"|"unique"|"graduated"|"proportional", "field"?, "classes"?: 1..12, "method"?: "naturalBreaks"|"equalInterval"|"quantile"|"geometricInterval"|"standardDeviation", "ramp"?: string}"#), C::new("layer.ramp", "Color Scheme", "Style › Drawing", |s, p| { let r = s.ramp(s_req(p, "ramp")?)?; with(s, p, |l| match &mut l.raster { diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 2629d2efb..7868ba668 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -311,15 +311,27 @@ pub enum ClassMethod { NaturalBreaks, EqualInterval, Quantile, + /// Class widths that grow by one ratio, for values that pile up at the low end. + GeometricInterval, + /// Classes one standard deviation wide about the mean. + StandardDeviation, } impl ClassMethod { - pub const ALL: [ClassMethod; 3] = [ClassMethod::NaturalBreaks, ClassMethod::EqualInterval, ClassMethod::Quantile]; + pub const ALL: [ClassMethod; 5] = [ + ClassMethod::NaturalBreaks, + ClassMethod::EqualInterval, + ClassMethod::Quantile, + ClassMethod::GeometricInterval, + ClassMethod::StandardDeviation, + ]; pub fn label(self) -> &'static str { match self { ClassMethod::NaturalBreaks => "Natural Breaks (Jenks)", ClassMethod::EqualInterval => "Equal Interval", ClassMethod::Quantile => "Quantile", + ClassMethod::GeometricInterval => "Geometric Interval", + ClassMethod::StandardDeviation => "Standard Deviation", } } pub fn from_name(s: &str) -> Option { @@ -328,6 +340,8 @@ impl ClassMethod { "naturalbreaks" | "natural_breaks" | "jenks" | "natural breaks" => ClassMethod::NaturalBreaks, "equalinterval" | "equal_interval" | "equal interval" => ClassMethod::EqualInterval, "quantile" => ClassMethod::Quantile, + "geometricinterval" | "geometric_interval" | "geometric interval" | "geometric" => ClassMethod::GeometricInterval, + "standarddeviation" | "standard_deviation" | "standard deviation" | "stddev" => ClassMethod::StandardDeviation, _ => return None, }) } @@ -1048,8 +1062,50 @@ pub fn class_breaks(sorted: &[f64], k: usize, method: ClassMethod) -> Vec { ClassMethod::EqualInterval => (1..=k).map(|i| lo + (hi - lo) * i as f64 / k as f64).collect::>(), ClassMethod::Quantile => (1..=k).map(|i| sorted.get(((n * i) / k).saturating_sub(1).min(n - 1)).copied().unwrap_or(hi)).collect(), ClassMethod::NaturalBreaks => jenks(sorted, k), + ClassMethod::GeometricInterval => { + // Positive values: each bound is the one before times (hi / lo)^(1/k), worked out on + // logarithms. Values from zero or below are taken as if the minimum sat at a hundredth + // of the range, which makes the ratio 101^(1/k); each bound is then a share of the way + // from lo to hi. Neither adds large values, so the bounds stay finite and in order. + let range = hi - lo; + (1..=k) + .map(|i| { + let f = i as f64 / k as f64; + let v = if lo > 0.0 { + (lo.ln() + (hi.ln() - lo.ln()) * f).exp() + } else { + let t = (101f64.powf(f) - 1.0) / 100.0; + if range.is_finite() { lo + range * t } else { lo * (1.0 - t) + hi * t } + }; + v.clamp(lo, hi) + }) + .collect() + } + ClassMethod::StandardDeviation => { + // On values scaled by the largest magnitude, so the squares can't overflow. + let s = lo.abs().max(hi.abs()); + let mean_s = sorted.iter().map(|v| v / s).sum::() / n as f64; + let mean = mean_s * s; + let sd = s * (sorted.iter().map(|v| (v / s - mean_s).powi(2)).sum::() / n as f64).sqrt(); + // k − 1 inner bounds one deviation apart, centred on the mean. A bound with no value + // above the one before it is dropped, so no class is empty. + let inner = (1..k).map(|i| mean + sd * (i as f64 - k as f64 / 2.0)).filter(|&v| v > lo && v < hi); + let mut b = Vec::new(); + let mut below = 0; + for v in inner.chain(std::iter::once(hi)) { + let upto = sorted.partition_point(|x| *x <= v); + if upto > below { + b.push(v); + below = upto; + } + } + b + } }; - b.dedup_by(|a, b| (*a - *b).abs() < f64::EPSILON); + // Only bounds that are the same value: any tolerance merges distinct values somewhere (tiny + // ones under an absolute one, large ones under a relative one). Repeated bounds come from + // repeated values (quantiles, Jenks), so they are equal exactly. + b.dedup(); if let Some(l) = b.last_mut() { *l = hi; } @@ -1770,6 +1826,86 @@ mod tests { assert_eq!(class_breaks(&[5.0, 5.0], 4, ClassMethod::NaturalBreaks), vec![5.0]); } + fn close(a: &[f64], b: &[f64]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9 * y.abs().max(1.0)) + } + + #[test] + fn geometric_interval_breaks_grow_by_one_ratio() { + // Positive values: each bound is the one before times (hi / lo)^(1/k). + let v = [1.0, 5.0, 40.0, 300.0, 1000.0]; + let b = class_breaks(&v, 3, ClassMethod::GeometricInterval); + assert!(close(&b, &[10.0, 100.0, 1000.0]), "{b:?}"); + // From zero (or below), the values are shifted so the minimum sits at a hundredth of the + // range: the widths still grow by one ratio, and the last bound is the maximum. + let b = class_breaks(&[-50.0, 0.0, 50.0], 2, ClassMethod::GeometricInterval); + assert!(close(&b, &[-51.0 + 101f64.sqrt(), 50.0]), "{b:?}"); + let b = class_breaks(&[0.0, 100.0], 4, ClassMethod::GeometricInterval); + let widths: Vec = std::iter::once(b[0]).chain(b.windows(2).map(|w| w[1] - w[0])).collect(); + assert!(widths.windows(2).all(|w| w[1] > w[0]), "{b:?}"); + assert_eq!(b.len(), 4); + assert_eq!(b.last(), Some(&100.0)); + // Far from zero the shift is not added and taken away again, so it can't round off. + let b = class_breaks(&[-1e16, -9_999_999_999_999_998.0], 2, ClassMethod::GeometricInterval); + assert!(b.iter().all(|v| v.is_finite()), "{b:?}"); + assert_eq!(b.last(), Some(&-9_999_999_999_999_998.0)); + // Near the largest float nothing overflows: the bounds stay finite and in order. + for v in [[1e308, 1.1e308], [-1e308, 1e308], [1e-308, 1e308]] { + let b = class_breaks(&v, 2, ClassMethod::GeometricInterval); + assert!(b.len() == 2 && b.iter().all(|x| x.is_finite()) && b[0] >= v[0] && b[0] < b[1], "{v:?}: {b:?}"); + } + } + + #[test] + fn standard_deviation_breaks_are_one_deviation_wide_about_the_mean() { + // 0..=100: mean 50, population deviation sqrt(850). + let v: Vec = (0..=100).map(f64::from).collect(); + let sd = 850f64.sqrt(); + // An even count puts a bound on the mean; an odd one centres a class on it. + let b = class_breaks(&v, 4, ClassMethod::StandardDeviation); + assert!(close(&b, &[50.0 - sd, 50.0, 50.0 + sd, 100.0]), "{b:?}"); + let b = class_breaks(&v, 3, ClassMethod::StandardDeviation); + assert!(close(&b, &[50.0 - sd / 2.0, 50.0 + sd / 2.0, 100.0]), "{b:?}"); + // Bounds past the data are dropped rather than leaving empty classes: 8 classes of one + // deviation would reach ±4 deviations, but the data spans less than ±2. + let b = class_breaks(&v, 8, ClassMethod::StandardDeviation); + assert!(close(&b, &[50.0 - sd, 50.0, 50.0 + sd, 100.0]), "{b:?}"); + // A bound with no value above the one before it goes too: mean 4, deviation sqrt(24), so + // (4, 4 + sqrt(24)] would hold nothing between the zeros and the tens. + let b = class_breaks(&[0.0, 0.0, 0.0, 10.0, 10.0], 4, ClassMethod::StandardDeviation); + assert!(close(&b, &[4.0, 10.0]), "{b:?}"); + // Large values: the squared deviations would overflow without scaling. + let b = class_breaks(&[1e160, 2e160], 2, ClassMethod::StandardDeviation); + assert!(close(&b, &[1.5e160, 2e160]), "{b:?}"); + let b = class_breaks(&[-1e308, 1e308], 2, ClassMethod::StandardDeviation); + assert!(close(&b, &[0.0, 1e308]), "{b:?}"); + } + + #[test] + fn close_values_keep_their_classes() { + // Bounds closer than f64::EPSILON in absolute terms are still distinct when the values + // themselves are that small. + for m in ClassMethod::ALL { + let b = class_breaks(&[0.0, 1e-18], 2, m); + assert!(b.len() == 2 && b[0] >= 0.0 && b[0] < b[1], "{m:?}: {b:?}"); + } + // And close values far from zero keep theirs too. + let b = class_breaks(&[1e16, 1e16 + 2.0], 2, ClassMethod::Quantile); + assert_eq!(b, vec![1e16, 1e16 + 2.0]); + } + + #[test] + fn every_method_is_listed_named_and_read_back() { + for m in ClassMethod::ALL { + let name = format!("{m:?}"); + let camel = format!("{}{}", name[..1].to_ascii_lowercase(), &name[1..]); + assert_eq!(ClassMethod::from_name(&camel), Some(m), "{camel}"); + assert_eq!(ClassMethod::from_name(&m.label().replace(" (Jenks)", "")), Some(m), "{}", m.label()); + } + assert_eq!(ClassMethod::from_name("stddev"), Some(ClassMethod::StandardDeviation)); + assert_eq!(ClassMethod::from_name("geometric"), Some(ClassMethod::GeometricInterval)); + } + #[test] fn graduated_symbols_by_value() { let mut d = Dataset::new("t", GeometryType::Polygon, vec![Field::new("v", FieldType::Double)]); diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 55dada6d2..6a45dbc56 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -18,8 +18,13 @@ Select a layer in Layers and open **Style › Symbology** (or the Symbology pane is not fully transparent. A layout legend can leave it out with **Hide other values** (see [Layouts](layouts.md)). - **Graduated Colors**: 1 to 12 classes of a numeric field by natural breaks (Jenks), equal - interval or quantile. If no field is chosen, the current renderer's field is kept when it is - numeric; otherwise the first numeric field is used. If the layer has no numeric field, or the + interval, quantile, geometric interval or standard deviation. Geometric interval makes class + widths grow by one ratio, for values piled up at the low end; for a field that reaches zero or + below, the minimum is taken as if it sat at a hundredth of the range, so the widths grow by + 101^(1/classes). Standard deviation makes classes one deviation wide about the mean, and leaves + out a class no value falls in, so a skewed field can get fewer classes than asked. If no field is + chosen, the current renderer's field is kept when it is numeric; otherwise the first numeric + field is used. If the layer has no numeric field, or the switch fails for another reason, the pane shows why and keeps the current renderer. When numeric fields exist, a field picker lets you try another one; the error stays visible if a retry fails. A problem no field can fix, such as a definition query that keeps no features, needs fixing From 81a8368dfc77e85b64baa1cd17b19fb89621a1f5 Mon Sep 17 00:00:00 2001 From: Mavrick Fitzgerald Date: Sat, 24 Oct 2026 10:16:30 -0500 Subject: [PATCH 106/297] feat(data): read ArcGIS layers past their record limit, in the app and the browser (#823) An ArcGIS FeatureServer or MapServer query that stops at the server's maxRecordCount (exceededTransferLimit) is now read again from the start in pages of resultOffset and resultRecordCount, ordered by the layer's object id field, up to 100,000 features. The first page, asked for in no order, can hold other features than the ordered first (Hawaii's zoning MapServer: 964 of 1,000 shared), so offsets continued from it skip and repeat features. The layer's description is read once to check advancedQueryCapabilities.supportsPagination; layers without it, or whose paging fails before an ordered page lands, keep the first page and its note. A server that ignores resultOffset, and a query that sets its own window, are read as before. Paging is now a sans-I/O state machine (ogc::read_pages -> Step::Fetch / Step::Done). service.add drives it blocking, as before; the app's download poll drives it off the frame, so layers added from Add From Service or the Connections tree are paged on the desktop and in the browser. That path previously read one page for OGC API collections too (#519 paged only service.add), so they are now paged there as well. The shared test server matches a route's path with its query, so a layer's pages are told apart; tests/support/ogc.rs gains ArcGIS page and layer generators. Closes #764 Co-authored-by: Claude Opus 5.5 --- crates/data/src/ogc.rs | 470 ++++++++++++++++++++++++++++--- crates/engine/src/cmd/service.rs | 42 +-- crates/engine/src/tests.rs | 56 ++++ crates/ui-egui/src/lib.rs | 60 ++-- crates/ui-egui/src/serviceui.rs | 82 +++++- crates/ui-egui/tests/smoke.rs | 49 ++++ docs/user-guide/data-formats.md | 4 +- tests/support/ogc.rs | 23 ++ tests/support/ogc_server.rs | 7 +- 9 files changed, 701 insertions(+), 92 deletions(-) diff --git a/crates/data/src/ogc.rs b/crates/data/src/ogc.rs index 83b8ae1ba..d982b6071 100644 --- a/crates/data/src/ogc.rs +++ b/crates/data/src/ogc.rs @@ -1252,12 +1252,14 @@ fn arcgis(url: &str, v: &serde_json::Value) -> Result { } else { return Err("an ArcGIS address, but not a FeatureServer, MapServer, ImageServer or VectorTileServer".into()); } - // A feature layer is one request: the server's record limit bounds it (a cut-off answer is - // pointed out when it lands). + // A feature layer is read past the server's record limit, page by page, where the layer + // supports it ([`read_pages`]). A cut-off answer is pointed out when it lands. if let Some(max) = v.get("maxRecordCount").and_then(|m| m.as_i64()) && service.layers.iter().any(|l| matches!(l.offer, Offer::Features { .. })) { - service.notes.push(format!("a feature layer is read up to the server's {max} features per request")); + service.notes.push(format!( + "a feature layer is read up to {MAX_PAGED_FEATURES} features, {max} per request, where the server pages its layers" + )); } // A secured service's token goes with every request. if let Some(token) = query_value(url, "token").filter(|t| !t.is_empty()) { @@ -1479,11 +1481,7 @@ fn ogc_api(url: &str, v: &serde_json::Value) -> Result { } service.incomplete = unreadable > 0 || collections.len() > MAX_LAYERS; if service.layers.iter().any(|l| matches!(l.offer, Offer::Features { .. })) { - service.notes.push(if cfg!(target_arch = "wasm32") { - format!("a collection is read up to {OGC_API_LIMIT} features (one page of the server's)") - } else { - format!("a collection is read up to {MAX_PAGED_FEATURES} features, page by page") - }); + service.notes.push(format!("a collection is read up to {MAX_PAGED_FEATURES} features, page by page")); } if tiled > 0 { service.kind = if service.layers.iter().any(|l| matches!(l.offer, Offer::Features { .. })) { @@ -1691,60 +1689,300 @@ fn next_page(url: &str, page: &serde_json::Value) -> Option { resolve(url, link.get("href")?.as_str()?) } -/// A feature collection read page by page: `first` (from `url`), then each `next` page `get` -/// returns, up to `max` features. The pages' features in one collection, with the first page's -/// other members and without its paging ones, and a note when some features were left unread. A -/// first page without a `next` link comes back unchanged. +/// Whether an ArcGIS answer says it stopped at the server's record limit (`exceededTransferLimit`, +/// a member of the collection or, in GeoJSON, of its `properties`). +fn exceeded(page: &serde_json::Value) -> bool { + [page.get("exceededTransferLimit"), page.pointer("/properties/exceededTransferLimit")] + .iter() + .any(|v| v.and_then(|v| v.as_bool()) == Some(true)) +} + +/// Whether an address is an ArcGIS layer's query (`…/FeatureServer/0/query`, `…/MapServer/3/query`). +fn is_arcgis_query(url: &str) -> bool { + let b = base(url).trim_end_matches('/').to_ascii_lowercase(); + b.ends_with("/query") && (b.contains("/featureserver/") || b.contains("/mapserver/")) +} + +/// How an ArcGIS layer is read past its record limit: pages of `size` features, in the order of +/// its object ids (`order`) so that no feature is skipped or read twice. +#[derive(Debug, PartialEq)] +struct ArcgisPaging { + size: Option, + order: Option, +} + +/// How a layer's description (`…/FeatureServer/0?f=json`) lets it be read in pages: `None` unless it +/// says it supports them (`advancedQueryCapabilities.supportsPagination`, from ArcGIS Server 10.3). +/// Its `maxRecordCount` is the page size when it is a count RustGIS would ask for; its object id +/// field, the order, when it is a plain field name (it goes into the address). +fn arcgis_paging(layer: &serde_json::Value) -> Option { + if layer.pointer("/advancedQueryCapabilities/supportsPagination").and_then(|p| p.as_bool()) != Some(true) { + return None; + } + let size = layer + .get("maxRecordCount") + .and_then(|m| m.as_u64()) + .and_then(|m| usize::try_from(m).ok()) + .filter(|m| (1..=MAX_PAGED_FEATURES).contains(m)); + let oid_field = || { + layer + .get("fields")? + .as_array()? + .iter() + .find(|f| f.get("type").and_then(|t| t.as_str()) == Some("esriFieldTypeOID"))? + .get("name")? + .as_str() + }; + let order = layer + .get("objectIdField") + .and_then(|o| o.as_str()) + .or_else(oid_field) + .filter(|n| !n.is_empty() && n.len() <= 128 && n.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')) + .map(str::to_string); + Some(ArcgisPaging { size, order }) +} + +/// The description of the layer an ArcGIS query reads (`…/FeatureServer/0?f=json`), with the +/// query's token. +fn arcgis_layer_url(query: &str) -> String { + let b = base(query).trim_end_matches('/'); + let layer = b.get(..b.len().saturating_sub("/query".len())).unwrap_or(b); + match query_value(query, "token").filter(|t| !t.is_empty()) { + Some(token) => format!("{layer}?f=json&token={token}"), + None => format!("{layer}?f=json"), + } +} + +/// The page of an ArcGIS query that starts after `offset` features (`resultOffset`), `size` long. +fn arcgis_page_url(query: &str, offset: usize, size: usize, order: Option<&str>) -> String { + let url = with_query(query, &format!("resultOffset={offset}&resultRecordCount={size}")); + match order { + Some(o) if query_value(&url, "orderByFields").is_none() => with_query(&url, &format!("orderByFields={o}")), + _ => url, + } +} + +/// A feature collection read page by page: `first` (from `url`), then each page `get` returns, up +/// to `max` features, as [`read_pages`] leads (the blocking way to drive it). The pages' features +/// in one collection, and a note when some features were left unread; a first page that leads +/// nowhere comes back unchanged. pub fn join_pages( url: &str, first: Vec, max: usize, mut get: impl FnMut(&str) -> Result, String>, ) -> (Vec, Option) { - let (head, tail) = edges(&first); - if !(head.contains("\"next\"") || tail.contains("\"next\"")) { - return (first, None); + let mut step = read_pages(url, first, max); + loop { + match step { + Step::Done(bytes, note) => return (bytes, note), + Step::Fetch(pages) => { + let answer = get(pages.next_url()); + step = (*pages).land(answer); + } + } } - let Ok(mut doc) = serde_json::from_slice::(&first) else { return (first, None) }; - let Some(mut next) = next_page(url, &doc) else { return (first, None) }; - let Some(mut features) = doc.get_mut("features").and_then(|f| f.as_array_mut()).map(std::mem::take) else { - return (first, None); +} + +/// Where reading a collection page by page has got to ([`read_pages`]). +#[derive(Debug)] +pub enum Step { + /// The next answer to fetch ([`Pages::next_url`]), to hand to [`Pages::land`]. + Fetch(Box), + /// The collection as read (the pages joined, or the first page as it came) and a note when + /// some features were left unread. + Done(Vec, Option), +} + +/// A feature collection being read page by page, sans I/O: it says which address to fetch next +/// and takes the answer, so a blocking command ([`join_pages`]) and the app, which can't wait, +/// read the same way. +#[derive(Debug)] +pub struct Pages { + /// The first page's address. + url: String, + /// The first page, without its features. + doc: serde_json::Value, + features: Vec, + max: usize, + seen: std::collections::HashSet, + /// The address to fetch now, and the one it was found from. + next: String, + from: String, + phase: Phase, +} + +/// What the answer being fetched is. +#[derive(Debug)] +enum Phase { + /// An ArcGIS layer's description, which says whether it can be read in pages; `first` is the + /// first page as it came, kept for when it can't. + Describe { first: Vec }, + /// An OGC API or WFS page, which links to the next. + Linked, + /// An ArcGIS page, `size` long from an offset, in `order`. `start` is the first such page's + /// first feature (a server that ignores the offset sends it again), once it is read; until + /// then `first` keeps the first page as it came, to fall back on. + Offsets { size: usize, order: Option, start: Option, first: Option> }, +} + +/// Starts reading a feature collection page by page from its first page (`first`, from `url`), up +/// to `max` features. An OGC API or WFS page leads to the next by its `next` link. An ArcGIS +/// query that stopped at the server's record limit (`exceededTransferLimit`) is read again in +/// pages (`resultOffset`, `resultRecordCount`, in the order of its object ids) when the layer's +/// description says it can be (fetched first: `advancedQueryCapabilities.supportsPagination`): +/// from the start, since the first page, asked for in no order, may not be the paged order's. The pages' features end up in one collection, +/// with the first page's other members and without its paging ones. A first page that leads +/// nowhere is done at once, unchanged (and [`partial_note`] says what it holds). +pub fn read_pages(url: &str, first: Vec, max: usize) -> Step { + let (head, tail) = edges(&first); + let linked = head.contains("\"next\"") || tail.contains("\"next\""); + // A query that asks for a window of its own (`resultOffset`, `resultRecordCount`) is read as asked. + let limited = is_arcgis_query(url) + && ["resultOffset", "resultRecordCount"].iter().all(|k| query_value(url, k).is_none()) + && (is_true(&head, "exceededTransferLimit") || is_true(&tail, "exceededTransferLimit")); + if !(linked || limited) { + return Step::Done(first, None); + } + let Ok(mut doc) = serde_json::from_slice::(&first) else { return Step::Done(first, None) }; + let Some(features) = doc.get_mut("features").and_then(|f| f.as_array_mut()).map(std::mem::take) else { + return Step::Done(first, None); }; - let mut seen = std::collections::HashSet::from([url.to_string()]); - let note = loop { - if features.len() >= max { - features.truncate(max); - break Some(format!("the first {max} features are read; the rest aren't")); - } - if !seen.insert(next.clone()) { - break Some("the service's pages lead back to an earlier page; the rest aren't read".into()); + let (next, phase) = if linked && let Some(next) = next_page(url, &doc) { + (next, Phase::Linked) + } else if limited && exceeded(&doc) { + (arcgis_layer_url(url), Phase::Describe { first }) + } else { + return Step::Done(first, None); + }; + let pages = Pages { + url: url.to_string(), + doc, + features, + max, + seen: std::collections::HashSet::from([url.to_string()]), + next, + from: url.to_string(), + phase, + }; + match pages.phase { + Phase::Linked => pages.advance(), + _ => Step::Fetch(Box::new(pages)), + } +} + +impl Pages { + /// The address to fetch now. + pub fn next_url(&self) -> &str { + &self.next + } + + /// The address [`next_url`](Self::next_url) was found from (the page or query that leads to + /// it), which it may only point where that one may. + pub fn referrer(&self) -> &str { + &self.from + } + + /// How many features have been read so far. + pub fn read(&self) -> usize { + self.features.len() + } + + /// Takes the answer to [`next_url`](Self::next_url) (or why it couldn't be fetched): the next + /// address to fetch, or the collection as read. + pub fn land(mut self, answer: Result, String>) -> Step { + if let Phase::Describe { first } = &mut self.phase { + let first = std::mem::take(first); + let paging = answer.ok().and_then(|b| serde_json::from_slice(&b).ok()).and_then(|l| arcgis_paging(&l)); + let Some(paging) = paging else { return Step::Done(first, None) }; + let size = paging.size.unwrap_or(self.features.len()); + if size == 0 { + return Step::Done(first, None); + } + // Offsets count in one order: the first page, asked for in none, may hold other + // features than the paged order's first, so the layer is read again from the start. + self.features.clear(); + self.next = arcgis_page_url(&self.url, 0, size, paging.order.as_deref()); + self.from = self.url.clone(); + self.phase = Phase::Offsets { size, order: paging.order, start: None, first: Some(first) }; + return self.advance(); } - let mut page = match get(&next).and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) { + let read = self.features.len(); + let mut page = match answer.and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) { Ok(page) => page, - Err(e) => break Some(format!("the page after feature {} couldn't be read ({e}); the rest aren't read", features.len())), + Err(e) => return self.stop(format!("the page after feature {read} couldn't be read ({e}); the rest aren't read")), }; let Some(more) = page.get_mut("features").and_then(|f| f.as_array_mut()).map(std::mem::take) else { - break Some(format!("the page after feature {} isn't a feature collection; the rest aren't read", features.len())); + return self.stop(format!("the page after feature {read} isn't a feature collection; the rest aren't read")); + }; + let following = match &mut self.phase { + Phase::Offsets { size, order, start, first } => { + // A server that doesn't honour `resultOffset` sends its first page again. + if start.is_some() && more.first() == start.as_ref() { + return self.stop("the server sent its first features again instead of the next ones; the rest aren't read".into()); + } + if start.is_none() && !more.is_empty() { + *start = more.first().cloned(); + *first = None; + } + exceeded(&page).then(|| arcgis_page_url(&self.url, read + more.len(), *size, order.as_deref())) + } + _ => next_page(&self.next, &page), }; - features.extend(more); - if features.len() > max { - features.truncate(max); - break Some(format!("the first {max} features are read; the rest aren't")); + self.features.extend(more); + if self.features.len() > self.max { + return self.finish_at_max(); } - match next_page(&next, &page) { - Some(n) => next = n, - None => break None, + match following { + Some(n) => { + self.from = std::mem::replace(&mut self.next, n); + self.advance() + } + None => self.finish(None), } - }; - if let Some(members) = doc.as_object_mut() { - for paging in ["links", "numberMatched", "numberReturned"] { - members.remove(paging); + } + + /// Done early, with a note; or, when an ArcGIS layer's paged reading stops before any of its + /// features are read, with the first page as it came (which says it was cut off). + fn stop(mut self, note: String) -> Step { + if let Phase::Offsets { first: Some(first), .. } = &mut self.phase { + return Step::Done(std::mem::take(first), None); } - members.insert("features".into(), serde_json::Value::Array(features)); + self.finish(Some(note)) } - match serde_json::to_vec(&doc) { - Ok(joined) => (joined, note), - Err(_) => (first, None), + + /// The next page fetched, unless the features are all read that may be or the pages lead + /// back to one already read. + fn advance(mut self) -> Step { + if self.features.len() >= self.max { + return self.finish_at_max(); + } + if !self.seen.insert(self.next.clone()) { + return self.stop("the service's pages lead back to an earlier page; the rest aren't read".into()); + } + Step::Fetch(Box::new(self)) + } + + /// Done with the first `max` features. + fn finish_at_max(mut self) -> Step { + let max = self.max; + self.features.truncate(max); + self.finish(Some(format!("the first {max} features are read; the rest aren't"))) + } + + /// The pages' features in the first page's collection, without its paging members. + fn finish(mut self, note: Option) -> Step { + if let Some(members) = self.doc.as_object_mut() { + for paging in ["links", "numberMatched", "numberReturned", "exceededTransferLimit"] { + members.remove(paging); + } + if let Some(p) = members.get_mut("properties").and_then(|p| p.as_object_mut()) { + p.remove("exceededTransferLimit"); + } + members.insert("features".into(), serde_json::Value::Array(self.features)); + } + // A JSON value always writes; an empty answer would be said as unreadable. + Step::Done(serde_json::to_vec(&self.doc).unwrap_or_default(), note) } } @@ -2102,6 +2340,148 @@ mod tests { assert_eq!(join_pages(url, html_only.to_vec(), 100, unasked), (html_only.to_vec(), None)); } + /// An ArcGIS layer past its record limit: read again from the start in pages of the layer's + /// `maxRecordCount` ordered by its object ids (its first page, asked for in no order, may hold + /// other features than the ordered first: Hawaii's zoning did), each from where the last + /// stopped, until a page no longer says it was cut off. A layer that can't be paged, or whose + /// paging fails before a page is read, comes back as it was (with its note). + #[test] + fn arcgis_pages_joined_from_offsets() { + let query = "https://x.org/arcgis/rest/services/P/FeatureServer/0/query?where=1%3D1&outFields=*&f=geojson&token=t"; + // A page of the features `from..to` (of 5), cut off unless it reaches the last. + let page = |from: u64, to: u64| { + let features: Vec<_> = (from..to).map(|i| serde_json::json!({"type": "Feature", "id": i, "properties": {}})).collect(); + let mut p = serde_json::json!({"type": "FeatureCollection", "features": features}); + if to < 5 { + p["properties"] = serde_json::json!({"exceededTransferLimit": true}); + } + serde_json::to_vec(&p).unwrap() + }; + let layer = |extra: &str| { + format!(r#"{{"currentVersion": 11.1, "objectIdField": "OBJECTID", "advancedQueryCapabilities": {{"supportsPagination": true}}{extra}}}"#) + .into_bytes() + }; + let ids = |b: &[u8]| -> Vec { + let v: serde_json::Value = serde_json::from_slice(b).unwrap(); + v["features"].as_array().unwrap().iter().map(|f| f["id"].as_u64().unwrap()).collect() + }; + // Answers by address, keeping the addresses asked for. + let server = |info: Vec| { + move |asked: &mut Vec, u: &str| -> Result, String> { + asked.push(u.to_string()); + if u == "https://x.org/arcgis/rest/services/P/FeatureServer/0?f=json&token=t" { + return Ok(info.clone()); + } + let at = |k: &str| query_value(u, k).and_then(|v| v.parse::().ok()); + match (at("resultOffset"), at("resultRecordCount")) { + (Some(o), Some(n)) => Ok(page(o, (o + n).min(5))), + _ => Err("http status: 404".into()), + } + } + }; + // The first page in the server's own order: two of the five, not the ordered first two. + let unordered = br#"{"type":"FeatureCollection","features":[{"id":3},{"id":0}],"properties":{"exceededTransferLimit":true}}"#; + let mut asked = Vec::new(); + let answer = server(layer(r#", "maxRecordCount": 2"#)); + let (joined, note) = join_pages(query, unordered.to_vec(), 100, |u| answer(&mut asked, u)); + assert_eq!((ids(&joined), note), (vec![0, 1, 2, 3, 4], None)); + assert_eq!(asked[1], format!("{query}&resultOffset=0&resultRecordCount=2&orderByFields=OBJECTID")); + assert!(asked[2].contains("resultOffset=2&resultRecordCount=2") && asked[3].contains("resultOffset=4&resultRecordCount=2")); + assert_eq!(asked.len(), 4); + let v: serde_json::Value = serde_json::from_slice(&joined).unwrap(); + assert!(v.pointer("/properties/exceededTransferLimit").is_none()); + assert!(partial_note(&joined).is_none()); + // The limit cuts it short, with a note. + let answer = server(layer(r#", "maxRecordCount": 2"#)); + let (joined, note) = join_pages(query, page(0, 2), 3, |u| answer(&mut Vec::new(), u)); + assert_eq!(ids(&joined), [0, 1, 2]); + assert!(note.is_some_and(|n| n.contains("first 3 features"))); + // A hostile `maxRecordCount` isn't asked for: the pages are as long as the first. + for hostile in + [r#", "maxRecordCount": 0"#, r#", "maxRecordCount": -5"#, r#", "maxRecordCount": 1e300"#, r#", "maxRecordCount": "x""#, ""] + { + let mut asked = Vec::new(); + let answer = server(layer(hostile)); + let (joined, note) = join_pages(query, page(0, 2), 100, |u| answer(&mut asked, u)); + assert_eq!((ids(&joined), note), (vec![0, 1, 2, 3, 4], None), "{hostile}"); + assert!(asked[1].contains("resultOffset=0&resultRecordCount=2&"), "{hostile}: {asked:?}"); + } + let answer = server(layer(r#", "maxRecordCount": 18446744073709551615"#)); + assert_eq!(ids(&join_pages(query, page(0, 2), 100, |u| answer(&mut Vec::new(), u)).0), [0, 1, 2, 3, 4]); + // A layer that doesn't page (or says nothing of it, before 10.3), or whose description + // can't be read: the first page as it came, which says it was cut off. + for info in [ + br#"{"currentVersion": 11, "advancedQueryCapabilities": {"supportsPagination": false}}"#.to_vec(), + br#"{"currentVersion": 10.22, "maxRecordCount": 2}"#.to_vec(), + b"".to_vec(), + ] { + let mut asked = Vec::new(); + let answer = server(info); + let (joined, note) = join_pages(query, page(0, 2), 100, |u| answer(&mut asked, u)); + assert_eq!((joined, note), (page(0, 2), None)); + assert_eq!(asked.len(), 1); + assert!(partial_note(&page(0, 2)).is_some_and(|n| n.contains("record limit"))); + } + // A server that ignores `resultOffset` sends its first page again: not read twice. + let (joined, note) = + join_pages(query, page(0, 2), 100, |u| if u.contains("resultOffset") { Ok(page(0, 2)) } else { Ok(layer("")) }); + assert_eq!(ids(&joined), [0, 1]); + assert!(note.is_some_and(|n| n.contains("first features again"))); + // Paging that fails before a page is read (a page that adds nothing would ask for itself + // again; a page that can't be fetched): the first page as it came. + let empty = br#"{"type":"FeatureCollection","features":[],"properties":{"exceededTransferLimit":true}}"#; + let (joined, note) = + join_pages(query, page(0, 2), 100, |u| if u.contains("resultOffset") { Ok(empty.to_vec()) } else { Ok(layer("")) }); + assert_eq!((joined, note), (page(0, 2), None)); + let (joined, note) = + join_pages(query, page(0, 2), 100, |u| if u.contains("resultOffset") { Err("http status: 500".into()) } else { Ok(layer("")) }); + assert_eq!((joined, note), (page(0, 2), None)); + // A page that fails later ends the reading with what was read, and says so. + let answer = server(layer(r#", "maxRecordCount": 2"#)); + let (joined, note) = join_pages(query, page(0, 2), 100, |u| { + if u.contains("resultOffset=2&") { Err("http status: 500".into()) } else { answer(&mut Vec::new(), u) } + }); + assert_eq!(ids(&joined), [0, 1]); + assert!(note.is_some_and(|n| n.contains("after feature 2 couldn't be read (http status: 500)"))); + // Only an ArcGIS query is asked again, only one that was cut off, and not one that asks + // for a window of its own. + let unasked = |_: &str| -> Result, String> { panic!("no page is asked for") }; + let elsewhere = "https://x.org/collections/a/items?f=json"; + assert_eq!(join_pages(elsewhere, page(0, 2), 100, unasked), (page(0, 2), None)); + assert_eq!(join_pages(query, page(0, 5), 100, unasked), (page(0, 5), None)); + let window = format!("{query}&resultRecordCount=2"); + assert_eq!(join_pages(&window, page(0, 2), 100, unasked), (page(0, 2), None)); + } + + /// The addresses an ArcGIS layer is paged with: its description's (with the token), each page's + /// (keeping the query's own order), and the order from the layer's object id field. + #[test] + fn arcgis_page_addresses() { + let q = "https://x.org/a/MapServer/3/query?where=1%3D1&f=geojson#top"; + assert!(is_arcgis_query(q) && !is_arcgis_query("https://x.org/a/MapServer/3") && !is_arcgis_query("https://x.org/query")); + assert_eq!(arcgis_layer_url(q), "https://x.org/a/MapServer/3?f=json"); + assert_eq!( + arcgis_page_url(q, 4, 2, Some("FID")), + "https://x.org/a/MapServer/3/query?where=1%3D1&f=geojson&resultOffset=4&resultRecordCount=2&orderByFields=FID#top" + ); + // An order the query already has is kept. + let ordered = "https://x.org/a/FeatureServer/0/query?orderByFields=NAME"; + assert_eq!(arcgis_page_url(ordered, 1, 1, Some("FID")), format!("{ordered}&resultOffset=1&resultRecordCount=1")); + // The object id field from the fields when `objectIdField` is missing; never a name that + // isn't plain. + let paging = |v: serde_json::Value| arcgis_paging(&v); + let pages = serde_json::json!({"supportsPagination": true}); + assert_eq!( + paging(serde_json::json!({"advancedQueryCapabilities": pages, "fields": [{"name": "FID", "type": "esriFieldTypeOID"}]})), + Some(ArcgisPaging { size: None, order: Some("FID".into()) }) + ); + assert_eq!( + paging(serde_json::json!({"advancedQueryCapabilities": pages, "maxRecordCount": 1000, "objectIdField": "id&f=html"})), + Some(ArcgisPaging { size: Some(1000), order: None }) + ); + assert_eq!(paging(serde_json::json!({"maxRecordCount": 1000})), None); + } + #[test] fn wms_layers_in_web_mercator() { let xml = r#" diff --git a/crates/engine/src/cmd/service.rs b/crates/engine/src/cmd/service.rs index 77254bf3d..976487667 100644 --- a/crates/engine/src/cmd/service.rs +++ b/crates/engine/src/cmd/service.rs @@ -196,30 +196,36 @@ pub fn features_answer(s: &mut Session, name: &str, bytes: Vec) -> Vec { rustgis_data::ogc::fix_axes(&bytes).unwrap_or(bytes) } -/// A service's features at `url`, with the pages its `next` links lead to: at most `max` bytes -/// and [`BLOCKING_TIMEOUT`](crate::render::rustgis_tiles::cache::BLOCKING_TIMEOUT) in all. A -/// `next` link is fetched only where its page may point. +/// A service's features at `url`, with the pages that follow (its `next` links, or an ArcGIS +/// layer's pages past its record limit, see [`read_pages`](rustgis_data::ogc::read_pages)): at +/// most `max` bytes and [`BLOCKING_TIMEOUT`](crate::render::rustgis_tiles::cache::BLOCKING_TIMEOUT) +/// in all. A page is fetched only where the page before it may point. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn download_features(s: &mut Session, name: &str, url: &str, max: usize) -> Result, String> { use crate::render::rustgis_tiles::{address, cache}; let until = std::time::Instant::now() + cache::BLOCKING_TIMEOUT; let first = cache::download_blocking_limited(url, max).map_err(|e| format!("{url}: {e}"))?; let mut left = max.saturating_sub(first.len()); - let mut from = url.to_string(); - let (bytes, note) = rustgis_data::ogc::join_pages(url, first, rustgis_data::ogc::MAX_PAGED_FEATURES, |next| { - address::check(next, Some(&from))?; - let wait = until.saturating_duration_since(std::time::Instant::now()); - if wait.is_zero() { - return Err("the pages took too long".into()); - } - let page = cache::download_blocking_within(next, left, wait)?; - if page.len() > left { - return Err("the pages are larger than RustGIS downloads".into()); - } - left -= page.len(); - from = next.to_string(); - Ok(page) - }); + let mut step = rustgis_data::ogc::read_pages(url, first, rustgis_data::ogc::MAX_PAGED_FEATURES); + let (bytes, note) = loop { + let pages = match step { + rustgis_data::ogc::Step::Done(bytes, note) => break (bytes, note), + rustgis_data::ogc::Step::Fetch(pages) => pages, + }; + let answer = address::check(pages.next_url(), Some(pages.referrer())).and_then(|()| { + let wait = until.saturating_duration_since(std::time::Instant::now()); + if wait.is_zero() { + return Err("the pages took too long".to_string()); + } + let page = cache::download_blocking_within(pages.next_url(), left, wait)?; + if page.len() > left { + return Err("the pages are larger than RustGIS downloads".into()); + } + left -= page.len(); + Ok(page) + }); + step = (*pages).land(answer); + }; if let Some(note) = note { s.notify(format!("{name}: {note}"), false); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 033cefa8c..12795133f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -7643,6 +7643,62 @@ fn ogc_api_features_follow_next_pages() { assert!(s.notices.iter().any(|n| n.text.starts_with("Inward: ") && n.text.contains("never fetched"))); } +/// An ArcGIS layer past its record limit is read again in pages ordered by its object ids, from +/// the start (its first page, in the server's own order, may hold other features than the ordered +/// first: Hawaii's zoning did), when its description says it can be; a layer that can't keeps its +/// first page, and says so. +#[test] +fn arcgis_features_read_past_the_record_limit() { + use ogc_server::{Route, Server, arcgis_layer, arcgis_page}; + let q = "where=1%3D1&outFields=*&returnGeometry=true&outSR=4326&f=geojson"; + let parcels = "/arcgis/rest/services/P/MapServer/0"; + let page = |offset: u32| format!("{parcels}/query?{q}&resultOffset={offset}&resultRecordCount=2&orderByFields=OBJECTID"); + let server = Server::new(vec![ + // The first page in the server's own order: two of the five, not the ordered first two. + Route::new(&format!("{parcels}/query?{q}"), &arcgis_page(&[3, 0], true)), + Route::new(parcels, &arcgis_layer(2, true)), + Route::new(&page(0), &arcgis_page(&[0, 1], true)), + Route::new(&page(2), &arcgis_page(&[2, 3], true)), + Route::new(&page(4), &arcgis_page(&[4], false)), + Route::new("/arcgis/rest/services/P/MapServer/1/query", &arcgis_page(&[0, 1], true)), + Route::new("/arcgis/rest/services/P/MapServer/1", &arcgis_layer(2, false)), + ]); + let service = r#"{"currentVersion": 11.5, "maxRecordCount": 2, "layers": [{"id": 0, "name": "Parcels"}, {"id": 1, "name": "Zoning"}]}"#; + let url = format!("{}/arcgis/rest/services/P/MapServer", server.base); + let mut s = Session::new(Project::default()); + let r = s.run("service.add", &json!({"url": url, "layer": "Parcels", "text": service})).unwrap(); + assert_eq!(r["features"], 5); + let ids: Vec<_> = s.map().layer_by_name("Parcels").unwrap().dataset.features.iter().map(|f| f.attrs[0].as_f64()).collect(); + assert_eq!(ids, [Some(0.0), Some(1.0), Some(2.0), Some(3.0), Some(4.0)]); + assert!(!s.notices.iter().any(|n| n.text.contains("aren't read")), "{:?}", s.notices.iter().map(|n| &n.text).collect::>()); + let r = s.run("service.add", &json!({"url": url, "layer": "Zoning", "text": service})).unwrap(); + assert_eq!(r["features"], 2); + assert!(s.notices.iter().any(|n| n.text.starts_with("Zoning: ") && n.text.contains("record limit"))); + // The description once, then the pages from the start; a layer that can't page, its + // description alone. + let asked: Vec<_> = server + .requests() + .iter() + .map(|r| { + let offset = r.query.iter().find(|(k, _)| k == "resultOffset").map(|(_, v)| v.clone()); + (r.path.trim_start_matches("/arcgis/rest/services/P/MapServer").to_string(), offset) + }) + .collect(); + let at = |path: &str, offset: Option<&str>| (path.to_string(), offset.map(str::to_string)); + assert_eq!( + asked, + [ + at("/0/query", None), + at("/0", None), + at("/0/query", Some("0")), + at("/0/query", Some("2")), + at("/0/query", Some("4")), + at("/1/query", None), + at("/1", None) + ] + ); +} + /// A WCS coverage (from a local server) becomes a raster of the view, asked for in EPSG:4326 within /// its bounds; an error answer is refused. A catalogue's records come with the links RustGIS can /// follow, those pointing inward left out. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index becdadbd5..ef36a824e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -538,6 +538,8 @@ pub struct UiState { /// that can be read in place are added so, others downloaded (the `ui.addUrl` parameters, /// and the answer). pub cog_probes: Vec<(Value, Slot>)>, + /// Service layers whose features are being read page by page, after their first page landed. + pub service_pages: Vec, /// A link's data (`?data=`) has zoomed the map: the first of its files to land with something /// to show does, whichever it is. pub link_zoomed: bool, @@ -685,6 +687,7 @@ impl Default for UiState { project_download: None, sql: Default::default(), cog_probes: Vec::new(), + service_pages: Vec::new(), link_zoomed: false, service: None, service_browse: ServiceBrowse::default(), @@ -3107,32 +3110,11 @@ impl RustGisApp { for (id, url, mut p, r) in landed { let origin = connectiontree::take_origin_key(&mut p); match r { - Ok(bytes) => { - if !self.session.download_landed(id, bytes) { - self.session.notify( - i18n::fmt("{url}: downloaded after its project was closed, so it wasn't added", &[("url", &url)]), - false, - ); - continue; - } - p["id"] = json!(id); - p["url"] = json!(url); - let zoom = p.get("zoom").and_then(Value::as_bool) == Some(true); - if zoom && self.ui.link_zoomed { - p["zoom"] = json!(false); - } - let before = self.session.sources.generation; - match self.run("layer.addDownload", p) { - Ok(r) => { - connectiontree::name_recent(self, before, origin.as_deref()); - if r.get("zoomed").and_then(Value::as_bool) == Some(true) { - self.ui.link_zoomed = true; - } - self.map.invalidate(); - } - Err(e) => self.session.notify(if e.starts_with(&url) { e } else { format!("{url}: {e}") }, true), - } + // A service's features: the pages after the first are read, off the frame, first. + Ok(bytes) if p.get("service").and_then(Value::as_bool) == Some(true) => { + serviceui::read_pages(self, serviceui::Landed { id, url, params: p, origin }, bytes); } + Ok(bytes) => self.add_landed(serviceui::Landed { id, url, params: p, origin }, bytes), Err(e) => { self.session.download_failed(id); self.session.notify(if e.starts_with(&url) { e } else { format!("{url}: {e}") }, true); @@ -3141,6 +3123,33 @@ impl RustGisApp { } } + /// Adds a download that landed (`layer.addDownload`), named in Recent after the connection it + /// came from; one whose project was closed meanwhile is said and dropped. + pub(crate) fn add_landed(&mut self, landed: serviceui::Landed, bytes: Vec) { + let serviceui::Landed { id, url, params: mut p, origin } = landed; + if !self.session.download_landed(id, bytes) { + self.session.notify(i18n::fmt("{url}: downloaded after its project was closed, so it wasn't added", &[("url", &url)]), false); + return; + } + p["id"] = json!(id); + p["url"] = json!(url); + let zoom = p.get("zoom").and_then(Value::as_bool) == Some(true); + if zoom && self.ui.link_zoomed { + p["zoom"] = json!(false); + } + let before = self.session.sources.generation; + match self.run("layer.addDownload", p) { + Ok(r) => { + connectiontree::name_recent(self, before, origin.as_deref()); + if r.get("zoomed").and_then(Value::as_bool) == Some(true) { + self.ui.link_zoomed = true; + } + self.map.invalidate(); + } + Err(e) => self.session.notify(if e.starts_with(&url) { e } else { format!("{url}: {e}") }, true), + } + } + /// Tells how a service layer added outside the add-service dialog (or in it) went: in the /// dialog's own line, or as a notice for the Connections tree. fn report_step(&mut self, dialog: bool, r: Result) { @@ -3321,6 +3330,7 @@ impl RustGisApp { sqlpane::poll(self, ctx); self.poll_service(); serviceui::poll(self, ctx); + serviceui::poll_pages(self, ctx); // Sources the AI Assistant or a background add brought in (not through `run`) are kept // too: a generation compared each frame. So is a default raster style it set. sourcespane::save(self); diff --git a/crates/ui-egui/src/serviceui.rs b/crates/ui-egui/src/serviceui.rs index 5ed443f5a..e63b138a5 100644 --- a/crates/ui-egui/src/serviceui.rs +++ b/crates/ui-egui/src/serviceui.rs @@ -1,7 +1,8 @@ //! Background group import/refresh for both desktop and browser. Discovery and updates live in //! engine commands; this state machine only supplies their downloaded documents. -use crate::{RustGisApp, Slot, fetch_into, fetch_into_limited}; +use crate::{RustGisApp, Slot, fetch_into, fetch_into_limited, i18n}; +use rustgis_engine::data::ogc::{self, Pages, Step}; use serde_json::{Value, json}; use std::collections::VecDeque; @@ -249,3 +250,82 @@ pub fn poll(app: &mut RustGisApp, ctx: &egui::Context) { app.ui.service_import = Some(task); } } + +/// A download that landed, with what it is added with: its id, address and `layer.addDownload` +/// parameters, and the connection it came from (named in Recent). +pub(crate) struct Landed { + pub id: u64, + pub url: String, + pub params: Value, + pub origin: Option, +} + +/// A service layer's features read page by page off the frame, after its first page landed (#764): +/// each further answer [`Pages`] asks for is fetched in the background and handed to it, until +/// the collection is read; then it is added as the download it began as. +pub struct PageRead { + landed: Landed, + pages: Box, + /// The bytes the answers still to come may take (the download's budget, less those read). + left: usize, + slot: Slot, String>>, +} + +/// Starts reading a service's features past their first page (`first`, landed): added now when it +/// leads nowhere, else read on in the background ([`poll_pages`]). +pub(crate) fn read_pages(app: &mut RustGisApp, landed: Landed, first: Vec) { + let left = rustgis_engine::render::rustgis_tiles::cache::MAX_DOWNLOAD.saturating_sub(first.len()); + let step = ogc::read_pages(&landed.url, first, ogc::MAX_PAGED_FEATURES); + if matches!(step, Step::Fetch(_)) { + app.session.notify(i18n::fmt("Reading {url} page by page…", &[("url", &landed.url)]), false); + } + go_on(app, landed, left, step); +} + +/// Hands each page that landed to its read, and adds the layers whose features are all read. A +/// read whose project was closed meanwhile is dropped. +pub fn poll_pages(app: &mut RustGisApp, ctx: &egui::Context) { + if app.ui.service_pages.is_empty() { + return; + } + for read in std::mem::take(&mut app.ui.service_pages) { + if !app.session.download_is_pending(read.landed.id) { + let url = &read.landed.url; + app.session.notify(i18n::fmt("{url}: downloaded after its project was closed, so it wasn't added", &[("url", url)]), false); + continue; + } + let answer = read.slot.lock().unwrap_or_else(|e| e.into_inner()).take(); + match answer { + Some(answer) => { + let left = read.left.saturating_sub(answer.as_ref().map_or(0, Vec::len)); + let step = (*read.pages).land(answer); + go_on(app, read.landed, left, step); + } + None => app.ui.service_pages.push(read), + } + } + if !app.ui.service_pages.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } +} + +/// The next answer fetched, or the collection added (with a note when some features were left +/// unread). +fn go_on(app: &mut RustGisApp, landed: Landed, left: usize, step: Step) { + match step { + Step::Fetch(pages) => { + let slot = match rustgis_engine::render::rustgis_tiles::address::check(pages.next_url(), Some(pages.referrer())) { + Ok(()) => fetch_into_limited(pages.next_url(), left), + Err(e) => Slot::new(std::sync::Mutex::new(Some(Err(e)))), + }; + app.ui.service_pages.push(PageRead { landed, pages, left, slot }); + } + Step::Done(bytes, note) => { + if let Some(note) = note { + let name = landed.params.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).unwrap_or(&landed.url); + app.session.notify(format!("{name}: {note}"), false); + } + app.add_landed(landed, bytes); + } + } +} diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index aed864063..022f12757 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -1402,6 +1402,55 @@ fn data_from_a_web_address_lands_later() { assert!(h.state().ui.downloads.is_empty()); } +/// A service layer added from the app (Add From Service, the Connections tree) is read past its +/// first page off the frame (#764): an ArcGIS layer again from the start in pages ordered by its +/// object ids, an OGC API collection by its `next` links; the layer is added once the pages are +/// in, with no note. +#[test] +fn service_features_are_read_page_by_page_off_the_frame() { + use ogc_server::{Route, Server, arcgis_layer, arcgis_page}; + let q = "where=1%3D1&outFields=*&f=geojson"; + let layer = "/arcgis/rest/services/P/MapServer/0"; + let page = |offset: u32| format!("{layer}/query?{q}&resultOffset={offset}&resultRecordCount=2&orderByFields=OBJECTID"); + let collection = |ids: &[u32], next: Option<&str>| { + let mut c: serde_json::Value = serde_json::from_str(&arcgis_page(ids, false)).unwrap(); + if let Some(href) = next { + c["links"] = json!([{"rel": "next", "href": href}]); + } + c.to_string() + }; + let server = Server::new(vec![ + Route::new(&format!("{layer}/query?{q}"), &arcgis_page(&[3, 0], true)), + Route::new(layer, &arcgis_layer(2, true)), + Route::new(&page(0), &arcgis_page(&[0, 1], true)), + Route::new(&page(2), &arcgis_page(&[2, 3], true)), + Route::new(&page(4), &arcgis_page(&[4], false)), + Route::new("/collections/wells/items?page=2", &collection(&[2], None)), + Route::new("/collections/wells/items", &collection(&[0, 1], Some("items?page=2"))), + ]); + let mut h = harness(); + h.step(); + let base = server.base.clone(); + h.state_mut().run("ui.addUrl", json!({"url": format!("{base}{layer}/query?{q}"), "name": "Parcels", "service": true})).unwrap(); + h.state_mut().run("ui.addUrl", json!({"url": format!("{base}/collections/wells/items"), "name": "Wells", "service": true})).unwrap(); + let ids = |app: &RustGisApp, name: &str| { + app.session.map().layer_by_name(name).map(|l| l.dataset.features.iter().map(|f| f.attrs[0].as_f64()).collect::>()) + }; + wait_for_ogc(&mut h, |app| ids(app, "Parcels").is_some() && ids(app, "Wells").is_some()); + let notes: Vec<_> = h.state().session.notices.iter().map(|n| n.text.clone()).collect(); + let n = |i: &[u32]| Some(i.iter().map(|&i| Some(f64::from(i))).collect::>()); + assert_eq!((ids(h.state(), "Parcels"), ids(h.state(), "Wells")), (n(&[0, 1, 2, 3, 4]), n(&[0, 1, 2])), "{notes:?}"); + assert!(!notes.iter().any(|n| n.contains("aren't read")), "{notes:?}"); + assert!(h.state().ui.service_pages.is_empty() && h.state().ui.downloads.is_empty()); + let offsets: Vec<_> = server + .requests() + .into_iter() + .filter(|r| r.path.ends_with("/0/query")) + .map(|r| r.query.into_iter().find(|(k, _)| k == "resultOffset").map(|(_, v)| v)) + .collect(); + assert_eq!(offsets, [None, Some("0".into()), Some("2".into()), Some("4".into())]); +} + /// A project by its web address (a `?project=` link) downloads off the frame and replaces the /// open project when it lands, its layers and view with it; a failed download is said and keeps /// the open project. diff --git a/docs/user-guide/data-formats.md b/docs/user-guide/data-formats.md index 5d18e1b54..ef1ed3168 100644 --- a/docs/user-guide/data-formats.md +++ b/docs/user-guide/data-formats.md @@ -174,10 +174,10 @@ Scripts use `service.import {"url":"…","name":"My OGC Layer"}` and | WMS 1.1.1 / 1.3.0 | Named layers offered in Web Mercator | A tile layer of GetMap images (PNG when the service offers it, else JPEG) | | WMTS | Layers whose tile matrix set is Web Mercator's pyramid | A tile layer from the tile template (or KVP GetTile) | | WFS 1.1 / 2.0 | Feature types | Their features downloaded as GeoJSON, in the output format the service names (up to 50,000) | -| OGC API Features | Collections | Their items downloaded as GeoJSON, following the service's `next` pages up to 100,000 features or a minute of downloading (the web app reads the first page) | +| OGC API Features | Collections | Their items downloaded as GeoJSON, following the service's `next` pages up to 100,000 features (a command such as `service.add` waits a minute at most) | | OGC API Tiles | Collections' map and vector tiles; a landing page's dataset map tiles | A tile layer in WebMercatorQuad, from the tile template the tileset advertises (its `item` link; `…/tiles/WebMercatorQuad/{z}/{y}/{x}`, the usual path, when that can't be read), keeping the tiles link's query (a token), map tiles in the server's image format, vector tiles drawn layer by layer in colours of their own | | WCS 1.0 / 2.0 (a 1.1 service is asked in 1.0) | Coverages, with their bounds | A raster: GetCoverage of the map's view within the coverage's bounds (or its whole extent: `"bbox": "full"`), as a GeoTIFF in EPSG:4326, at most 2048 pixels on a side | -| ArcGIS REST | FeatureServer and MapServer layers; a MapServer's map; an ImageServer; a VectorTileServer | Layers queried as GeoJSON (up to the server's record limit); a map as its tile cache when it is Web Mercator's pyramid, else exported images; an ImageServer as exported images; a VectorTileServer as its MapLibre style | +| ArcGIS REST | FeatureServer and MapServer layers; a MapServer's map; an ImageServer; a VectorTileServer | Layers queried as GeoJSON, page by page past the server's record limit (`resultOffset`) up to 100,000 features (a command such as `service.add` waits a minute at most), where the layer supports paging (ArcGIS Server 10.3 and later; otherwise up to the record limit); a map as its tile cache when it is Web Mercator's pyramid, else exported images; an ImageServer as exported images; a VectorTileServer as its MapLibre style | The type is guessed from the address (`?SERVICE=`, `/wmts`, `/wfs`, `/wcs`, `/collections`, `/rest/services`…) or chosen (select **OGC API Features and Tiles** when a landing page's address doesn't identify diff --git a/tests/support/ogc.rs b/tests/support/ogc.rs index 17027b8bb..b6d5cf627 100644 --- a/tests/support/ogc.rs +++ b/tests/support/ogc.rs @@ -58,3 +58,26 @@ pub fn fixture_lines() -> &'static str { LazyLock::new(|| feature("route-1", "Test Route", json!({"type":"LineString", "coordinates":[[10.0,50.0],[11.0,51.0]]}))); &TEXT } + +/// An ArcGIS layer query's GeoJSON answer: points whose `OBJECTID` are `ids`, cut off at the +/// server's record limit (`exceededTransferLimit`, among its `properties` as ArcGIS puts it) when +/// `more`. +pub fn arcgis_page(ids: &[u32], more: bool) -> String { + let features: Vec = ids + .iter() + .map(|&i| json!({"type":"Feature", "id":i, "properties":{"OBJECTID":i}, "geometry":{"type":"Point", "coordinates":[i, 1]}})) + .collect(); + let mut page = json!({"type":"FeatureCollection", "features":features}); + if more { + page["properties"] = json!({"exceededTransferLimit": true}); + } + page.to_string() +} + +/// An ArcGIS layer's description (`…/MapServer/0?f=json`): its record limit and whether it can be +/// read in pages. The object id field is named only among its fields, as a MapServer layer's is. +pub fn arcgis_layer(max: u32, pages: bool) -> String { + json!({"currentVersion":11.5, "maxRecordCount":max, "advancedQueryCapabilities":{"supportsPagination":pages}, + "fields":[{"name":"OBJECTID", "type":"esriFieldTypeOID"}]}) + .to_string() +} diff --git a/tests/support/ogc_server.rs b/tests/support/ogc_server.rs index 6da043872..ef7c06a9c 100644 --- a/tests/support/ogc_server.rs +++ b/tests/support/ogc_server.rs @@ -19,6 +19,8 @@ pub const LINE_ITEMS: &str = "/ogc/user/test-token/collections/routes/items"; #[derive(Clone)] pub struct Route { + /// The path answered, or a path and query (`/query?resultOffset=2`) to answer that request + /// alone (a page of a layer that comes in pages). pub path: String, pub body: String, pub status: u16, @@ -121,7 +123,10 @@ impl Server { }) .collect(), }); - let route = serving.lock().unwrap().iter().find(|r| r.path == path).cloned(); + let route = { + let routes = serving.lock().unwrap(); + routes.iter().find(|r| r.path == target).or_else(|| routes.iter().find(|r| r.path == path)).cloned() + }; let (status, body, extra) = match route { Some(r) => { let until = Instant::now() + r.delay; From d908c53716e75a5e64d2b34bbe37ff6b2b01742b Mon Sep 17 00:00:00 2001 From: Subarna Kandel Date: Sat, 24 Oct 2026 21:02:25 +0545 Subject: [PATCH 107/297] fix(geom): ring area across the antimeridian (#811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit geodesic_ring_area_m2 summed raw longitude differences, so an edge from 179° to -179° counted as 358° west instead of 2° east. A 2°x2° box at the equator measured 8,851,074 km² instead of 49,447 km². Wrap jumps between 180° and 360°; keep exact 360° edges (-180° to 180°) so a world-wide ring keeps its area. --- crates/geom/src/lib.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index f2a05175f..da958ee76 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -294,7 +294,11 @@ pub fn geodesic_ring_area_m2(ring: &[Coord]) -> f64 { let mut total = 0.0; for i in 0..n { let (Some(p1), Some(p2)) = (ring.get(i), ring.get((i + 1) % n)) else { continue }; - total += (p2.x - p1.x).to_radians() * (2.0 + p1.y.to_radians().sin() + p2.y.to_radians().sin()); + // An edge that jumps across the antimeridian (179° to -179°) is a short hop east, not 358° + // west. A full 360° edge (-180° to 180°, as a world-wide rectangle has) is kept as it is. + let dlon = p2.x - p1.x; + let dlon = if dlon.abs() > 180.0 && dlon.abs() < 360.0 { wrap_lon(dlon) } else { dlon }; + total += dlon.to_radians() * (2.0 + p1.y.to_radians().sin() + p2.y.to_radians().sin()); } (total * EARTH_RADIUS_M * EARTH_RADIUS_M / 2.0).abs() } @@ -446,6 +450,22 @@ mod tests { assert!((a - 1.2364e10).abs() / 1.2364e10 < 0.01, "{a}"); } + #[test] + fn ring_area_across_the_antimeridian() { + let c = Coord::new; + let across = geodesic_ring_area_m2(&[c(179.0, 0.0), c(-179.0, 0.0), c(-179.0, 2.0), c(179.0, 2.0)]); + let unwrapped = geodesic_ring_area_m2(&[c(179.0, 0.0), c(181.0, 0.0), c(181.0, 2.0), c(179.0, 2.0)]); + let reversed = geodesic_ring_area_m2(&[c(179.0, 2.0), c(-179.0, 2.0), c(-179.0, 0.0), c(179.0, 0.0)]); + assert!((across - unwrapped).abs() / unwrapped < 1e-9, "{across} vs {unwrapped}"); + assert!((reversed - unwrapped).abs() / unwrapped < 1e-9, "{reversed}"); + // The same box away from the antimeridian has the same area to within the latitude band. + let plain = geodesic_ring_area_m2(&[c(0.0, 0.0), c(2.0, 0.0), c(2.0, 2.0), c(0.0, 2.0)]); + assert!((across - plain).abs() / plain < 1e-9, "{across} vs {plain}"); + // A full -180° to 180° edge is not collapsed: a world-wide band keeps its area. + let band = geodesic_ring_area_m2(&[c(-180.0, -10.0), c(180.0, -10.0), c(180.0, 10.0), c(-180.0, 10.0)]); + assert!(band > 1.0e13, "{band}"); + } + #[test] fn nice_distances() { assert_eq!(nice_distance(730.0), 500.0); From 08eff6343d2514153449f47114401a7f6e5a7665 Mon Sep 17 00:00:00 2001 From: Subarna Kandel Date: Sat, 24 Oct 2026 21:04:35 +0545 Subject: [PATCH 108/297] fix(data): NOT over a null is unknown in where clauses (#814) A comparison with a null field is false, and NOT flipped it to true, so `NOT (pop > 100)` selected rows with no population. SQL treats it as unknown and keeps the row out. Evaluate with three values (true, false, unknown); eval still means "is true", so callers don't change. --- crates/data/src/query.rs | 51 ++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/crates/data/src/query.rs b/crates/data/src/query.rs index 892f688ae..ed756b08c 100644 --- a/crates/data/src/query.rs +++ b/crates/data/src/query.rs @@ -331,27 +331,41 @@ pub fn like(text: &str, pat: &str) -> bool { p.get(pi..).is_some_and(|rest| rest.iter().all(|c| *c == '%')) } -/// Evaluates for one row. +/// Evaluates for one row: whether the row matches. pub fn eval(e: &Expr, ds: &Dataset, row: usize) -> bool { + eval3(e, ds, row) == Some(true) +} + +/// SQL's three-valued logic: `None` is "unknown", what a comparison with a null gives. `NOT` +/// leaves it unknown, so `NOT (pop > 100)` doesn't match a row whose `pop` is null. +fn eval3(e: &Expr, ds: &Dataset, row: usize) -> Option { match e { - Expr::And(a, b) => eval(a, ds, row) && eval(b, ds, row), - Expr::Or(a, b) => eval(a, ds, row) || eval(b, ds, row), - Expr::Not(a) => !eval(a, ds, row), - Expr::IsNull { field, negate } => ds.value(row, *field).is_null() != *negate, + Expr::And(a, b) => match (eval3(a, ds, row), eval3(b, ds, row)) { + (Some(false), _) | (_, Some(false)) => Some(false), + (Some(true), Some(true)) => Some(true), + _ => None, + }, + Expr::Or(a, b) => match (eval3(a, ds, row), eval3(b, ds, row)) { + (Some(true), _) | (_, Some(true)) => Some(true), + (Some(false), Some(false)) => Some(false), + _ => None, + }, + Expr::Not(a) => eval3(a, ds, row).map(|v| !v), + Expr::IsNull { field, negate } => Some(ds.value(row, *field).is_null() != *negate), Expr::Like { field, pattern, negate } => { let v = ds.value(row, *field); - !v.is_null() && like(&v.to_string(), pattern) != *negate + (!v.is_null()).then(|| like(&v.to_string(), pattern) != *negate) } Expr::In { field, values, negate } => { let v = ds.value(row, *field); - !v.is_null() && values.iter().any(|x| eq(v, x)) != *negate + (!v.is_null()).then(|| values.iter().any(|x| eq(v, x)) != *negate) } Expr::Cmp { field, op, lit } => { let v = ds.value(row, *field); if v.is_null() || lit.is_null() { - return false; + return None; } - match op { + Some(match op { Op::Eq => eq(v, lit), Op::Ne => !eq(v, lit), _ => match v.partial_cmp(lit) { @@ -364,7 +378,7 @@ pub fn eval(e: &Expr, ds: &Dataset, row: usize) -> bool { }, None => false, }, - } + }) } } } @@ -402,6 +416,23 @@ mod tests { assert!(select("pop >", &d).is_err()); } + #[test] + fn nulls_are_unknown_not_false() { + let d = ds(); + // Row 3 has no population: neither a comparison nor its negation matches it. + assert_eq!(select("pop > 300000", &d).unwrap(), vec![0, 1]); + assert_eq!(select("NOT pop > 300000", &d).unwrap(), vec![2]); + assert_eq!(select("NOT (pop > 300000 OR pop < 300000)", &d).unwrap(), Vec::::new()); + assert_eq!(select("NOT (pop > 300000 AND name = 'Lyon')", &d).unwrap(), vec![0, 2, 3]); + // A false side settles AND, a true side settles OR, whatever the null side is. + assert_eq!(select("pop > 1 OR name = 'Nowhere'", &d).unwrap(), vec![0, 1, 2, 3]); + assert_eq!(select("NOT (pop > 1 AND name = 'Paris')", &d).unwrap(), vec![1, 2, 3]); + assert_eq!(select("NOT (pop > 1 AND name = 'Nowhere')", &d).unwrap(), vec![0, 1, 2]); + // IS NULL is never unknown. + assert_eq!(select("NOT pop IS NULL", &d).unwrap(), vec![0, 1, 2]); + assert_eq!(select("NOT name LIKE 'l%'", &d).unwrap(), vec![0, 3]); + } + #[test] fn quoted_field_names_are_never_keywords() { let mut d = Dataset::new("t", GeometryType::None, vec![Field::new("OR", FieldType::Integer), Field::new("null", FieldType::Text)]); From 3429749a72263def6b8ff77cda2ffeb576a4fe5f Mon Sep 17 00:00:00 2001 From: elpidioassinaturas Date: Sat, 24 Oct 2026 12:50:38 -0300 Subject: [PATCH 109/297] fix(tiles): read a large GeoTIFF tile index in place, doubling the header step (#826) * fix(tiles): read a large GeoTIFF tile index in place, doubling the header step The TIFF decoder reads a tag's values 512 bytes at a time, so each header round read exactly 256 KB and the 12 rounds stopped at about 3 MB, far below MAX_HEADER (32 MB). A national 30 m mosaic in 256-pixel tiles (MapBiomas's 346,092 tiles, a 4 MB index) was refused as "too large or too scattered". The step now doubles each round, up to MAX_BYTES. Co-Authored-By: Claude Opus 5.5 * fix(tiles): keep a doubled header read within MAX_HEADER A read is clamped to what is left of the header's cap, so a step grown to 16 MB can't take what is held past it. Co-Authored-By: Claude Opus 5.5 * fix(tiles): refuse a header range larger than what is left of MAX_HEADER Rather than read the rest of the cap and give up a round later. Co-Authored-By: Claude Opus 5.5 * fix(tiles): stop a header read where a part already read starts A doubled step could run on over parts already held (the tags at the header's end, or a later round's), holding those bytes twice and counting them twice against MAX_HEADER. A read now ends at the next part held. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: elpidioassinaturas Co-authored-by: Claude Opus 5.5 --- crates/tiles/src/cog.rs | 116 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 4 deletions(-) diff --git a/crates/tiles/src/cog.rs b/crates/tiles/src/cog.rs index 6d8153b44..da4c33a23 100644 --- a/crates/tiles/src/cog.rs +++ b/crates/tiles/src/cog.rs @@ -26,7 +26,8 @@ pub const SCHEME: &str = "cog:"; /// The first read of a file: enough for a COG's IFDs, as GDAL writes them, in one request. const HEAD: u64 = 64 << 10; -/// More of the header, when it's longer, is read in steps at least this large. +/// More of the header, when it's longer, is read in steps this large at first, doubling each +/// round ([`header_read`]). const STEP: u64 = 256 << 10; /// The most header reads before giving up. const MAX_ROUNDS: usize = 12; @@ -336,11 +337,10 @@ fn step(url: String, have: Sparse, rounds: usize, done: Box stats(url, have, *layout, done), Ok(Parse::NotTiled(why)) => done(Err(format!("{NOT_COG}: {why}"))), Ok(Parse::Need(a, b)) => { - if rounds >= MAX_ROUNDS || have.held() >= cog::MAX_HEADER || a >= cog::MAX_HEADER.saturating_mul(64) { + let Some(len) = header_read(&have, a, b, rounds) else { done(Err(format!("{url}: the GeoTIFF's header is too large or too scattered to read in place"))); return; - } - let len = b.saturating_sub(a).max(STEP).min(MAX_BYTES as u64); + }; let u = url.clone(); read( &url, @@ -358,6 +358,29 @@ fn step(url: String, have: Sparse, rounds: usize, done: Box Option { + if rounds >= MAX_ROUNDS || have.held() >= cog::MAX_HEADER || a >= cog::MAX_HEADER.saturating_mul(64) { + return None; + } + let room = cog::MAX_HEADER - have.held(); + // What the layout stopped for has to fit in what is left, or the read would be wasted. + if b.saturating_sub(a) > room { + return None; + } + // A part read after `a` (a round that went past it, or the tags at the header's end) isn't + // read again. + let gap = have.next_start(a).map_or(u64::MAX, |next| next - a); + // `rounds` is below MAX_ROUNDS here, so the shift stays far from overflowing. + Some(b.saturating_sub(a).max(STEP << rounds).min(MAX_BYTES as u64).min(room).min(gap)) +} + /// What a file that isn't a cloud-optimised GeoTIFF says (it is read whole instead). pub const NOT_COG: &str = "not a cloud-optimised GeoTIFF"; @@ -1147,6 +1170,91 @@ mod tests { assert_eq!(l.levels[stats_level(&l)].width, 256); } + /// Reads `file`'s layout as [`load`] and [`step`] do, a range at a time: the layout, how many + /// reads past the first it took and the bytes held, or the reads made when it gave up. + fn layout_by_ranges(file: &[u8]) -> Result<(Box, usize, u64), usize> { + let part = |a: u64, len: u64| file.get(a as usize..file.len().min((a + len) as usize)).unwrap_or_default().to_vec(); + let mut have = Sparse::default().with(0, part(0, HEAD)); + let mut rounds = 0; + loop { + match cog::layout(&have) { + Ok(Parse::Done(layout)) => return Ok((layout, rounds, have.held())), + Ok(Parse::Need(a, b)) => { + let len = header_read(&have, a, b, rounds).ok_or(rounds)?; + have = have.with(a, part(a, len)); + rounds += 1; + } + other => panic!("{other:?}"), + } + } + } + + /// The header alone of a tiled GeoTIFF, `side` pixels a side in 256-pixel tiles, laid out as + /// MapBiomas's: the tiles' byte counts and offsets right after the IFD, its georeferencing + /// after them. The tiles themselves aren't there. + fn big_index(side: u32) -> Vec { + let n = side.div_ceil(256).pow(2); + let counts_at = 8 + 2 + 12 * 14 + 4; + let offsets_at = counts_at + 4 * n; + let scale_at = offsets_at + 4 * n; + let (tie_at, keys_at) = (scale_at + 24, scale_at + 72); + // (tag, type, count, value or where the values are); 3 short, 4 long, 12 double. + let entries: [(u16, u16, u32, u32); 14] = [ + (256, 4, 1, side), + (257, 4, 1, side), + (258, 3, 1, 8), + (259, 3, 1, 1), + (262, 3, 1, 1), + (277, 3, 1, 1), + (284, 3, 1, 1), + (322, 3, 1, 256), + (323, 3, 1, 256), + (324, 4, n, offsets_at), + (325, 4, n, counts_at), + (33550, 12, 3, scale_at), + (33922, 12, 6, tie_at), + (34735, 3, 12, keys_at), + ]; + let mut out = b"II*\0".to_vec(); + out.extend(8u32.to_le_bytes()); + out.extend(14u16.to_le_bytes()); + for (tag, kind, count, value) in entries { + out.extend(tag.to_le_bytes()); + out.extend(kind.to_le_bytes()); + out.extend(count.to_le_bytes()); + out.extend(value.to_le_bytes()); + } + out.extend(0u32.to_le_bytes()); + let data_at = keys_at + 24; + out.extend((0..n).flat_map(|_| 1u32.to_le_bytes())); + out.extend((0..n).flat_map(|i| (data_at + i).to_le_bytes())); + let px = 1.0 / 3600.0; + out.extend([px, px, 0.0, 0.0, 0.0, 0.0, 0.0, -74.0, 6.0, 0.0].iter().flat_map(|v: &f64| v.to_le_bytes())); + out.extend([1u16, 1, 0, 2, 1024, 0, 1, 2, 2048, 0, 1, 4326].iter().flat_map(|v| v.to_le_bytes())); + out + } + + /// A tile index of 3.9 MB (490,000 tiles) is read within the rounds allowed: the decoder asks + /// for 512 bytes at a time, and steps of a fixed 256 KB stopped at about 3 MB. + #[test] + fn a_large_tile_index_is_read_in_a_few_rounds() { + let file = big_index(700 * 256); + let (layout, rounds, held) = layout_by_ranges(&file).unwrap_or_else(|r| panic!("gave up after {r} reads")); + assert_eq!(layout.levels.len(), 1); + assert_eq!(layout.levels[0].offsets.len(), 490_000); + assert_eq!(layout.levels[0].width, 179_200); + assert!(rounds <= 6, "{rounds} reads"); + // No byte is read twice: a read stops where a part already read starts. + assert!(held <= file.len() as u64, "{held} bytes held of a {}-byte header", file.len()); + // A cloud-optimised file's header is in its first read, as before. + assert_eq!(layout_by_ranges(&rustgis_data::raster::cog::example(1024, 64)).map(|(_, r, _)| r), Ok(0)); + // A read stops at the header's cap, however large the step has grown. + let near = Sparse::default().with(0, vec![0; cog::MAX_HEADER as usize - 1000]); + assert_eq!(header_read(&near, cog::MAX_HEADER, cog::MAX_HEADER + 512, 8), Some(1000)); + assert_eq!(header_read(&near, cog::MAX_HEADER, cog::MAX_HEADER + 1001, 8), None, "more than is left"); + assert_eq!(header_read(&near.with(cog::MAX_HEADER, vec![0; 1000]), cog::MAX_HEADER + 1000, cog::MAX_HEADER + 1512, 9), None); + } + #[test] fn looks_in_addresses() { let u = "https://x.org/a.tiff"; From bb244b8fb3164a32f0c00fb7fa6d1e2b82f1a105 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 24 Oct 2026 11:58:05 -0400 Subject: [PATCH 110/297] test: fix main CI on macOS and Windows (thin-font halo test, sign-in cancel race) (#820) * test(render): draw the curved-halo test's letters large enough for thin fonts At 12 px, Segoe UI's and SF's thin 'l' never reaches the darkness the test looks for, so it found no letter pixels on Windows and macOS. At 30 px every font's strokes have solid middles; the letters sit closer so a halo drawn per letter still covers its neighbour. * test(assistant): cancel a sign-in request without racing the clock The cancelling thread could run after the 150 ms work had finished on a loaded macOS runner, so the request came back Ok. The work now runs until the cancel is set (at most 5 s), however late that is. * Address CodeRabbit review feedback - The curved-halo test now checks that both letters are drawn: the pair's dark columns outnumber a single letter's, so one missing glyph can no longer pass it. --- crates/assistant/src/chatgpt.rs | 9 +++++++-- crates/render/src/lib.rs | 26 ++++++++++++++++---------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/crates/assistant/src/chatgpt.rs b/crates/assistant/src/chatgpt.rs index f1e8851a5..b683032f1 100644 --- a/crates/assistant/src/chatgpt.rs +++ b/crates/assistant/src/chatgpt.rs @@ -1597,8 +1597,13 @@ mod native { std::thread::sleep(Duration::from_millis(30)); *cancel.lock().unwrap() = true; }); - let e = cancellable(&cancel, Instant::now() + Duration::from_secs(10), || { - std::thread::sleep(Duration::from_millis(150)); + // The work outlasts the cancel however late the cancelling thread runs. + let flag = cancel.clone(); + let e = cancellable(&cancel, Instant::now() + Duration::from_secs(10), move || { + let end = Instant::now() + Duration::from_secs(5); + while !*flag.lock().unwrap() && Instant::now() < end { + std::thread::sleep(Duration::from_millis(5)); + } Ok(()) }) .unwrap_err(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9f378a9ed..adb204a84 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6019,13 +6019,14 @@ mod tests { #[test] fn a_curved_labels_halos_go_under_all_its_letters() { // Two letters close together along a line, with a halo: the second one's halo does not - // cover the first one's strokes, so the letters are as dark as drawn without halos. + // cover the first one's strokes, so the letters are as dark as drawn without halos. Large + // enough that even a thin system font's strokes (Segoe UI, SF) have solid middles. let glyph = |dx: f32| LabelGlyph { text: "l".into(), dx, dy: 0.0, angle: 0.0 }; - let label = |halo: Option| PlacedLabel { + let label = |halo: Option, glyphs: Vec| PlacedLabel { text: "ll".into(), - x: 20.0, - y: 20.0, - size: 12.0, + x: 30.0, + y: 40.0, + size: 30.0, color: Rgba::rgb(0, 0, 0), halo, bold: false, @@ -6037,15 +6038,20 @@ mod tests { line_align: TextAlign::Left, angle: None, collide: Default::default(), - glyphs: vec![glyph(-0.75), glyph(0.75)], + glyphs, }; - let drawn = |halo| { - let mut pm = Pixmap::new(40, 40).unwrap(); + let drawn = |halo, glyphs| { + let mut pm = Pixmap::new(60, 80).unwrap(); pm.fill(tiny_skia::Color::from_rgba8(128, 128, 128, 255)); - text::draw(&mut pm, &label(halo), 1.0); + text::draw(&mut pm, &label(halo, glyphs), 1.0); pm }; - let (plain, haloed) = (drawn(None), drawn(Some(Rgba::rgb(255, 255, 255)))); + let pair = || vec![glyph(-1.0), glyph(1.0)]; + let (plain, haloed) = (drawn(None, pair()), drawn(Some(Rgba::rgb(255, 255, 255)), pair())); + // Both letters are drawn: the pair's ink is wider than one letter's. + let inked = |pm: &Pixmap| (0..60).filter(|&x| (0..80).any(|y| pm.pixel(x, y).is_some_and(|p| p.red() < 40))).count(); + let one = drawn(None, vec![glyph(-1.0)]); + assert!(inked(&plain) > inked(&one), "two letters, not one: {} vs {}", inked(&plain), inked(&one)); let mut letters = 0; for (a, b) in plain.pixels().iter().zip(haloed.pixels()) { if a.red() < 40 { From bc5218cc8c18976a25f98f8ccdc71ed8b7201084 Mon Sep 17 00:00:00 2001 From: Qiusheng Wu Date: Sat, 24 Oct 2026 12:36:20 -0400 Subject: [PATCH 111/297] test(ui): give the connection-chooser smoke test its own database (#833) The smoke tests share one process, and passwords typed for an address are remembered process-wide. Another test leaving one for the shared address made the project open without the chooser now and then (main CI, and 1 in 72 runs here under load; 0 in 144 with this change). --- crates/ui-egui/tests/remembered_database/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/tests/remembered_database/mod.rs b/crates/ui-egui/tests/remembered_database/mod.rs index 41c95ba41..bc3832322 100644 --- a/crates/ui-egui/tests/remembered_database/mod.rs +++ b/crates/ui-egui/tests/remembered_database/mod.rs @@ -83,6 +83,9 @@ fn remembered_database_failed_store_has_visible_notice() { #[test] fn remembered_database_chooser_cancel_selection_and_project_scope() { + // Its own database: passwords for an address are remembered process-wide, and one the other + // tests leave for the shared address would open the project without asking which to use. + const ADDRESS: &str = "postgres:host=127.0.0.1 port=1 dbname=ui_remembered_chooser user=smoke sslmode=disable"; let store = Arc::new(MemorySecrets::new()); let mut seed = with_store(store.clone()); for name in ["First database", "Second database"] { From 9e54cd1a89be0af4dcca846779c8a0a127764713 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Sat, 24 Oct 2026 11:59:59 -0500 Subject: [PATCH 112/297] test(ui): scroll the Script results under the run's line (#832) --- crates/ui-egui/src/scriptcolor.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/scriptcolor.rs b/crates/ui-egui/src/scriptcolor.rs index 3bc34e6ff..c790a39c5 100644 --- a/crates/ui-egui/src/scriptcolor.rs +++ b/crates/ui-egui/src/scriptcolor.rs @@ -539,7 +539,22 @@ mod tests { assert!(h.get_by_label("Run").rect().max.y < 700.0); let first = h.get_by_label_contains("line 0:").rect(); assert!(first.min.y < 700.0, "the first result shows: {first:?}"); - assert!(h.get_by_label_contains("Run #1 (").rect().max.y < first.min.y, "the run above them"); + let run = h.get_by_label_contains("Run #1 (").rect(); + assert!(run.max.y < first.min.y, "the run above them"); + // Scrolled down, the results move under the run's line, which stays where it was: it's + // not in their scroll. + h.event(egui::Event::PointerMoved(first.center())); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -100_000.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Wheel, + }); + // egui eases a wheel's scroll over frames, asking for a repaint until it's done. + h.run_steps(60); + assert!(h.get_by_label_contains("line 0:").rect().min.y < first.min.y, "the results scrolled"); + assert_eq!(h.get_by_label_contains("Run #1 (").rect(), run, "the run's line didn't move"); } /// The kept unknown ids follow the script: a change is read again, the same script isn't. From 0411ce6330645570734b3c9351e99cd992dda540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Gregorio=20Rinc=C3=B3n=20Albarrac=C3=ADn?= Date: Sat, 24 Oct 2026 12:00:31 -0500 Subject: [PATCH 113/297] feat(ui): offer a gp.run tool's parameter values by kind in the Script pane (#827) * refactor(ui): one rule for the layers and fields a tool's parameter takes * feat(ui): offer a gp.run tool's parameter values by kind in the Script pane --- crates/ui-egui/src/modelbuilder.rs | 5 +- crates/ui-egui/src/panes.rs | 80 ++++++++---- crates/ui-egui/src/scriptcomplete.rs | 187 +++++++++++++++++++++++---- docs/user-guide/analysis.md | 6 +- 4 files changed, 225 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/modelbuilder.rs b/crates/ui-egui/src/modelbuilder.rs index 6fa73b6ac..507200f77 100644 --- a/crates/ui-egui/src/modelbuilder.rs +++ b/crates/ui-egui/src/modelbuilder.rs @@ -1321,10 +1321,7 @@ fn input_settings(app: &mut RustGisApp, ui: &mut Ui, i: usize, n: &ModelNode) { /// The layer feeding a tool's first layer parameter, when it is an input node's (for its fields). fn source_layer(app: &RustGisApp, n: &ModelNode, spec: &ToolSpec) -> Option { - let first = spec - .params - .iter() - .find(|p| matches!(p.kind, ParamKind::Layer | ParamKind::PolygonLayer | ParamKind::PointLayer | ParamKind::VectorLayer))?; + let first = crate::panes::first_layer(spec)?; match app.mb.graph.edges.iter().find(|e| e.to == n.id && e.to_port == first.name) { Some(e) => app.mb.graph.nodes.iter().find(|u| u.id == e.from && u.kind == ModelNodeKind::Input).and_then(|u| u.layer.clone()), None => n.params.get(first.name).and_then(Value::as_str).map(str::to_string), diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index f9d925844..c8d40524c 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -2784,6 +2784,51 @@ fn geolibre_web_card(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }); } +/// Whether a tool's parameter of `kind` takes layer `l`, as the tool's form and the Script pane +/// offer layers: by its geometry, or raster or not; any for a `Layer`. False for a kind that isn't +/// a layer's. +pub(crate) fn takes_layer(kind: ParamKind, l: &rustgis_engine::map::Layer) -> bool { + match kind { + ParamKind::Layer => true, + ParamKind::PolygonLayer => l.geometry_type() == GeometryType::Polygon, + ParamKind::PointLayer => l.geometry_type() == GeometryType::Point, + ParamKind::RasterLayer | ParamKind::RasterLayers => l.is_raster(), + ParamKind::VectorLayer | ParamKind::VectorLayers => !l.is_raster(), + _ => false, + } +} + +/// Whether a tool's parameter of `kind` takes field `i` of `d`: a numeric one for a +/// `NumericField`, one holding coordinates for a `CoordinateField`, any for a `Field`. False for +/// a kind that isn't a field's. +pub(crate) fn takes_field(kind: ParamKind, d: &rustgis_engine::data::Dataset, i: usize) -> bool { + match kind { + ParamKind::Field => true, + ParamKind::NumericField => d.fields.get(i).is_some_and(|f| f.ty.is_numeric()), + ParamKind::CoordinateField => rustgis_engine::analysis::holds_coordinates(d, i), + _ => false, + } +} + +/// The tool's first layer parameter, whose fields its field parameters take. +pub(crate) fn first_layer(spec: &rustgis_engine::analysis::ToolSpec) -> Option<&rustgis_engine::analysis::ParamSpec> { + spec.params + .iter() + .find(|p| matches!(p.kind, ParamKind::Layer | ParamKind::PolygonLayer | ParamKind::PointLayer | ParamKind::VectorLayer)) +} + +/// The layer or table of name `name`, as a tool finds its input: a layer by its name, else a +/// table (by its name, else its id). +pub(crate) fn input_dataset<'a>(session: &'a Session, name: &str) -> Option<&'a rustgis_engine::data::Dataset> { + session.map().layer_by_name(name).map(|l| &l.dataset).or_else(|| input_table(session, name).map(|t| &t.dataset)) +} + +/// The standalone table of name `name`, as a tool finds one: by its name, else its id. +pub(crate) fn input_table<'a>(session: &'a Session, name: &str) -> Option<&'a rustgis_engine::project::Table> { + let tables = &session.project.tables; + rustgis_engine::project::find_table(tables, name).and_then(|i| tables.get(i)) +} + fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let Some(spec) = app.gp.tool.and_then(rustgis_engine::analysis::tool) else { // Tool finder. @@ -2963,18 +3008,11 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.add_space(4.0); // A standalone table is picked by its id, since names can repeat, and shown by its name. let tables: Vec<(u32, String)> = app.session.project.tables.iter().map(|t| (t.id, t.name.clone())).collect(); - let layer_names = |filter: Option| -> Vec { - app.session.map().layers.iter().filter(|l| filter.is_none_or(|g| l.geometry_type() == g)).map(|l| l.name.clone()).collect() - }; - let kind_names = |raster: bool| -> Vec { - app.session.map().layers.iter().filter(|l| l.is_raster() == raster).map(|l| l.name.clone()).collect() + let layer_names = |kind: ParamKind| -> Vec { + app.session.map().layers.iter().filter(|l| takes_layer(kind, l)).map(|l| l.name.clone()).collect() }; let plugin_tool = spec.id.starts_with(rustgis_engine::plugins::PREFIX); - let first_layer = spec - .params - .iter() - .find(|p| matches!(p.kind, ParamKind::Layer | ParamKind::PolygonLayer | ParamKind::PointLayer | ParamKind::VectorLayer)) - .map(|p| p.name); + let first_layer = first_layer(spec).map(|p| p.name); // The file parameter whose Browse was pressed: picked once the form is drawn. let mut browse_for = None; for p in spec.params { @@ -2993,7 +3031,7 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { .selected_text(shown) .width(ui.available_width() - 4.0) .show_ui(ui, |ui| { - for name in layer_names(None) { + for name in layer_names(ParamKind::Layer) { if ui.selectable_label(picked.is_none() && name == cur, &name).clicked() { app.gp.values.insert(p.name, name); app.gp.tables.remove(p.name); @@ -3011,26 +3049,17 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ui.add_space(4.0); continue; } - ParamKind::PolygonLayer => Some(layer_names(Some(GeometryType::Polygon))), - ParamKind::PointLayer => Some(layer_names(Some(GeometryType::Point))), + ParamKind::PolygonLayer | ParamKind::PointLayer => Some(layer_names(p.kind)), ParamKind::Field | ParamKind::NumericField | ParamKind::CoordinateField => { let lname = first_layer.and_then(|n| app.gp.values.get(n)).cloned().unwrap_or_default(); let table = first_layer.and_then(|n| app.gp.tables.get(n)).copied(); // The table picked; else the layer or table of that name, as the tool finds it. let ds = match table { Some(id) => app.session.project.table(id).map(|t| &t.dataset), - None => app.session.map().layer_by_name(&lname).map(|l| &l.dataset).or_else(|| { - let tables = &app.session.project.tables; - rustgis_engine::project::find_table(tables, &lname).and_then(|i| tables.get(i)).map(|t| &t.dataset) - }), - }; - let offered = |d: &rustgis_engine::data::Dataset, i: usize, f: &rustgis_engine::data::Field| match p.kind { - ParamKind::NumericField => f.ty.is_numeric(), - ParamKind::CoordinateField => rustgis_engine::analysis::holds_coordinates(d, i), - _ => true, + None => input_dataset(&app.session, &lname), }; let fields = |d: &rustgis_engine::data::Dataset| -> Vec { - d.fields.iter().enumerate().filter(|(i, f)| offered(d, *i, f)).map(|(_, f)| f.name.clone()).collect() + d.fields.iter().enumerate().filter(|(i, _)| takes_field(p.kind, d, *i)).map(|(_, f)| f.name.clone()).collect() }; // Telling which fields hold coordinates reads their rows, too much for every frame: // the list is kept until the project or the input changes. @@ -3063,15 +3092,14 @@ fn geoprocessing(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } ParamKind::Choice(c) => Some(c.iter().map(|s| s.to_string()).collect()), ParamKind::RasterLayer | ParamKind::VectorLayer => { - let mut v = kind_names(p.kind == ParamKind::RasterLayer); + let mut v = layer_names(p.kind); if !p.required { v.insert(0, String::new()); } Some(v) } ParamKind::RasterLayers | ParamKind::VectorLayers => { - let raster = p.kind == ParamKind::RasterLayers; - let offered = app.session.map().layers.iter().filter(|l| l.is_raster() == raster).map(|l| l.name.clone()).collect(); + let offered = layer_names(p.kind); layer_list(&mut app.gp, ui, p.name, &cur, offered); ui.add_space(4.0); continue; diff --git a/crates/ui-egui/src/scriptcomplete.rs b/crates/ui-egui/src/scriptcomplete.rs index 84f485c8b..accf7261b 100644 --- a/crates/ui-egui/src/scriptcomplete.rs +++ b/crates/ui-egui/src/scriptcomplete.rs @@ -5,9 +5,10 @@ //! its `{…}` and in its second-level objects (`key_at`), and the ids some values take: tools for //! a `gp.run` line's `"tool"`, commands for a `task.addStep` line's `"command"` (`value_at`), //! whose own keys then go in its `"params": {…}`, and what the project holds: layers for a -//! `"layer"`, the line's layer's fields for a `"field"`. Typing aids beside it: a command's -//! parameters added on accept (`template`), and braces, brackets and quotes that close themselves -//! (`brace`). +//! `"layer"`, the line's layer's fields for a `"field"`, and in a `gp.run` line's `"params"` what +//! the tool's parameter takes by its kind (`tool_value`), as the tool's form offers it. Typing +//! aids beside it: a command's parameters added on accept (`template`), and braces, brackets and +//! quotes that close themselves (`brace`). //! Quoted text is read by one rule everywhere (`rustgis_engine::Quotes`), the one the colours use. use egui::text::{CCursor, CCursorRange}; @@ -37,8 +38,10 @@ pub struct Offer { /// its id), the keys of the second-level object of a command's entry `key`, a geoprocessing /// tool's parameter keys (by its id) in a `gp.run` line's `"params"`, or the ids a value takes: /// the tools for a `gp.run` line's `"tool"`, the commands for a `task.addStep` line's -/// `"command"`; or what the project holds for a value: the map's layers for a `"layer"`, the -/// fields of a layer (by its id) for a `"field"`. +/// `"command"`; or what the project holds for a value: the map's layers for a `"layer"` (and the +/// tables, for a tool's parameter that takes any layer), the fields of a layer (by its id) for a +/// `"field"`, the fields of a tool's input (by the name its line gives) for a tool's field +/// parameter, a tool's parameter's choices. #[derive(Clone, Debug, PartialEq)] pub enum Of { CommandKeys(String), @@ -48,12 +51,14 @@ pub enum Of { CommandIds, Layers, Fields(u32), + ToolFields(String), + Choices, } impl Of { /// Whether its items are a value's (written as a JSON string's text), not keys. fn is_value(&self) -> bool { - matches!(self, Of::ToolIds | Of::CommandIds | Of::Layers | Of::Fields(_)) + matches!(self, Of::ToolIds | Of::CommandIds | Of::Layers | Of::Fields(_) | Of::ToolFields(_) | Of::Choices) } } @@ -426,8 +431,8 @@ fn line_layer<'a>(session: &'a rustgis_engine::Session, line: &str) -> Option<&' /// What to offer at char `at` of `script`: the command ids for a line's first word, the keys of /// the line's command for a key being typed in its `{…}`, and in its second-level objects the /// keys their entries describe; in a `gp.run` line the tool ids for its `"tool"` and the tool's -/// keys in its `"params"`, in a `task.addStep` line the command ids for its `"command"` and that -/// command's keys in its `"params"`. +/// keys and values (`tool_value`) in its `"params"`, in a `task.addStep` line the command ids for +/// its `"command"` and that command's keys in its `"params"`. fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::Session) -> Option { if let Some((start, word)) = word_at(script, at) { return complete(start, at, &word, ids); @@ -464,6 +469,9 @@ fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::S _ => None, }; } + if let Some(offer) = tool_value(script, at, session) { + return Some(offer); + } let KeyAt { hint: h, start, word, nested } = key_at(script, at)?; let after = object_rest(script.get(byte_at(script, at)?..)?.split('\n').next()?); // The object the key goes in, from its `{` to its `}`, the word being typed taken out. @@ -486,6 +494,52 @@ fn offer_at(script: &str, at: usize, ids: &[String], session: &rustgis_engine::S offer_keys(Of::SubKeys { id: h.id, key: nested.key }, entries, &object, start, at, &word) } +/// What a `gp.run` line's tool takes for the value being typed in its `"params"` (#788), by the +/// kind of the tool's parameter of that key, as the tool's form offers it (`takes_layer`, +/// `takes_field`): the layers of its kind, then the tables for one that takes any layer; the +/// fields it takes of the layer or table the tool's first layer parameter names (before or after +/// it); or its choices. None for another kind: a number, a bool, a path, a text, several layers in +/// one string. +fn tool_value(script: &str, at: usize, session: &rustgis_engine::Session) -> Option { + use crate::panes::{takes_field, takes_layer}; + use rustgis_engine::analysis::ParamKind as K; + let (h, Scan { nested, .. }, _) = scan_at(script, at)?; + let brace = h.brace + nested.filter(|n| n.key == "params")?.brace; + let (Named::Tool, name) = named_in(script, &h)? else { + return None; + }; + let tool = rustgis_engine::analysis::tool(&name)?; + // The `"params"` object, read as a line's object from its `{`. + let text = script.get(byte_at(script, brace)?..byte_at(script, at)?)?; + let (start, key) = scan(text).value?; + let word = word_from(text, start)?; + let start = brace + start; + let p = tool.params.iter().find(|p| p.name == key)?; + let map = session.map(); + match p.kind { + K::Layer | K::PolygonLayer | K::PointLayer | K::RasterLayer | K::VectorLayer => { + // Those Run finds by their name, as for a `"layer"`; then the tables no layer's name + // hides, as the form lists them, of two named alike the one Run finds. + let layers = map.layers.iter().filter(|l| map.layer_by_name(&l.name).is_some_and(|f| f.id == l.id) && takes_layer(p.kind, l)); + let all = &session.project.tables; + let tables = all.iter().enumerate().filter(|(i, t)| { + p.kind == K::Layer && map.layer_by_name(&t.name).is_none() && rustgis_engine::project::find_table(all, &t.name) == Some(*i) + }); + let names = layers.map(|l| l.name.as_str()).chain(tables.map(|(_, t)| t.name.as_str())); + value_ids(start, at, &word, names, Of::Layers) + } + K::Field | K::NumericField | K::CoordinateField => { + let after = object_rest(script.get(byte_at(script, at)?..)?.split('\n').next()?); + let input = value_of(&format!("{text}{after}"), crate::panes::first_layer(tool)?.name)?; + let d = crate::panes::input_dataset(session, &input)?; + let names = d.fields.iter().enumerate().filter(|(i, _)| takes_field(p.kind, d, *i)).map(|(_, f)| f.name.as_str()); + value_ids(start, at, &word, names, Of::ToolFields(input)) + } + K::Choice(choices) => value_ids(start, at, &word, choices.iter().copied(), Of::Choices), + _ => None, + } +} + /// What follows a cursor in a key (`after`, to the line's end) up to the `}` that closes the key's /// object, or all of it when nothing closes it: an object after it on the line (the next one in /// its array, a later key's) isn't read as holding its keys. @@ -521,10 +575,11 @@ fn sub_desc(desc: &str, key: &str) -> Option { } /// The ids of `ids` that complete `word`, typed from char `start` to `end`, case aside, each -/// once: the tools, commands, layers or fields a value takes (`of`), offered from its quote, so -/// not cut to `MAX_ITEMS` (the list scrolls). Commands sorted, as a line's first word offers -/// them; the rest in their order: tools with the built-in ones first (an external one with the -/// same id isn't the one Run finds), layers top first, fields as the layer's table has them. +/// once: the tools, commands, layers, tables, fields or choices a value takes (`of`), offered +/// from its quote, so not cut to `MAX_ITEMS` (the list scrolls). Commands sorted, as a line's +/// first word offers them; the rest in their order: tools with the built-in ones first (an +/// external one with the same id isn't the one Run finds), layers top first (then tables), fields +/// as the layer's table has them, choices as the tool lists them. fn value_ids<'a>(start: usize, end: usize, word: &str, ids: impl Iterator, of: Of) -> Option { let mut seen = std::collections::HashSet::new(); let mut items: Vec = ids.filter(|id| starts_with_ci(id, word) && seen.insert(*id)).map(str::to_string).collect(); @@ -536,9 +591,12 @@ fn value_ids<'a>(start: usize, end: usize, word: &str, ids: impl Iterator Option<(String, Option)> { let described = |desc: &str| entries(desc).into_iter().find(|(keys, _)| keys.iter().any(|k| k == item)).map(|(_, text)| (text, None)); + let field = |d: &rustgis_engine::data::Dataset| { + d.fields.iter().find(|f| f.name == item).map(|f| (tl!(f.ty.label()).to_string(), f.alias.clone())) + }; match of { Of::CommandKeys(id) => described(¶ms_of(session, id)?), Of::SubKeys { id, key: entry } => described(&sub_desc(¶ms_of(session, id)?, entry)?), @@ -547,7 +605,11 @@ fn item_help(session: &rustgis_engine::Session, of: &Of, item: &str) -> Option<( Some((param_line(p), (!p.help.is_empty()).then(|| tl!(p.help).to_string()))) } Of::Layers => { - let l = session.map().layer_by_name(item)?; + let Some(l) = session.map().layer_by_name(item) else { + // A table, after the layers (a tool's parameter that takes any layer). + let t = crate::panes::input_table(session, item)?; + return Some((crate::i18n::fmt("Table · {n} rows", &[("n", &t.dataset.features.len().to_string())]), None)); + }; if l.is_raster() { return Some((tl!("Raster").to_string(), None)); } @@ -561,11 +623,9 @@ fn item_help(session: &rustgis_engine::Session, of: &Of, item: &str) -> Option<( ); Some((text, None)) } - Of::Fields(id) => { - let f = session.map().layer(*id)?.dataset.fields.iter().find(|f| f.name == item)?; - Some((tl!(f.ty.label()).to_string(), f.alias.clone())) - } - Of::ToolIds | Of::CommandIds => None, + Of::Fields(id) => field(&session.map().layer(*id)?.dataset), + Of::ToolFields(input) => field(crate::panes::input_dataset(session, input)?), + Of::ToolIds | Of::CommandIds | Of::Choices => None, } } @@ -575,7 +635,9 @@ fn item_icon(session: &rustgis_engine::Session, of: Option<&Of>, item: &str) -> use rustgis_engine::data::GeometryType; match of? { Of::Layers => { - let l = session.map().layer_by_name(item)?; + let Some(l) = session.map().layer_by_name(item) else { + return crate::panes::input_table(session, item).map(|_| "table"); + }; Some(match l.geometry_type() { _ if l.is_raster() => "raster_layer", GeometryType::Point => "point_layer", @@ -583,7 +645,7 @@ fn item_icon(session: &rustgis_engine::Session, of: Option<&Of>, item: &str) -> _ => "polygon_layer", }) } - Of::Fields(_) => Some("field"), + Of::Fields(_) | Of::ToolFields(_) => Some("field"), _ => None, } } @@ -2361,7 +2423,7 @@ mod tests { assert_eq!(offer(&app, r#"labels.field {"field": ""#).unwrap().items, fields, "the active layer"); app.session.active_layer = None; assert!(offer(&app, r#"labels.field {"field": ""#).is_none(), "no active layer"); - assert!(offer(&app, r#"gp.run {"params": {"layer": ""#).is_none(), "a second-level value (#662's 3b)"); + assert!(offer(&app, r#"gp.run {"params": {"layer": ""#).is_none(), "in params, without a tool"); // Two layers named alike but for case: the first, the one Run finds by either name. let other = app.session.map().layers.iter().position(|l| l.name != "Cities").unwrap(); app.session.map_mut().layers[other].name = "CITIES".into(); @@ -2389,6 +2451,87 @@ mod tests { assert_eq!(item_icon(s, Some(&Of::ToolIds), "buffer"), None); } + /// In a `gp.run` line's `"params"`, a value offers what the tool's parameter of its key takes, + /// by its kind, as the tool's form offers it (#788): the layers of a polygon parameter, every + /// layer then the tables for any layer, the numeric fields of the input for a numeric field (the + /// input before or after it, a table's too), a choice's choices in order; nothing for a + /// number, nor in another command's `"params"`. + #[test] + fn a_tools_values_are_offered_by_kind() { + use rustgis_engine::data::{Dataset, Field, FieldType, GeometryType}; + let mut app = crate::RustGisApp::with_sample(); + let offer = |app: &crate::RustGisApp, s: &str| super::offer_at(s, s.chars().count(), &[], &app.session); + let names = |app: &crate::RustGisApp, keep: fn(&rustgis_engine::map::Layer) -> bool| -> Vec { + app.session.map().layers.iter().filter(|l| keep(l)).map(|l| l.name.clone()).collect() + }; + let polygons = names(&app, |l| l.geometry_type() == GeometryType::Polygon); + assert!(polygons.iter().any(|n| n == "Countries") && !polygons.iter().any(|n| n == "Cities")); + let o = offer(&app, r#"gp.run {"tool": "dissolve", "params": {"input": ""#).unwrap(); + assert_eq!((o.items, o.of), (polygons, Some(super::Of::Layers)), "a polygon layer"); + // A table after the layers; one named as a layer isn't the one Run finds, so not offered. + app.session.project.add_table(Dataset::new("Stats", GeometryType::None, vec![Field::new("total", FieldType::Integer)])); + app.session.project.add_table(Dataset::new("Cities", GeometryType::None, vec![])); + let mut any = names(&app, |_| true); + any.push("Stats".into()); + assert_eq!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"input": ""#).unwrap().items, any, "any layer, then the tables"); + assert_eq!( + offer(&app, r#"gp.run {"tool": "Buffer", "params": {"input": "st"#).unwrap().items, + ["Stats"], + "the tool by name, case aside" + ); + let cities = app.session.map().layer_by_name("Cities").unwrap(); + let fields: Vec = cities.dataset.fields.iter().map(|f| f.name.clone()).collect(); + let numeric: Vec = cities.dataset.fields.iter().filter(|f| f.ty.is_numeric()).map(|f| f.name.clone()).collect(); + assert!(!numeric.is_empty() && numeric.len() < fields.len()); + let o = offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"input": "Cities", "field": ""#).unwrap(); + assert_eq!((o.items, o.of), (numeric.clone(), Some(super::Of::ToolFields("Cities".into()))), "a numeric field"); + let o = offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"input": "Cities", "case": ""#).unwrap(); + assert_eq!(o.items, fields, "any field"); + let s = r#"gp.run {"tool": "summaryStatistics", "params": {"field": ""#; + let o = super::offer_at(&format!(r#"{s}", "input": "Cities"}}}}"#), s.chars().count(), &[], &app.session).unwrap(); + assert_eq!(o.items, numeric, "the input after the field"); + let o = offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"input": "Stats", "field": ""#).unwrap(); + assert_eq!(o.items, ["total"], "a table's fields"); + assert!(offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"field": ""#).is_none(), "no input"); + assert!(offer(&app, r#"gp.run {"tool": "summaryStatistics", "params": {"input": "Nowhere", "field": ""#).is_none()); + let o = offer(&app, r#"gp.run {"tool": "buffer", "params": {"units": ""#).unwrap(); + assert_eq!((o.items, o.of), (["Kilometers", "Meters", "Miles", "Feet"].map(String::from).to_vec(), Some(super::Of::Choices))); + assert_eq!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"units": "mi"#).unwrap().items, ["Miles"], "case aside"); + assert!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"distance": ""#).is_none(), "a number"); + assert!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"nope": ""#).is_none(), "a key the tool doesn't take"); + assert!(offer(&app, r#"gp.run {"tool": "nope", "params": {"input": ""#).is_none(), "a tool that isn't there"); + assert!(offer(&app, r#"gp.run {"tool": "buffer", "params": {"input": {"x": ""#).is_none(), "deeper"); + assert!(offer(&app, r#"task.addStep {"command": "buffer", "params": {"input": ""#).is_none(), "another command's params"); + } + + /// Beside a table, its rows and the table icon; beside a tool's field, its type and the field + /// icon; no help for a choice. + #[test] + fn a_tools_values_have_help_and_icons() { + use super::{Of, item_help, item_icon}; + use rustgis_engine::data::{Dataset, Field, FieldType, GeometryType}; + let mut app = crate::RustGisApp::with_sample(); + app.session.project.add_table(Dataset::new("Stats", GeometryType::None, vec![Field::new("total", FieldType::Integer)])); + let s = &app.session; + assert_eq!(item_help(s, &Of::Layers, "Stats").map(|h| h.0), Some("Table · 0 rows".into())); + assert_eq!(item_icon(s, Some(&Of::Layers), "Stats"), Some("table")); + let of = Of::ToolFields("Stats".into()); + assert_eq!(item_help(s, &of, "total").map(|h| h.0), Some(FieldType::Integer.label().to_string())); + assert_eq!(item_icon(s, Some(&of), "total"), Some("field")); + assert_eq!(item_help(s, &Of::Choices, "Miles"), None); + } + + /// In the pane: accepting a choice closes it, the cursor after its quote. + #[test] + fn a_tools_choice_is_accepted() { + let mut h = pane(); + type_in(&mut h, r#"gp.run {"tool": "buffer", "params": {"units": "Mi"#); + h.key_press(egui::Key::Enter); + h.run(); + type_in(&mut h, "}"); + assert_eq!(h.state().workflow.script, r#"gp.run {"tool": "buffer", "params": {"units": "Miles"}"#); + } + /// In the pane: accepting a layer closes its name, written as a JSON string holds it. #[test] fn a_layer_is_accepted_as_json_writes_it() { diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 04e0c314c..2ff923ad8 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -296,7 +296,11 @@ help; there the help by the cursor is the tool's. A `task.addStep` line works th `"command": "` offers the commands, and its `"params"` that command's keys and help. For a command that takes them, `"layer": "` offers the map's layers, top first, each with its kind's icon and beside it what it holds; `"field": "` offers the fields of the layer the line names -(before or after it), or of the active layer when it names none, with each field's type beside it. Accepting one writes its name +(before or after it), or of the active layer when it names none, with each field's type beside it. +In a `gp.run` line's `"params"`, a value offers what the tool's parameter takes, as the tool's +form does: the layers of its kind (polygon, point, raster, vector; any layer, then the tables), +the fields of the tool's input that it takes (numeric, holding coordinates), or its choices +(`"units": "` offers `Kilometers`, `Meters`, …). Accepting one writes its name as the line's JSON needs it (`"Big \"lakes\""`). A `{` or `[` typed closes itself, the cursor inside, unless text follows it or it's in a string or a comment; so does a `"` where a string can start (after a space, `{`, `[`, `,` or `:`). A `}` or `]` typed From e1e70a97c73e6b2c09e62bc0ef09e8bdf4da50e1 Mon Sep 17 00:00:00 2001 From: quanguet0409 Date: Sun, 25 Oct 2026 00:52:55 +0700 Subject: [PATCH 114/297] =?UTF-8?q?feat(analysis,engine):=20add=20zonal=20?= =?UTF-8?q?statistics=20geoprocessing=20tool=20(gp.zo=E2=80=A6=20(#718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(analysis,engine): add zonal statistics geoprocessing tool (gp.zonalStats) - Implement numerically stable statistical accumulator (Welford's algorithm) for mean and population stddev - Implement in-place polygon raster mask sampling with scanline hole subtraction - Add zonalStats geoprocessing tool with multi-band support, stat selection, and field collision resolution - Register gp.zonalStats command in the engine under 'Analyze › Raster' - Add Zonal Statistics workflow section in the Analysis UI pane - Add unit and end-to-end regression tests covering polygon holes, NoData handling, and undo/redo * fix(analysis,ui): address code review for gp.zonalStats - Reproject polygon geometries to WGS 84 before raster sampling when source CRS is projected (e.g. EPSG:3857) - Fix Accumulator::default() to initialize min/max with INFINITY/NEG_INFINITY consistent with new() - Overhaul parse_bands with strict integer/finiteness validation, integral float acceptance, and 'all' expansion - Deduplicate StatOp supported operations with StatOp::ALL constant - Add UI band validation and localized amber warning in Geoprocessing Analysis pane - Add regression tests covering Accumulator default/new extrema, WebMercator reprojection, and band validation * fix(analysis,ui): address review issues on zonal stats reprojection and UI buffers * Address review feedback - Share polygon transformation across zonal statistics, masking and clipping; reject failed holes and report partial features. - Pad source attributes before statistics and resolve field names case-insensitively, with regressions. - Share band parsing with the UI, validate before conversion and reject invalid statistics types. - Send the clipping selection choice explicitly and document selection and per-zone processing semantics. * fix(analysis,engine,ui): address review feedback on polygon transforms, attribute padding, band parsing, and clip semantics * fix(ui): address egui 0.37 clippy warnings on deprecated id and unused close * fix(ui): add accessible labels for zonal stats controls and restore Ctrl+Enter script shortcut * fix(ui): preserve selection-aware clipping default and associate zonal stats labels * fix(analysis): prefer exact match in find_field_case_insensitive before ambiguous collision scan * fix(analysis): reject empty string items in bands array * fix(ui): dynamic selected_only default and localized selection count label --------- Co-authored-by: giswqs --- crates/analysis/src/lib.rs | 2 +- crates/analysis/src/rasterops.rs | 699 ++++++++++++++++++++- crates/analysis/src/tools.rs | 946 ++++++++++++++++++++++++++++- crates/engine/src/cmd/gp.rs | 10 + crates/engine/src/cmd/imagery.rs | 87 ++- crates/engine/src/tests.rs | 378 +++++++++++- crates/ui-egui/src/analysispane.rs | 144 ++++- crates/ui-egui/tests/smoke.rs | 24 + docs/user-guide/analysis.md | 14 + 9 files changed, 2273 insertions(+), 31 deletions(-) diff --git a/crates/analysis/src/lib.rs b/crates/analysis/src/lib.rs index 37ef54706..eda905cb1 100644 --- a/crates/analysis/src/lib.rs +++ b/crates/analysis/src/lib.rs @@ -24,7 +24,7 @@ pub mod topology; pub mod trend; pub mod warp; -pub use tools::{TOOLS, holds_coordinates, simplify_geometry}; +pub use tools::{TOOLS, find_field_case_insensitive, holds_coordinates, simplify_geometry, zonal_bands}; use rustgis_data::Dataset; use serde_json::Value; diff --git a/crates/analysis/src/rasterops.rs b/crates/analysis/src/rasterops.rs index ef44aae51..786d4b9cb 100644 --- a/crates/analysis/src/rasterops.rs +++ b/crates/analysis/src/rasterops.rs @@ -4,7 +4,7 @@ use std::ops::Range; -use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; +use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster, Transformer}; use rustgis_geom::{Coord, Extent}; /// The no-data value written by these operations. @@ -523,6 +523,199 @@ pub fn zonal_means(r: &Raster, bands: &[usize], rings: &[Vec]) -> Option< (n > 0).then(|| (sums.into_iter().map(|s| s / n as f64).collect(), n)) } +/// Transforms one polygon part, keeping its exterior and holes together. +/// A failed coordinate or degenerate ring (< 3 points) rejects the entire polygon part. +pub fn transform_polygon(rings: &[Vec], mut transform: impl FnMut(Coord) -> Option) -> Option>> { + if rings.first()?.len() < 3 { + return None; + } + let mut out = Vec::new(); + for ring in rings { + if ring.len() < 3 { + return None; + } + let transformed: Option> = ring + .iter() + .map(|&c| { + let p = transform(c)?; + (p.x.is_finite() && p.y.is_finite()).then_some(p) + }) + .collect(); + out.push(transformed?); + } + Some(out) +} + +/// An error encountered while validating or transforming a polygon geometry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PolygonTransformError { + EmptyPolygon, + InvalidExterior(String), + InvalidHole { index: usize, reason: String }, +} + +impl std::fmt::Display for PolygonTransformError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyPolygon => write!(f, "polygon has no rings"), + Self::InvalidExterior(r) => write!(f, "exterior ring is invalid: {r}"), + Self::InvalidHole { index, reason } => write!(f, "hole {index} is invalid: {reason}"), + } + } +} + +impl std::error::Error for PolygonTransformError {} + +/// Transforms a single polygon's rings (exterior ring + optional holes) to WGS 84 lon/lat coordinates. +/// +/// - The exterior ring must have at least 3 points and all points must project successfully. +/// - Every interior ring (hole) must have at least 3 points and all points must project successfully. +/// - If any hole fails or is invalid, the entire polygon is rejected with an error rather than +/// silently discarding the hole or altering the polygon geometry. +pub fn transform_polygon_to_lonlat(rings: &[Vec], tr: Option<&Transformer>) -> Result>, PolygonTransformError> { + let exterior = rings.first().ok_or(PolygonTransformError::EmptyPolygon)?; + let mut ext_lonlat = Vec::with_capacity(exterior.len()); + for &c in exterior { + if !c.is_finite() { + return Err(PolygonTransformError::InvalidExterior("non-finite coordinate".into())); + } + let ll = match tr { + Some(t) => t.to_lonlat(c).ok_or_else(|| PolygonTransformError::InvalidExterior("coordinate transformation failed".into()))?, + None => c, + }; + if !ll.is_finite() { + return Err(PolygonTransformError::InvalidExterior("transformed coordinate is non-finite".into())); + } + ext_lonlat.push(ll); + } + if ext_lonlat.len() < 3 { + return Err(PolygonTransformError::InvalidExterior("fewer than 3 vertices".into())); + } + + let mut out_rings = vec![ext_lonlat]; + for (idx, hole) in rings.iter().skip(1).enumerate() { + let hole_num = idx + 1; + let mut hole_lonlat = Vec::with_capacity(hole.len()); + for &c in hole { + if !c.is_finite() { + return Err(PolygonTransformError::InvalidHole { index: hole_num, reason: "non-finite coordinate".into() }); + } + let ll = match tr { + Some(t) => t.to_lonlat(c).ok_or_else(|| PolygonTransformError::InvalidHole { + index: hole_num, + reason: "coordinate transformation failed".into(), + })?, + None => c, + }; + if !ll.is_finite() { + return Err(PolygonTransformError::InvalidHole { index: hole_num, reason: "transformed coordinate is non-finite".into() }); + } + hole_lonlat.push(ll); + } + if hole_lonlat.len() < 3 { + return Err(PolygonTransformError::InvalidHole { index: hole_num, reason: "fewer than 3 vertices".into() }); + } + out_rings.push(hole_lonlat); + } + + Ok(out_rings) +} + +/// Transforms and validates all polygon parts of a (multi-)polygon feature to WGS 84 lon/lat coordinates. +pub fn transform_feature_polygons_to_lonlat( + polys: &[Vec>], + tr: Option<&Transformer>, +) -> Result>>, PolygonTransformError> { + if polys.is_empty() { + return Err(PolygonTransformError::EmptyPolygon); + } + let mut out = Vec::with_capacity(polys.len()); + for (part_idx, rings) in polys.iter().enumerate() { + let valid_poly = transform_polygon_to_lonlat(rings, tr).map_err(|e| match e { + PolygonTransformError::InvalidHole { index, reason } => { + PolygonTransformError::InvalidHole { index, reason: format!("part {part_idx}: {reason}") } + } + PolygonTransformError::InvalidExterior(reason) => PolygonTransformError::InvalidExterior(format!("part {part_idx}: {reason}")), + other => other, + })?; + out.push(valid_poly); + } + Ok(out) +} + +/// Parses a band specification string into 0-based band indices. +/// +/// Supported inputs: +/// - `""` (empty string): defaults to band 1 (`vec![0]`) +/// - `"all"`: all bands `0..total_bands` +/// - comma, semicolon, or space-separated list of 1-based band numbers: `"1, 2, 3"`, `"[1, 2]"`, etc. +/// +/// Validates that: +/// - Every entry is a valid, finite positive integer. +/// - Decimals (e.g. `"1.5"`), negative numbers (e.g. `"-1"`), zero (`"0"`), non-numeric (e.g. `"abc"`), +/// or non-finite values (e.g. `"NaN"`, `"Infinity"`) are strictly rejected. +/// - Every band number is within `1..=total_bands`. +/// - Empty parts (such as `"1,,2"` or `","`) are rejected. +pub fn parse_bands_str(s: &str, total_bands: usize) -> Result, String> { + if total_bands == 0 { + return Err("raster has no bands".into()); + } + let txt = s.trim(); + if txt.is_empty() { + return Ok(vec![0]); + } + if txt.eq_ignore_ascii_case("all") { + return Ok((0..total_bands).collect()); + } + let clean = txt.trim_matches(|c| c == '[' || c == ']'); + if clean.trim().is_empty() { + return Err("band selection cannot be empty brackets".into()); + } + let mut out = Vec::new(); + for part in clean.split([',', ';']) { + let part = part.trim().trim_matches('"').trim(); + if part.is_empty() { + return Err("band selection contains an empty band entry".into()); + } + for subpart in part.split_whitespace() { + let subpart = subpart.trim(); + if subpart.is_empty() { + continue; + } + if subpart.eq_ignore_ascii_case("all") { + for b in 0..total_bands { + if !out.contains(&b) { + out.push(b); + } + } + continue; + } + let f: f64 = subpart.parse().map_err(|_| format!("invalid band number '{subpart}'"))?; + if !f.is_finite() { + return Err("band number must be a finite number".into()); + } + if f <= 0.0 { + return Err("band numbers must be positive (1-based)".into()); + } + if f.fract() != 0.0 { + return Err("band numbers must be integers, not decimals".into()); + } + let b = f as usize; + if b > total_bands { + return Err(format!("band index {b} is invalid: raster has bands 1 to {total_bands}")); + } + let b_idx = b - 1; + if !out.contains(&b_idx) { + out.push(b_idx); + } + } + } + if out.is_empty() { + return Err("no valid band numbers found in input".into()); + } + Ok(out) +} + /// The cells of `r` whose centres fall inside any of `polygons` (each an exterior ring and its /// holes, in longitude/latitude): the column and row window around the polygons, clamped to the /// raster, and a row-major mask over it. `None` when no cell centre falls inside. @@ -533,14 +726,44 @@ pub fn polygon_mask(r: &Raster, polygons: &[Vec>]) -> Option<(Range>> = polygons .iter() - .map(|rings| { - rings - .iter() - .map(|ring| ring.iter().filter_map(|c| tr.from_lonlat(*c)).map(|w| r.transform.to_pixel(w)).collect::>()) - .filter(|ring| ring.len() >= 3) - .collect::>() + .filter_map(|rings| { + let exterior = rings.first()?; + let mut ext_pts = Vec::with_capacity(exterior.len()); + for c in exterior { + if !c.is_finite() { + return None; + } + let w = tr.from_lonlat(*c)?; + let pt = r.transform.to_pixel(w); + if !pt.0.is_finite() || !pt.1.is_finite() { + return None; + } + ext_pts.push(pt); + } + if ext_pts.len() < 3 { + return None; + } + let mut valid_rings = vec![ext_pts]; + for hole in rings.iter().skip(1) { + let mut hole_pts = Vec::with_capacity(hole.len()); + for c in hole { + if !c.is_finite() { + return None; + } + let w = tr.from_lonlat(*c)?; + let pt = r.transform.to_pixel(w); + if !pt.0.is_finite() || !pt.1.is_finite() { + return None; + } + hole_pts.push(pt); + } + if hole_pts.len() < 3 { + return None; + } + valid_rings.push(hole_pts); + } + Some(valid_rings) }) - .filter(|rings| !rings.is_empty()) .collect(); let (mut c0, mut r0, mut c1, mut r1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); for &(x, y) in projected.iter().flatten().flatten() { @@ -601,6 +824,141 @@ pub fn polygon_mask(r: &Raster, polygons: &[Vec>]) -> Option<(Range, + /// Arithmetic mean of valid raster cells inside the zone. + pub mean: Option, + /// Minimum value among valid raster cells inside the zone. + pub min: Option, + /// Maximum value among valid raster cells inside the zone. + pub max: Option, + /// Population standard deviation of valid raster cells inside the zone: sqrt(sum((x - mean)^2) / n). + pub stddev: Option, +} + +/// Numerically stable online accumulator using Welford's algorithm. +#[derive(Clone, Debug)] +pub struct Accumulator { + count: usize, + sum: f64, + min: f64, + max: f64, + mean: f64, + m2: f64, +} + +impl Default for Accumulator { + /// Constructs an empty accumulator with `min = INFINITY` and `max = NEG_INFINITY`. + fn default() -> Self { + Self::new() + } +} + +impl Accumulator { + /// Constructs a new empty accumulator with `min = INFINITY` and `max = NEG_INFINITY`. + pub fn new() -> Self { + Self { count: 0, sum: 0.0, min: f64::INFINITY, max: f64::NEG_INFINITY, mean: 0.0, m2: 0.0 } + } + + /// Feeds a valid finite measurement into the accumulator, updating count, sum, extrema, + /// and running mean / variance using Welford's algorithm. Non-finite values are ignored. + pub fn add(&mut self, val: f64) { + if !val.is_finite() { + return; + } + self.count += 1; + self.sum += val; + if val < self.min { + self.min = val; + } + if val > self.max { + self.max = val; + } + let delta = val - self.mean; + self.mean += delta / (self.count as f64); + let delta2 = val - self.mean; + self.m2 += delta * delta2; + } + + /// Number of valid finite samples accumulated so far. + pub fn count(&self) -> usize { + self.count + } + + /// Computes and returns the accumulated [`ZoneStats`]. + /// + /// If no valid values were added (`count == 0`), returns `ZoneStats::default()` + /// where `count` is 0 and all statistical metrics are `None`. + pub fn stats(&self) -> ZoneStats { + if self.count == 0 { + ZoneStats::default() + } else { + let variance = (self.m2 / (self.count as f64)).max(0.0); + ZoneStats { + count: self.count, + sum: Some(self.sum), + mean: Some(self.mean), + min: Some(self.min), + max: Some(self.max), + stddev: Some(variance.sqrt()), + } + } + } +} + +/// Computes summary statistics (count, sum, mean, min, max, stddev) for each requested band +/// over the raster cells whose centres fall inside `polygons` (exterior ring and holes in WGS 84 lon/lat). +/// +/// Returns one `ZoneStats` per requested band. Cells with NoData, NaN, or non-finite values are +/// excluded. If no valid cells fall inside the polygon or if the polygon is outside the raster extent, +/// returns `ZoneStats` with `count = 0` and all numeric statistics set to `None`. +pub fn zonal_statistics(r: &Raster, bands: &[usize], polygons: &[Vec>]) -> Result, String> { + r.require_coordinates()?; + for &b in bands { + if b >= r.band_count() { + return Err(format!("band index {} is out of range: {} has {} bands", b + 1, r.name, r.band_count())); + } + } + if bands.is_empty() { + return Ok(Vec::new()); + } + + let Some((cols, rows, mask)) = polygon_mask(r, polygons) else { + return Ok(vec![ZoneStats::default(); bands.len()]); + }; + + let w = (cols.end - cols.start) as usize; + let mut accs: Vec = (0..bands.len()).map(|_| Accumulator::new()).collect(); + + for (ri, row) in rows.enumerate() { + let line_offset = ri * w; + let line = match mask.get(line_offset..line_offset + w) { + Some(l) => l, + None => continue, + }; + for (ci, &inside) in line.iter().enumerate() { + if !inside { + continue; + } + let col = cols.start + ci as u32; + for (acc_idx, &band) in bands.iter().enumerate() { + if let Some(v) = r.value(band, col as i64, row as i64) + && let Some(acc) = accs.get_mut(acc_idx) + { + acc.add(f64::from(v)); + } + } + } + } + + Ok(accs.into_iter().map(|acc| acc.stats()).collect()) +} + /// A tiny arithmetic expression over band values. enum Expr { Num(f64), @@ -785,4 +1143,329 @@ mod tests { assert_eq!(p.len(), 20); assert!(p.last().and_then(|x| x.2).unwrap_or(0.0) > p[0].2.unwrap_or(0.0) + 150.0); } + + #[test] + fn accumulator_exact_math_and_numerical_stability() { + // 1. Exact math on [10.0, 20.0, 30.0, 40.0] + let mut acc = Accumulator::new(); + for &x in &[10.0, 20.0, 30.0, 40.0] { + acc.add(x); + } + let st = acc.stats(); + assert_eq!(st.count, 4); + assert_eq!(st.sum, Some(100.0)); + assert_eq!(st.mean, Some(25.0)); + assert_eq!(st.min, Some(10.0)); + assert_eq!(st.max, Some(40.0)); + let expected_stddev = 125.0f64.sqrt(); // sqrt(500 / 4) + assert!((st.stddev.unwrap() - expected_stddev).abs() < 1e-12); + + // 2. Empty accumulator + let empty_st = Accumulator::new().stats(); + assert_eq!(empty_st.count, 0); + assert_eq!(empty_st.sum, None); + assert_eq!(empty_st.mean, None); + assert_eq!(empty_st.min, None); + assert_eq!(empty_st.max, None); + assert_eq!(empty_st.stddev, None); + + // 3. Single value + let mut single_acc = Accumulator::new(); + single_acc.add(42.5); + let single_st = single_acc.stats(); + assert_eq!(single_st.count, 1); + assert_eq!(single_st.sum, Some(42.5)); + assert_eq!(single_st.mean, Some(42.5)); + assert_eq!(single_st.min, Some(42.5)); + assert_eq!(single_st.max, Some(42.5)); + assert_eq!(single_st.stddev, Some(0.0)); + + // 4. Constant values + let mut const_acc = Accumulator::new(); + for _ in 0..100 { + const_acc.add(7.0); + } + let const_st = const_acc.stats(); + assert_eq!(const_st.count, 100); + assert_eq!(const_st.mean, Some(7.0)); + assert_eq!(const_st.stddev, Some(0.0)); + + // 5. Large magnitude values (Welford's algorithm avoids catastrophic cancellation) + let base = 1e12; + let mut large_acc = Accumulator::new(); + for &x in &[base + 1.0, base + 2.0, base + 3.0, base + 4.0] { + large_acc.add(x); + } + let large_st = large_acc.stats(); + assert_eq!(large_st.count, 4); + assert_eq!(large_st.mean, Some(base + 2.5)); + let std_large = large_st.stddev.unwrap(); + // Variance of [1, 2, 3, 4] is 1.25, stddev is sqrt(1.25) = 1.118033988749895 + assert!((std_large - 1.25f64.sqrt()).abs() < 1e-10, "large magnitude precision: {std_large}"); + + // 6. Non-finite values (NaN and inf) are rejected + let mut defensive_acc = Accumulator::new(); + defensive_acc.add(10.0); + defensive_acc.add(f64::NAN); + defensive_acc.add(f64::INFINITY); + defensive_acc.add(f64::NEG_INFINITY); + defensive_acc.add(20.0); + let def_st = defensive_acc.stats(); + assert_eq!(def_st.count, 2); + assert_eq!(def_st.sum, Some(30.0)); + assert_eq!(def_st.mean, Some(15.0)); + } + + #[test] + fn accumulator_default_and_new_consistency_and_extrema() { + // default() and new() produce identical initial empty state + let d = Accumulator::default(); + let n = Accumulator::new(); + assert_eq!(d.count(), 0); + assert_eq!(n.count(), 0); + assert_eq!(d.stats(), ZoneStats::default()); + assert_eq!(n.stats(), ZoneStats::default()); + + // Negative-only values: min must be -30, max must be -10 (not 0.0) + let mut neg_acc = Accumulator::default(); + neg_acc.add(-30.0); + neg_acc.add(-20.0); + neg_acc.add(-10.0); + let neg_st = neg_acc.stats(); + assert_eq!(neg_st.count, 3); + assert_eq!(neg_st.min, Some(-30.0)); + assert_eq!(neg_st.max, Some(-10.0)); + assert_eq!(neg_st.sum, Some(-60.0)); + assert_eq!(neg_st.mean, Some(-20.0)); + + // Positive-only values: min must be 10 (not 0.0), max must be 30 + let mut pos_acc = Accumulator::default(); + pos_acc.add(10.0); + pos_acc.add(20.0); + pos_acc.add(30.0); + let pos_st = pos_acc.stats(); + assert_eq!(pos_st.count, 3); + assert_eq!(pos_st.min, Some(10.0)); + assert_eq!(pos_st.max, Some(30.0)); + + // Mixed positive and negative values + let mut mixed_acc = Accumulator::default(); + mixed_acc.add(-5.0); + mixed_acc.add(5.0); + let mixed_st = mixed_acc.stats(); + assert_eq!(mixed_st.count, 2); + assert_eq!(mixed_st.min, Some(-5.0)); + assert_eq!(mixed_st.max, Some(5.0)); + assert_eq!(mixed_st.sum, Some(0.0)); + assert_eq!(mixed_st.mean, Some(0.0)); + } + + #[test] + fn zonal_statistics_known_cells_nodata_and_holes() { + let r = ramp(); // 20x20 raster, band 0: (i % 20) * 10, band 1: i / 20. + let sq = |x0: f64, y0: f64, x1: f64, y1: f64| { + vec![Coord::new(x0, y0), Coord::new(x1, y0), Coord::new(x1, y1), Coord::new(x0, y1), Coord::new(x0, y0)] + }; + + // 1. Single known cell: col 5, row 5. + // origin_x = 0.0, origin_y = 0.02, pixel_width = 0.001, pixel_height = -0.001 + // col 5 x in [0.005, 0.006], centre at 0.0055. + // row 5 y in [0.014, 0.015], centre at 0.0145. + let cell_poly = vec![vec![sq(0.005, 0.014, 0.006, 0.015)]]; + let stats = zonal_statistics(&r, &[0, 1], &cell_poly).unwrap(); + assert_eq!(stats.len(), 2); + assert_eq!(stats[0].count, 1); + assert_eq!(stats[0].sum, Some(50.0)); // col 5 * 10 = 50 + assert_eq!(stats[0].mean, Some(50.0)); + assert_eq!(stats[0].min, Some(50.0)); + assert_eq!(stats[0].max, Some(50.0)); + assert_eq!(stats[0].stddev, Some(0.0)); + + assert_eq!(stats[1].count, 1); + assert_eq!(stats[1].sum, Some(5.0)); // row 5 = 5 + assert_eq!(stats[1].mean, Some(5.0)); + assert_eq!(stats[1].stddev, Some(0.0)); + + // 2. Polygon outside raster: empty zone result + let outside_poly = vec![vec![sq(1.0, 1.0, 2.0, 2.0)]]; + let outside_stats = zonal_statistics(&r, &[0], &outside_poly).unwrap(); + assert_eq!(outside_stats.len(), 1); + assert_eq!(outside_stats[0], ZoneStats::default()); + + // 3. Polygon with holes + let poly_with_hole = vec![vec![sq(0.005, 0.005, 0.015, 0.015), sq(0.009, 0.009, 0.011, 0.011)]]; + let hole_stats = zonal_statistics(&r, &[0], &poly_with_hole).unwrap(); + // 100 cells - 4 hole cells = 96 cells + assert_eq!(hole_stats[0].count, 96); + assert!(hole_stats[0].sum.is_some()); + assert!(hole_stats[0].mean.is_some()); + + // 4. MultiPolygon + let multipoly = vec![ + vec![sq(0.001, 0.018, 0.003, 0.019)], // 2 cells + vec![sq(0.005, 0.018, 0.007, 0.019)], // 2 cells + ]; + let multi_stats = zonal_statistics(&r, &[0], &multipoly).unwrap(); + assert_eq!(multi_stats[0].count, 4); + + // 5. NoData handling: create a raster with NoData values + let (w, h) = (4u32, 4u32); + // cells: + // [ 10, 20, -9999, 40 ] + // [ 50, NaN, 70, 80 ] + // [ -9999, -9999, -9999, -9999 ] + // [ 130, 140, 150, 160 ] + let data: Vec = + vec![10.0, 20.0, -9999.0, 40.0, 50.0, f32::NAN, 70.0, 80.0, -9999.0, -9999.0, -9999.0, -9999.0, 130.0, 140.0, 150.0, 160.0]; + let gt = GeoTransform { origin_x: 0.0, origin_y: 4.0, pixel_width: 1.0, pixel_height: -1.0 }; + let r_nodata = Raster::new("nd", w, h, vec![data], gt, Crs::LonLat, Some(-9999.0), ColorHint::Gray, "Float32").unwrap(); + + // Zone covering entire row 0: cells are 10, 20, -9999 (NoData), 40 + // Valid values: 10, 20, 40 (count = 3, sum = 70, mean = 70/3, min = 10, max = 40) + let row0_poly = vec![vec![sq(0.0, 3.0, 4.0, 4.0)]]; + let r0_stats = zonal_statistics(&r_nodata, &[0], &row0_poly).unwrap(); + assert_eq!(r0_stats[0].count, 3); + assert_eq!(r0_stats[0].sum, Some(70.0)); + assert_eq!(r0_stats[0].min, Some(10.0)); + assert_eq!(r0_stats[0].max, Some(40.0)); + assert!((r0_stats[0].mean.unwrap() - 70.0 / 3.0).abs() < 1e-10); + + // Zone covering row 2 where ALL cells are NoData (-9999) + let row2_poly = vec![vec![sq(0.0, 1.0, 4.0, 2.0)]]; + let r2_stats = zonal_statistics(&r_nodata, &[0], &row2_poly).unwrap(); + assert_eq!(r2_stats[0].count, 0); + assert_eq!(r2_stats[0].sum, None); + assert_eq!(r2_stats[0].mean, None); + assert_eq!(r2_stats[0].min, None); + assert_eq!(r2_stats[0].max, None); + assert_eq!(r2_stats[0].stddev, None); + + // 6. Invalid band index returns Err + assert!(zonal_statistics(&r, &[99], &cell_poly).is_err()); + + // 7. Invalid exterior ring (< 3 points) with surviving hole does NOT promote hole to exterior + let bad_ext_with_hole = vec![vec![ + vec![Coord::new(0.005, 0.005), Coord::new(0.015, 0.015)], // only 2 points! + sq(0.009, 0.009, 0.011, 0.011), // 5 points (hole) + ]]; + let bad_ext_stats = zonal_statistics(&r, &[0], &bad_ext_with_hole).unwrap(); + assert_eq!(bad_ext_stats[0], ZoneStats::default()); + assert!(polygon_mask(&r, &bad_ext_with_hole).is_none()); + + // 8. Exterior ring with non-finite/unprojectable coordinate is rejected + let nan_ext_with_hole = vec![vec![ + vec![Coord::new(f64::NAN, 0.005), Coord::new(0.015, 0.005), Coord::new(0.015, 0.015), Coord::new(f64::NAN, 0.005)], + sq(0.009, 0.009, 0.011, 0.011), + ]]; + let nan_ext_stats = zonal_statistics(&r, &[0], &nan_ext_with_hole).unwrap(); + assert_eq!(nan_ext_stats[0], ZoneStats::default()); + assert!(polygon_mask(&r, &nan_ext_with_hole).is_none()); + + // 9. Polygon with defective hole (< 3 vertices or non-finite) is rejected rather than silently discarding the hole + let bad_hole_poly = vec![vec![ + sq(0.005, 0.005, 0.015, 0.015), // valid exterior (5 points) + vec![Coord::new(0.009, 0.009), Coord::new(0.011, 0.011)], // invalid hole: only 2 points! + ]]; + assert!(polygon_mask(&r, &bad_hole_poly).is_none(), "polygon with defective hole must be rejected"); + let bad_hole_stats = zonal_statistics(&r, &[0], &bad_hole_poly).unwrap(); + assert_eq!(bad_hole_stats[0], ZoneStats::default()); + + let nan_hole_poly = vec![vec![ + sq(0.005, 0.005, 0.015, 0.015), + vec![Coord::new(f64::NAN, 0.009), Coord::new(0.011, 0.009), Coord::new(0.011, 0.011), Coord::new(f64::NAN, 0.009)], + ]]; + assert!(polygon_mask(&r, &nan_hole_poly).is_none(), "polygon with non-finite hole must be rejected"); + } + + #[test] + fn polygon_transformation_and_hole_validation_semantics() { + let sq = |x0: f64, y0: f64, x1: f64, y1: f64| { + vec![Coord::new(x0, y0), Coord::new(x1, y0), Coord::new(x1, y1), Coord::new(x0, y1), Coord::new(x0, y0)] + }; + let ext = sq(0.0, 0.0, 10.0, 10.0); + let hole1 = sq(2.0, 2.0, 4.0, 4.0); + let hole2 = sq(6.0, 6.0, 8.0, 8.0); + + // Valid exterior and valid holes + let valid_poly = vec![ext.clone(), hole1.clone(), hole2.clone()]; + let res = transform_polygon_to_lonlat(&valid_poly, None).unwrap(); + assert_eq!(res.len(), 3); + assert_eq!(res[0].len(), ext.len()); + assert_eq!(res[1].len(), hole1.len()); + assert_eq!(res[2].len(), hole2.len()); + + // Empty polygon + let empty_poly: Vec> = Vec::new(); + assert_eq!(transform_polygon_to_lonlat(&empty_poly, None), Err(PolygonTransformError::EmptyPolygon)); + + // Invalid exterior: fewer than 3 vertices + let bad_ext = vec![vec![Coord::new(0.0, 0.0), Coord::new(1.0, 1.0)]]; + assert!(matches!(transform_polygon_to_lonlat(&bad_ext, None), Err(PolygonTransformError::InvalidExterior(_)))); + + // Invalid exterior: non-finite coordinate + let nan_ext = vec![vec![Coord::new(0.0, 0.0), Coord::new(f64::NAN, 1.0), Coord::new(2.0, 2.0), Coord::new(0.0, 0.0)]]; + assert!(matches!(transform_polygon_to_lonlat(&nan_ext, None), Err(PolygonTransformError::InvalidExterior(_)))); + + // Invalid hole 1: fewer than 3 vertices + let bad_hole = vec![ext.clone(), vec![Coord::new(2.0, 2.0), Coord::new(3.0, 3.0)]]; + assert_eq!( + transform_polygon_to_lonlat(&bad_hole, None), + Err(PolygonTransformError::InvalidHole { index: 1, reason: "fewer than 3 vertices".into() }) + ); + + // Invalid hole 2: non-finite coordinate + let nan_hole = vec![ext.clone(), hole1.clone(), vec![Coord::new(6.0, 6.0), Coord::new(f64::INFINITY, 8.0), Coord::new(8.0, 8.0)]]; + assert!(matches!(transform_polygon_to_lonlat(&nan_hole, None), Err(PolygonTransformError::InvalidHole { index: 2, .. }))); + + // Multipart features + let multi_valid = vec![vec![ext.clone(), hole1.clone()], vec![sq(20.0, 20.0, 30.0, 30.0)]]; + let multi_res = transform_feature_polygons_to_lonlat(&multi_valid, None).unwrap(); + assert_eq!(multi_res.len(), 2); + assert_eq!(multi_res[0].len(), 2); + assert_eq!(multi_res[1].len(), 1); + + // Multipart with failing part + let multi_bad = vec![vec![ext.clone()], vec![vec![Coord::new(1.0, 1.0)]]]; + assert!(transform_feature_polygons_to_lonlat(&multi_bad, None).is_err()); + } + + #[test] + fn parse_bands_str_validation_and_boundaries() { + let total = 3usize; + + // Valid formats + assert_eq!(parse_bands_str("", total).unwrap(), vec![0]); + assert_eq!(parse_bands_str(" ", total).unwrap(), vec![0]); + assert_eq!(parse_bands_str("all", total).unwrap(), vec![0, 1, 2]); + assert_eq!(parse_bands_str("ALL", total).unwrap(), vec![0, 1, 2]); + assert_eq!(parse_bands_str("1", total).unwrap(), vec![0]); + assert_eq!(parse_bands_str("3", total).unwrap(), vec![2]); // boundary: total_bands + assert_eq!(parse_bands_str("1, 2", total).unwrap(), vec![0, 1]); + assert_eq!(parse_bands_str("[1, 2, 3]", total).unwrap(), vec![0, 1, 2]); + assert_eq!(parse_bands_str("1; 3", total).unwrap(), vec![0, 2]); + assert_eq!(parse_bands_str(" 1 2 ", total).unwrap(), vec![0, 1]); + + // De-duplication + assert_eq!(parse_bands_str("1, 1, 2, 2, 3", total).unwrap(), vec![0, 1, 2]); + + // Boundary violations and negative + assert!(parse_bands_str("0", total).is_err(), "0 is invalid (1-based)"); + assert!(parse_bands_str("-1", total).is_err(), "negative band is invalid"); + assert!(parse_bands_str("4", total).is_err(), "out of range band is invalid"); + assert!(parse_bands_str("999", total).is_err(), "out of range band is invalid"); + + // Decimals and non-finite + assert!(parse_bands_str("1.5", total).is_err(), "decimal band is invalid"); + assert_eq!(parse_bands_str("1.0", total).unwrap(), vec![0]); // 1.0 has fract == 0.0 + assert!(parse_bands_str("NaN", total).is_err(), "NaN is invalid"); + assert!(parse_bands_str("Infinity", total).is_err(), "Infinity is invalid"); + + // Malformed strings + assert!(parse_bands_str("abc", total).is_err(), "non-numeric string is invalid"); + assert!(parse_bands_str("[]", total).is_err(), "empty brackets are invalid"); + assert!(parse_bands_str("[ ]", total).is_err(), "whitespace brackets are invalid"); + assert!(parse_bands_str("1,,2", total).is_err(), "empty item between commas is invalid"); + assert!(parse_bands_str(",", total).is_err(), "standalone comma is invalid"); + assert!(parse_bands_str(";", total).is_err(), "standalone semicolon is invalid"); + } } diff --git a/crates/analysis/src/tools.rs b/crates/analysis/src/tools.rs index 109aaf622..feeaf7054 100644 --- a/crates/analysis/src/tools.rs +++ b/crates/analysis/src/tools.rs @@ -457,6 +457,56 @@ pub static TOOLS: &[ToolSpec] = &[ ], run: kriging_interpolation, }, + ToolSpec { + id: "zonalStats", + name: "Zonal Statistics", + toolbox: "Analysis › Raster", + summary: "Calculates raster summary statistics for each polygon feature.", + params: &[ + P { + name: "raster", + label: "Input Raster", + kind: K::RasterLayer, + required: true, + default: "", + help: "The raster containing pixel values to summarize.", + }, + P { + name: "polygons", + label: "Zone Polygons", + kind: K::PolygonLayer, + required: true, + default: "", + help: "Polygon layer defining the zones.", + }, + P { + name: "bands", + label: "Bands", + kind: K::Text, + required: false, + default: "\"1\"", + help: "1-based band number(s) (e.g. 1 or [1, 2]); defaults to band 1.", + }, + P { + name: "stats", + label: "Statistics", + kind: K::Text, + required: false, + default: "\"All\"", + help: "Statistical operations: All, Mean, Min, Max, Sum, Count, StdDev (comma-separated or array); defaults to All.", + }, + P { + name: "prefix", + label: "Field Prefix", + kind: K::Text, + required: false, + default: "", + help: "Prefix for generated fields; defaults to raster/band name.", + }, + P { name: "output", label: "Output Name", kind: K::Text, required: false, default: "", help: "Output polygon layer name." }, + ], + run: zonal_stats, + }, ]; // --------------------------------------------------------------------------------------------- @@ -523,6 +573,33 @@ fn copy_schema(ds: &Dataset, name: String, g: GeometryType) -> Dataset { Dataset::new(name, g, ds.fields.clone()) } +/// Finds the index of a field by case-insensitive name, explicitly detecting collisions. +/// +/// If multiple fields in `ds.fields` match `name` case-insensitively, returns an error +/// detailing the collision rather than silently selecting an arbitrary field. +pub fn find_field_case_insensitive(ds: &Dataset, name: &str) -> Result { + if let Some(idx) = ds.fields.iter().position(|f| f.name == name) { + return Ok(idx); + } + let mut matches = Vec::new(); + for (idx, f) in ds.fields.iter().enumerate() { + if f.name.eq_ignore_ascii_case(name) { + matches.push(idx); + } + } + match matches.len() { + 0 => Err(format!("no field named '{name}'")), + 1 => Ok(matches[0]), + _ => { + let names: Vec = matches.iter().map(|&i| ds.fields[i].name.clone()).collect(); + Err(format!( + "field name '{name}' is ambiguous: case-insensitive collision between {}", + names.iter().map(|n| format!("'{n}'")).collect::>().join(" and ") + )) + } + } +} + /// Union of polygons; rings are oriented first because the overlay infers its fill rule from /// the first ring's winding, and input files mix orientations. fn union_all<'a>(polys: impl IntoIterator>) -> MultiPolygon { @@ -632,7 +709,7 @@ fn dissolve(inp: &dyn Inputs, p: &Json) -> Result { return Err("Dissolve needs a polygon layer".into()); } let field = s(p, "field").trim(); - let fi = if field.is_empty() { None } else { Some(ds.field_index(field).ok_or_else(|| format!("no field named '{field}'"))?) }; + let fi = if field.is_empty() { None } else { Some(find_field_case_insensitive(ds, field)?) }; let mut groups: Vec<(Value, Vec>)> = Vec::new(); for r in rows(inp, p, "input", ds) { let Some(mp) = ds.features.get(r).and_then(|f| f.geometry.as_ref()).and_then(to_multipolygon) else { continue }; @@ -745,7 +822,7 @@ fn xy_table_to_point(inp: &dyn Inputs, p: &Json) -> Result { let ds = input(inp, p, "input")?; let field = |k: &str| { let name = s(p, k).trim(); - ds.field_index(name).ok_or_else(|| format!("no field named '{name}'")) + find_field_case_insensitive(ds, name) }; let (xi, yi) = (field("x")?, field("y")?); let crs = match s(p, "crs").trim() { @@ -863,7 +940,7 @@ fn select_by_attributes(inp: &dyn Inputs, p: &Json) -> Result Result { let ds = input(inp, p, "input")?; let field = s(p, "field"); - let fi = ds.field_index(field).ok_or_else(|| format!("no field named '{field}'"))?; + let fi = find_field_case_insensitive(ds, field)?; let mut pts: Vec<(Coord, f64)> = Vec::new(); for r in rows(inp, p, "input", ds) { let Some(g) = ds.features.get(r).and_then(|f| f.geometry.as_ref()) else { continue }; @@ -935,7 +1012,7 @@ fn kriging_interpolation(inp: &dyn Inputs, p: &Json) -> Result = Vec::new(); for r in rows(inp, p, "input", ds) { let Some(g) = ds.features.get(r).and_then(|f| f.geometry.as_ref()) else { continue }; @@ -1155,9 +1232,9 @@ fn calculate_geometry(inp: &dyn Inputs, p: &Json) -> Result fn summary_statistics(inp: &dyn Inputs, p: &Json) -> Result { let ds = input(inp, p, "input")?; let field = s(p, "field"); - let fi = ds.field_index(field).ok_or_else(|| format!("no field named '{field}'"))?; + let fi = find_field_case_insensitive(ds, field)?; let case = s(p, "case").trim(); - let ci = if case.is_empty() { None } else { Some(ds.field_index(case).ok_or_else(|| format!("no field named '{case}'"))?) }; + let ci = if case.is_empty() { None } else { Some(find_field_case_insensitive(ds, case)?) }; let mut groups: Vec<(Value, Vec)> = Vec::new(); for r in rows(inp, p, "input", ds) { let key = ci.map(|c| ds.value(r, c).clone()).unwrap_or(Value::Null); @@ -1421,9 +1498,320 @@ fn spatial_join(inp: &dyn Inputs, p: &Json) -> Result { result(out, format!("Spatial Join: {written_features} features joined ({total_matches} matches)")) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StatOp { + Mean, + Min, + Max, + Sum, + Count, + StdDev, +} + +impl StatOp { + pub const ALL: [StatOp; 6] = [StatOp::Mean, StatOp::Min, StatOp::Max, StatOp::Sum, StatOp::Count, StatOp::StdDev]; + + fn name(self) -> &'static str { + match self { + StatOp::Mean => "mean", + StatOp::Min => "min", + StatOp::Max => "max", + StatOp::Sum => "sum", + StatOp::Count => "count", + StatOp::StdDev => "stddev", + } + } + + fn field_type(self) -> FieldType { + match self { + StatOp::Count => FieldType::Integer, + _ => FieldType::Double, + } + } + + fn parse(raw: &str) -> Result, String> { + let clean = raw.trim().to_ascii_lowercase(); + match clean.as_str() { + "all" => Ok(Self::ALL.to_vec()), + "mean" => Ok(vec![StatOp::Mean]), + "min" => Ok(vec![StatOp::Min]), + "max" => Ok(vec![StatOp::Max]), + "sum" => Ok(vec![StatOp::Sum]), + "count" => Ok(vec![StatOp::Count]), + "stddev" | "std" | "std_dev" => Ok(vec![StatOp::StdDev]), + "" => Err("empty statistic name: choose from mean, min, max, sum, count, stddev, or all".into()), + other => Err(format!("unknown statistic '{other}': choose from mean, min, max, sum, count, stddev, or all")), + } + } +} + +/// Parses the `bands` parameter for zonal statistics. +pub fn zonal_bands(value: &Json, total_bands: usize) -> Result, String> { + let dummy = serde_json::json!({ "bands": value }); + parse_bands(&dummy, total_bands) +} + +fn parse_bands(p: &Json, total_bands: usize) -> Result, String> { + if total_bands == 0 { + return Err("raster has no bands".into()); + } + match p.get("bands") { + None | Some(Json::Null) => Ok(vec![0]), + Some(Json::String(s)) => { + if s.trim().is_empty() { + return Err("bands parameter cannot be an empty string".into()); + } + crate::rasterops::parse_bands_str(s, total_bands) + } + Some(Json::Number(n)) => { + let f = n.as_f64().ok_or_else(|| "invalid band number".to_string())?; + if !f.is_finite() { + return Err("band number must be a finite number".into()); + } + if f <= 0.0 { + return Err("band numbers must be positive (1-based)".into()); + } + if f.fract() != 0.0 { + return Err("band numbers must be integers, not decimals".into()); + } + let b = f as usize; + if b > total_bands { + return Err(format!("band index {b} is invalid: raster has bands 1 to {total_bands}")); + } + Ok(vec![b - 1]) + } + Some(Json::Array(arr)) => { + if arr.is_empty() { + return Err("bands array cannot be empty".into()); + } + let mut out = Vec::new(); + for item in arr { + match item { + Json::Number(n) => { + let f = n.as_f64().ok_or_else(|| "invalid band number".to_string())?; + if !f.is_finite() { + return Err("band number must be a finite number".into()); + } + if f <= 0.0 { + return Err("band numbers must be positive (1-based)".into()); + } + if f.fract() != 0.0 { + return Err("band numbers must be integers, not decimals".into()); + } + let b = f as usize; + if b > total_bands { + return Err(format!("band index {b} is invalid: raster has bands 1 to {total_bands}")); + } + let b_idx = b - 1; + if !out.contains(&b_idx) { + out.push(b_idx); + } + } + Json::String(s) => { + if s.trim().is_empty() { + return Err("band items cannot be empty strings".into()); + } + let sub = crate::rasterops::parse_bands_str(s, total_bands)?; + for b_idx in sub { + if !out.contains(&b_idx) { + out.push(b_idx); + } + } + } + _ => return Err("band items must be numbers or strings".into()), + } + } + Ok(out) + } + _ => Err("bands must be a number, string, or array of numbers/strings".into()), + } +} + +fn parse_stats(p: &Json) -> Result, String> { + match p.get("stats") { + None | Some(Json::Null) => Ok(StatOp::ALL.to_vec()), + Some(Json::Array(arr)) => { + if arr.is_empty() { + return Err("stats array cannot be empty".into()); + } + let mut out = Vec::new(); + for item in arr { + let txt = item.as_str().ok_or_else(|| "statistic names must be strings".to_string())?; + if txt.trim().is_empty() { + return Err("statistic name cannot be empty".into()); + } + let ops = StatOp::parse(txt)?; + for op in ops { + if !out.contains(&op) { + out.push(op); + } + } + } + Ok(out) + } + Some(Json::String(txt)) => { + let trimmed = txt.trim(); + if trimmed.is_empty() { + return Err("stats parameter cannot be empty string".into()); + } + let clean = trimmed.trim_matches(|c| c == '[' || c == ']'); + if clean.trim().is_empty() { + return Err("stats parameter cannot be empty brackets".into()); + } + let mut out = Vec::new(); + for part in clean.split([',', ';']) { + let part = part.trim().trim_matches('"').trim(); + if part.is_empty() { + return Err("stats selection contains an empty statistic entry".into()); + } + let ops = StatOp::parse(part)?; + for op in ops { + if !out.contains(&op) { + out.push(op); + } + } + } + Ok(out) + } + _ => Err("stats parameter must be a string or array of strings".into()), + } +} + +fn zonal_stats(inp: &dyn Inputs, p: &Json) -> Result { + let r_name = { + let n = s(p, "raster"); + if n.is_empty() { s(p, "layer") } else { n } + }; + if r_name.is_empty() { + return Err("parameter 'raster' is required".into()); + } + let r_input = inp.raster(r_name).ok_or_else(|| format!("no raster layer named '{r_name}'"))?; + let r = r_input.raster; + r.require_coordinates()?; + + let poly_param = if !s(p, "polygons").is_empty() { "polygons" } else { "input" }; + let poly_ds = input(inp, p, poly_param)?; + if poly_ds.geometry_type != GeometryType::Polygon { + return Err(format!( + "layer '{}' is {} data; zonal statistics needs polygon geometries", + poly_ds.name, + poly_ds.geometry_type.label() + )); + } + let poly_tr = match poly_ds.crs { + Some(crs) if crs != rustgis_data::raster::Crs::LonLat && crs != rustgis_data::raster::Crs::Epsg(4326) => { + Some(crs.transformer().map_err(|e| format!("polygon layer coordinate system isn't supported: {e}"))?) + } + _ => None, + }; + + let bands = parse_bands(p, r.band_count())?; + let stats = parse_stats(p)?; + + let mut out = copy_schema(poly_ds, out_name(p, format!("{}_ZonalStats", poly_ds.name)), poly_ds.geometry_type); + out.source = None; + out.crs = poly_ds.crs; + + let user_prefix = s(p, "prefix").trim(); + let multi_bands = bands.len() > 1; + let mut out_fields = Vec::new(); + let mut new_field_names: Vec = Vec::new(); + + for (b_idx, &band) in bands.iter().enumerate() { + let band_num = band + 1; + let band_name = r.bands.get(band).map(|b| b.name.as_str()).unwrap_or(""); + let has_named_band = !band_name.is_empty() && !band_name.starts_with("Band ") && band_name != "Gray"; + let prefix = if !user_prefix.is_empty() { + if multi_bands { format!("{user_prefix}_b{band_num}") } else { user_prefix.to_string() } + } else if multi_bands { + if has_named_band { format!("{}_{}", r.name, band_name) } else { format!("{}_b{band_num}", r.name) } + } else if has_named_band { + band_name.to_string() + } else { + r.name.to_string() + }; + + for &stat in &stats { + let base_name = format!("{prefix}_{}", stat.name()); + let mut final_name = base_name.clone(); + let mut counter = 1usize; + while out.field_index(&final_name).is_some() || new_field_names.iter().any(|n| n.eq_ignore_ascii_case(&final_name)) { + counter += 1; + final_name = format!("{base_name}_{counter}"); + } + new_field_names.push(final_name.clone()); + out_fields.push((b_idx, band, stat, final_name, stat.field_type())); + } + } + + for (_, _, _, name, ty) in &out_fields { + out.fields.push(Field::new(name.clone(), *ty)); + } + + let target_rows = rows(inp, p, poly_param, poly_ds); + let mut skipped_features = 0usize; + let mut failure_details = Vec::new(); + for r_idx in target_rows { + let Some(f) = poly_ds.features.get(r_idx) else { continue }; + let (stats_by_band, feature_valid) = match &f.geometry { + Some(Geometry::Polygon(polys)) if !polys.is_empty() => { + match crate::rasterops::transform_feature_polygons_to_lonlat(polys, poly_tr.as_ref()) { + Ok(polys_lonlat) => { + let st = crate::rasterops::zonal_statistics(r, &bands, &polys_lonlat)?; + (st, true) + } + Err(e) => { + failure_details.push(format!("feature {}: {e}", f.id)); + (vec![crate::rasterops::ZoneStats::default(); bands.len()], false) + } + } + } + _ => { + failure_details.push(format!("feature {}: missing or empty geometry", f.id)); + (vec![crate::rasterops::ZoneStats::default(); bands.len()], false) + } + }; + + if !feature_valid { + skipped_features += 1; + } + + let mut attrs = f.attrs.clone(); + attrs.resize(poly_ds.fields.len(), Value::Null); + for (b_idx, _, stat, _, _) in &out_fields { + let b_stats = stats_by_band.get(*b_idx); + let val = match (b_stats, stat) { + (Some(s), StatOp::Count) => Value::Int(s.count as i64), + (Some(s), StatOp::Mean) => s.mean.map(Value::Float).unwrap_or(Value::Null), + (Some(s), StatOp::Min) => s.min.map(Value::Float).unwrap_or(Value::Null), + (Some(s), StatOp::Max) => s.max.map(Value::Float).unwrap_or(Value::Null), + (Some(s), StatOp::Sum) => s.sum.map(Value::Float).unwrap_or(Value::Null), + (Some(s), StatOp::StdDev) => s.stddev.map(Value::Float).unwrap_or(Value::Null), + _ => Value::Null, + }; + attrs.push(val); + } + out.features.push(rustgis_data::Feature { id: f.id, geometry: f.geometry.clone(), attrs }); + } + + let n_features = out.features.len(); + let mut messages = vec![format!("Calculated zonal statistics for {n_features} feature(s)")]; + if skipped_features > 0 { + messages.push(format!("{skipped_features} feature(s) had invalid geometry or failed coordinate transformation")); + for detail in failure_details.iter().take(5) { + messages.push(detail.clone()); + } + if failure_details.len() > 5 { + messages.push(format!("... and {} more feature failure(s)", failure_details.len() - 5)); + } + } + Ok(ToolResult { output: Output::Layer(out), messages }) +} + #[cfg(test)] mod tests { use super::*; + use rustgis_data::Feature; use std::collections::HashMap; struct Map(HashMap); @@ -1436,6 +1824,24 @@ mod tests { } } + struct RasterMap<'a> { + datasets: HashMap, + rasters: HashMap, + } + + impl<'a> Inputs for RasterMap<'a> { + fn dataset(&self, layer: &str) -> Option<&Dataset> { + self.datasets.get(layer) + } + fn selection(&self, _: &str) -> &[usize] { + &[] + } + fn raster(&self, layer: &str) -> Option> { + let r = self.rasters.get(layer)?; + Some(crate::RasterInput { raster: r, path: None, embedded: None }) + } + } + fn sq(x: f64, y: f64, s: f64) -> Geometry { Geometry::Polygon(vec![vec![vec![ Coord::new(x, y), @@ -2215,4 +2621,532 @@ mod tests { let err = crate::run("spatialJoin", &m_crs, &serde_json::json!({"target": "pts", "join": "polys"})).unwrap_err(); assert!(err.contains("coordinate reference system")); } + + #[test] + fn zonal_stats_tool_cases() { + let (w, h) = (10u32, 10u32); + let mut b1_data: Vec = Vec::with_capacity((w * h) as usize); + let mut b2_data: Vec = Vec::with_capacity((w * h) as usize); + for row in 0..h { + for col in 0..w { + b1_data.push((col * 10 + row) as f32); + let v2 = 100.0 - (col * 10 + row) as f32; + if col == 1 && row == 1 { + b2_data.push(-9999.0); + } else { + b2_data.push(v2); + } + } + } + let gt = rustgis_data::raster::GeoTransform { origin_x: 0.0, origin_y: 10.0, pixel_width: 1.0, pixel_height: -1.0 }; + let raster = rustgis_data::raster::Raster::new( + "elev", + w, + h, + vec![b1_data, b2_data], + gt, + rustgis_data::raster::Crs::LonLat, + Some(-9999.0), + rustgis_data::raster::ColorHint::Gray, + "Float32", + ) + .unwrap(); + + let mut poly_ds = Dataset::new( + "zones", + GeometryType::Polygon, + vec![Field::new("zone_id", FieldType::Integer), Field::new("elev_mean", FieldType::Double)], + ); + + let sq_geom = |x0: f64, y0: f64, x1: f64, y1: f64| { + Geometry::Polygon(vec![vec![vec![ + Coord::new(x0, y0), + Coord::new(x1, y0), + Coord::new(x1, y1), + Coord::new(x0, y1), + Coord::new(x0, y0), + ]]]) + }; + + poly_ds.push(Some(sq_geom(1.0, 7.0, 3.0, 9.0)), vec![Value::Int(101), Value::Float(999.0)]); + poly_ds.push(Some(sq_geom(20.0, 20.0, 25.0, 25.0)), vec![Value::Int(102), Value::Float(888.0)]); + + let hole_geom = Geometry::Polygon(vec![vec![ + vec![Coord::new(0.0, 7.0), Coord::new(3.0, 7.0), Coord::new(3.0, 10.0), Coord::new(0.0, 10.0), Coord::new(0.0, 7.0)], + vec![Coord::new(1.0, 8.0), Coord::new(2.0, 8.0), Coord::new(2.0, 9.0), Coord::new(1.0, 9.0), Coord::new(1.0, 8.0)], + ]]); + poly_ds.push(Some(hole_geom), vec![Value::Int(103), Value::Float(777.0)]); + poly_ds.push(None, vec![Value::Int(104), Value::Float(666.0)]); + + let input_clone = poly_ds.clone(); + let m = RasterMap { + datasets: HashMap::from([("zones".to_string(), poly_ds)]), + rasters: HashMap::from([("elev".to_string(), &raster)]), + }; + + let res = crate::run( + "zonalStats", + &m, + &serde_json::json!({ + "raster": "elev", + "polygons": "zones", + "bands": [1], + }), + ) + .unwrap(); + + let out = layer(res); + assert_eq!(out.features.len(), 4); + + assert_eq!(out.fields[0].name, "zone_id"); + assert_eq!(out.fields[1].name, "elev_mean"); + assert_eq!(out.value(0, 0), &Value::Int(101)); + assert_eq!(out.value(0, 1), &Value::Float(999.0)); + + let mean_idx = out.field_index("elev_mean_2").expect("collision should produce elev_mean_2"); + let min_idx = out.field_index("elev_min").unwrap(); + let max_idx = out.field_index("elev_max").unwrap(); + let sum_idx = out.field_index("elev_sum").unwrap(); + let count_idx = out.field_index("elev_count").unwrap(); + let stddev_idx = out.field_index("elev_stddev").unwrap(); + + assert_eq!(out.value(0, count_idx), &Value::Int(4)); + assert_eq!(out.value(0, sum_idx), &Value::Float(66.0)); + assert_eq!(out.value(0, min_idx), &Value::Float(11.0)); + assert_eq!(out.value(0, max_idx), &Value::Float(22.0)); + assert_eq!(out.value(0, mean_idx), &Value::Float(16.5)); + let std_val = out.value(0, stddev_idx).as_f64().unwrap(); + assert!((std_val - 25.25f64.sqrt()).abs() < 1e-10); + + assert_eq!(out.value(1, count_idx), &Value::Int(0)); + assert_eq!(out.value(1, sum_idx), &Value::Null); + assert_eq!(out.value(1, mean_idx), &Value::Null); + assert_eq!(out.value(1, min_idx), &Value::Null); + assert_eq!(out.value(1, max_idx), &Value::Null); + assert_eq!(out.value(1, stddev_idx), &Value::Null); + + assert_eq!(out.value(2, count_idx), &Value::Int(8)); + + assert_eq!(out.value(3, count_idx), &Value::Int(0)); + assert_eq!(out.value(3, mean_idx), &Value::Null); + + let res_mb = crate::run( + "zonalStats", + &m, + &serde_json::json!({ + "raster": "elev", + "polygons": "zones", + "bands": [1, 2], + "stats": ["mean", "count"], + }), + ) + .unwrap(); + let out_mb = layer(res_mb); + let b1_count = out_mb.field_index("elev_b1_count").unwrap(); + let b2_count = out_mb.field_index("elev_b2_count").unwrap(); + let b2_mean = out_mb.field_index("elev_b2_mean").unwrap(); + + assert_eq!(out_mb.value(0, b1_count), &Value::Int(4)); + assert_eq!(out_mb.value(0, b2_count), &Value::Int(3)); + let b2_m = out_mb.value(0, b2_mean).as_f64().unwrap(); + assert!((b2_m - 245.0 / 3.0).abs() < 1e-10); + + let mut pt_ds = Dataset::new("points", GeometryType::Point, vec![]); + pt_ds.push(Some(Geometry::Point(Coord::new(1.0, 8.0))), vec![]); + let m_pts = + RasterMap { datasets: HashMap::from([("points".to_string(), pt_ds)]), rasters: HashMap::from([("elev".to_string(), &raster)]) }; + let err_pt = crate::run( + "zonalStats", + &m_pts, + &serde_json::json!({ + "raster": "elev", + "polygons": "points", + }), + ) + .unwrap_err(); + assert!(err_pt.contains("Point data")); + + let err_band = crate::run( + "zonalStats", + &m, + &serde_json::json!({ + "raster": "elev", + "polygons": "zones", + "bands": [5], + }), + ) + .unwrap_err(); + assert!(err_band.contains("band index 5 is invalid")); + + let empty_ds = Dataset::new("empty", GeometryType::Polygon, vec![Field::new("id", FieldType::Integer)]); + let m_empty = RasterMap { + datasets: HashMap::from([("empty".to_string(), empty_ds)]), + rasters: HashMap::from([("elev".to_string(), &raster)]), + }; + let res_empty = crate::run( + "zonalStats", + &m_empty, + &serde_json::json!({ + "raster": "elev", + "polygons": "empty", + }), + ) + .unwrap(); + let out_empty = layer(res_empty); + assert_eq!(out_empty.features.len(), 0); + assert!(out_empty.field_index("elev_mean").is_some()); + + // 9. Bands validation: "all", integral float, decimal rejection, zero rejection, negative rejection + let res_all_bands = crate::run( + "zonalStats", + &m, + &serde_json::json!({ + "raster": "elev", + "polygons": "zones", + "bands": "all", + }), + ) + .unwrap(); + let out_all = layer(res_all_bands); + assert!(out_all.field_index("elev_b1_mean").is_some()); + assert!(out_all.field_index("elev_b2_mean").is_some()); + + let res_float_band = crate::run( + "zonalStats", + &m, + &serde_json::json!({ + "raster": "elev", + "polygons": "zones", + "bands": [1.0], + }), + ) + .unwrap(); + let out_float = layer(res_float_band); + assert!(out_float.field_index("elev_mean").is_some()); + + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": [1.5]})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": [0]})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": [-1]})).is_err()); + + // 10. Projected CRS reprojection: polygon in EPSG:3857 (WebMercator) + let merc_tr = rustgis_data::raster::Crs::WebMercator.transformer().unwrap(); + let p0 = merc_tr.from_lonlat(Coord::new(1.0, 7.0)).unwrap(); + let p1 = merc_tr.from_lonlat(Coord::new(3.0, 9.0)).unwrap(); + let merc_geom = Geometry::Polygon(vec![vec![vec![p0, Coord::new(p1.x, p0.y), p1, Coord::new(p0.x, p1.y), p0]]]); + let mut merc_poly_ds = Dataset::new("merc_zones", GeometryType::Polygon, vec![Field::new("id", FieldType::Integer)]); + merc_poly_ds.crs = Some(rustgis_data::raster::Crs::WebMercator); + merc_poly_ds.push(Some(merc_geom.clone()), vec![Value::Int(1)]); + + let m_merc = RasterMap { + datasets: HashMap::from([("merc_zones".to_string(), merc_poly_ds)]), + rasters: HashMap::from([("elev".to_string(), &raster)]), + }; + let res_merc = crate::run( + "zonalStats", + &m_merc, + &serde_json::json!({ + "raster": "elev", + "polygons": "merc_zones", + "bands": [1], + }), + ) + .unwrap(); + let out_merc = layer(res_merc); + assert_eq!(out_merc.crs, Some(rustgis_data::raster::Crs::WebMercator)); + assert_eq!(out_merc.features[0].geometry, Some(merc_geom)); + let merc_count = out_merc.field_index("elev_count").unwrap(); + let merc_mean = out_merc.field_index("elev_mean").unwrap(); + assert_eq!(out_merc.value(0, merc_count), &Value::Int(4)); + assert_eq!(out_merc.value(0, merc_mean), &Value::Float(16.5)); + + // 11. Unsupported polygon CRS returns error + let mut bad_crs_ds = Dataset::new("bad_crs", GeometryType::Polygon, vec![]); + bad_crs_ds.crs = Some(rustgis_data::raster::Crs::Epsg(60000)); + bad_crs_ds.push(Some(sq_geom(1.0, 7.0, 3.0, 9.0)), vec![]); + let m_bad_crs = RasterMap { + datasets: HashMap::from([("bad_crs".to_string(), bad_crs_ds)]), + rasters: HashMap::from([("elev".to_string(), &raster)]), + }; + assert!(crate::run("zonalStats", &m_bad_crs, &serde_json::json!({"raster": "elev", "polygons": "bad_crs"})).is_err()); + + // 12. Exterior ring dropped during reprojection (does not promote hole) + let hole_p0 = merc_tr.from_lonlat(Coord::new(1.0, 8.0)).unwrap(); + let hole_p1 = merc_tr.from_lonlat(Coord::new(2.0, 9.0)).unwrap(); + let bad_ext_geom = Geometry::Polygon(vec![vec![ + vec![p0, p1], // Only 2 points (< 3 points, invalid exterior ring) + vec![hole_p0, Coord::new(hole_p1.x, hole_p0.y), hole_p1, Coord::new(hole_p0.x, hole_p1.y), hole_p0], // valid hole + ]]); + let mut drop_ext_ds = Dataset::new("drop_ext", GeometryType::Polygon, vec![Field::new("id", FieldType::Integer)]); + drop_ext_ds.crs = Some(rustgis_data::raster::Crs::WebMercator); + drop_ext_ds.push(Some(bad_ext_geom), vec![Value::Int(1)]); + let m_drop_ext = RasterMap { + datasets: HashMap::from([("drop_ext".to_string(), drop_ext_ds)]), + rasters: HashMap::from([("elev".to_string(), &raster)]), + }; + let res_drop_ext = crate::run("zonalStats", &m_drop_ext, &serde_json::json!({"raster": "elev", "polygons": "drop_ext"})).unwrap(); + assert!(res_drop_ext.messages.iter().any(|m| m.contains("failed coordinate transformation") || m.contains("invalid geometry"))); + let out_drop_ext = layer(res_drop_ext); + let drop_count_idx = out_drop_ext.field_index("elev_count").unwrap(); + let drop_mean_idx = out_drop_ext.field_index("elev_mean").unwrap(); + assert_eq!(out_drop_ext.value(0, drop_count_idx), &Value::Int(0)); + assert_eq!(out_drop_ext.value(0, drop_mean_idx), &Value::Null); + + // 13. Silent vertex loss prevention during reprojection: + // A polygon where one exterior ring vertex has non-finite coords (cannot be reprojected to lon/lat). + let nan_ext_geom = Geometry::Polygon(vec![vec![vec![ + Coord::new(f64::NAN, p0.y), + Coord::new(p1.x, p0.y), + p1, + Coord::new(p0.x, p1.y), + Coord::new(f64::NAN, p0.y), + ]]]); + let mut nan_ext_ds = Dataset::new("nan_ext", GeometryType::Polygon, vec![Field::new("id", FieldType::Integer)]); + nan_ext_ds.crs = Some(rustgis_data::raster::Crs::WebMercator); + nan_ext_ds.push(Some(nan_ext_geom), vec![Value::Int(1)]); + let m_nan_ext = RasterMap { + datasets: HashMap::from([("nan_ext".to_string(), nan_ext_ds)]), + rasters: HashMap::from([("elev".to_string(), &raster)]), + }; + let res_nan_ext = crate::run("zonalStats", &m_nan_ext, &serde_json::json!({"raster": "elev", "polygons": "nan_ext"})).unwrap(); + assert!(res_nan_ext.messages.iter().any(|m| m.contains("failed coordinate transformation") || m.contains("invalid geometry"))); + let out_nan_ext = layer(res_nan_ext); + let nan_count_idx = out_nan_ext.field_index("elev_count").unwrap(); + let nan_mean_idx = out_nan_ext.field_index("elev_mean").unwrap(); + assert_eq!(out_nan_ext.value(0, nan_count_idx), &Value::Int(0)); + assert_eq!(out_nan_ext.value(0, nan_mean_idx), &Value::Null); + + // 14. Valid projected polygon with holes reprojects both and subtracts holes correctly + let merc_outer0 = merc_tr.from_lonlat(Coord::new(0.0, 7.0)).unwrap(); + let merc_outer1 = merc_tr.from_lonlat(Coord::new(3.0, 10.0)).unwrap(); + let merc_hole0 = merc_tr.from_lonlat(Coord::new(1.0, 8.0)).unwrap(); + let merc_hole1 = merc_tr.from_lonlat(Coord::new(2.0, 9.0)).unwrap(); + let valid_hole_geom = Geometry::Polygon(vec![vec![ + vec![merc_outer0, Coord::new(merc_outer1.x, merc_outer0.y), merc_outer1, Coord::new(merc_outer0.x, merc_outer1.y), merc_outer0], + vec![merc_hole0, Coord::new(merc_hole1.x, merc_hole0.y), merc_hole1, Coord::new(merc_hole0.x, merc_hole1.y), merc_hole0], + ]]); + let mut hole_ds = Dataset::new("merc_hole", GeometryType::Polygon, vec![Field::new("id", FieldType::Integer)]); + hole_ds.crs = Some(rustgis_data::raster::Crs::WebMercator); + hole_ds.push(Some(valid_hole_geom), vec![Value::Int(1)]); + let m_hole = RasterMap { + datasets: HashMap::from([("merc_hole".to_string(), hole_ds)]), + rasters: HashMap::from([("elev".to_string(), &raster)]), + }; + let res_hole = crate::run("zonalStats", &m_hole, &serde_json::json!({"raster": "elev", "polygons": "merc_hole"})).unwrap(); + let out_hole = layer(res_hole); + let hole_cnt_idx = out_hole.field_index("elev_count").unwrap(); + // 9 outer cells - 1 hole cell = 8 cells + assert_eq!(out_hole.value(0, hole_cnt_idx), &Value::Int(8)); + + assert_eq!(m.datasets["zones"], input_clone); + } + + #[test] + fn zonal_stats_attribute_padding_and_alignment_regression() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; + + let (w, h) = (4u32, 4u32); + let data: Vec = (1..=16).map(|v| v as f32).collect(); + let gt = GeoTransform { origin_x: 0.0, origin_y: 4.0, pixel_width: 1.0, pixel_height: -1.0 }; + let raster = Raster::new("elev", w, h, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + + let fields = + vec![Field::new("name", FieldType::Text), Field::new("category", FieldType::Text), Field::new("score", FieldType::Double)]; + let mut ds = Dataset::new("zones_pad", GeometryType::Polygon, fields); + let sq_geom = |x0: f64, y0: f64, x1: f64, y1: f64| { + Geometry::Polygon(vec![vec![vec![ + Coord::new(x0, y0), + Coord::new(x1, y0), + Coord::new(x1, y1), + Coord::new(x0, y1), + Coord::new(x0, y0), + ]]]) + }; + let geom = Some(sq_geom(0.0, 3.0, 2.0, 4.0)); // 2 cells: 1.0, 2.0 + + // Feature 1: shorter attribute array (only 1 attribute instead of 3) + ds.features.push(Feature { id: 1, geometry: geom.clone(), attrs: vec![Value::Text("FeatureA".into())] }); + // Feature 2: full attribute array (3 attributes) + ds.features.push(Feature { + id: 2, + geometry: geom.clone(), + attrs: vec![Value::Text("FeatureB".into()), Value::Text("Cat1".into()), Value::Float(88.5)], + }); + // Feature 3: empty attribute array (0 attributes) + ds.features.push(Feature { id: 3, geometry: geom, attrs: vec![] }); + + let m = + RasterMap { datasets: HashMap::from([("zones_pad".to_string(), ds)]), rasters: HashMap::from([("elev".to_string(), &raster)]) }; + + let res = crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones_pad"})).unwrap(); + let out = layer(res); + + // Schema must contain 3 original fields + 6 stat fields + assert_eq!(out.fields.len(), 9); + assert_eq!(out.fields[0].name, "name"); + assert_eq!(out.fields[1].name, "category"); + assert_eq!(out.fields[2].name, "score"); + assert_eq!(out.fields[3].name, "elev_mean"); + assert_eq!(out.fields[4].name, "elev_min"); + assert_eq!(out.fields[5].name, "elev_max"); + assert_eq!(out.fields[6].name, "elev_sum"); + assert_eq!(out.fields[7].name, "elev_count"); + assert_eq!(out.fields[8].name, "elev_stddev"); + + // Feature 1: attrs must be padded to 3 before stats fields! + let f1 = &out.features[0]; + assert_eq!(f1.attrs.len(), 9); + assert_eq!(f1.attrs[0], Value::Text("FeatureA".into())); + assert_eq!(f1.attrs[1], Value::Null, "missing category attribute must be padded with Null"); + assert_eq!(f1.attrs[2], Value::Null, "missing score attribute must be padded with Null"); + assert_eq!(f1.attrs[3], Value::Float(1.5), "elev_mean must be at index 3, not in column 1!"); + assert_eq!(f1.attrs[7], Value::Int(2), "elev_count must be at index 7"); + + // Feature 2: complete attributes + let f2 = &out.features[1]; + assert_eq!(f2.attrs.len(), 9); + assert_eq!(f2.attrs[0], Value::Text("FeatureB".into())); + assert_eq!(f2.attrs[1], Value::Text("Cat1".into())); + assert_eq!(f2.attrs[2], Value::Float(88.5)); + assert_eq!(f2.attrs[3], Value::Float(1.5)); + + // Feature 3: empty attributes padded completely + let f3 = &out.features[2]; + assert_eq!(f3.attrs.len(), 9); + assert_eq!(f3.attrs[0], Value::Null); + assert_eq!(f3.attrs[1], Value::Null); + assert_eq!(f3.attrs[2], Value::Null); + assert_eq!(f3.attrs[3], Value::Float(1.5)); + } + + #[test] + fn field_name_case_insensitive_and_collision_regression() { + let fields = vec![Field::new("Population", FieldType::Double), Field::new("Area_KM2", FieldType::Double)]; + let mut ds = Dataset::new("cities", GeometryType::None, fields); + ds.push(None, vec![Value::Float(100.0), Value::Float(10.0)]); + ds.push(None, vec![Value::Float(200.0), Value::Float(20.0)]); + + // 1. Case-insensitive lookup succeeds when casing differs + assert_eq!(find_field_case_insensitive(&ds, "population").unwrap(), 0); + assert_eq!(find_field_case_insensitive(&ds, "POPULATION").unwrap(), 0); + assert_eq!(find_field_case_insensitive(&ds, "area_km2").unwrap(), 1); + assert_eq!(find_field_case_insensitive(&ds, "AREA_km2").unwrap(), 1); + + // 2. Missing field + assert!(find_field_case_insensitive(&ds, "unknown").is_err()); + + // 3. Summary statistics with lowercase lookup preserves original casing in output fields + let m = Map(HashMap::from([("cities".to_string(), ds.clone())])); + let res = crate::run("summaryStatistics", &m, &serde_json::json!({"input": "cities", "field": "population"})).unwrap(); + let out = layer(res); + assert!(out.field_index("COUNT_Population").is_some(), "output field must preserve original casing"); + assert!(out.field_index("SUM_Population").is_some()); + assert!(out.field_index("MEAN_Population").is_some()); + + // 4. Exact match precedence and case-insensitive collision detection + let colliding_fields = vec![Field::new("Population", FieldType::Double), Field::new("population", FieldType::Double)]; + let ds_collision = Dataset::new("colliding", GeometryType::None, colliding_fields); + // Exact matches must resolve directly: + assert_eq!(find_field_case_insensitive(&ds_collision, "population").unwrap(), 1); + assert_eq!(find_field_case_insensitive(&ds_collision, "Population").unwrap(), 0); + // Inexact matches with multiple candidates are ambiguous: + let err = find_field_case_insensitive(&ds_collision, "POPULATION").unwrap_err(); + assert!(err.contains("ambiguous") && err.contains("collision"), "collision must be explicitly reported: {err}"); + let err2 = find_field_case_insensitive(&ds_collision, "pOpUlAtIoN").unwrap_err(); + assert!(err2.contains("collision")); + } + + #[test] + fn zonal_stats_partial_feature_failures_regression() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; + + let (w, h) = (4u32, 4u32); + let data: Vec = (1..=16).map(|v| v as f32).collect(); + let gt = GeoTransform { origin_x: 0.0, origin_y: 4.0, pixel_width: 1.0, pixel_height: -1.0 }; + let raster = Raster::new("elev", w, h, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + + let fields = vec![Field::new("zone_id", FieldType::Integer)]; + let mut ds = Dataset::new("mixed_zones", GeometryType::Polygon, fields); + + let sq_ring = |x0: f64, y0: f64, x1: f64, y1: f64| { + vec![Coord::new(x0, y0), Coord::new(x1, y0), Coord::new(x1, y1), Coord::new(x0, y1), Coord::new(x0, y0)] + }; + + // Feature 1: Valid polygon with hole (outer square 0..3, inner hole 1..2) + let f1_geom = Geometry::Polygon(vec![vec![ + sq_ring(0.0, 1.0, 3.0, 4.0), // outer 3x3 = 9 cells + sq_ring(1.0, 2.0, 2.0, 3.0), // hole 1x1 = 1 cell + ]]); + ds.features.push(Feature { id: 101, geometry: Some(f1_geom), attrs: vec![Value::Int(1)] }); + + // Feature 2: Defective polygon: hole has fewer than 3 vertices! + let f2_geom = Geometry::Polygon(vec![vec![ + sq_ring(0.0, 1.0, 3.0, 4.0), + vec![Coord::new(1.0, 2.0), Coord::new(2.0, 3.0)], // only 2 points! + ]]); + ds.features.push(Feature { id: 102, geometry: Some(f2_geom), attrs: vec![Value::Int(2)] }); + + // Feature 3: Valid simple polygon + let f3_geom = Geometry::Polygon(vec![vec![sq_ring(0.0, 3.0, 1.0, 4.0)]]); // 1 cell + ds.features.push(Feature { id: 103, geometry: Some(f3_geom), attrs: vec![Value::Int(3)] }); + + let m = RasterMap { + datasets: HashMap::from([("mixed_zones".to_string(), ds)]), + rasters: HashMap::from([("elev".to_string(), &raster)]), + }; + + let res = crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "mixed_zones"})).unwrap(); + let messages = res.messages.clone(); + let out = layer(res); + + assert_eq!(out.features.len(), 3); + let count_idx = out.field_index("elev_count").unwrap(); + let mean_idx = out.field_index("elev_mean").unwrap(); + + // Feature 1 succeeded (9 - 1 = 8 cells) + assert_eq!(out.value(0, count_idx), &Value::Int(8)); + assert!(out.value(0, mean_idx).as_f64().is_some()); + + // Feature 2 failed (hole was defective -> polygon rejected -> default stats) + assert_eq!(out.value(1, count_idx), &Value::Int(0)); + assert_eq!(out.value(1, mean_idx), &Value::Null); + + // Feature 3 succeeded (1 cell) + assert_eq!(out.value(2, count_idx), &Value::Int(1)); + assert!(out.value(2, mean_idx).as_f64().is_some()); + + // Messages must explicitly report feature 102 failure + assert!(messages.iter().any(|m| m.contains("1 feature(s) had invalid geometry"))); + assert!(messages.iter().any(|m| m.contains("feature 102") && m.contains("fewer than 3 vertices"))); + } + + #[test] + fn parse_bands_and_stats_validation_regression() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; + + let (w, h) = (2u32, 2u32); + let data: Vec = vec![1.0, 2.0, 3.0, 4.0]; + let gt = GeoTransform { origin_x: 0.0, origin_y: 2.0, pixel_width: 1.0, pixel_height: -1.0 }; + let raster = Raster::new("elev", w, h, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + + let mut ds = Dataset::new("zones", GeometryType::Polygon, vec![Field::new("id", FieldType::Integer)]); + let sq_ring = vec![Coord::new(0.0, 0.0), Coord::new(2.0, 0.0), Coord::new(2.0, 2.0), Coord::new(0.0, 2.0), Coord::new(0.0, 0.0)]; + ds.push(Some(Geometry::Polygon(vec![vec![sq_ring]])), vec![Value::Int(1)]); + + let m = RasterMap { datasets: HashMap::from([("zones".to_string(), ds)]), rasters: HashMap::from([("elev".to_string(), &raster)]) }; + + // 1. Invalid stats inputs + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "stats": []})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "stats": ""})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "stats": ["median"]})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "stats": [123]})).is_err()); + + // 2. Invalid bands inputs + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": []})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": ""})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": 1.5})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": 99})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": -1})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": [""]})).is_err()); + assert!(crate::run("zonalStats", &m, &serde_json::json!({"raster": "elev", "polygons": "zones", "bands": [" "]})).is_err()); + } } diff --git a/crates/engine/src/cmd/gp.rs b/crates/engine/src/cmd/gp.rs index 4276822c3..f7c0e8095 100644 --- a/crates/engine/src/cmd/gp.rs +++ b/crates/engine/src/cmd/gp.rs @@ -15,6 +15,11 @@ pub fn commands() -> Vec { .params( r#"{"target": layer, "join": layer, "match"?: "Intersects" (default) | "Contains" | "Within", "operation"?: "Join one to one" (default) | "Join one to many", "keepAll"?: "Keep all" (default) | "Keep only matching", "prefix"?: "join_", "output"?: text}"#, ), + C::new("gp.zonalStats", "Zonal Statistics", "Analyze › Raster", zonal_stats) + .when(need_layers) + .params( + r#"{"raster": raster, "polygons": polygon layer, "bands"?: [1-based band numbers] or single band (default 1), "stats"?: ["mean", "min", "max", "sum", "count", "stddev"] or "all" (default "all"), "prefix"?: text, "output"?: text}"#, + ), C::new("gp.bufferPoint", "Buffer A Point", "Analyze › Geoprocessing", buffer_point).params( r#"{"lon": number, "lat": number, "distance": number, "units"?: "Kilometers" (default) | "Meters" | "Miles" | "Feet", "name"?: output}"#, ), @@ -701,6 +706,11 @@ fn spatial_join(s: &mut Session, p: &Value) -> CmdResult { run_tool(s, "spatialJoin", p) } +/// Calculates raster summary statistics for polygon zones. +fn zonal_stats(s: &mut Session, p: &Value) -> CmdResult { + run_tool(s, "zonalStats", p) +} + /// Runs a (built-in) tool and applies its result, as `gp.run` does. pub fn run_tool(s: &mut Session, tool: &str, params: &Value) -> CmdResult { let tool = tool_id(tool).unwrap_or(tool).to_string(); diff --git a/crates/engine/src/cmd/imagery.rs b/crates/engine/src/cmd/imagery.rs index fecf2d1e2..4fb18210b 100644 --- a/crates/engine/src/cmd/imagery.rs +++ b/crates/engine/src/cmd/imagery.rs @@ -70,7 +70,7 @@ pub fn commands() -> Vec { r#"{"layer": raster, "index": "ndvi"|"ndwi"|"savi"|"nbr"|"ndbi"|"evi", "red"?, "green"?, "blue"?, "nir"?, "swir"?: band numbers, "name"?: output}"#, ), C::new("raster.process", "Process", "Analyze › Imagery Tools", process) - .params(r#"{"layer": raster, "operation": "clip"|"resample", "extent"?: [minLon, minLat, maxLon, maxLat] (clip; the view when omitted), "polygon"?: polygon layer (clip to its selected or all polygons; cells outside become no-data), "factor"?: n (resample: n×n pixels become one), "name"?: output}"#), + .params(r#"{"layer": raster, "operation": "clip"|"resample", "extent"?: [minLon, minLat, maxLon, maxLat] (clip; the view when omitted), "polygon"?: polygon layer (clip to its selected or all polygons; cells outside become no-data), "selectedOnly"?: bool (clip to selected polygons when true, all when false), "factor"?: n (resample: n×n pixels become one), "name"?: output}"#), C::new("raster.mosaic", "Mosaic Rasters", "Analyze › Imagery Tools", raster_mosaic).params( r#"{"inputs": [raster layer or file path, …] (two or more, in one coordinate system and cell size; where they overlap the first wins), "name"?: output, "path"?: GeoTIFF to write the mosaic to}"#, ), @@ -519,6 +519,28 @@ fn index(s: &mut Session, p: &Value) -> CmdResult { add(s, rasterops::derive(&r, &name(p, format!("{}_{idx}", r.name)), vec![out])?, false) } +/// Processes a raster via clipping or resampling. +/// +/// ### Polygon Clipping Semantics (`operation: "clip"`, `"polygon": ""`) +/// - **Selection Propagation (`selectedOnly`)**: +/// - When `Some(true)`: Clips strictly using features currently selected in the polygon layer. +/// If the selection is empty, returns an actionable error (`": no features are selected"`). +/// - When `Some(false)`: Clips using all features in the polygon layer, ignoring any selection. +/// - When omitted (`None` or `null`): For backward compatibility, clips to the selection if non-empty, +/// otherwise clips using all features. +/// - Non-boolean values are rejected with an explicit validation error. +/// - **Per-Zone and Overlap Semantics**: +/// - Every polygon geometry (including multipart geometries) is transformed to WGS 84 lon/lat. +/// - Polygon exterior rings and interior holes are strictly validated; invalid or failed holes reject +/// the containing polygon rather than distorting the boundary. +/// - Polygons are combined into a composite scanline mask over the raster. Holes cut out cells using the +/// even-odd rule within each polygon. Where multiple polygons overlap, their coverage is combined +/// (union: any cell falling inside at least one polygon is retained). +/// - The raster is cropped to the tight bounding box of all selected polygons, and cells outside the +/// polygons receive NoData. +/// - **Partial Failure Reporting**: +/// - Features with invalid geometry or unprojectable coordinates are skipped and logged with a warning. +/// - If all features fail, the operation fails and reports the specific failure cause of the first feature. fn process(s: &mut Session, p: &Value) -> CmdResult { let r = raster(s, p)?; match ps(p, "operation").ok_or("operation is required")? { @@ -531,16 +553,59 @@ fn process(s: &mut Session, p: &Value) -> CmdResult { .layer_by_name(pl) .filter(|l| l.geometry_type() == GeometryType::Polygon) .ok_or_else(|| format!("no polygon layer '{pl}'"))?; - // The selected polygons, or all of them. - let rows: Vec = if l.selection.is_empty() { (0..l.dataset.features.len()).collect() } else { l.selection.clone() }; - let polygons: Vec>> = rows - .iter() - .filter_map(|row| match l.dataset.features.get(*row).and_then(|f| f.geometry.as_ref()) { - Some(Geometry::Polygon(polys)) => Some(polys.iter().cloned()), - _ => None, - }) - .flatten() - .collect(); + let rows: Vec = match p.get("selectedOnly") { + Some(Value::Bool(true)) => { + if l.selection.is_empty() { + return Err(format!("{pl}: no features are selected")); + } + l.selection.clone() + } + Some(Value::Bool(false)) => (0..l.dataset.features.len()).collect(), + None | Some(Value::Null) => { + // Backward-compatible default: if features are selected, clip to selection; otherwise clip all. + if l.selection.is_empty() { (0..l.dataset.features.len()).collect() } else { l.selection.clone() } + } + Some(_) => { + return Err("parameter 'selectedOnly' must be a boolean (true or false)".into()); + } + }; + if rows.is_empty() { + return Err(format!("{pl}: has no features to clip by")); + } + let poly_tr = match l.dataset.crs { + Some(crs) if crs != Crs::LonLat && crs != Crs::Epsg(4326) => { + Some(crs.transformer().map_err(|e| format!("{pl} coordinate system isn't supported: {e}"))?) + } + _ => None, + }; + let mut polygons: Vec>> = Vec::new(); + let mut failed_features = Vec::new(); + for &row in &rows { + let Some(f) = l.dataset.features.get(row) else { continue }; + match &f.geometry { + Some(Geometry::Polygon(polys)) => match rasterops::transform_feature_polygons_to_lonlat(polys, poly_tr.as_ref()) { + Ok(transformed) => { + polygons.extend(transformed); + } + Err(e) => { + failed_features.push((f.id, e)); + } + }, + _ => { + failed_features + .push((f.id, rasterops::PolygonTransformError::InvalidExterior("missing or non-polygon geometry".into()))); + } + } + } + if polygons.is_empty() { + if let Some((first_id, first_err)) = failed_features.first() { + return Err(format!("{pl}: has no valid polygon geometries (feature {first_id}: {first_err})")); + } + return Err(format!("{pl}: has no valid polygon geometries")); + } + if !failed_features.is_empty() { + log::warn!("{pl}: {} feature(s) failed polygon transformation during clipping", failed_features.len()); + } let (cols, rows, mask) = rasterops::polygon_mask(&r, &polygons).ok_or_else(|| format!("no cell of {} falls inside {pl}", r.name))?; let out = clip_window(&r, cols, rows, Some(&mask), name(p, format!("{}_clip", r.name)))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 12795133f..223bf8319 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -11306,7 +11306,14 @@ fn literal_keys(body: &str, prefix: &str) -> Vec { #[test] fn params_name_every_key_a_command_reads() { // Read by a shared helper but of no use to the command. - let unused = [("gp.spatialJoin", "input"), ("profile.alongSelected", "row"), ("sources.remove", "name"), ("sources.remove", "extra")]; + let unused = [ + ("gp.spatialJoin", "input"), + ("gp.zonalStats", "input"), + ("gp.zonalStats", "target"), + ("profile.alongSelected", "row"), + ("sources.remove", "name"), + ("sources.remove", "extra"), + ]; let s = Session::new(Project::default()); let mut missing = Vec::new(); let found = keys_read_by_commands(); @@ -14249,6 +14256,49 @@ fn dem_tiles_mosaic_clip_by_polygon_share_a_stretch_and_report_overviews() { assert!(e.to_string().contains("no cell"), "{e}"); assert!(s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "west"})).is_err()); + // selectedOnly: true with no selected features fails with a descriptive error. + s.map_mut().layers.iter_mut().find(|l| l.name == "City").unwrap().selection.clear(); + let e_sel_none = + s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "City", "selectedOnly": true})).unwrap_err(); + assert!(e_sel_none.to_string().contains("no features are selected"), "{e_sel_none}"); + + // Add a second feature to City further east in dem's extent. + let ring2 = [ll(361_500.0, 5_700_000.0), ll(362_000.0, 5_700_000.0), ll(362_000.0, 5_699_000.0), ll(361_500.0, 5_700_000.0)]; + s.run("edit.addFeature", &json!({"layer": "City", "geometry": {"type": "Polygon", "coordinates": [ring2]}})).unwrap(); + assert_eq!(s.map().layer_by_name("City").unwrap().dataset.features.len(), 2); + + // With feature 0 selected: selectedOnly: true clips only feature 0. + s.map_mut().layers.iter_mut().find(|l| l.name == "City").unwrap().selection = vec![0]; + let v_sel = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "City", "selectedOnly": true})).unwrap(); + let c_sel = raster(&s, &v_sel); + assert_eq!(c_sel.bands[0].stats.count, 25); + + // selectedOnly: false clips all features even with selection active. + let v_all = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "City", "selectedOnly": false})).unwrap(); + let c_all = raster(&s, &v_all); + assert!(c_all.bands[0].stats.count > 25); + + // An empty polygon layer fails validation. + s.run("layer.template", &json!({"template": "polygons", "name": "EmptyPoly"})).unwrap(); + let e_empty = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "EmptyPoly"})).unwrap_err(); + assert!(e_empty.to_string().contains("has no features"), "{e_empty}"); + + // A polygon layer in a projected CRS (UTM) reprojects and clips accurately. + let mut proj_ds = rustgis_data::Dataset::new("CityProj", rustgis_data::GeometryType::Polygon, vec![]); + proj_ds.crs = Some(utm); + let utm_ring = [ + rustgis_geom::Coord::new(360_500.0, 5_700_000.0), + rustgis_geom::Coord::new(361_500.0, 5_700_000.0), + rustgis_geom::Coord::new(361_500.0, 5_699_500.0), + rustgis_geom::Coord::new(360_500.0, 5_700_000.0), + ]; + proj_ds.push(Some(rustgis_data::Geometry::Polygon(vec![vec![utm_ring.to_vec()]])), vec![]); + s.map_mut().add_layer(rustgis_map::Layer::new(0, proj_ds)); + let v_proj = s.run("raster.process", &json!({"layer": "dem", "operation": "clip", "polygon": "CityProj"})).unwrap(); + let c_proj = raster(&s, &v_proj); + assert_eq!((c_proj.width, c_proj.height, c_proj.crs), (9, 5, utm)); + assert_eq!(c_proj.bands[0].stats.count, 25); + // One stretch shared across tiles, as fixed bounds. s.run("raster.stretch", &json!({"layer": "west", "type": "custom", "min": 100, "max": 300})).unwrap(); s.run("raster.stretch", &json!({"layer": "east", "like": "west"})).unwrap(); @@ -15204,6 +15254,332 @@ fn scans_have_no_coordinates_to_give_away() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn gp_zonal_stats_end_to_end_and_undo() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; + use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}; + use rustgis_geom::Coord; + + let mut s = world(); + + let (w, h) = (10u32, 10u32); + let mut data = Vec::with_capacity((w * h) as usize); + for row in 0..h { + for col in 0..w { + data.push((col * 10 + row) as f32); + } + } + let gt = GeoTransform { origin_x: 0.0, origin_y: 10.0, pixel_width: 1.0, pixel_height: -1.0 }; + let raster = Raster::new("dem", w, h, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let raster_layer = rustgis_map::RasterLayer::from_path(raster, "dem.tif"); + s.map_mut().add_raster("dem", raster_layer); + + let mut poly_ds = Dataset::new("districts", GeometryType::Polygon, vec![Field::new("code", FieldType::Text)]); + let sq = |x0: f64, y0: f64, x1: f64, y1: f64| { + Geometry::Polygon(vec![vec![vec![ + Coord::new(x0, y0), + Coord::new(x1, y0), + Coord::new(x1, y1), + Coord::new(x0, y1), + Coord::new(x0, y0), + ]]]) + }; + poly_ds.push(Some(sq(1.0, 7.0, 3.0, 9.0)), vec![Value::Text("D1".into())]); + poly_ds.push(Some(sq(50.0, 50.0, 52.0, 52.0)), vec![Value::Text("D2".into())]); + s.map_mut().add_layer(rustgis_map::Layer::new(0, poly_ds)); + + let layers_before = s.map().layers.len(); + + let res = s.run( + "gp.zonalStats", + &json!({ + "raster": "dem", + "polygons": "districts", + "output": "zonal_out", + }), + ); + assert!(res.is_ok(), "gp.zonalStats failed: {res:?}"); + assert_eq!(s.map().layers.len(), layers_before + 1); + + let out_layer = s.map().layer_by_name("zonal_out").expect("output layer should exist"); + assert_eq!(out_layer.dataset.features.len(), 2); + + let mean_idx = out_layer.dataset.field_index("dem_mean").expect("dem_mean field"); + let count_idx = out_layer.dataset.field_index("dem_count").expect("dem_count field"); + let min_idx = out_layer.dataset.field_index("dem_min").expect("dem_min field"); + let max_idx = out_layer.dataset.field_index("dem_max").expect("dem_max field"); + let sum_idx = out_layer.dataset.field_index("dem_sum").expect("dem_sum field"); + let stddev_idx = out_layer.dataset.field_index("dem_stddev").expect("dem_stddev field"); + + assert_eq!(out_layer.dataset.value(0, 0), &Value::Text("D1".into())); + assert_eq!(out_layer.dataset.value(0, count_idx), &Value::Int(4)); + assert_eq!(out_layer.dataset.value(0, mean_idx), &Value::Float(16.5)); + assert_eq!(out_layer.dataset.value(0, min_idx), &Value::Float(11.0)); + assert_eq!(out_layer.dataset.value(0, max_idx), &Value::Float(22.0)); + assert_eq!(out_layer.dataset.value(0, sum_idx), &Value::Float(66.0)); + let std = out_layer.dataset.value(0, stddev_idx).as_f64().unwrap(); + assert!((std - 25.25f64.sqrt()).abs() < 1e-10); + + assert_eq!(out_layer.dataset.value(1, 0), &Value::Text("D2".into())); + assert_eq!(out_layer.dataset.value(1, count_idx), &Value::Int(0)); + assert_eq!(out_layer.dataset.value(1, mean_idx), &Value::Null); + assert_eq!(out_layer.dataset.value(1, min_idx), &Value::Null); + assert_eq!(out_layer.dataset.value(1, max_idx), &Value::Null); + assert_eq!(out_layer.dataset.value(1, sum_idx), &Value::Null); + assert_eq!(out_layer.dataset.value(1, stddev_idx), &Value::Null); + + assert!(s.undo()); + assert_eq!(s.map().layers.len(), layers_before); + assert!(s.map().layer_by_name("zonal_out").is_none()); + + assert!(s.redo()); + assert_eq!(s.map().layers.len(), layers_before + 1); + assert!(s.map().layer_by_name("zonal_out").is_some()); + + let run_res = s.run( + "gp.run", + &json!({ + "tool": "zonalStats", + "params": { + "raster": "dem", + "polygons": "districts", + "stats": ["mean", "count"], + "output": "via_gp_run", + } + }), + ); + assert!(run_res.is_ok(), "gp.run with zonalStats failed: {run_res:?}"); + let run_layer = s.map().layer_by_name("via_gp_run").expect("via_gp_run layer should exist"); + assert!(run_layer.dataset.field_index("dem_mean").is_some()); + assert!(run_layer.dataset.field_index("dem_count").is_some()); + assert!(run_layer.dataset.field_index("dem_min").is_none()); + + // Projected CRS (WebMercator) reprojection end-to-end + let merc_tr = Crs::WebMercator.transformer().unwrap(); + let mp0 = merc_tr.from_lonlat(Coord::new(1.0, 7.0)).unwrap(); + let mp1 = merc_tr.from_lonlat(Coord::new(3.0, 9.0)).unwrap(); + let mut merc_ds = Dataset::new("merc_districts", GeometryType::Polygon, vec![Field::new("code", FieldType::Text)]); + merc_ds.crs = Some(Crs::WebMercator); + merc_ds.push(Some(sq(mp0.x, mp0.y, mp1.x, mp1.y)), vec![Value::Text("M1".into())]); + s.map_mut().add_layer(rustgis_map::Layer::new(0, merc_ds)); + + let merc_res = s.run( + "gp.zonalStats", + &json!({ + "raster": "dem", + "polygons": "merc_districts", + "output": "merc_out", + "bands": [1.0], + }), + ); + assert!(merc_res.is_ok(), "gp.zonalStats with WebMercator failed: {merc_res:?}"); + let merc_layer = s.map().layer_by_name("merc_out").expect("merc_out should exist"); + assert_eq!(merc_layer.dataset.crs, Some(Crs::WebMercator)); + let m_count_idx = merc_layer.dataset.field_index("dem_count").unwrap(); + let m_mean_idx = merc_layer.dataset.field_index("dem_mean").unwrap(); + assert_eq!(merc_layer.dataset.value(0, m_count_idx), &Value::Int(4)); + assert_eq!(merc_layer.dataset.value(0, m_mean_idx), &Value::Float(16.5)); + + // "bands": "all" + let all_res = s.run( + "gp.zonalStats", + &json!({ + "raster": "dem", + "polygons": "districts", + "output": "all_bands_out", + "bands": "all", + }), + ); + assert!(all_res.is_ok(), "gp.zonalStats with bands: all failed: {all_res:?}"); + + assert!(s.run("gp.zonalStats", &json!({})).is_err()); + assert!(s.run("gp.zonalStats", &json!({"raster": "nonexistent", "polygons": "districts"})).is_err()); + assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "Cities"})).is_err()); + assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [999]})).is_err()); + assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [0]})).is_err()); + assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [-1]})).is_err()); + assert!(s.run("gp.zonalStats", &json!({"raster": "dem", "polygons": "districts", "bands": [1.5]})).is_err()); +} + +#[test] +fn raster_clip_polygon_selection_semantics_and_partial_failure() { + use rustgis_data::raster::{ColorHint, Crs, GeoTransform, Raster}; + use rustgis_data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}; + use rustgis_geom::Coord; + + let mut s = world(); + + // 10x10 raster: origin (0.0, 10.0), cell size (1.0, -1.0) + let (w, h) = (10u32, 10u32); + let mut data = Vec::with_capacity((w * h) as usize); + for row in 0..h { + for col in 0..w { + data.push((col * 10 + row) as f32); + } + } + let gt = GeoTransform { origin_x: 0.0, origin_y: 10.0, pixel_width: 1.0, pixel_height: -1.0 }; + let raster = Raster::new("dem", w, h, vec![data], gt, Crs::LonLat, None, ColorHint::Gray, "Float32").unwrap(); + let raster_layer = rustgis_map::RasterLayer::from_path(raster, "dem.tif"); + s.map_mut().add_raster("dem", raster_layer); + + let sq = |x0: f64, y0: f64, x1: f64, y1: f64| { + Geometry::Polygon(vec![vec![vec![ + Coord::new(x0, y0), + Coord::new(x1, y0), + Coord::new(x1, y1), + Coord::new(x0, y1), + Coord::new(x0, y0), + ]]]) + }; + + let mut poly_ds = Dataset::new("clip_zones", GeometryType::Polygon, vec![Field::new("name", FieldType::Text)]); + // Feature 0: [1.0, 7.0] to [3.0, 9.0] + poly_ds.push(Some(sq(1.0, 7.0, 3.0, 9.0)), vec![Value::Text("Z1".into())]); + // Feature 1: [2.0, 6.0] to [4.0, 8.0] (overlaps with Z1) + poly_ds.push(Some(sq(2.0, 6.0, 4.0, 8.0)), vec![Value::Text("Z2".into())]); + // Feature 2: [6.0, 1.0] to [8.0, 3.0] (separate area) + poly_ds.push(Some(sq(6.0, 1.0, 8.0, 3.0)), vec![Value::Text("Z3".into())]); + // Feature 3: Degenerate hole (< 3 coords) -> should trigger rejection of this feature + let bad_hole_poly = Geometry::Polygon(vec![vec![ + vec![Coord::new(4.0, 4.0), Coord::new(6.0, 4.0), Coord::new(6.0, 6.0), Coord::new(4.0, 6.0), Coord::new(4.0, 4.0)], + vec![Coord::new(4.5, 4.5), Coord::new(5.0, 5.0)], // invalid hole with only 2 points + ]]); + poly_ds.push(Some(bad_hole_poly), vec![Value::Text("Z4_bad".into())]); + + let poly_layer_id = s.map_mut().add_layer(rustgis_map::Layer::new(0, poly_ds)); + + // Test 1: selectedOnly: "invalid" or non-boolean returns validation error + let err_type = s.run( + "raster.process", + &json!({ + "layer": "dem", + "operation": "clip", + "polygon": "clip_zones", + "selectedOnly": "yes", + }), + ); + assert!(err_type.is_err()); + assert!(err_type.unwrap_err().contains("must be a boolean")); + + // Test 2: selectedOnly: true with empty selection returns error + let err_empty_sel = s.run( + "raster.process", + &json!({ + "layer": "dem", + "operation": "clip", + "polygon": "clip_zones", + "selectedOnly": true, + }), + ); + assert!(err_empty_sel.is_err()); + assert!(err_empty_sel.unwrap_err().contains("no features are selected")); + + // Test 3: selectedOnly: true with Feature 0 selected + { + let l = s.map_mut().layer_mut(poly_layer_id).unwrap(); + l.selection = vec![0]; + } + let res_sel = s.run( + "raster.process", + &json!({ + "layer": "dem", + "operation": "clip", + "polygon": "clip_zones", + "selectedOnly": true, + "name": "dem_sel_0", + }), + ); + assert!(res_sel.is_ok(), "clip selected failed: {res_sel:?}"); + let sel_layer = s.map().layer_by_name("dem_sel_0").expect("dem_sel_0 exists"); + let sel_raster = sel_layer.raster.as_ref().unwrap().data.as_ref().unwrap(); + // Feature 0 covers x: [1.0, 3.0], y: [7.0, 9.0] -> 2 cols x 2 rows + assert_eq!(sel_raster.width, 2); + assert_eq!(sel_raster.height, 2); + + // Test 4: Overlapping polygons combined coverage (Features 0 and 1) + { + let l = s.map_mut().layer_mut(poly_layer_id).unwrap(); + l.selection = vec![0, 1]; + } + let res_overlap = s.run( + "raster.process", + &json!({ + "layer": "dem", + "operation": "clip", + "polygon": "clip_zones", + "selectedOnly": true, + "name": "dem_overlap", + }), + ); + assert!(res_overlap.is_ok(), "clip overlap failed: {res_overlap:?}"); + let ov_layer = s.map().layer_by_name("dem_overlap").expect("dem_overlap exists"); + let ov_raster = ov_layer.raster.as_ref().unwrap().data.as_ref().unwrap(); + // Features 0 & 1 union bounding box: x: [1.0, 4.0], y: [6.0, 9.0] -> 3 cols x 3 rows + assert_eq!(ov_raster.width, 3); + assert_eq!(ov_raster.height, 3); + + // Test 5: selectedOnly: false ignores active selection and clips using all valid features + let res_all = s.run( + "raster.process", + &json!({ + "layer": "dem", + "operation": "clip", + "polygon": "clip_zones", + "selectedOnly": false, + "name": "dem_all", + }), + ); + assert!(res_all.is_ok(), "clip all failed: {res_all:?}"); + let all_layer = s.map().layer_by_name("dem_all").expect("dem_all exists"); + let all_raster = all_layer.raster.as_ref().unwrap().data.as_ref().unwrap(); + // Features 0, 1, 2 bounding box spans x: [1.0, 8.0], y: [1.0, 9.0] -> 7 cols x 8 rows + assert_eq!(all_raster.width, 7); + assert_eq!(all_raster.height, 8); + + // Test 6: Partial failure reporting: Feature 0 (valid) + Feature 3 (invalid hole) + // Feature 3 should be skipped with a warning, Feature 0 should succeed + { + let l = s.map_mut().layer_mut(poly_layer_id).unwrap(); + l.selection = vec![0, 3]; + } + let res_partial = s.run( + "raster.process", + &json!({ + "layer": "dem", + "operation": "clip", + "polygon": "clip_zones", + "selectedOnly": true, + "name": "dem_partial", + }), + ); + assert!(res_partial.is_ok(), "clip partial should succeed for valid feature: {res_partial:?}"); + let partial_layer = s.map().layer_by_name("dem_partial").expect("dem_partial exists"); + let partial_raster = partial_layer.raster.as_ref().unwrap().data.as_ref().unwrap(); + assert_eq!(partial_raster.width, 2); + assert_eq!(partial_raster.height, 2); + + // Test 7: Total failure when all selected features fail validation (only Feature 3 selected) + { + let l = s.map_mut().layer_mut(poly_layer_id).unwrap(); + l.selection = vec![3]; + } + let res_bad = s.run( + "raster.process", + &json!({ + "layer": "dem", + "operation": "clip", + "polygon": "clip_zones", + "selectedOnly": true, + "name": "dem_bad", + }), + ); + assert!(res_bad.is_err()); + let err_msg = res_bad.unwrap_err(); + assert!(err_msg.contains("has no valid polygon geometries")); + assert!(err_msg.contains("feature 4")); + assert!(err_msg.contains("hole 1 is invalid")); +} + #[test] fn layer_selection_replaces_toggles_and_extends() { let mut s = world(); diff --git a/crates/ui-egui/src/analysispane.rs b/crates/ui-egui/src/analysispane.rs index bcab7947b..22d02bcb0 100644 --- a/crates/ui-egui/src/analysispane.rs +++ b/crates/ui-egui/src/analysispane.rs @@ -36,6 +36,16 @@ pub struct Workflow { /// The Script pane's last run, shown above its results. pub script_last: Option, pub trend: TrendForm, + pub selected_only: Option, + pub zonal_stats: ZonalStatsForm, +} + +/// The Zonal Statistics section's inputs. +#[derive(Default)] +pub struct ZonalStatsForm { + pub bands: String, + pub prefix: String, + pub output: String, } /// A Script pane run, as the line above its results tells it: its number this session (so a run @@ -124,6 +134,7 @@ pub const SECTIONS: &[(&str, &str)] = &[ ("rasterFunctions", "Raster Functions"), ("functionEditor", "Function Editor"), ("suitability", "Suitability Modeler"), + ("zonalStats", "Zonal Statistics"), ("visibility", "Visibility Analysis"), ("lineOfSight", "Exploratory 3D Analysis"), ("interpolate", "Geostatistical Wizard"), @@ -206,6 +217,7 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let inputs = match w.section { "rasterFunctions" | "functionEditor" | "suitability" | "visibility" | "lineOfSight" | "georeference" | "change" | "spectral" | "classify" | "supervised" | "deepLearning" | "hosted" | "indices" | "process" | "pixels" | "mensuration" | "reality" + | "zonalStats" // The trend's one-layer mode takes a raster (a Zarr variable, or a raster of several bands). | "trend" => &rasters, "interpolate" => &points, @@ -216,6 +228,9 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { if !inputs.contains(&w.layer) { w.layer = inputs.first().cloned().unwrap_or_default(); } + if (w.section == "zonalStats" || w.section == "process") && !polygons.contains(&w.layer2) { + w.layer2 = polygons.first().cloned().unwrap_or_default(); + } ui.add_space(6.0); match app.workflow.section { "rasterFunctions" | "functionEditor" => { @@ -301,6 +316,66 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { run(app, "analysis.suitability", json!({"criteria": [c1, c2]})); } } + "zonalStats" => { + heading(ui, "Calculate raster statistics per polygon"); + ui.label(egui::RichText::new(tl!("Raster layer")).small().weak()); + combo(ui, "wf_zs_raster", "Raster layer", &mut app.workflow.layer, &rasters); + ui.label(egui::RichText::new(tl!("Polygon layer")).small().weak()); + combo(ui, "wf_zs_poly", "Polygon layer", &mut app.workflow.layer2, &polygons); + ui.horizontal(|ui| { + let band_label = ui.label(tl!("Band")); + ui.add(egui::TextEdit::singleline(&mut app.workflow.zonal_stats.bands).hint_text("1").desired_width(40.0)) + .labelled_by(band_label.id); + let prefix_label = ui.label(tl!("Prefix")); + ui.add(egui::TextEdit::singleline(&mut app.workflow.zonal_stats.prefix).hint_text("elev").desired_width(70.0)) + .labelled_by(prefix_label.id); + }); + ui.horizontal(|ui| { + let output_label = ui.label(tl!("Output")); + ui.add( + egui::TextEdit::singleline(&mut app.workflow.zonal_stats.output) + .hint_text(tl!("output layer name")) + .desired_width(120.0), + ) + .labelled_by(output_label.id); + }); + let band_str = app.workflow.zonal_stats.bands.trim(); + let total_bands = app + .session + .map() + .layer_by_name(&app.workflow.layer) + .and_then(|l| l.raster.as_ref()) + .and_then(|r| r.data.as_ref()) + .map(|d| d.bands.len()) + .unwrap_or(1); + let band_res = + if band_str.is_empty() { Ok(vec![0]) } else { rustgis_engine::analysis::rasterops::parse_bands_str(band_str, total_bands) }; + let band_valid = band_res.is_ok(); + let band_err = band_res.err(); + let layers_ready = !app.workflow.layer.is_empty() && !app.workflow.layer2.is_empty(); + let can_run = layers_ready && band_valid; + if !layers_ready { + ui.label(egui::RichText::new(tl!("Add a raster and a polygon layer to calculate zonal statistics.")).small().weak()); + } else if let Some(err) = band_err { + ui.label(egui::RichText::new(err).small().color(t.amber)); + } + if ui.add_enabled(can_run, egui::Button::new(tl!("Calculate Statistics"))).clicked() { + let mut params = json!({ + "raster": app.workflow.layer, + "polygons": app.workflow.layer2, + }); + if !band_str.is_empty() { + params["bands"] = json!(band_str); + } + if !app.workflow.zonal_stats.prefix.trim().is_empty() { + params["prefix"] = json!(app.workflow.zonal_stats.prefix.trim()); + } + if !app.workflow.zonal_stats.output.trim().is_empty() { + params["output"] = json!(app.workflow.zonal_stats.output.trim()); + } + run(app, "gp.zonalStats", params); + } + } "visibility" => { heading(ui, "Viewshed from a point"); combo(ui, "wf_v", "Layer", &mut app.workflow.layer, &rasters); @@ -611,13 +686,69 @@ pub fn workflows(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } "process" => { heading(ui, "Clip or resample"); - combo(ui, "wf_pr", "Layer", &mut app.workflow.layer, &rasters); + combo(ui, "wf_pr", "Raster layer", &mut app.workflow.layer, &rasters); + + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!("Clip to polygon layer")).small().weak()); + let has_polys = !polygons.is_empty(); + if has_polys { + let layer2_before = app.workflow.layer2.clone(); + combo(ui, "wf_pr_poly", "Polygon layer", &mut app.workflow.layer2, &polygons); + if app.workflow.layer2 != layer2_before { + app.workflow.selected_only = None; + } + let poly_layer = app.session.map().layer_by_name(&app.workflow.layer2); + let sel_count = poly_layer.as_ref().map_or(0, |l| l.selection.len()); + let total_features = poly_layer.as_ref().map_or(0, |l| l.dataset.features.len()); + + let effective_selected_only = app.workflow.selected_only.unwrap_or(sel_count > 0); + let mut checked = effective_selected_only; + ui.horizontal(|ui| { + if ui.checkbox(&mut checked, tl!("Selected only")).changed() { + app.workflow.selected_only = Some(checked); + } + if sel_count > 0 { + let text = crate::i18n::fmt( + "({selected} of {total})", + &[("selected", &sel_count.to_string()), ("total", &total_features.to_string())], + ); + ui.label(egui::RichText::new(text).small().weak()); + } + }); + + let sel_invalid = effective_selected_only && sel_count == 0; + let can_clip_poly = !app.workflow.layer.is_empty() && !app.workflow.layer2.is_empty() && !sel_invalid; + + if sel_invalid { + ui.label(egui::RichText::new(tl!("No features are selected in this layer.")).small().color(t.amber)); + } + + if ui.add_enabled(can_clip_poly, egui::Button::new(tl!("Clip To Polygon"))).clicked() { + let mut p = json!({ + "layer": app.workflow.layer, + "operation": "clip", + "polygon": app.workflow.layer2, + }); + p["selectedOnly"] = json!(app.workflow.selected_only); + run(app, "raster.process", p); + } + } else { + ui.label(egui::RichText::new(tl!("No polygon layer available in the map.")).small().weak()); + } + + ui.add_space(4.0); ui.horizontal(|ui| { - if ui.button(tl!("Clip To Map View")).clicked() { + if ui.add_enabled(!app.workflow.layer.is_empty(), egui::Button::new(tl!("Clip To Map View"))).clicked() { run(app, "raster.process", json!({"layer": app.workflow.layer, "operation": "clip"})); } - ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text(tl!("factor")).desired_width(50.0)); - if ui.button(tl!("Resample")).clicked() { + }); + + ui.add_space(4.0); + ui.horizontal(|ui| { + let factor_label = ui.label(tl!("factor")); + ui.add(egui::TextEdit::singleline(&mut app.workflow.number).hint_text(tl!("factor")).desired_width(50.0)) + .labelled_by(factor_label.id); + if ui.add_enabled(!app.workflow.layer.is_empty(), egui::Button::new(tl!("Resample"))).clicked() { run( app, "raster.process", @@ -1245,6 +1376,11 @@ mod tests { assert!(h.query_by_role_and_label(egui::accesskit::Role::ComboBox, label).is_some()); } } + if section == "zonalStats" { + for label in ["Band", "Prefix", "Output"] { + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, label).is_some()); + } + } } } } diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index 022f12757..f520ea392 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -235,6 +235,30 @@ fn command_search_workflow_collects_inputs_before_georeferencing() { assert_eq!(h.state().ui.right, Some(rustgis_ui_egui::RightPane::Workflows)); } +/// Clipping follows a selection until the user explicitly opts out, even after changing it. +#[test] +fn polygon_clip_defaults_to_selection_and_honors_opt_out() { + let mut h = harness(); + h.state_mut().workflow.layer2 = "Countries".into(); + h.state_mut().run("ui.pane.workflows", json!(null)).unwrap(); + h.state_mut().workflow.section = "process"; + h.state_mut().session.map_mut().layers.iter_mut().find(|l| l.name == "Countries").unwrap().selection.push(0); + h.run_steps(4); + assert_eq!(h.state().workflow.selected_only, None); + let total = h.state().session.map().layer_by_name("Countries").unwrap().dataset.features.len(); + assert!(h.query_by_label(&format!("(1 of {total})")).is_some()); + h.get_by_label("Selected only").click(); + h.run_steps(3); + assert_eq!(h.state().workflow.selected_only, Some(false)); + h.state_mut().session.map_mut().layers.iter_mut().find(|l| l.name == "Countries").unwrap().selection.push(1); + h.run_steps(3); + assert_eq!(h.state().workflow.selected_only, Some(false)); + assert!(h.query_by_label(&format!("(2 of {total})")).is_some()); + h.get_by_label("Selected only").click(); + h.run_steps(3); + assert_eq!(h.state().workflow.selected_only, Some(true)); +} + #[cfg(not(target_arch = "wasm32"))] #[test] fn mcp_relay_early_start_wakes_queued_request_and_updates_ui() { diff --git a/docs/user-guide/analysis.md b/docs/user-guide/analysis.md index 2ff923ad8..71a87dfe3 100644 --- a/docs/user-guide/analysis.md +++ b/docs/user-guide/analysis.md @@ -537,6 +537,20 @@ rustgis-cli run --data cobertura_1985.tif --data cobertura_1986.tif --data cober rustgis-cli run --cmd 'cube.reduce={"url":"https://data.inpe.br/bdc/stac/v1","collection":"mod13q1-6.1","asset":"NDVI","bbox":[-44.8,-7.5,-44.3,-7.0],"start":"2001","end":"2025","cellSize":1000,"stat":"mean","by":"year","minValid":0.5,"name":"NDVI"}' --save urucui.rgis ``` +## Zonal statistics and polygon clipping + +Zonal Statistics uses the selected polygon features when the layer has a selection. It keeps +original geometries and fields, and reports features with missing or rejected polygon parts. +If any polygon part fails validation or transformation, the feature receives count 0 and null +statistics, so results never summarize only the surviving parts. Large +overlapping zones each require a separate pass over their raster window. + +For `raster.process` polygon clipping, `selectedOnly: true` requires a selection, +`selectedOnly: false` uses every polygon, and omitting it uses the selection when present. +The Process pane initially checks Selected only when the chosen layer has a selection, and sends +the choice explicitly. Clipping and Zonal Statistics share +polygon transformation rules so a failed hole cannot silently enlarge the sampled area. + ## Deep learning and hosted imagery **Analyze › Deep Learning Tools** trains a neural network (a multilayer perceptron) on the pixels From 7ad5091f59a3515ec229725b5a7e74840c64acc1 Mon Sep 17 00:00:00 2001 From: Ninh Hai Dang Date: Sun, 25 Oct 2026 00:58:11 +0700 Subject: [PATCH 115/297] fix(data): recognize GDA2020 as WGS 84-like, fixing #730 (#837) GDA2020 (EPSG:7844) wasn't in wgs84_like's datum list, so a GDA2020 .prj got the "datum has no conversion to WGS 84... may be off by up to a few hundred metres" warning even though it's drawn in the right place, like GDA94. Same false-alarm pattern #400 fixed for SIRGAS 2000. Scoped narrowly per the issue discussion: this only silences the warning. The ~1.7 m GDA94/GDA2020 discrepancy itself is a separate, deliberate consequence of GDA94's own null shift and belongs with the roadmap's datum/grid-shift work. --- crates/data/src/raster/wkt.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/data/src/raster/wkt.rs b/crates/data/src/raster/wkt.rs index 028801b10..e639cf28c 100644 --- a/crates/data/src/raster/wkt.rs +++ b/crates/data/src/raster/wkt.rs @@ -718,6 +718,11 @@ fn wgs84_like(datum: Option<&Node>) -> Option<&'static str> { | "GDA_1994" | "GDA94" | "GEOCENTRIC_DATUM_OF_AUSTRALIA_1994" + // GDA2020 (EPSG:7844): not in the list before, so it got the "no conversion" warning + // though it's drawn in the right place, like GDA94. + | "GDA_2020" + | "GDA2020" + | "GEOCENTRIC_DATUM_OF_AUSTRALIA_2020" // SIRGAS 2000, the datum of Brazil and most of Latin America (EPSG:4674): its // transformation to WGS 84 is null, like the others here. | "SIRGAS_2000" @@ -1168,6 +1173,16 @@ mod tests { } } + #[test] + fn gda2020_reads_as_wgs_84_without_a_note() { + // As ArcGIS writes a GDA2020 .prj (Esri name). + let geographic = r#"GEOGCS["GCS_GDA2020",DATUM["D_GDA2020",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]"#; + assert_eq!(read_wkt(geographic), Ok((Crs::LonLat, None))); + // The EPSG datum name, as OGC WKT writes it. + let ogc = geographic.replace("D_GDA2020", "Geocentric_Datum_of_Australia_2020"); + assert_eq!(read_wkt(&ogc), Ok((Crs::LonLat, None))); + } + #[test] fn polar_stereographic() { // GDAL's WKT for EPSG:3031 (true scale at 71°S) and EPSG:32761 (UPS South, scale 0.994 at From 7db5130bce79b634db204d1a077deb4acdfe1939 Mon Sep 17 00:00:00 2001 From: Daniel Hofbauer <67418167+deniial00@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:59:07 +0200 Subject: [PATCH 116/297] feat(ui): add reusable ribbon split button (#835) * feat(ui): add reusable ribbon split button * fix(ui): show disabled split button tooltips --- crates/ui-egui/examples/ui_shot.rs | 108 ++++++++- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/widgets.rs | 103 ++++++++- crates/ui-egui/src/widgets/split_tests.rs | 259 ++++++++++++++++++++++ docs/architecture.md | 7 + 5 files changed, 462 insertions(+), 16 deletions(-) create mode 100644 crates/ui-egui/src/widgets/split_tests.rs diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index d6333868f..ce92d135f 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -21,9 +21,101 @@ //! `{"width": w, "height": h}` on the first line sets the size (default //! 1480×900 at 2×; `"dt": seconds` there sets each frame's length, default 0.25, and a //! short one such as 0.016 catches the map mid-motion). The window opens the World Atlas sample. +//! +//! Add `"ribbonSplit": true` to the first line for a compact reusable split-button showcase +//! instead of the app. Its "Disable arrows", "Disable enclosing UI", and "Long two-line labels" +//! checkboxes are script-clickable. `{"cmd":"ui.language","params":{"code":"de"}}` changes the +//! language (untranslated strings fall back to English); `{"dark":true}` changes theme. The +//! three checkboxes precede the buttons in Tab order; click "Select options" to open its menu. +//! Shots retain pointer hover from the last click. No selection initially disables "Zoom To" +//! but leaves "Zoom options" enabled. use egui_kittest::kittest::Queryable; -use rustgis_ui_egui::RustGisApp; +use rustgis_ui_egui::{RustGisApp, big_split_menu, i18n, theme}; +use serde_json::{Value, json}; + +#[derive(Default)] +struct RibbonSplitShowcase { + disable_arrows: bool, + disable_enclosing_ui: bool, + long_labels: bool, + fonts_installed: bool, + applied_dark: Option, +} + +fn split_tool_menu(ui: &mut egui::Ui, app: &mut RustGisApp) { + for (label, tool) in [("Pan", "explore"), ("Measure Distance", "measure")] { + if ui.button(i18n::t(label)).clicked() { + if let Err(e) = app.run("tool.set", json!({"tool": tool})) { + eprintln!("tool.set: {e}"); + } + ui.close(); + } + } +} + +impl RibbonSplitShowcase { + fn show(&mut self, ui: &mut egui::Ui, app: &mut RustGisApp) { + if !self.fonts_installed { + theme::install_fonts(ui.ctx(), &[], None); + self.fonts_installed = true; + } + if self.applied_dark != Some(app.ui.dark) { + let tokens = if app.ui.dark { theme::Tokens::dark() } else { theme::Tokens::light() }; + theme::apply(ui.ctx(), &tokens); + self.applied_dark = Some(app.ui.dark); + } + i18n::set_current(app.chosen_lang()); + ui.heading("Ribbon split buttons"); + ui.horizontal(|ui| { + ui.checkbox(&mut self.disable_arrows, "Disable arrows"); + ui.checkbox(&mut self.disable_enclosing_ui, "Disable enclosing UI"); + ui.checkbox(&mut self.long_labels, "Long two-line labels"); + }); + ui.add_space(8.0); + let tokens = theme::Tokens::get(ui.ctx()); + egui::Frame::new().fill(tokens.ribbon).inner_margin(12.0).show(ui, |ui| { + ui.add_enabled_ui(!self.disable_enclosing_ui, |ui| { + ui.horizontal_top(|ui| { + let select_checked = app.session.tool.code() == "select"; + big_split_menu( + ui, + app, + "select", + if self.long_labels { "Select Features\nwith a rectangle" } else { "Select" }, + "Click or drag a box to select features. Shift adds, Ctrl removes.", + "tool.set", + json!({"tool": "select"}), + select_checked, + "Select options", + "Choose an alternative map tool.", + !self.disable_arrows, + split_tool_menu, + ); + ui.add_space(20.0); + big_split_menu( + ui, + app, + "zoom_sel", + if self.long_labels { "Zoom To\nselected features" } else { "Zoom To" }, + "Zoom to the selected features.", + "select.zoomTo", + Value::Null, + false, + "Zoom options", + "Choose a map tool even when nothing is selected.", + !self.disable_arrows, + split_tool_menu, + ); + }); + }); + }); + ui.add_space(8.0); + ui.label(format!("Current tool: {} ({})", i18n::t(app.session.tool.label()), app.session.tool.code())); + ui.label("Select always runs selection; either options menu can switch to Pan or Measure Distance."); + ui.label("Zoom To needs selected features; its options remain available without a selection."); + } +} fn main() { let path = std::env::args().nth(1).expect("usage: ui_shot script.jsonl"); @@ -33,6 +125,8 @@ fn main() { let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1480.0) as f32; let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32; let dt = lines.iter().find_map(|l| l.get("dt").and_then(|v| v.as_f64())).unwrap_or(0.25) as f32; + let ribbon_split = lines.first().and_then(|l| l.get("ribbonSplit")).and_then(Value::as_bool).unwrap_or(false); + let mut split_showcase = RibbonSplitShowcase::default(); let mut app = RustGisApp::with_sample(); // One shared wgpu render state: the app's GPU map backend and the harness's renderer must // live on the same device for the map's paint callback to be valid. @@ -55,10 +149,14 @@ fn main() { .with_step_dt(dt) .renderer(egui_kittest::wgpu::WgpuTestRenderer::from_render_state(rs)) .build_ui_state( - |ui, app: &mut RustGisApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); + move |ui, app: &mut RustGisApp| { + if ribbon_split { + split_showcase.show(ui, app); + } else { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + } }, app, ); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ef36a824e..bf30b6672 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,7 @@ use serde_json::{Value, json}; pub use chrome::{DoubleClick, TitlebarMetrics}; pub use rustgis_assistant; pub use rustgis_engine; +pub use widgets::big_split_menu; /// Files read asynchronously (browser uploads, drops) waiting to be opened: (file name, bytes). pub type Inbox = std::sync::Arc)>>>; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9d54ccd9b..9c39a8432 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -76,7 +76,7 @@ pub fn why_disabled(app: &RustGisApp, id: &str) -> Option<&'static str> { pub fn tooltip(app: &RustGisApp, resp: Response, title: &str, desc: &str, id: &str, params: &Value) -> Response { let sc = crate::keymap::shortcut_text(&resp.ctx, app, id, params); let why = why_disabled(app, id); - resp.on_hover_ui(|ui| { + let contents = |ui: &mut Ui| { ui.set_max_width(280.0); // Callers pass text that is already translated. let head = if sc.is_empty() { title.to_string() } else { format!("{title} ({sc})") }; @@ -87,7 +87,8 @@ pub fn tooltip(app: &RustGisApp, resp: Response, title: &str, desc: &str, id: &s if let Some(w) = why { ui.label(egui::RichText::new(crate::i18n::fmt("Unavailable: {reason}", &[("reason", tl_raw!(w))])).size(11.0).italics().weak()); } - }) + }; + if resp.enabled() { resp.on_hover_ui(contents) } else { resp.on_disabled_hover_ui(contents) } } fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) { @@ -131,29 +132,106 @@ pub fn big( let on = enabled(app, id) || menu; resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, label.replace('\n', " "))); bg(ui, r, &resp, checked, &t); + paint_big(ui, r, icon, label, on, menu, &t); + let resp = tooltip(app, resp, &label.replace('\n', " "), desc, id, ¶ms); + crate::showme::ring(ui, app, r, id, ¶ms); + if resp.clicked() && on && !menu { + let _ = app.run(id, params); + } + resp +} + +/// The shared icon and label layout of large ribbon controls. A split button gives this only +/// its primary region, keeping even a long translated label clear of the adjacent arrow. +fn paint_big(ui: &Ui, r: Rect, icon: &str, label: &str, on: bool, menu: bool, t: &Tokens) { let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + 21.0), vec2(32.0, 32.0)); let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) }; icons::paint(ui.painter(), ic, icon, c, a); - let lines: Vec<&str> = label.split('\n').collect(); + let mut lines = label.split('\n').peekable(); let mut y = r.min.y + 42.0; - for (i, l) in lines.iter().enumerate() { + while let Some(l) = lines.next() { let col = if on { t.text } else { t.text_disabled }; - if menu && i + 1 == lines.len() { + if menu && lines.peek().is_none() { let tw = text_w(ui, l, regular(11.5)); let x0 = r.center().x - (tw + 9.0) / 2.0; - ui.painter().text(pos2(x0, y), Align2::LEFT_TOP, *l, regular(11.5), col); + ui.painter().text(pos2(x0, y), Align2::LEFT_TOP, l, regular(11.5), col); caret(ui.painter(), pos2(x0 + tw + 5.0, y + 7.5), col); } else { - ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, *l, regular(11.5), col); + ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, l, regular(11.5), col); } y += 13.5; } - let resp = tooltip(app, resp, &label.replace('\n', " "), desc, id, ¶ms); - crate::showme::ring(ui, app, r, id, ¶ms); - if resp.clicked() && on && !menu { +} + +/// A large ribbon button with a fixed primary command and an independent alternatives menu. +/// +/// Labels and descriptions are translated here, as for [`big`]. `menu_label` names the arrow +/// for assistive technology; give it a distinct name such as "Select options". `menu_enabled` +/// is independent of the primary command's availability. Both regions respect a disabled UI. +/// Menu rows should use [`menu_item`] internally, or run their action and call `ui.close()`. +/// Alternatives never change `id` or `params`. Escape/outside clicks use egui's menu dismissal. +/// +/// Returns the primary and arrow responses, in that order. Both are keyboard focusable when +/// enabled and activate with Enter/Space. Enclose repeated controls in `ui.push_id` when their +/// positions in a dynamic list can change. +pub fn big_split_menu( + ui: &mut Ui, + app: &mut RustGisApp, + icon: &str, + label: &str, + desc: &str, + id: &str, + params: Value, + checked: bool, + menu_label: &str, + menu_desc: &str, + menu_enabled: bool, + menu: impl FnOnce(&mut Ui, &mut RustGisApp), +) -> (Response, Response) { + let (label, desc, menu_label, menu_desc) = (tl!(label), tl!(desc), tl!(menu_label), tl!(menu_desc)); + let t = Tokens::get(ui.ctx()); + let w = (label.split('\n').map(|l| text_w(ui, l, regular(11.5))).fold(0.0, f32::max) + 14.0).max(50.0); + let on = enabled(app, id); + let (primary, arrow) = ui + .allocate_ui_with_layout(vec2(w + 20.0, CONTENT_H - 2.0), egui::Layout::left_to_right(egui::Align::Min), |ui| { + ui.spacing_mut().item_spacing.x = 0.0; + let primary = ui.add_enabled_ui(on, |ui| ui.allocate_exact_size(vec2(w, CONTENT_H - 2.0), Sense::click()).1).inner; + let arrow = ui.add_enabled_ui(menu_enabled, |ui| ui.allocate_exact_size(vec2(20.0, CONTENT_H - 2.0), Sense::click()).1).inner; + (primary, arrow) + }) + .inner; + primary.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, primary.enabled(), label.replace('\n', " "))); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, arrow.enabled(), menu_label)); + let popup_id = egui::Popup::default_response_id(&arrow); + if !arrow.enabled() { + egui::Popup::close_id(ui.ctx(), popup_id); + } + if primary.enabled() { + bg(ui, primary.rect, &primary, checked, &t); + } + if arrow.enabled() { + bg(ui, arrow.rect, &arrow, egui::Popup::is_id_open(ui.ctx(), popup_id), &t); + } + paint_big(ui, primary.rect, icon, label, primary.enabled(), false, &t); + let x = arrow.rect.min.x; + ui.painter().line_segment([pos2(x, arrow.rect.min.y + 6.0), pos2(x, arrow.rect.max.y - 6.0)], Stroke::new(1.0, t.border)); + caret(ui.painter(), arrow.rect.center(), if arrow.enabled() { t.text } else { t.text_disabled }); + for resp in [&primary, &arrow] { + if resp.has_focus() { + ui.painter().rect_stroke(resp.rect, CornerRadius::same(6), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + } + let primary = tooltip(app, primary, &label.replace('\n', " "), desc, id, ¶ms); + let arrow = tooltip(app, arrow, menu_label, menu_desc, "ui.menu", &Value::Null); + crate::showme::ring(ui, app, primary.rect, id, ¶ms); + if primary.clicked() { let _ = app.run(id, params); } - resp + egui::Popup::menu(&arrow).show(|ui| { + ui.set_min_width(200.0); + menu(ui, app); + }); + (primary, arrow) } /// A small button: 18 px icon with an optional label. @@ -591,3 +669,6 @@ mod tests { assert!(s.real > 3.0 && s.whole > 4, "kept: {} and {}", s.real, s.whole); } } + +#[cfg(test)] +mod split_tests; diff --git a/crates/ui-egui/src/widgets/split_tests.rs b/crates/ui-egui/src/widgets/split_tests.rs new file mode 100644 index 000000000..916cdf405 --- /dev/null +++ b/crates/ui-egui/src/widgets/split_tests.rs @@ -0,0 +1,259 @@ +#![allow(clippy::unwrap_used)] + +use egui::{Key, accesskit::Role, pos2, vec2}; +use egui_kittest::{ + Harness, + kittest::{NodeT, Queryable}, +}; +use rustgis_engine::Tool; +use serde_json::{Value, json}; + +use super::{big_split_menu, menu_item}; +use crate::RustGisApp; + +#[derive(Clone, Copy)] +struct Options { + zoom_main: bool, + menu_enabled: bool, + ui_enabled: bool, + second_button: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { zoom_main: false, menu_enabled: true, ui_enabled: true, second_button: false } + } +} + +/// Draws only the controls: no app.logic, map frame, networking, or global Escape shortcut. +fn harness(options: Options) -> Harness<'static, RustGisApp> { + let mut app = RustGisApp::with_sample(); + app.run("tool.set", json!({"tool": "measure"})).unwrap(); + let mut fonts = false; + let mut h = Harness::builder().with_size(vec2(700.0, 420.0)).build_ui_state( + move |ui, app: &mut RustGisApp| { + if !fonts { + crate::theme::install_fonts(ui.ctx(), &[], None); + fonts = true; + return; + } + ui.add_enabled_ui(options.ui_enabled, |ui| { + ui.horizontal(|ui| { + let checked = app.session.tool == Tool::Select; + big_split_menu( + ui, + app, + "select", + if options.zoom_main { "Zoom To" } else { "Select" }, + "Click or drag a box to select features.", + if options.zoom_main { "select.zoomTo" } else { "tool.set" }, + if options.zoom_main { Value::Null } else { json!({"tool": "select"}) }, + checked, + "Select options", + "Choose another map tool.", + options.menu_enabled, + |ui, app| { + menu_item(ui, app, "explore", "Explore", "tool.set", json!({"tool": "explore"})); + menu_item(ui, app, "measure", "Measure Distance", "tool.set", json!({"tool": "measure"})); + }, + ); + if options.second_button { + // Identical main command, icon and label must not share a popup identity. + let checked = app.session.tool == Tool::Select; + big_split_menu( + ui, + app, + "select", + "Select", + "Click or drag a box to select features.", + "tool.set", + json!({"tool": "select"}), + checked, + "Other select options", + "Choose another map tool.", + true, + |ui, app| { + menu_item(ui, app, "select", "Other Select", "tool.set", json!({"tool": "select"})); + }, + ); + } + }); + }); + }, + app, + ); + h.run(); + h +} + +fn assert_menu(h: &Harness<'_, RustGisApp>, visible: bool) { + assert_eq!(egui::Popup::is_any_open(&h.ctx), visible); +} + +/// The popup opens immediately below the arrow in this fixed-size widget harness. +fn click_menu_row(h: &mut Harness<'_, RustGisApp>, anchor: &str, row: usize) { + let rect = h.get_by_label(anchor).rect(); + let at = pos2(rect.min.x + 42.0, rect.max.y + 11.0 + row as f32 * 24.0); + h.hover_at(at); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run(); +} + +#[test] +fn primary_and_arrow_are_independent_and_alternatives_never_replace_the_default() { + for (label, tool) in [("Explore", Tool::Explore), ("Measure Distance", Tool::Measure)] { + let mut h = harness(Options::default()); + assert_eq!(h.get_by_label("Select").accesskit_node().role(), Role::Button); + assert_eq!(h.get_by_label("Select options").accesskit_node().role(), Role::Button); + assert_menu(&h, false); + + h.get_by_label("Select").click(); + h.run(); + assert_eq!(h.state().session.tool, Tool::Select); + assert_menu(&h, false); + + h.get_by_label("Select options").click(); + h.run(); + assert_eq!(h.state().session.tool, Tool::Select, "the arrow must not run the primary command"); + assert_menu(&h, true); + click_menu_row(&mut h, "Select options", if label == "Measure Distance" { 1 } else { 0 }); + assert_eq!(h.state().session.tool, tool); + assert_menu(&h, false); + + h.get_by_label("Select").click(); + h.run(); + assert_eq!(h.state().session.tool, Tool::Select, "the primary action stays Select after either alternative"); + assert_menu(&h, false); + } +} + +#[test] +fn escape_and_outside_click_dismiss_without_running_an_action() { + for escape in [true, false] { + let mut h = harness(Options::default()); + h.get_by_label("Select options").click(); + h.run(); + assert_menu(&h, true); + assert_eq!(h.state().session.tool, Tool::Measure); + if escape { + h.key_press(Key::Escape); + } else { + let at = pos2(650.0, 380.0); + h.hover_at(at); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + } + h.run(); + assert_menu(&h, false); + assert_eq!(h.state().session.tool, Tool::Measure, "dismissal must not select a tool"); + } +} + +#[test] +fn tab_reaches_each_region_and_enter_or_space_activates_it() { + for key in [Key::Enter, Key::Space] { + let mut h = harness(Options::default()); + h.key_press(Key::Tab); + h.run(); + assert!(h.get_by_label("Select").is_focused()); + h.key_press(key); + h.run(); + assert_eq!(h.state().session.tool, Tool::Select); + assert_menu(&h, false); + + h.state_mut().run("tool.set", json!({"tool": "measure"})).unwrap(); + h.key_press(Key::Tab); + h.run(); + assert!(h.get_by_label("Select options").is_focused()); + h.key_press(key); + h.run(); + assert_menu(&h, true); + assert_eq!(h.state().session.tool, Tool::Measure, "keyboard activation of the arrow only opens its menu"); + click_menu_row(&mut h, "Select options", 0); + assert_eq!(h.state().session.tool, Tool::Explore); + assert_menu(&h, false); + } +} + +#[test] +fn disabled_primary_still_allows_enabled_menu_actions() { + let mut h = harness(Options { zoom_main: true, ..Options::default() }); + assert!(h.state().session.map().selection_extent().is_empty()); + assert!(h.get_by_label("Zoom To").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Select options").accesskit_node().is_disabled()); + let resolution = h.state().session.map().camera.res; + h.get_by_label("Zoom To").click(); + h.run(); + h.get_by_label("Zoom To").hover(); + h.run(); + assert!(h.query_all_by_label_contains("Unavailable").next().is_some(), "disabled primary still explains why it is unavailable"); + assert_eq!(h.state().session.map().camera.res, resolution); + assert_eq!(h.state().session.tool, Tool::Measure); + assert_menu(&h, false); + + // The disabled command is skipped by Tab; its arrow remains a keyboard target. + h.remove_cursor(); + h.key_press(Key::Tab); + h.run(); + assert!(h.get_by_label("Select options").is_focused()); + h.key_press(Key::Enter); + h.run(); + assert_menu(&h, true); + click_menu_row(&mut h, "Select options", 0); + assert_eq!(h.state().session.tool, Tool::Explore); + assert_eq!(h.state().session.map().camera.res, resolution); + assert_menu(&h, false); +} + +#[test] +fn disabled_arrow_does_not_disable_the_primary_action() { + let mut h = harness(Options { menu_enabled: false, ..Options::default() }); + assert!(!h.get_by_label("Select").accesskit_node().is_disabled()); + assert!(h.get_by_label("Select options").accesskit_node().is_disabled()); + h.get_by_label("Select options").click(); + h.run(); + assert_menu(&h, false); + assert_eq!(h.state().session.tool, Tool::Measure); + h.get_by_label("Select").click(); + h.run(); + assert_eq!(h.state().session.tool, Tool::Select); + assert_menu(&h, false); +} + +#[test] +fn enclosing_disabled_ui_disables_both_regions() { + let mut h = harness(Options { ui_enabled: false, ..Options::default() }); + for label in ["Select", "Select options"] { + assert!(h.get_by_label(label).accesskit_node().is_disabled()); + h.get_by_label(label).click(); + h.run(); + assert_eq!(h.state().session.tool, Tool::Measure); + assert_menu(&h, false); + } +} + +#[test] +fn identical_primary_commands_keep_distinct_popup_contents() { + let mut h = harness(Options { second_button: true, ..Options::default() }); + h.get_by_label("Select options").click(); + h.run(); + assert_menu(&h, true); + h.key_press(Key::Escape); + h.run(); + assert_menu(&h, false); + + h.get_by_label("Other select options").click(); + h.run(); + assert_menu(&h, true); + assert_eq!(h.state().session.tool, Tool::Measure); + click_menu_row(&mut h, "Other select options", 0); + assert_eq!(h.state().session.tool, Tool::Select); +} diff --git a/docs/architecture.md b/docs/architecture.md index 8c88a0371..44132ceea 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,13 @@ description, an `enabled` check and a `run` function. The ribbon, keyboard short search, CLI and Python dispatch engine commands through `Session::run`. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`; engine commands never open dialogs. +The UI exports `big_split_menu` for a large ribbon control with independent, accessible primary +and alternatives buttons. Pass the primary command id and parameters separately from the menu +closure; menu choices run their own commands and never replace the primary action. The primary's +enabled state comes from its command, while `menu_enabled` controls the arrow independently. Use +`ui.push_id` when placing repeated controls whose order can change. Keep whole-button dropdowns +for choices without a clear everyday default. + Mutating commands snapshot the project for undo and restore it on an error or escaped panic. View-only commands do not take undo snapshots. Undo and rollback cover project state, not external effects such as files already written. From 91c3b0a47f700d60469d66854a3d28ce22d543d0 Mon Sep 17 00:00:00 2001 From: Alexey Tkachenko <182909819+alteralto@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:47:27 +0300 Subject: [PATCH 117/297] feat(symbology): manual class breaks (#822) * test(symbology): manual class breaks, as given * feat(symbology): manual class breaks, typed in the Symbology pane * fix(symbology): a dragged bound applies once; manual is refused for rasters * fix(symbology): manual breaks show a refused edit, keep the closing bound, key held values by layer; breaks need the manual method --- ROADMAP.md | 3 +- .../rustgis-python/python/rustgis/__init__.py | 3 +- crates/assistant/skills/thematic-map/SKILL.md | 2 +- crates/engine/src/cmd/layer.rs | 29 ++++++- crates/engine/src/cmd/raster.rs | 3 + crates/engine/src/tests.rs | 43 ++++++++++ crates/map/src/symbology.rs | 58 +++++++++++++- crates/ui-egui/src/panes.rs | 79 ++++++++++++++++--- docs/user-guide/cartography.md | 4 +- 9 files changed, 200 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 483933bd6..a9a18b442 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,7 +57,6 @@ The bugs users hit, the docs and a few quick wins: good first pull requests. - A pass over the user guide, the comparison pages and the README against what the app does today (vertex editing, spatial join and kriging are listed as missing or undocumented; some pages still say 16 languages). -- Styling: manual breaks _(open)_. - Selection by polygon and lasso, and a radius tool on the map (Neighborhood Explorer already selects by radius) _(open)_. @@ -188,7 +187,7 @@ Open questions rather than plans; discuss before building. layers (XYZ/TMS/WMS, Mapbox Vector Tiles, MapLibre styles, PMTiles and MBTiles archives), a basemap gallery (OpenFreeMap, USGS Imagery), worldviews. - Cartography: single, unique values, graduated colours (natural breaks, equal interval, quantile, - geometric interval, standard deviation) and proportional symbols; layer groups; + geometric interval, standard deviation, manual) and proportional symbols; layer groups; label classes with SQL queries, placement and annotation; custom colour ramps (and QGIS, GDAL and GMT palette files); masking, display filters, blend modes, aggregation, extrusion; graphics. - Editing: create, move, rotate, scale, merge, split, explode, vertex editing; snapping, templates, elevation, diff --git a/apps/rustgis-python/python/rustgis/__init__.py b/apps/rustgis-python/python/rustgis/__init__.py index 4e68bfe08..18b8185c2 100644 --- a/apps/rustgis-python/python/rustgis/__init__.py +++ b/apps/rustgis-python/python/rustgis/__init__.py @@ -158,7 +158,8 @@ def style(self, type: str = "single", field: Optional[str] = None, **params: Any field: The field the colours or sizes come from (not for ``"single"``). **params: More ``layer.symbology`` parameters: ``classes`` (1 to 12), ``method`` (``"naturalBreaks"``, ``"equalInterval"``, ``"quantile"``, - ``"geometricInterval"``, ``"standardDeviation"``), ``ramp``. + ``"geometricInterval"``, ``"standardDeviation"``, ``"manual"``), + ``breaks`` (manual: the classes' upper bounds), ``ramp``. Returns: The layer, so calls can be chained. diff --git a/crates/assistant/skills/thematic-map/SKILL.md b/crates/assistant/skills/thematic-map/SKILL.md index 8ffd91f3c..b52dbf9ba 100644 --- a/crates/assistant/skills/thematic-map/SKILL.md +++ b/crates/assistant/skills/thematic-map/SKILL.md @@ -9,7 +9,7 @@ description: Style a layer by its data (graduated colours, categories, proportio field suits graduated or proportional symbols; text or a few distinct values suit unique values. 2. Style it with `run_command` `layer.symbology`: - Numbers: `{"layer": L, "type": "graduated", "field": F, "classes": 5, "method": "naturalBreaks"}` (or - `quantile`, `equalInterval`, `geometricInterval`, `standardDeviation`), with an optional `"ramp"`. + `quantile`, `equalInterval`, `geometricInterval`, `standardDeviation`, or `"breaks": [upper bounds]` for classes set by hand), with an optional `"ramp"`. For counts or sizes, `"type": "proportional"` usually reads better than colour. - Categories: `{"layer": L, "type": "unique", "field": F}`. More than about 12 categories is hard to read, so suggest grouping first. diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 034e0f2a5..6b93c44b2 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -109,7 +109,7 @@ pub fn commands() -> Vec { .when(need_layer), C::new("layer.symbology", "Symbology", "Style › Drawing", symbology) .when(need_feature_layer) - .params(r#"{"layer"?, "type": "single"|"unique"|"graduated"|"proportional", "field"?, "classes"?: 1..12, "method"?: "naturalBreaks"|"equalInterval"|"quantile"|"geometricInterval"|"standardDeviation", "ramp"?: string}"#), + .params(r#"{"layer"?, "type": "single"|"unique"|"graduated"|"proportional", "field"?, "classes"?: 1..12, "method"?: "naturalBreaks"|"equalInterval"|"quantile"|"geometricInterval"|"standardDeviation"|"manual", "breaks"?: [numbers] (manual: the classes' upper bounds, closed by the field's largest value), "ramp"?: string}"#), C::new("layer.ramp", "Color Scheme", "Style › Drawing", |s, p| { let r = s.ramp(s_req(p, "ramp")?)?; with(s, p, |l| match &mut l.raster { @@ -2672,7 +2672,20 @@ fn symbology(s: &mut Session, p: &Value) -> CmdResult { Some(n) => Some(s.ramp(n).or_else(|e| current.filter(|r| r.name().eq_ignore_ascii_case(n.trim())).ok_or(e))?), None => current, }; - let method = ps(p, "method").and_then(ClassMethod::from_name).unwrap_or_default(); + // Manual bounds, as typed; "breaks" alone means manual. + let typed = match p.get("breaks") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => Some(a.iter().map(|v| v.as_f64().ok_or("breaks are numbers")).collect::, _>>()?), + Some(_) => return Err("breaks are a list of numbers".into()), + }; + let method = match ps(p, "method").and_then(ClassMethod::from_name) { + Some(m) if m != ClassMethod::Manual && typed.is_some() => { + return Err(format!("breaks go with the manual method, not {}", m.label())); + } + Some(m) => m, + None if typed.is_some() => ClassMethod::Manual, + None => ClassMethod::default(), + }; let classes = f(p, "classes").map(|c| c.clamp(1.0, 12.0) as usize).unwrap_or(5); let l = s.map().layer(id).ok_or("no such layer")?; let g = l.geometry_type(); @@ -2714,7 +2727,17 @@ fn symbology(s: &mut Session, p: &Value) -> CmdResult { "single" | "singlesymbol" => Renderer::Single(base), "unique" | "uniquevalues" => Renderer::unique(&l.dataset, &rows()?, &field(false)?, ramp.unwrap_or(Ramp::Harbour), base)?, "graduated" | "graduatedcolors" => { - Renderer::graduated(&l.dataset, &rows()?, &field(true)?, classes, method, ramp.unwrap_or(Ramp::Ember), base)? + let (field, ramp) = (field(true)?, ramp.unwrap_or(Ramp::Ember)); + // Manual with no bounds keeps the layer's current ones on the same field, so switching + // to it changes nothing. + let kept = match &l.renderer { + Renderer::Graduated { field: f, breaks, .. } if *f == field => Some(breaks.clone()), + _ => None, + }; + match (method, typed.or(kept)) { + (ClassMethod::Manual, Some(b)) => Renderer::graduated_manual(&l.dataset, &rows()?, &field, &b, ramp, base)?, + _ => Renderer::graduated(&l.dataset, &rows()?, &field, classes, method, ramp, base)?, + } } "proportional" | "proportionalsymbols" => { if g != GeometryType::Point { diff --git a/crates/engine/src/cmd/raster.rs b/crates/engine/src/cmd/raster.rs index ca37047eb..996f35003 100644 --- a/crates/engine/src/cmd/raster.rs +++ b/crates/engine/src/cmd/raster.rs @@ -572,6 +572,9 @@ fn symbology(s: &mut Session, p: &Value) -> CmdResult { "classify" => { let classes = f(p, "classes").map(|c| c.clamp(1.0, 32.0) as usize).unwrap_or(5); let method = ps(p, "method").and_then(ClassMethod::from_name).unwrap_or_default(); + if method == ClassMethod::Manual { + return Err("manual breaks are for Graduated Colors; a raster's classes are worked out".into()); + } let ramp = ramp.or(cur_ramp.filter(|r| !r.is_qualitative())).unwrap_or(Ramp::Ember); classify_masked(r, bi, classes, method, ramp, &rl.style.mask) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 223bf8319..5c54d0b62 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1206,6 +1206,8 @@ fn raster_layers_open_style_identify_and_round_trip() { ] { s.run("raster.symbology", &p).unwrap_or_else(|e| panic!("{p}: {e}")); } + // Bounds typed by hand are for Graduated Colors: a raster's classes are worked out. + assert!(s.run("raster.symbology", &json!({"type": "classify", "method": "manual"})).is_err()); assert!(s.run("raster.symbology", &json!({"type": "colormap"})).is_err()); assert!(s.run("raster.symbology", &json!({"band": 9})).is_err()); s.run("raster.bands", &json!({"red": 3, "green": 2, "blue": 1})).unwrap(); @@ -15776,3 +15778,44 @@ fn toggling_out_the_only_picked_layer_leaves_none_active() { assert!(s.selected_layers().is_empty()); assert!(s.run("layer.remove", &Value::Null).is_err(), "nothing picked, nothing removed"); } + +#[test] +fn manual_breaks_are_the_ones_given() { + use rustgis_map::{ClassMethod, Renderer}; + let mut s = world(); + let breaks = |s: &Session| match &s.map().layer_by_name("Countries").unwrap().renderer { + Renderer::Graduated { breaks, method, .. } => (breaks.clone(), *method), + r => panic!("{r:?}"), + }; + let l = s.map().layer_by_name("Countries").unwrap(); + let fi = l.dataset.field_index("pop_est").unwrap(); + let max = (0..l.dataset.features.len()).filter_map(|r| l.dataset.value(r, fi).as_f64()).fold(f64::MIN, f64::max); + let manual = |b: Value| json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "method": "manual", "breaks": b}); + // The bounds as typed; the field's maximum closes the last class, so every country has one. + s.run("layer.symbology", &manual(json!([1e6, 1e7, 1e8]))).unwrap(); + assert_eq!(breaks(&s), (vec![1e6, 1e7, 1e8, max], ClassMethod::Manual)); + // Out of order or repeated, they come back sorted, once. + s.run("layer.symbology", &manual(json!([5e7, 1e6, 5e7]))).unwrap(); + assert_eq!(breaks(&s).0, vec![1e6, 5e7, max]); + // "breaks" alone means manual. + let mut p = manual(json!([2e7])); + p.as_object_mut().unwrap().remove("method"); + s.run("layer.symbology", &p).unwrap(); + assert_eq!(breaks(&s), (vec![2e7, max], ClassMethod::Manual)); + // Manual with no bounds keeps the layer's current ones, so switching to it changes nothing. + s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "method": "quantile", "classes": 4})) + .unwrap(); + let q = breaks(&s).0; + s.run("layer.symbology", &json!({"layer": "Countries", "type": "graduated", "field": "pop_est", "method": "manual"})).unwrap(); + assert_eq!(breaks(&s), (q, ClassMethod::Manual)); + // Bounds with a method that works them out are refused rather than ignored. + let mut p = manual(json!([2e7])); + p["method"] = json!("quantile"); + assert!(s.run("layer.symbology", &p).is_err()); + // Not numbers, none, or more than twelve classes are refused, and the layer keeps its style. + for bad in [json!([]), json!(["a lot"]), json!((1..=13).map(|i| i as f64 * 1e6).collect::>())] { + let before = breaks(&s); + assert!(s.run("layer.symbology", &manual(bad.clone())).is_err(), "{bad}"); + assert_eq!(breaks(&s), before); + } +} diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 7868ba668..795a73db4 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -315,15 +315,18 @@ pub enum ClassMethod { GeometricInterval, /// Classes one standard deviation wide about the mean. StandardDeviation, + /// Bounds typed by hand, kept as they are (see [`Renderer::graduated_manual`]). + Manual, } impl ClassMethod { - pub const ALL: [ClassMethod; 5] = [ + pub const ALL: [ClassMethod; 6] = [ ClassMethod::NaturalBreaks, ClassMethod::EqualInterval, ClassMethod::Quantile, ClassMethod::GeometricInterval, ClassMethod::StandardDeviation, + ClassMethod::Manual, ]; pub fn label(self) -> &'static str { match self { @@ -332,6 +335,7 @@ impl ClassMethod { ClassMethod::Quantile => "Quantile", ClassMethod::GeometricInterval => "Geometric Interval", ClassMethod::StandardDeviation => "Standard Deviation", + ClassMethod::Manual => "Manual", } } pub fn from_name(s: &str) -> Option { @@ -342,6 +346,7 @@ impl ClassMethod { "quantile" => ClassMethod::Quantile, "geometricinterval" | "geometric_interval" | "geometric interval" | "geometric" => ClassMethod::GeometricInterval, "standarddeviation" | "standard_deviation" | "standard deviation" | "stddev" => ClassMethod::StandardDeviation, + "manual" => ClassMethod::Manual, _ => return None, }) } @@ -520,6 +525,51 @@ impl Renderer { method: ClassMethod, ramp: Ramp, base: Symbol, + ) -> Result { + let k = classes.clamp(1, 12); + Self::graduated_by(ds, rows, field, method, ramp, base, |vals| Ok(class_breaks(vals, k, method))) + } + + /// Builds a graduated-colours renderer on the class bounds typed by hand: the classes' upper + /// bounds, sorted and each once, closed by the field's largest value when they stop short of + /// it, so every feature has a class. At most 12 classes. + pub fn graduated_manual( + ds: &Dataset, + rows: &[bool], + field: &str, + breaks: &[f64], + ramp: Ramp, + base: Symbol, + ) -> Result { + Self::graduated_by(ds, rows, field, ClassMethod::Manual, ramp, base, |vals| { + let mut b: Vec = breaks.iter().copied().filter(|v| v.is_finite()).collect(); + if b.is_empty() { + return Err("manual breaks need at least one number".into()); + } + b.sort_by(|a, b| a.total_cmp(b)); + b.dedup(); + if let Some(&hi) = vals.last() + && b.last().is_some_and(|&l| l < hi) + { + b.push(hi); + } + if b.len() > 12 { + return Err(format!("{} classes; Graduated Colors takes at most 12", b.len())); + } + Ok(b) + }) + } + + /// A graduated renderer on `field`, with the bounds `breaks` works out from the field's sorted + /// values. + fn graduated_by( + ds: &Dataset, + rows: &[bool], + field: &str, + method: ClassMethod, + ramp: Ramp, + base: Symbol, + breaks: impl FnOnce(&[f64]) -> Result, String>, ) -> Result { let fi = ds.field_index(field).ok_or_else(|| format!("no field named '{field}'"))?; if !ds.fields.get(fi).is_some_and(|f| f.ty.is_numeric()) { @@ -530,8 +580,7 @@ impl Renderer { return Err(format!("'{field}' has no numeric values")); } vals.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let k = classes.clamp(1, 12); - let breaks = class_breaks(&vals, k, method); + let breaks = breaks(&vals)?; let n = breaks.len(); let symbols = (0..n).map(|i| base.with_ramp_fill(ramp.pick(i, n), ds.geometry_type)).collect(); let min = vals.first().copied().unwrap_or(0.0); @@ -1059,7 +1108,8 @@ pub fn class_breaks(sorted: &[f64], k: usize, method: ClassMethod) -> Vec { return vec![hi]; } let mut b = match method { - ClassMethod::EqualInterval => (1..=k).map(|i| lo + (hi - lo) * i as f64 / k as f64).collect::>(), + // Manual bounds are typed, not worked out: asked to work them out, it starts from equal intervals. + ClassMethod::EqualInterval | ClassMethod::Manual => (1..=k).map(|i| lo + (hi - lo) * i as f64 / k as f64).collect::>(), ClassMethod::Quantile => (1..=k).map(|i| sorted.get(((n * i) / k).saturating_sub(1).min(n - 1)).copied().unwrap_or(hi)).collect(), ClassMethod::NaturalBreaks => jenks(sorted, k), ClassMethod::GeometricInterval => { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index c8d40524c..ca444c888 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3526,7 +3526,7 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { ); legend_list(app, ui, &l, t); } - Renderer::Graduated { field, breaks, ramp, method, .. } => { + Renderer::Graduated { field, breaks, ramp, method, min, .. } => { field_combo(app, ui, "Field", field, &numeric, "graduated"); ui.horizontal(|ui| { let input_label = ui.label(tl!("Method")); @@ -3538,16 +3538,20 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { } }).response.labelled_by(input_label.id); }); - ui.horizontal(|ui| { - let input_label = ui.label(tl!("Classes")); - let mut k = breaks.len(); - if ui.add(egui::DragValue::new(&mut k).range(1..=12)).labelled_by(input_label.id).changed() { - let _ = app.run( - "layer.symbology", - json!({"type": "graduated", "field": field, "method": format!("{method:?}"), "classes": k, "ramp": ramp.name()}), - ); - } - }); + if *method == ClassMethod::Manual { + manual_breaks(app, ui, field, breaks, *min, ramp); + } else { + ui.horizontal(|ui| { + let input_label = ui.label(tl!("Classes")); + let mut k = breaks.len(); + if ui.add(egui::DragValue::new(&mut k).range(1..=12)).labelled_by(input_label.id).changed() { + let _ = app.run( + "layer.symbology", + json!({"type": "graduated", "field": field, "method": format!("{method:?}"), "classes": k, "ramp": ramp.name()}), + ); + } + }); + } ramp_picker(app, ui, ramp, t); ui.add_space(6.0); legend_list(app, ui, &l, t); @@ -3921,6 +3925,56 @@ fn legend_list(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { ); } +/// Graduated Colors' bounds typed by hand: each class's upper bound as a number, a button to take +/// a bound out, and one to split the widest class in two. A bound being dragged or typed is held +/// here and applied once, when the drag stops or the field is left, so a drag is one undo step. +fn manual_breaks(app: &mut RustGisApp, ui: &mut Ui, field: &str, breaks: &[f64], min: f64, ramp: &Ramp) { + let mut b = breaks.to_vec(); + let speed = ((b.last().copied().unwrap_or(min) - min).abs() / 200.0).max(1e-9); + let mut apply = false; + let mut remove = None; + let (layer, last) = (app.session.active_layer, b.len().saturating_sub(1)); + for (i, v) in b.iter_mut().enumerate() { + ui.horizontal(|ui| { + let id = egui::Id::unique(("manual-break", layer, field, i)); + if let Some(held) = ui.data(|d| d.get_temp::(id)) { + *v = held; + } + let input_label = ui.label(crate::i18n::fmt("Class {n} up to", &[("n", &(i + 1).to_string())])); + let r = ui.add(egui::DragValue::new(v).speed(speed)).labelled_by(input_label.id); + if r.changed() { + ui.data_mut(|d| d.insert_temp(id, *v)); + } + if (r.drag_stopped() || r.lost_focus()) && ui.data(|d| d.get_temp::(id)).is_some() { + ui.data_mut(|d| d.remove_temp::(id)); + apply = true; + } + // The last bound closes the classes at the field's largest value: taking it out would + // only bring it back. + if i < last && ui.small_button("×").on_hover_text(tl!("Take this bound out")).clicked() { + remove = Some(i); + } + }); + } + if let Some(i) = remove { + b.remove(i); + apply = true; + } + if b.len() < 12 && ui.button(tl!("Split the widest class")).clicked() { + let lower = |i: usize| if i == 0 { min } else { b[i - 1] }; + if let Some(i) = (0..b.len()).max_by(|&x, &y| (b[x] - lower(x)).total_cmp(&(b[y] - lower(y)))) { + b.insert(i, (lower(i) + b[i]) / 2.0); + apply = true; + } + } + if apply + && let Err(e) = + app.run("layer.symbology", json!({"type": "graduated", "field": field, "method": "manual", "breaks": b, "ramp": ramp.name()})) + { + app.session.notify(e, true); + } +} + fn symbol_editor(app: &mut RustGisApp, ui: &mut Ui, s: Symbol, g: GeometryType, t: &Tokens) { egui::Frame::NONE.fill(t.pane_header).corner_radius(CornerRadius::same(8)).inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(ui.available_width()); @@ -4795,7 +4849,8 @@ fn raster_symbology(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { .selected_text(tl!(method.label())) .width(180.0) .show_ui(ui, |ui| { - for mm in ClassMethod::ALL { + // Manual bounds are for Graduated Colors; a raster's are worked out. + for mm in ClassMethod::ALL.into_iter().filter(|m| *m != ClassMethod::Manual) { if ui.selectable_label(mm == m, tl!(mm.label())).clicked() { let _ = app.run( "raster.symbology", diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 6a45dbc56..4244d4082 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -22,7 +22,9 @@ Select a layer in Layers and open **Style › Symbology** (or the Symbology pane widths grow by one ratio, for values piled up at the low end; for a field that reaches zero or below, the minimum is taken as if it sat at a hundredth of the range, so the widths grow by 101^(1/classes). Standard deviation makes classes one deviation wide about the mean, and leaves - out a class no value falls in, so a skewed field can get fewer classes than asked. If no field is + out a class no value falls in, so a skewed field can get fewer classes than asked. Manual keeps the + bounds you type: each class's upper bound is a number to edit or take out, **Split the widest + class** adds one, and the field's largest value closes the last class. If no field is chosen, the current renderer's field is kept when it is numeric; otherwise the first numeric field is used. If the layer has no numeric field, or the switch fails for another reason, the pane shows why and keeps the current renderer. When numeric From 8c718b072e849cf9894b4e1c9b13b4e09f26fa58 Mon Sep 17 00:00:00 2001 From: Juhans Date: Sun, 25 Oct 2026 01:58:52 +0700 Subject: [PATCH 118/297] fix(ui): allow selecting a field before applying unique symbology (#738) * Fix unique symbology field selection * Address review feedback for unique symbology picker * Fix pending symbology selection state * Fix clippy warning in symbology pane * Address review feedback - Show a visible error notice when applying pending unique symbology fails, retaining the field for retry. * Address CodeRabbit review feedback - Scope pending Unique Values fields to their project, map and layer. - Drop stale choices after renderer changes, including commands made while the pane is closed, and validate external edits when drawing. - Cover context changes, undo and failed-command retries in UI tests. * Fix workspace Clippy validation warnings Simplify boolean guards and the navigation benchmark range check without changing validation behavior. * Address CodeRabbit review feedback Associate the pending Unique Values field selector with its visible label. Exercise the accessible label in the existing field-selection UI test. --------- Co-authored-by: giswqs --- apps/rustgis/examples/navigate_bench.rs | 2 +- apps/rustgis/src/main.rs | 2 +- crates/analysis/src/trend.rs | 4 +- crates/assistant/src/agent.rs | 2 +- crates/data/src/raster/mod.rs | 2 +- crates/engine/src/cmd/select.rs | 2 +- crates/engine/src/model.rs | 2 +- crates/map/src/globe.rs | 2 +- crates/tiles/src/embeddings.rs | 2 +- crates/ui-egui/src/lib.rs | 39 +++++++++++- crates/ui-egui/src/panes.rs | 54 +++++++++++++++-- crates/ui-egui/tests/smoke.rs | 80 +++++++++++++++++++++++++ 12 files changed, 178 insertions(+), 15 deletions(-) diff --git a/apps/rustgis/examples/navigate_bench.rs b/apps/rustgis/examples/navigate_bench.rs index 2d519766f..e0c5fc560 100644 --- a/apps/rustgis/examples/navigate_bench.rs +++ b/apps/rustgis/examples/navigate_bench.rs @@ -78,7 +78,7 @@ impl eframe::App for Bench { // From the zoom's start, all of it, however long a frame took. let dt = (t - self.last_wheel.max(LOAD + PAN)).max(0.0); self.last_wheel = t; - let dir = if z < ZOOM / 3.0 || z >= 2.0 * ZOOM / 3.0 { -1.0 } else { 1.0 }; + let dir = if !(ZOOM / 3.0..2.0 * ZOOM / 3.0).contains(&z) { -1.0 } else { 1.0 }; input.events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, (dir * 600.0 * dt) as f32), diff --git a/apps/rustgis/src/main.rs b/apps/rustgis/src/main.rs index 2377d413e..d57c2cd65 100644 --- a/apps/rustgis/src/main.rs +++ b/apps/rustgis/src/main.rs @@ -309,7 +309,7 @@ fn main() -> eframe::Result { let files: Vec<&String> = args .iter() .enumerate() - .filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--backend"))) + .filter(|(i, a)| !(a.starts_with("--") || *i > 0 && args.get(i - 1).is_some_and(|p| p == "--backend"))) .map(|(_, a)| a) .collect(); // Installed plugins, before the first session lists commands and tools. diff --git a/crates/analysis/src/trend.rs b/crates/analysis/src/trend.rs index 73eb4873d..4db29388c 100644 --- a/crates/analysis/src/trend.rs +++ b/crates/analysis/src/trend.rs @@ -111,7 +111,7 @@ pub fn trend_with(t: &[f64], y: &[f64], test: Test, alpha: f64, slopes: &mut Vec kept.sort_by(|a, b| a.0.total_cmp(&b.0)); let (t, y): (Vec, Vec) = kept.into_iter().unzip(); let n = y.len(); - if !(MIN_VALUES..=MAX_VALUES).contains(&n) || !(alpha > 0.0 && alpha < 1.0) { + if !((MIN_VALUES..=MAX_VALUES).contains(&n) && alpha > 0.0 && alpha < 1.0) { return None; } if let Test::Seasonal(m) = test { @@ -222,7 +222,7 @@ pub fn trend_seasonal_with(t: &[f64], y: &[f64], season: &[usize], alpha: f64, s let mut kept: Vec<(f64, f64, usize)> = t.iter().zip(y).zip(season).filter(|((t, y), _)| t.is_finite() && y.is_finite()).map(|((t, y), s)| (*t, *y, *s)).collect(); kept.sort_by(|a, b| a.0.total_cmp(&b.0)); - if !(MIN_VALUES..=MAX_VALUES).contains(&kept.len()) || !(alpha > 0.0 && alpha < 1.0) { + if !((MIN_VALUES..=MAX_VALUES).contains(&kept.len()) && alpha > 0.0 && alpha < 1.0) { return None; } let t: Vec = kept.iter().map(|k| k.0).collect(); diff --git a/crates/assistant/src/agent.rs b/crates/assistant/src/agent.rs index 91a06c97f..c793244ec 100644 --- a/crates/assistant/src/agent.rs +++ b/crates/assistant/src/agent.rs @@ -209,7 +209,7 @@ impl Agent { fn local_delegation(&self, call: &ToolCall) -> bool { if call.name != "delegate" || self.depth != 0 - || !call.args.get("task").and_then(serde_json::Value::as_str).is_some_and(|s| !s.trim().is_empty()) + || call.args.get("task").and_then(serde_json::Value::as_str).is_none_or(|s| s.trim().is_empty()) { return false; } diff --git a/crates/data/src/raster/mod.rs b/crates/data/src/raster/mod.rs index 24206647f..5335a5158 100644 --- a/crates/data/src/raster/mod.rs +++ b/crates/data/src/raster/mod.rs @@ -400,7 +400,7 @@ impl Raster { if self.georeferenced { return Err(DataError::Format("only a raster with no coordinates can be placed".into())); } - if !bbox.iter().all(|v| v.is_finite()) || !(w < e && s < n) { + if !(bbox.iter().all(|v| v.is_finite()) && w < e && s < n) { return Err(DataError::Format("the box is [west, south, east, north]".into())); } let old = self.transform; diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 8f5fb2544..a14b49e10 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -218,7 +218,7 @@ fn identify(s: &mut Session, p: &Value) -> CmdResult { let map = s.map(); let mut results: Vec = hits.iter().filter_map(|(id, row)| feature_json(map, *id, *row)).collect(); // Then the features of vector tile layers, beneath the layers; tiles are never selectable. - if matches!(mode, PopupMode::Topmost | PopupMode::Visible) && !(first_layer && !results.is_empty()) { + if matches!(mode, PopupMode::Topmost | PopupMode::Visible) && (!first_layer || results.is_empty()) { let room = MAX_IDENTIFIED.saturating_sub(results.len()); results.extend(tile_features(s, lon, lat, tol_px, first_layer, room)); } diff --git a/crates/engine/src/model.rs b/crates/engine/src/model.rs index 9593a843d..977669d17 100644 --- a/crates/engine/src/model.rs +++ b/crates/engine/src/model.rs @@ -223,7 +223,7 @@ pub fn validate(s: &Session, g: &ModelGraph) -> Vec { }; for p in t.params.iter().filter(|p| p.required) { let port = port_kind(p.kind).is_some(); - if !(port && wired(p.name)) && !is_set(n.params.get(p.name)) && p.default.is_empty() { + if !(port && wired(p.name) || is_set(n.params.get(p.name))) && p.default.is_empty() { let msg = if port { "\"{p}\" needs a connection or a layer." } else { "\"{p}\" needs a value." }; out.push(problem("missing-input", Some(&n.id), msg.replace("{p}", p.label))); } diff --git a/crates/map/src/globe.rs b/crates/map/src/globe.rs index c9ae4a565..ed0699e56 100644 --- a/crates/map/src/globe.rs +++ b/crates/map/src/globe.rs @@ -560,7 +560,7 @@ impl Globe { pub fn drape_mesh(&self, merc: &Extent) -> DrapeMesh { use rustgis_geom::merc_to_lonlat; let mut out = DrapeMesh::default(); - if merc.is_empty() || !(merc.width() > 0.0 && merc.height() > 0.0) || !merc.width().is_finite() || !merc.height().is_finite() { + if !(!merc.is_empty() && merc.width() > 0.0 && merc.height() > 0.0 && merc.width().is_finite() && merc.height().is_finite()) { return out; } let ll = diff --git a/crates/tiles/src/embeddings.rs b/crates/tiles/src/embeddings.rs index a83af5400..dda9b6ee9 100644 --- a/crates/tiles/src/embeddings.rs +++ b/crates/tiles/src/embeddings.rs @@ -403,7 +403,7 @@ fn utm_zone(lon: f64, lat: f64) -> u32 { /// The Sentinel-2 (MGRS 100 km) tile holding a place, such as `17SKV`; `None` outside MGRS's /// UTM latitudes (80° S to 84° N). pub fn mgrs_tile(lon: f64, lat: f64) -> Option { - if !(lon.is_finite() && lat.is_finite()) || !(-80.0..84.0).contains(&lat) { + if !(lon.is_finite() && lat.is_finite() && (-80.0..84.0).contains(&lat)) { return None; } let zone = utm_zone(lon, lat); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bf30b6672..0f30e5b93 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -440,6 +440,35 @@ pub enum RightPane { Plugin, } +/// A Unique Values field choice scoped to the layer and renderer that opened it. +#[derive(Clone, Debug, PartialEq)] +pub struct PendingUniqueSymbology { + project: u64, + map: usize, + layer: u32, + renderer: std::sync::Arc, + pub field: String, +} + +impl PendingUniqueSymbology { + fn new(session: &Session, layer: &rustgis_engine::map::Layer, field: String) -> Self { + Self { + project: session.opened, + map: session.project.active_map, + layer: layer.id, + renderer: std::sync::Arc::new(layer.renderer.clone()), + field, + } + } + + fn is_current(&self, session: &Session) -> bool { + self.project == session.opened + && self.map == session.project.active_map + && Some(self.layer) == session.active_layer + && session.map().layer(self.layer).is_some_and(|layer| layer.renderer == *self.renderer) + } +} + pub struct UiState { pub tab: String, pub backstage: bool, @@ -612,6 +641,8 @@ pub struct UiState { pub copy_image: Option, /// The scale being typed into the status bar, while its box has focus. pub scale_text: Option, + /// A field chosen in the Symbology pane before applying Unique Values. + pub symbology_pending: Option, } impl UiState { @@ -731,6 +762,7 @@ impl Default for UiState { layers_view: LayersView::DrawingOrder, dynamic_text: None, scale_text: None, + symbology_pending: None, } } } @@ -1492,6 +1524,11 @@ impl RustGisApp { if let Err(e) = &r { log::info!("{id}: {e}"); } + // Commands can replace the project, switch maps or change the renderer while the pane + // is closed. A field picked for the old context must not overwrite the new renderer. + if self.ui.symbology_pending.as_ref().is_some_and(|pending| !pending.is_current(&self.session)) { + self.ui.symbology_pending = None; + } // A project replacing this one hasn't been saved in the browser yet (opening a file // names it again). if r.is_ok() && matches!(id, "project.new" | "project.sample" | "project.open" | "project.openText" | "project.fromTemplate") { @@ -1578,7 +1615,7 @@ impl RustGisApp { let active = self.session.active_layer.take(); let needs = (spec.enabled)(&self.session).is_some(); self.session.active_layer = active; - if !defaults && !(needs && (spec.enabled)(&self.session).is_none()) { + if !(defaults || needs && (spec.enabled)(&self.session).is_none()) { return None; } let map = self.session.map(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index ca444c888..024ebafe7 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -3401,6 +3401,9 @@ fn pending_symbology(app: &RustGisApp, ctx: &egui::Context) -> Option = l.dataset.fields.iter().filter(|f| f.ty.is_numeric()).map(|f| f.name.clone()).collect(); + let all: Vec = l.dataset.fields.iter().map(|f| f.name.clone()).collect(); ui.label(egui::RichText::new(&l.name).font(semibold(14.0))); ui.add_space(6.0); ui.label(egui::RichText::new(tl!("Primary symbology")).size(11.5).color(t.text_dim)); @@ -3432,7 +3437,7 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { "graduated" => "graduated", _ => "proportional", }; - let sel = cur_kind == *label; + let sel = if app.ui.symbology_pending.is_some() { *code == "unique" } else { cur_kind == *label }; let (r, resp) = ui.allocate_exact_size(vec2(68.0, 56.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, sel, tl!(*label))); ui.painter().rect_filled( @@ -3462,6 +3467,14 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { let short = tl!(label).split(' ').next().unwrap_or(label); ui.painter().text(pos2(r.center().x, r.max.y - 11.0), Align2::CENTER_CENTER, short, regular(11.0), t.text); if resp.on_hover_text(tl!(*label)).clicked() && !sel { + if *code == "unique" && !all.is_empty() { + let field = l.dataset.fields.iter().find(|f| !f.ty.is_numeric()).or_else(|| l.dataset.fields.first()); + if let Some(field) = field { + app.ui.symbology_pending = Some(crate::PendingUniqueSymbology::new(&app.session, &l, field.name.clone())); + clear_pending_symbology(ui.ctx()); + } + continue; + } match app.run("layer.symbology", json!({"type": code})) { Err(error) => { let pending = PendingSymbology { @@ -3474,20 +3487,53 @@ fn symbology(app: &mut RustGisApp, ui: &mut Ui, t: &Tokens) { }; ui.data_mut(|data| data.insert_temp(egui::Id::unique(PENDING_SYMBOLOGY), pending)); } - Ok(_) => clear_pending_symbology(ui.ctx()), + Ok(_) => { + clear_pending_symbology(ui.ctx()); + app.ui.symbology_pending = None; + } } } } }); ui.add_space(8.0); + if let Some(mut pending) = app.ui.symbology_pending.clone() { + let pending_field = pending.field.clone(); + let mut selected = if all.contains(&pending_field) { pending_field.clone() } else { all.first().cloned().unwrap_or_default() }; + ui.horizontal(|ui| { + let input_label = ui.label(tl!("Field")); + egui::ComboBox::from_id_salt("sym_pending_field") + .selected_text(&selected) + .width(200.0) + .show_ui(ui, |ui| { + for field in &all { + ui.selectable_value(&mut selected, field.clone(), field); + } + }) + .response + .labelled_by(input_label.id); + let applied = ui.button(tl!("Apply")).clicked() + && match app.run("layer.symbology", json!({"type": "unique", "field": selected.clone()})) { + Ok(_) => true, + Err(error) => { + app.session.notify(error, true); + false + } + }; + if applied { + app.ui.symbology_pending = None; + } else if selected != pending_field { + pending.field = selected; + app.ui.symbology_pending = Some(pending); + } + }); + ui.add_space(6.0); + } if g == GeometryType::Polygon && !l.graphics { if ui.button(tl!("No Fill")).on_hover_text(tl!("Draw only polygon outlines, keeping their colors and widths.")).clicked() { let _ = app.run("layer.noFill", Value::Null); } ui.add_space(6.0); } - let numeric: Vec = l.dataset.fields.iter().filter(|f| f.ty.is_numeric()).map(|f| f.name.clone()).collect(); - let all: Vec = l.dataset.fields.iter().map(|f| f.name.clone()).collect(); if let Some(pending) = pending_symbology(app, ui.ctx()) { ui.label(egui::RichText::new(pending.error.as_ref()).color(ui.visuals().error_fg_color)); let fields = if pending.kind == "unique" { &all } else { &numeric }; diff --git a/crates/ui-egui/tests/smoke.rs b/crates/ui-egui/tests/smoke.rs index f520ea392..61bb5c73a 100644 --- a/crates/ui-egui/tests/smoke.rs +++ b/crates/ui-egui/tests/smoke.rs @@ -3059,6 +3059,86 @@ fn geometry_calculator_switching_back_creates_a_unique_field() { assert_eq!(active.value(0, 0).as_f64(), Some(5.0)); } +#[test] +fn unique_symbology_lets_the_user_choose_a_text_field_before_applying() { + use rustgis_ui_egui::rustgis_engine::data::{Dataset, Field, FieldType, Geometry, GeometryType, Value}; + use rustgis_ui_egui::rustgis_engine::geom::Coord; + + let mut h = harness(); + let mut ds = Dataset::new( + "Text attributes", + GeometryType::Point, + vec![Field::new("id", FieldType::Integer), Field::new("category", FieldType::Text), Field::new("other", FieldType::Text)], + ); + ds.push(Some(Geometry::Point(Coord::new(117.0, 3.0))), vec![Value::Int(1), Value::Text("village".into()), Value::Text("town".into())]); + let id = h.state_mut().session.map_mut().add_dataset(ds); + h.state_mut().session.active_layer = Some(id); + h.state_mut().run("ui.pane.symbology", json!(null)).unwrap(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Unique Values").click(); + h.step(); + assert_eq!(h.state().ui.symbology_pending.as_ref().map(|pending| pending.field.as_str()), Some("category")); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Field").click(); + h.step(); + h.step(); // Combo popups enter the accessibility tree on the following frame. + h.get_by_label("other").click(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Apply").click(); + h.step(); + let layer = h.state().session.map().layer(id).unwrap(); + assert!(matches!(layer.renderer, rustgis_ui_egui::rustgis_engine::map::Renderer::Unique { ref field, .. } if field == "other")); + assert_eq!(h.state().ui.symbology_pending, None); +} + +#[test] +fn unique_symbology_pending_choice_follows_its_context() { + for change in ["map", "project", "renderer", "undo", "external renderer", "layer"] { + let mut h = harness(); + h.state_mut().run("layer.select", json!({"layer": "Cities"})).unwrap(); + h.state_mut().run("layer.symbology", json!({"type": "unique", "field": "name"})).unwrap(); + h.state_mut().run("layer.symbology", json!({"type": "single"})).unwrap(); + // A second map deliberately has the same layer IDs and renderer as the first. + let mut other = h.state().session.map().clone(); + other.name = "Other map".into(); + h.state_mut().session.project.maps.push(other); + h.state_mut().run("ui.pane.symbology", json!(null)).unwrap(); + h.step(); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Unique Values").click(); + h.step(); + assert!(h.state().ui.symbology_pending.is_some()); + // Failed commands leave the valid choice available for retry. + assert!(h.state_mut().run("layer.symbology", json!({"type": "unique", "field": "missing field"})).is_err()); + assert!(h.state().ui.symbology_pending.is_some()); + if change != "external renderer" { + h.state_mut().ui.close_right(rustgis_ui_egui::RightPane::Symbology); + } + match change { + "map" => { + h.state_mut().run("map.activate", json!({"name": "Other map"})).unwrap(); + } + "project" => { + h.state_mut().run("project.sample", json!(null)).unwrap(); + } + "renderer" => { + h.state_mut().run("layer.symbology", json!({"type": "unique", "field": "name"})).unwrap(); + } + "undo" => { + h.state_mut().run("edit.undo", json!(null)).unwrap(); + } + "external renderer" => { + // Commands from other clients can bypass RustGisApp::run. + h.state_mut().session.run("layer.symbology", &json!({"type": "unique", "field": "name"})).unwrap(); + h.step(); + } + "layer" => { + h.state_mut().run("layer.select", json!({"layer": "Countries"})).unwrap(); + } + _ => unreachable!(), + } + assert!(h.state().ui.symbology_pending.is_none(), "pending choice survived {change}"); + } +} + #[test] fn map_and_layout_tabs_rename_from_their_menus() { let mut h = harness(); From 8e101b230ff6f57215a62b3d88519bbad798e4a8 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sat, 24 Oct 2026 21:05:15 +0200 Subject: [PATCH 119/297] feat(labels): draw labels in an installed font family, by name (#824) * feat(labels): draw labels in an installed font family, by name A label class can name any installed font family ("Liberation Sans Narrow"), as QGIS's text formats do. Labels draw in it on the map and in exports; bold and italic take the family's own faces where it has them. - render: `families` indexes the system's font folders once, from each file's name and OS/2 tables (read piecemeal, bounded; hostile files are skipped), picks the face nearest the asked weight and slope, and keeps a face's bytes once read. Labels are measured and drawn in it, with the label font for characters it lacks. - map: `LabelClass::font`, kept by name (trimmed, at most 128 characters, none when blank): a computer without the family, and the browser, draw the labels in the label font. - engine: `labels.style` takes `font` ("" is the label font); `labels.fonts` lists the installed families. - ui: the map view loads a label's family into egui when it first draws it; the Labeling pane's Text Symbol gets a Font picker with a search box, showing a family that isn't installed as such. - docs: cartography.md, Font. * fix(labels): installed fonts are indexed off the UI thread, read without holding the lock, and kept within 128 MB - families::warm_up indexes the font folders on a thread of their own when the app starts; the label pane and map view read installed_now and never wait for the walk. - A face's file is read with the lock let go, and at most 128 MB of faces is kept (was 512). - Label fonts aren't set in a frame that set the interface fonts, which they would undo. - The bold weight and the heavier-than-the-face rule are in families, shared by the map view and the export. * fix(labels): font faces read without blocking text or the first frame, widths cached, at most 200 fonts listed - family_font reads and parses a face with the PARSED lock let go, keeping the first winner. - measure_in caches named-family and serif widths as measure does, keyed by the whole face. - Drawing finds a family with families::find_ready: in the app, while the index runs, a label draws in the label font and the map is drawn again once it is done; scripts index at once. - The font list shows at most 200 families; the rest are found by typing. - A collection's file is kept by path and each face checked on its own, so a damaged face doesn't hide its siblings. * fix(labels): exports wait for the font index, a folder is walked once, and a font that isn't a name is dropped - An export (full-resolution render) resolves label fonts with families::find; frames on screen keep find_ready. - The font-folder walk keeps the real paths of the folders it walked (at most 20,000), so links back up the tree add nothing twice. - A label class's font that isn't a string reads as none instead of failing the project. * fix(labels): labels.fonts doesn't wait for the font index, a cut-off name table keeps its names, and the export contract is written down - labels.fonts answers {fonts: [], indexing: true} while the app is still indexing (families::available, shared with find_ready). - A name table cut short keeps the records before the cut. - render_frame's doc says full_res_rasters marks an export, which waits for the font index. - de_font has one doc comment. --- crates/engine/src/cmd/labels.rs | 12 + crates/engine/src/cmd/layer.rs | 8 +- crates/engine/src/tests.rs | 24 ++ crates/map/src/symbology.rs | 60 ++++ crates/render/src/families.rs | 489 ++++++++++++++++++++++++++++++ crates/render/src/layout.rs | 3 +- crates/render/src/lib.rs | 31 +- crates/render/src/report.rs | 2 +- crates/render/src/stream_tests.rs | 1 + crates/render/src/text.rs | 102 ++++++- crates/render/src/text/widths.rs | 39 ++- crates/render/src/tiles.rs | 1 + crates/ui-egui/src/labelfonts.rs | 81 +++++ crates/ui-egui/src/labelpane.rs | 57 +++- crates/ui-egui/src/lib.rs | 24 ++ crates/ui-egui/src/mapview.rs | 31 +- docs/user-guide/cartography.md | 7 + 17 files changed, 927 insertions(+), 45 deletions(-) create mode 100644 crates/render/src/families.rs create mode 100644 crates/ui-egui/src/labelfonts.rs diff --git a/crates/engine/src/cmd/labels.rs b/crates/engine/src/cmd/labels.rs index 6e3ace0bb..ac4322bfd 100644 --- a/crates/engine/src/cmd/labels.rs +++ b/crates/engine/src/cmd/labels.rs @@ -33,6 +33,18 @@ pub fn commands() -> Vec { }) .view() .params(r#"{"on"?: bool}"#), + C::new("labels.fonts", "Label Fonts", "Style › Text Symbol", |_, p| { + // The families a font picker lists, those containing `search` (in any case) if given. + // While the app is still indexing them (commands run on its UI thread), none yet, + // and `indexing` says so: asking again later lists them. + let search = ps(p, "search").map(str::to_lowercase).unwrap_or_default(); + let Some(all) = crate::render::families::available() else { return Ok(json!({"fonts": [], "indexing": true})) }; + let fonts: Vec<&str> = + all.iter().filter(|f| f.listed && f.name.to_lowercase().contains(&search)).map(|f| f.name.as_str()).collect(); + Ok(json!({"fonts": fonts})) + }) + .view() + .params(r#"{"search"?: text in the family name} → {"fonts": [the installed font families, by name], "indexing"?: true (still being indexed: none listed yet)}"#), C::new("labels.convert", "Convert To Annotation", "View › Labeling", convert) .when(need_feature_layer) .params(r#"{"layer"?, "name"?: string}"#), diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 6b93c44b2..649be122b 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -288,7 +288,13 @@ pub fn commands() -> Vec { let size = f(p, "size").map(|v| v.clamp(*LABEL_SIZE.start(), *LABEL_SIZE.end()) as f32); let color = s.get_color(p, "color")?; let (halo, bold, italic) = (b(p, "halo"), b(p, "bold"), b(p, "italic")); + // A family by name, whether or not this machine has it: the project may be drawn on + // one that does. Blank goes back to the label font. + let font = ps(p, "font").map(rustgis_map::symbology::font_name); super::labels::with_class(s, p, |c| { + if let Some(name) = font.clone() { + c.font = name; + } if let Some(v) = size { c.size = v; } @@ -307,7 +313,7 @@ pub fn commands() -> Vec { }) }) .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, "bold"?: bool, "italic"?: bool}"##), + .params(r##"{"layer"?, "class"?: name (none: the Default class, by whatever name it has), "size"?: number, "color"?: "#RRGGBB"|"#RRGGBBAA", "halo"?: bool, "bold"?: bool, "italic"?: bool, "font"?: installed family name (labels.fonts lists them; "" is the label font)}"##), 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)?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5c54d0b62..5fc6ae7e3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1712,6 +1712,30 @@ fn label_range_placement_lock_and_convert() { assert!(!s.map().layer_by_name("Countries").unwrap().labels.enabled); } +/// A label class's font is set by its family name, installed here or not (the project may be +/// drawn on a machine that has it), and cleared with a blank one; `labels.fonts` lists the +/// installed families. +#[test] +fn labels_take_a_font_by_family_name() { + let mut s = world(); + let font = |s: &Session| s.map().layer_by_name("Cities").unwrap().labels.default.font.clone(); + s.run("labels.style", &json!({"layer": "Cities", "font": " Liberation Sans Narrow "})).unwrap(); + assert_eq!(font(&s).as_deref(), Some("Liberation Sans Narrow")); + // Drawn in the label font where it isn't installed, without fail. + s.run("labels.enable", &json!({"layer": "Cities", "on": true})).unwrap(); + let v = crate::render::View { width: 400.0, height: 300.0, pixels_per_point: 1.0, camera: s.map().camera, sky: false }; + crate::render::render(s.map_mut(), &v).unwrap(); + s.run("labels.style", &json!({"layer": "Cities", "size": 12})).unwrap(); + assert!(font(&s).is_some(), "other settings leave it"); + s.run("labels.style", &json!({"layer": "Cities", "font": ""})).unwrap(); + assert_eq!(font(&s), None); + s.run("edit.undo", &json!({})).unwrap(); + assert!(font(&s).is_some(), "undoes"); + let listed = s.run("labels.fonts", &json!({"search": "zzz-no-such-family"})).unwrap(); + assert_eq!(listed["fonts"], json!([])); + assert!(s.run("labels.fonts", &json!({})).unwrap()["fonts"].is_array()); +} + /// 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). diff --git a/crates/map/src/symbology.rs b/crates/map/src/symbology.rs index 795a73db4..79f44cf70 100644 --- a/crates/map/src/symbology.rs +++ b/crates/map/src/symbology.rs @@ -1322,6 +1322,37 @@ pub struct LabelClass { /// Slanted (an italic face where the font has one). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub italic: bool, + /// The installed font family the labels are drawn in, by name ("Liberation Sans Narrow"), as + /// QGIS names a text format's; none is the label font. A machine without the family draws + /// them in the label font too. + #[serde(default, deserialize_with = "de_font", skip_serializing_if = "Option::is_none")] + pub font: Option, +} + +/// The longest font family name kept (characters). +pub const MAX_FONT_NAME: usize = 128; + +/// A label font's family name as it is kept: trimmed, at most [`MAX_FONT_NAME`] characters, and +/// none when blank. +pub fn font_name(name: &str) -> Option { + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_FONT_NAME).collect(); + let name = name.trim(); + (!name.is_empty()).then(|| name.to_string()) +} + +/// A label class's font as a project has it, kept as [`font_name`] keeps it: a name, or none for +/// anything else (a number, a list), so a damaged font doesn't make the project unreadable. +fn de_font<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum Font { + Name(String), + Other(serde::de::IgnoredAny), + } + Ok(match Option::::deserialize(d)? { + Some(Font::Name(n)) => font_name(&n), + Some(Font::Other(_)) | None => None, + }) } fn default_class_name() -> String { @@ -1465,6 +1496,7 @@ impl LabelClass { farthest: None, placement: LabelPlacement::Auto, italic: false, + font: None, } } @@ -1590,6 +1622,34 @@ mod tests { assert_eq!(serde_json::from_value::(v).unwrap(), with); } + /// A label font is kept by its family name, trimmed and bounded (a project file is hostile + /// input); a blank one is the label font, and isn't written. + #[test] + fn a_label_font_is_kept_by_name() { + let read = |font: serde_json::Value| { + serde_json::from_value::(serde_json::json!({ + "enabled": true, "field": "name", "size": 9.0, "color": [0, 0, 0, 255], "halo": false, "bold": false, "font": font + })) + .unwrap() + }; + let l = read(" Liberation Sans Narrow \n".into()); + assert_eq!(l.default.font.as_deref(), Some("Liberation Sans Narrow")); + assert_eq!(serde_json::to_value(&l).unwrap()["font"], "Liberation Sans Narrow"); + // Not a name (a number, a list, an object): no font, and the rest of the labels still read. + for blank in [ + serde_json::json!(" "), + serde_json::Value::Null, + serde_json::json!(7), + serde_json::json!(["Arial"]), + serde_json::json!({"a": 1}), + ] { + let l = read(blank); + assert_eq!(l.default.font, None); + assert!(serde_json::to_value(&l).unwrap().get("font").is_none()); + } + assert_eq!(read("x".repeat(10_000).into()).default.font.map(|f| f.chars().count()), Some(MAX_FONT_NAME)); + } + /// A project file is read with at most `MAX_LABEL_CLASSES` classes, and only that many count /// in a `Labeling` built with more. A class on /// a field the data doesn't have is pointed at Default's, and kept. diff --git a/crates/render/src/families.rs b/crates/render/src/families.rs new file mode 100644 index 000000000..91e9b9d4b --- /dev/null +++ b/crates/render/src/families.rs @@ -0,0 +1,489 @@ +//! Installed font families by name, so a label can be drawn in the family a project names (as +//! QGIS's text formats name theirs). The system's font folders are indexed once, from each +//! file's `name` and `OS/2` tables, read piecemeal so indexing never loads a whole file; a face +//! is read when it is first drawn, and kept. Nothing is installed or bundled: a family a machine +//! lacks is drawn in the label font. In the browser, and with `RUSTGIS_BUILTIN_FONTS` set (so +//! tests draw alike on every machine), there are none. + +use std::collections::{HashMap, HashSet}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; + +/// One face of an installed family: its file, its index in a collection, its weight (100–900) +/// and whether it is an italic (or oblique) face. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstalledFace { + pub path: PathBuf, + pub index: u32, + pub weight: u16, + pub italic: bool, +} + +/// An installed family: its name, and its faces. A face is listed under its typographic family +/// ("Noto Sans") and under its legacy one ("Noto Sans Light") when they differ, so either name +/// finds it; only the first kind is `listed` in a font picker. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Family { + pub name: String, + pub listed: bool, + pub faces: Vec, +} + +/// The longest family name kept (characters): longer ones are from broken files. +pub const MAX_NAME: usize = 128; +/// Most font files indexed, folders walked deep and faces in one collection. +const MAX_FILES: usize = 50_000; +const MAX_DEPTH: usize = 8; +/// Most folders walked. +const MAX_DIRS: usize = 20_000; +const MAX_COLLECTION: u32 = 64; +/// Most tables a face's directory lists, and the most of its `name` table read. +const MAX_TABLES: u16 = 512; +const MAX_NAME_TABLE: u32 = 1 << 20; +/// Largest font file read, and the most face bytes kept in all: a few families' worth, or two or +/// three CJK collections (30–48 MB each). Kept bytes are never freed. +const MAX_FONT: u64 = 48 << 20; +const MAX_LOADED: usize = 128 << 20; + +/// The weight bold text is drawn at in a variable face whose default is lighter (a variable +/// Noto Sans is one file for every weight); a static face ignores it. +pub const BOLD_WEIGHT: f32 = 700.0; + +/// Whether bold text in `face` is drawn heavier than the face is, at [`BOLD_WEIGHT`]: the face +/// standing in for the bold one is lighter than semibold (a variable face, or a family with no +/// bold). The map view and the export must agree on this, so both ask here. +pub fn heavier(face: &InstalledFace, bold: bool) -> bool { + bold && face.weight < 600 +} + +static FAMILIES: OnceLock> = OnceLock::new(); +/// Whether [`warm_up`] has started indexing them. +static WARMING: AtomicBool = AtomicBool::new(false); + +/// The installed families, indexed once, by name (in any case). The first call walks the font +/// folders; the UI starts that early with [`warm_up`] and reads [`installed_now`]. +pub fn installed() -> &'static [Family] { + FAMILIES.get_or_init(|| if std::env::var_os("RUSTGIS_BUILTIN_FONTS").is_some() { Vec::new() } else { index(&font_dirs()) }) +} + +/// The installed families if they are indexed, without waiting: `None` while [`warm_up`]'s +/// indexing runs (a machine can have tens of thousands of font files). +pub fn installed_now() -> Option<&'static [Family]> { + FAMILIES.get().map(Vec::as_slice) +} + +/// Indexes the installed families on a thread of their own (once), so the UI never waits for +/// the folder walk; in the browser, where there are none, at once. +pub fn warm_up() { + if WARMING.swap(true, Ordering::Relaxed) { + return; + } + #[cfg(not(target_arch = "wasm32"))] + if std::thread::Builder::new() + .name("font-index".into()) + .spawn(|| { + installed(); + }) + .is_ok() + { + return; + } + installed(); +} + +/// The installed family named `name` (in any case, spaces trimmed): its place in [`installed`]. +pub fn find(name: &str) -> Option { + find_in(installed(), name) +} + +/// [`find`] for drawing. Where [`warm_up`] is indexing the families (the app, which draws the +/// map on its UI thread), `None` until the index is done rather than waiting for it, so a label +/// draws in the label font for a moment; elsewhere (a script, the command line, which never warm +/// up) the families are indexed now, so an export draws in the named family. +pub fn find_ready(name: &str) -> Option { + find_in(available()?, name) +} + +/// The installed families without waiting for [`warm_up`]'s index: `None` while it runs (the +/// app); where nothing warmed up (a script, the command line), indexed now. +pub fn available() -> Option<&'static [Family]> { + if WARMING.load(Ordering::Relaxed) { installed_now() } else { Some(installed()) } +} + +fn find_in(families: &[Family], name: &str) -> Option { + let name = name.trim(); + if name.is_empty() { + return None; + } + let i = families.binary_search_by(|f| cmp_names(&f.name, name)).ok()?; + u16::try_from(i).ok() +} + +/// Names compared as the index sorts them: in any case. +fn cmp_names(a: &str, b: &str) -> std::cmp::Ordering { + a.chars().flat_map(char::to_lowercase).cmp(b.chars().flat_map(char::to_lowercase)) +} + +/// The face of `family` that draws `bold` and `italic` text, and whether it has to be slanted by +/// hand (an italic asked of a family with no italic face): the nearest weight to 400, or 700 for +/// bold, among the faces of the right slope, else among the upright ones. +pub fn pick(family: &Family, bold: bool, italic: bool) -> Option<(&InstalledFace, bool)> { + let target: i32 = if bold { 700 } else { 400 }; + let best = |slope: bool| { + family.faces.iter().filter(|f| f.italic == slope).min_by_key(|f| { + let w = i32::from(f.weight); + // At a tie, a regular takes the lighter face and a bold the heavier, as CSS does. + ((w - target).abs(), if bold { w < target } else { w > target }) + }) + }; + match best(italic) { + Some(f) => Some((f, false)), + None => best(!italic).map(|f| (f, italic && !f.italic)), + } +} + +/// The bytes of `face`'s file, read once and kept for the rest of the run (faces are drawn from +/// borrowed bytes); `None` when it can't be read, is too large, or `face` isn't a font in it. A +/// collection's file is kept by its path and each face checked on its own, so one damaged face +/// doesn't hide its siblings. +pub fn bytes(face: &InstalledFace) -> Option<&'static [u8]> { + static LOADED: OnceLock>, usize)>> = OnceLock::new(); + let parses = |b: &[u8], index: u32| ab_glyph::FontRef::try_from_slice_and_index(b, index).is_ok(); + let lock = LOADED.get_or_init(|| Mutex::new((HashMap::new(), 0))); + let room = { + let Ok(loaded) = lock.lock() else { return None }; + if let Some(b) = loaded.0.get(&face.path) { + return b.filter(|b| parses(b, face.index)); + } + MAX_LOADED.saturating_sub(loaded.1) + }; + // Read with the lock let go: a large file takes a while, and other threads' faces are kept. + // Kept when this face or the file's first one is a font (a collection's other faces may be). + let read = std::fs::metadata(&face.path) + .ok() + .filter(|m| m.len() <= MAX_FONT && m.len() as usize <= room) + .and_then(|_| std::fs::read(&face.path).ok()) + .filter(|b| parses(b, face.index) || parses(b, 0)); + let Ok(mut loaded) = lock.lock() else { return None }; + // Another thread read it meanwhile: its copy is the one kept. + if let Some(b) = loaded.0.get(&face.path) { + return b.filter(|b| parses(b, face.index)); + } + let kept: Option<&'static [u8]> = read.filter(|b| loaded.1 + b.len() <= MAX_LOADED).map(|b| { + loaded.1 += b.len(); + &*Box::leak(b.into_boxed_slice()) + }); + loaded.0.insert(face.path.clone(), kept); + kept.filter(|b| parses(b, face.index)) +} + +/// The folders fonts are installed in on this system, the user's own included. +fn font_dirs() -> Vec { + let home = std::env::var_os("HOME").map(PathBuf::from); + let mut dirs: Vec = Vec::new(); + if cfg!(target_os = "windows") { + let windir = std::env::var_os("WINDIR").map_or_else(|| PathBuf::from("C:\\Windows"), PathBuf::from); + dirs.push(windir.join("Fonts")); + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + dirs.push(PathBuf::from(local).join("Microsoft").join("Windows").join("Fonts")); + } + } else if cfg!(target_os = "macos") { + dirs.extend(["/System/Library/Fonts", "/Library/Fonts"].map(PathBuf::from)); + dirs.extend(home.iter().map(|h| h.join("Library").join("Fonts"))); + } else { + dirs.extend(["/usr/share/fonts", "/usr/local/share/fonts"].map(PathBuf::from)); + match std::env::var_os("XDG_DATA_HOME") { + Some(d) => dirs.push(PathBuf::from(d).join("fonts")), + None => dirs.extend(home.iter().map(|h| h.join(".local").join("share").join("fonts"))), + } + dirs.extend(home.iter().map(|h| h.join(".fonts"))); + } + dirs +} + +/// The families of the font files under `dirs`, sorted by name. +fn index(dirs: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + let mut seen = HashSet::new(); + for d in dirs { + walk(d, 0, &mut files, &mut seen); + } + // In name order, so the same file wins a duplicate face on every machine. + files.sort(); + files.dedup(); + let mut by_name: HashMap = HashMap::new(); + for path in files { + for (index, face) in read_faces(&path).into_iter().enumerate() { + let Some(face) = face else { continue }; + let Ok(index) = u32::try_from(index) else { continue }; + let installed = InstalledFace { path: path.clone(), index, weight: face.weight, italic: face.italic }; + for (n, name) in face.names.iter().enumerate() { + let fam = + by_name.entry(name.to_lowercase()).or_insert_with(|| Family { name: name.clone(), listed: false, faces: Vec::new() }); + fam.listed |= n == 0; + // One face per weight and slope: the first file's. + if !fam.faces.iter().any(|f| f.weight == installed.weight && f.italic == installed.italic) { + fam.faces.push(installed.clone()); + } + } + } + } + let mut families: Vec = by_name.into_values().collect(); + families.sort_by(|a, b| cmp_names(&a.name, &b.name)); + families +} + +/// The font files under `dir`, at most [`MAX_DEPTH`] folders deep, until there are +/// [`MAX_FILES`]. A folder is walked once (`seen`, by its real path, at most [`MAX_DIRS`]), so a +/// link back up the tree, or two links to one folder, add nothing twice. +fn walk(dir: &Path, depth: usize, out: &mut Vec, seen: &mut HashSet) { + if depth > MAX_DEPTH || out.len() >= MAX_FILES || seen.len() >= MAX_DIRS { + return; + } + if !seen.insert(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf())) { + return; + } + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for e in entries.flatten() { + if out.len() >= MAX_FILES { + return; + } + let path = e.path(); + // (A link to a folder is followed as the folder, within the limits.) + if path.is_dir() { + walk(&path, depth + 1, out, seen); + } else if path + .extension() + .and_then(|x| x.to_str()) + .is_some_and(|x| ["ttf", "otf", "ttc", "otc"].iter().any(|k| x.eq_ignore_ascii_case(k))) + { + out.push(path); + } + } +} + +/// What indexing reads of a face: its family names (the typographic one first), weight and +/// slope. +#[derive(Debug, PartialEq, Eq)] +struct FaceInfo { + names: Vec, + weight: u16, + italic: bool, +} + +/// The faces of the font file at `path` (one, or a collection's), each `None` where it can't be +/// read. +fn read_faces(path: &Path) -> Vec> { + let Ok(mut file) = std::fs::File::open(path) else { return Vec::new() }; + faces_of(&mut file) +} + +/// The faces of a font file (a reader over it): see [`read_faces`]. Only the table directories, +/// the `name` table and the start of `OS/2` are read. +fn faces_of(r: &mut (impl Read + Seek)) -> Vec> { + let Some(head) = read_at(r, 0, 12) else { return Vec::new() }; + let starts: Vec = if head.get(0..4) == Some(b"ttcf") { + let n = u32_at(&head, 8).unwrap_or(0).min(MAX_COLLECTION); + let Some(offsets) = read_at(r, 12, n * 4) else { return Vec::new() }; + (0..n).filter_map(|i| u32_at(&offsets, i as usize * 4)).collect() + } else { + vec![0] + }; + starts.into_iter().map(|start| face_info(r, start)).collect() +} + +fn face_info(r: &mut (impl Read + Seek), start: u32) -> Option { + let dir = read_at(r, start, 12)?; + let n = u16_at(&dir, 4)?.min(MAX_TABLES); + let records = read_at(r, start.checked_add(12)?, u32::from(n) * 16)?; + let table = |tag: &[u8; 4]| { + records.as_chunks::<16>().0.iter().find(|t| t.get(0..4) == Some(tag)).and_then(|t| Some((u32_at(t, 8)?, u32_at(t, 12)?))) + }; + let (name_at, name_len) = table(b"name")?; + let names = read_at(r, name_at, name_len.min(MAX_NAME_TABLE))?; + let read_name = |id: u16| name(&names, id); + let family = read_name(1)?; + let typographic = read_name(16); + let style = read_name(17).or_else(|| read_name(2)).unwrap_or_default().to_lowercase(); + let (weight, selection) = match table(b"OS/2") { + Some((at, len)) => { + let os2 = read_at(r, at, len.min(64))?; + (u16_at(&os2, 4).unwrap_or(400), u16_at(&os2, 62).unwrap_or(0)) + } + None => (400, 0), + }; + // fsSelection's ITALIC or OBLIQUE bit, or a style that says so. + let italic = selection & 0x0201 != 0 || style.contains("italic") || style.contains("oblique"); + let mut names = Vec::with_capacity(2); + names.extend(typographic); + if !names.iter().any(|n: &String| n.eq_ignore_ascii_case(&family)) { + names.push(family); + } + Some(FaceInfo { names, weight: weight.clamp(1, 1000), italic }) +} + +/// Name `id` of a `name` table, in English where it has it: a Windows (UTF-16) record first, then +/// a Unicode one, then a Macintosh Roman one. Control characters are dropped; `None` when it is +/// missing or blank. +fn name(table: &[u8], id: u16) -> Option { + let count = u16_at(table, 2)?; + let strings = usize::from(u16_at(table, 4)?); + let mut best: Option<(u8, String)> = None; + for i in 0..usize::from(count) { + // A table cut short keeps the records before the cut (and what they matched). + let Some(rec) = table.get(6 + i * 12..6 + i * 12 + 12) else { break }; + let (platform, lang, name_id) = (u16_at(rec, 0)?, u16_at(rec, 4)?, u16_at(rec, 6)?); + if name_id != id { + continue; + } + let (len, at) = (usize::from(u16_at(rec, 8)?), usize::from(u16_at(rec, 10)?)); + let Some(raw) = table.get(strings + at..strings + at + len) else { continue }; + let (rank, text) = match platform { + 3 | 0 => { + let units: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect(); + let english = platform == 0 || lang == 0x0409; + (if english { 0 } else { 2 }, String::from_utf16_lossy(&units)) + } + // Macintosh Roman: its ASCII half is all a family name needs. + 1 => (if lang == 0 { 1 } else { 3 }, raw.iter().map(|b| if b.is_ascii() { char::from(*b) } else { '?' }).collect()), + _ => continue, + }; + let text: String = text.chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); + let text = text.trim().to_string(); + if !text.is_empty() && best.as_ref().is_none_or(|(r, _)| rank < *r) { + best = Some((rank, text)); + } + } + best.map(|(_, t)| t) +} + +/// `len` bytes from `at` (`None` when the file ends before them). +fn read_at(r: &mut (impl Read + Seek), at: u32, len: u32) -> Option> { + r.seek(SeekFrom::Start(u64::from(at))).ok()?; + let mut buf = Vec::new(); + r.take(u64::from(len)).read_to_end(&mut buf).ok()?; + (buf.len() == len as usize).then_some(buf) +} + +fn u16_at(b: &[u8], i: usize) -> Option { + Some(u16::from_be_bytes(b.get(i..i + 2)?.try_into().ok()?)) +} + +fn u32_at(b: &[u8], i: usize) -> Option { + Some(u32::from_be_bytes(b.get(i..i + 4)?.try_into().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_installed_families_are_read_without_waiting_once_indexed() { + // No warm-up here (it would make every test in this process draw without waiting), so + // drawing finds a family as a command does: the families are indexed at once. + let all = installed(); + assert_eq!(installed_now().map(<[Family]>::len), Some(all.len())); + if let Some(f) = all.iter().find(|f| f.listed) { + assert_eq!(find_ready(&f.name), find(&f.name)); + } + assert!(find_ready("No Such Family").is_none()); + } + + #[test] + fn a_font_file_is_indexed_by_its_family_names_weight_and_slope() { + // egui's own faces, written into a folder as installed fonts would be. + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("ubuntu"); + std::fs::create_dir(&sub).unwrap(); + std::fs::write(sub.join("Ubuntu-Light.ttf"), epaint_default_fonts::UBUNTU_LIGHT).unwrap(); + std::fs::write(dir.path().join("Hack-Regular.TTF"), epaint_default_fonts::HACK_REGULAR).unwrap(); + std::fs::write(dir.path().join("broken.otf"), b"OTTO\x00\x05garbage").unwrap(); + std::fs::write(dir.path().join("notes.txt"), b"not a font").unwrap(); + let fams = index(&[dir.path().to_path_buf()]); + let names: Vec<&str> = fams.iter().map(|f| f.name.as_str()).collect(); + assert!(names.contains(&"Hack"), "{names:?}"); + let ubuntu = fams.iter().find(|f| f.name.to_lowercase().starts_with("ubuntu")).expect("ubuntu indexed"); + assert_eq!(ubuntu.faces.len(), 1); + assert!(ubuntu.faces[0].weight < 400 && !ubuntu.faces[0].italic, "{ubuntu:?}"); + // Found in any case; the face it draws in, its bytes read once. + let i = find_in(&fams, &ubuntu.name.to_uppercase()).unwrap(); + let fam = &fams[i as usize]; + let (face, slanted) = pick(fam, false, true).unwrap(); + assert!(slanted, "no italic face: slanted by hand"); + // A light face standing in for bold is drawn heavier, by the same rule everywhere. + assert!(heavier(face, true) && !heavier(face, false)); + assert!(!heavier(&InstalledFace { weight: 700, ..face.clone() }, true)); + // A face the file hasn't (a damaged face of a collection) is none, and doesn't hide the + // file's good one. + assert!(bytes(&InstalledFace { index: 5, ..face.clone() }).is_none()); + let b = bytes(face).unwrap(); + assert!(std::ptr::eq(b, bytes(face).unwrap()), "kept, not read again"); + assert!(find_in(&fams, "No Such Family").is_none() && find_in(&fams, " ").is_none()); + } + + #[cfg(unix)] + #[test] + fn a_folder_is_walked_once_however_many_links_reach_it() { + // Links back up the tree, four to a folder: followed blindly, they'd fan out to 4^8 folders + // and list the one font thousands of times. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Hack-Regular.ttf"), epaint_default_fonts::HACK_REGULAR).unwrap(); + for i in 0..4 { + std::os::unix::fs::symlink(dir.path(), dir.path().join(format!("up{i}"))).unwrap(); + } + let mut files = Vec::new(); + let mut seen = HashSet::new(); + walk(dir.path(), 0, &mut files, &mut seen); + assert_eq!((files.len(), seen.len()), (1, 1), "{files:?}"); + } + + #[test] + fn a_name_table_cut_short_keeps_the_names_before_the_cut() { + // Two records listed, the second cut off: the first's family name still reads. + let text: Vec = "Sans".encode_utf16().flat_map(u16::to_be_bytes).collect(); + let strings = 6 + 12 + 4; + let mut table = vec![0, 0, 0, 2]; + table.extend((strings as u16).to_be_bytes()); + for v in [3u16, 1, 0x0409, 1, text.len() as u16, 0] { + table.extend(v.to_be_bytes()); + } + table.extend([0, 3, 0, 1]); + table.extend(&text); + assert_eq!(name(&table, 1).as_deref(), Some("Sans")); + } + + #[test] + fn a_family_draws_in_its_nearest_face() { + let face = |weight, italic| InstalledFace { path: PathBuf::from(format!("{weight}{italic}")), index: 0, weight, italic }; + let fam = + Family { name: "F".into(), listed: true, faces: vec![face(300, false), face(500, false), face(800, false), face(400, true)] }; + let w = |bold, italic| pick(&fam, bold, italic).map(|(f, s)| (f.weight, f.italic, s)).unwrap(); + // 300 and 500 are as near 400: the regular takes the lighter. + assert_eq!(w(false, false), (300, false, false)); + assert_eq!(w(true, false), (800, false, false)); + assert_eq!(w(false, true), (400, true, false)); + assert_eq!(w(true, true), (400, true, false), "the only italic, not a slanted bold"); + let upright = Family { faces: vec![face(400, false)], ..fam.clone() }; + assert_eq!(pick(&upright, false, true).map(|(f, s)| (f.weight, s)), Some((400, true))); + assert!(pick(&Family { faces: Vec::new(), ..fam }, false, false).is_none()); + } + + #[test] + fn hostile_font_files_are_skipped() { + use std::io::Cursor; + // Truncated headers, a collection claiming millions of faces, tables past the end. + let mut ttc = b"ttcf\x00\x01\x00\x00\xff\xff\xff\xff".to_vec(); + ttc.extend([0u8; 16]); + let mut dir = b"\x00\x01\x00\x00\xff\xff\x00\x00\x00\x00\x00\x00".to_vec(); + dir.extend(b"name\x00\x00\x00\x00\xff\xff\xff\xf0\x7f\xff\xff\xff"); + for bytes in [&b""[..], b"\x00\x01", &ttc, &dir] { + let faces = faces_of(&mut Cursor::new(bytes.to_vec())); + assert!(faces.iter().all(Option::is_none), "{faces:?}"); + } + // A name table whose records point past its end. + let table = [0u8, 0, 0, 1, 0, 18, 0, 3, 0, 1, 4, 9, 0, 1, 0xff, 0xff, 0, 0]; + assert_eq!(name(&table, 1), None); + } +} diff --git a/crates/render/src/layout.rs b/crates/render/src/layout.rs index 52626570a..db47502d3 100644 --- a/crates/render/src/layout.rs +++ b/crates/render/src/layout.rs @@ -203,7 +203,8 @@ pub fn render(layout: &Layout, maps: &mut [Map], dpi: f64, info: &PageInfo) -> O ElementKind::Text { text: t, size, color, bold, italic, align, letter_spacing, font } => { let first = layout.first_frame().and_then(frame); let s = expand(t, layout, info, first.and_then(|f| maps.get(f.1).map(|m| (m, f.2)))); - let face = text::Face { bold: *bold, italic: *italic, serif: *font == rustgis_map::layout::FontFamily::Serif }; + let face = + text::Face { bold: *bold, italic: *italic, serif: *font == rustgis_map::layout::FontFamily::Serif, family: None }; let style = TextStyle { face, align: *align, spacing: *letter_spacing as f32 }; text_block(&mut pm, r, &s, *size as f32, *color, style, ppp); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index adb204a84..30efde401 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod effects; +pub mod families; pub mod fonts; pub mod gpu_scene; pub mod layout; @@ -187,6 +188,9 @@ pub struct PlacedLabel { /// upright with no spacing. pub italic: bool, pub spacing: f32, + /// The installed family it is drawn in, by its place in [`families::installed`] (a label + /// class's font); `None` is the label font. + pub font: Option, /// How the lines line up in their block (a text graphic's, #535); the block itself sits where /// `align` puts it. pub line_align: TextAlign, @@ -2531,7 +2535,10 @@ fn render_turned(map: &mut Map, view: &View, full_res_rasters: bool) -> Option) -> Option { let (w, h) = view.pixel_size(); let mut pm = Pixmap::new(w, h)?; @@ -3086,6 +3093,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac framed: selection.contains(&row), italic: rustgis_map::graphics::italic(ds_ptr, row), spacing: rustgis_map::graphics::letter_spacing(ds_ptr, row), + font: None, line_align: rustgis_map::graphics::align(ds_ptr, row), angle: None, collide: Default::default(), @@ -3103,6 +3111,10 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac }; if label_fields.iter().any(Option::is_some) { let label_factor = layer.size_factor(label_scale); + // Each class's font, where it is installed. An export (`full_res_rasters`: see + // `render_frame`'s contract) waits for the font index; a frame on screen doesn't. + let find = if full_res_rasters { families::find } else { families::find_ready }; + let class_fonts: Vec> = labels.in_order().map(|c| c.font.as_deref().and_then(find)).collect(); let classes = rustgis_map::LabelMatcher::new(labels, ds_ptr); let lid = layer.id; // Whether a class labels lines along them: those take the stretch of a line in @@ -3158,8 +3170,10 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac // Placed as at its own size, then that much farther out: the gap between // the label and its feature grows with it too. let size = class.size * label_factor; + let font = class_fonts.get(ci).copied().flatten(); + let face = text::Face { bold: class.bold, italic: class.italic, serif: false, family: font }; let (dx, dy) = class.placement.offset( - text::block_width(&text, class.size, class.bold), + text::block_width(&text, class.size, face), text::block_height(&text, class.size), marker, is_point, @@ -3175,13 +3189,13 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac let curved = class.placement == LabelPlacement::Curved && text.graphemes(true).nth(line_labels::MAX_CURVED_CHARS).is_none(); if !curved { - let width = text::block_width(&text, size, class.bold); + let width = text::block_width(&text, size, face); let Some((cx, cy, a)) = line_labels::parallel(line, width, overrun) else { continue }; (lx, ly, angle) = (cx, cy, Some(a)); } else { // Each character with its accents, drawn as one. let chars: Vec = text.graphemes(true).map(String::from).collect(); - let advances: Vec = chars.iter().map(|c| text::measure(c, size, class.bold)).collect(); + let advances: Vec = chars.iter().map(|c| text::measure_in(c, size, face)).collect(); let Some(((cx, cy), at)) = line_labels::curved(line, &advances, overrun) else { continue }; (lx, ly) = (cx, cy); glyphs = @@ -3206,6 +3220,7 @@ fn render_frame(map: &mut Map, view: &View, full_res_rasters: bool, backend: Bac framed: false, italic: class.italic, spacing: 0.0, + font, line_align: TextAlign::Left, angle, collide: Default::default(), @@ -3911,7 +3926,7 @@ fn turn_with(l: &mut PlacedLabel, at: &impl Fn(f32, f32) -> Option<(f32, f32)>) /// as far from that end as it was from the start, by its own width rather than that of the /// character whose place it takes, a step along the nearest one's angle from it. fn read_back(l: &mut PlacedLabel) { - let widths: Vec = l.glyphs.iter().map(|g| text::measure(&g.text, l.size, l.bold)).collect(); + let widths: Vec = l.glyphs.iter().map(|g| text::measure_in(&g.text, l.size, text::label_face(l))).collect(); let total: f32 = widths.iter().sum(); // How far along the text each character's centre was. let mut centres = Vec::with_capacity(widths.len()); @@ -4171,6 +4186,7 @@ pub(crate) fn draw_attribution(pm: &mut Pixmap, attribution: &[String], ppp: f32 framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), @@ -4196,6 +4212,7 @@ mod tests { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), @@ -4710,7 +4727,7 @@ mod tests { let (_, alone) = ink(&at("Atlantic", false)); assert!(cols.first().zip(alone.first()).is_some_and(|(a, b)| a.abs_diff(*b) <= 2), "the longer line keeps its place"); // As wide as the widest line, as tall as its lines; a trailing newline is an empty line. - assert_eq!(text::block_width("ab\ncdef", 10.0, false), text::measure("cdef", 10.0, false)); + assert_eq!(text::block_width("ab\ncdef", 10.0, text::Face::default()), text::measure("cdef", 10.0, false)); // Text drawn on one line (a table cell, a chart's name) is still measured whole, so it is // cut to its column as it is drawn. assert_eq!(text::measure("ab\ncdef", 10.0, false), text::measure("abcdef", 10.0, false)); @@ -5996,6 +6013,7 @@ mod tests { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), @@ -6035,6 +6053,7 @@ mod tests { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), diff --git a/crates/render/src/report.rs b/crates/render/src/report.rs index ed3b0b249..febf110ca 100644 --- a/crates/render/src/report.rs +++ b/crates/render/src/report.rs @@ -55,7 +55,7 @@ pub fn width_mm(s: &str, pt: f32, bold: bool) -> f64 { /// [`width_mm`] in the serif a layout draws a `"font": "serif"` text in (the sans where no serif is /// installed): the ink's width, which the line has to hold. pub fn serif_width_mm(s: &str, pt: f32, bold: bool) -> f64 { - let (lo, hi) = text::styled_extent(s, pt * 1.33, text::Face { bold, italic: false, serif: true }, 0.0); + let (lo, hi) = text::styled_extent(s, pt * 1.33, text::Face { bold, italic: false, serif: true, family: None }, 0.0); f64::from((hi - lo).max(0.0)) * 25.4 / 96.0 } diff --git a/crates/render/src/stream_tests.rs b/crates/render/src/stream_tests.rs index 2b5d26176..13d6e993a 100644 --- a/crates/render/src/stream_tests.rs +++ b/crates/render/src/stream_tests.rs @@ -546,6 +546,7 @@ fn chunked_label_compaction_keeps_late_priorities_stable_ties_and_nan_order() { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 810667cd4..e61291b74 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -37,6 +37,9 @@ pub struct Face { pub italic: bool, /// The installed serif; the sans where there is none. pub serif: bool, + /// An installed family, by its place in [`crate::families::installed`] (a label's named + /// font); the serif or sans where it can't be drawn. + pub family: Option, } /// The installed serif's faces, parsed once: (regular, bold, italic, bold italic). @@ -81,7 +84,38 @@ fn face_in(face: Face, serif: Option<&'static SerifFaces>) -> Option<(&'static F } fn face_font(face: Face) -> Option<(&'static FontRef<'static>, bool)> { - face_in(face, serif()) + let named = face.family.and_then(|i| crate::families::installed().get(usize::from(i))); + named.and_then(|fam| family_font(fam, face)).or_else(|| face_in(face, serif())) +} + +/// The face of an installed family that draws `face`'s weight and slope, parsed once and kept, +/// and whether it is slanted by hand (the family has no italic face): see +/// [`crate::families::pick`]. `None` when its file can't be read. +fn family_font(fam: &crate::families::Family, face: Face) -> Option<(&'static FontRef<'static>, bool)> { + type Parsed = std::collections::HashMap<(std::path::PathBuf, u32, bool), Option<&'static FontRef<'static>>>; + static PARSED: OnceLock> = OnceLock::new(); + let (installed, slanted) = crate::families::pick(fam, face.bold, face.italic)?; + let heavier = crate::families::heavier(installed, face.bold); + let key = (installed.path.clone(), installed.index, heavier); + let lock = PARSED.get_or_init(Default::default); + if let Some(f) = lock.lock().ok()?.get(&key) { + return f.map(|f| (f, slanted)); + } + // Read and parsed with the lock let go: a file can be 48 MB, and other text is drawn meanwhile. + let read = crate::families::bytes(installed).and_then(|b| FontRef::try_from_slice_and_index(b, installed.index).ok()); + let mut parsed = lock.lock().ok()?; + // Another thread parsed it meanwhile: its face is the one kept. + if let Some(f) = parsed.get(&key) { + return f.map(|f| (f, slanted)); + } + let f = read.map(|mut f| { + if heavier { + f.set_variation(b"wght", crate::families::BOLD_WEIGHT); + } + &*Box::leak(Box::new(f)) + }); + parsed.insert(key, f); + f.map(|f| (f, slanted)) } /// The label font of `face`'s weight, for the characters `f` lacks: `None` when `f` is it. @@ -220,6 +254,23 @@ fn measure_uncached(text: &str, size: f32, bold: bool) -> f32 { shape(text, size * 1.33, f, None).1 } +/// [`measure`] in `face`: a label in a named family is as wide as that family draws it. +pub fn measure_in(text: &str, size: f32, face: Face) -> f32 { + if face.family.is_none() && !face.serif { + return measure(text, size, face.bold); + } + // Labels in a named family are measured as often as those in the label font (every feature, + // every frame), so they share its retention, keyed by the whole face. + static WIDTHS: OnceLock)>> = OnceLock::new(); + let key = (face.bold, face.italic, face.serif, face.family); + WIDTHS.get_or_init(widths::Cache::default).measure(text, size, key, || { + let Some((f, _)) = face_font(face) else { + return text.chars().count() as f32 * size * 0.6; + }; + shape(text, size * 1.33, f, sans_beside(f, face)).1 + }) +} + /// How wide `text` is on one line at `size` with `spacing` points between letters, in points. fn spaced_width(text: &str, size: f32, bold: bool, spacing: f32) -> f32 { let w = measure(text, size, bold); @@ -292,8 +343,8 @@ pub fn wrap(line: &str, size: f32, bold: bool, spacing: f32, max_w: f32) -> Vec< /// A label's width in points: its widest line, as [`draw`] lays its lines out (#577). Text drawn /// on one line (table cells, chart names…) is measured with [`measure`]. -pub fn block_width(text: &str, size: f32, bold: bool) -> f32 { - lines(text).map(|line| measure(line, size, bold)).fold(0.0, f32::max) +pub fn block_width(text: &str, size: f32, face: Face) -> f32 { + lines(text).map(|line| measure_in(line, size, face)).fold(0.0, f32::max) } /// The distance between a label's lines in points, as [`draw`] sets them: 1.25 × the font's @@ -308,22 +359,28 @@ pub fn block_height(text: &str, size: f32) -> f32 { size * 1.25 + line_count(text).saturating_sub(1) as f32 * line_pitch(size) } -/// Whether a label is slanted or letter-spaced (a text graphic's, #535), drawn with -/// [`styled_at`]. +/// Whether a label is slanted or letter-spaced (a text graphic's, #535), or in a named family, +/// drawn with [`styled_at`]. fn styled(l: &PlacedLabel) -> bool { - l.italic || l.spacing != 0.0 + l.italic || l.spacing != 0.0 || l.font.is_some() +} + +/// The face a label is drawn in. +pub fn label_face(l: &PlacedLabel) -> Face { + Face { bold: l.bold, italic: l.italic, serif: false, family: l.font } } /// How wide a line of `l` is laid out, in pixels at `ppp`: its advance, and its letter spacing /// between letters (not past the last), as the map view lays it out (#535), so a text is placed /// alike on screen and in an export. A slant's overhang isn't counted, as it isn't there either. fn line_width(l: &PlacedLabel, line: &str, ppp: f32) -> f32 { - let advance = measure(line, l.size, l.bold) * ppp; + let face = label_face(l); + let advance = measure_in(line, l.size, face) * ppp; if l.spacing == 0.0 { return advance; } - let letters = match font(l.bold) { - Some(f) => shape(line, l.size * 1.33 * ppp, f, None).0.len(), + let letters = match face_font(face) { + Some((f, _)) => shape(line, l.size * 1.33 * ppp, f, sans_beside(f, face)).0.len(), None => line.chars().count(), }; // Letters drawn tighter than their own width (the most negative spacing) don't make it less @@ -521,7 +578,7 @@ fn draw_upright(pm: &mut Pixmap, l: &PlacedLabel, ppp: f32, ink: Ink) { let (styled, spacing) = (styled(l), l.spacing * 1.33 * ppp); let put = |pm: &mut Pixmap, &(line, left, lw, y): &(&str, f32, f32, f32), color: Rgba, (dx, dy): (f32, f32)| { if styled { - styled_at(pm, line, left + dx, y + dy, px, color, Face { bold: l.bold, italic: l.italic, serif: false }, spacing); + styled_at(pm, line, left + dx, y + dy, px, color, label_face(l), spacing); } else { draw_text(pm, line, left + lw / 2.0, y, px, color, l.bold, (dx, dy)); } @@ -593,6 +650,7 @@ pub fn scale_bar(pm: &mut Pixmap, view: &View, ground_per_pt: f64) { framed: false, italic: false, spacing: 0.0, + font: None, line_align: TextAlign::Left, angle: None, collide: Default::default(), @@ -694,7 +752,7 @@ mod coverage_tests { use super::{Face, face_in, font}; for bold in [false, true] { for italic in [false, true] { - let (f, slant) = face_in(Face { bold, italic, serif: true }, None).unwrap(); + let (f, slant) = face_in(Face { bold, italic, serif: true, family: None }, None).unwrap(); assert!(std::ptr::eq(f, font(bold).unwrap()) && slant == italic); } } @@ -711,7 +769,7 @@ mod coverage_tests { let faces: &'static SerifFaces = Box::leak(Box::new((parse(epaint_default_fonts::HACK_REGULAR), None, Some(parse(epaint_default_fonts::UBUNTU_LIGHT)), None))); let (regular, italic) = (&faces.0, faces.2.as_ref().unwrap()); - let pick = |bold, it| face_in(Face { bold, italic: it, serif: true }, Some(faces)).unwrap(); + let pick = |bold, it| face_in(Face { bold, italic: it, serif: true, family: None }, Some(faces)).unwrap(); let is = |(f, slant): (&FontRef, bool), g: &FontRef, s: bool| std::ptr::eq(f, g) && slant == s; assert!(is(pick(false, false), regular, false)); assert!(is(pick(false, true), italic, false), "the real italic, not slanted"); @@ -731,6 +789,26 @@ mod coverage_tests { assert!(!leans(Face { serif: true, ..Face::default() }, regular, false, sans), "upright text stays upright"); } + /// A label in an installed family is drawn in its face, parsed once; a family that isn't + /// installed is drawn in the label font. + #[test] + fn a_label_is_drawn_in_its_installed_family() { + use super::{Face, family_font, measure, measure_in, shape}; + use crate::families::{Family, InstalledFace}; + // Hack, a monospaced face, as an installed family: every letter as wide. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Hack-Regular.ttf"); + std::fs::write(&path, epaint_default_fonts::HACK_REGULAR).unwrap(); + let fam = Family { name: "Hack".into(), listed: true, faces: vec![InstalledFace { path, index: 0, weight: 400, italic: false }] }; + let (f, slanted) = family_font(&fam, Face::default()).unwrap(); + assert_eq!(shape("iiii", 20.0, f, None).1, shape("MMMM", 20.0, f, None).1); + assert!(std::ptr::eq(f, family_font(&fam, Face::default()).unwrap().0), "parsed once"); + assert!(!slanted && family_font(&fam, Face { italic: true, ..Face::default() }).unwrap().1, "no italic face: slanted"); + // A family that isn't installed measures and draws as the label font. + let missing = Face { family: Some(u16::MAX), ..Face::default() }; + assert_eq!(measure_in("Winterthur", 12.0, missing), measure("Winterthur", 12.0, false)); + } + /// With a serif installed (Georgia on Windows, Times on macOS, Noto or DejaVu Serif on Linux), /// a text asking for it is drawn in it, and italic in the family's italic face, not slanted /// by hand; the sans is untouched. diff --git a/crates/render/src/text/widths.rs b/crates/render/src/text/widths.rs index 8b92172f8..b787fdf17 100644 --- a/crates/render/src/text/widths.rs +++ b/crates/render/src/text/widths.rs @@ -11,35 +11,41 @@ const SHARD_BYTES: usize = 128 * 1024; const MAX_TEXT_BYTES: usize = 4096; /// The full key accompanies its hash so a collision can only cause a miss, never a wrong width. -struct Entry { +/// `S` is the face measured in: whether it's bold, or a label's whole face. +struct Entry { text: String, size: u32, - bold: bool, + bold: S, width: f32, } /// One shard's entries and owned string capacities; an eviction drops both allocations. -#[derive(Default)] -struct Widths { - entries: HashMap, +struct Widths { + entries: HashMap>, strings: usize, } -impl Widths { +impl Default for Widths { + fn default() -> Self { + Widths { entries: HashMap::new(), strings: 0 } + } +} + +impl Widths { /// Checks the complete measurement key, keeping signed zero and distinct finite sizes apart. - fn get(&self, hash: u64, text: &str, size: u32, bold: bool) -> Option { + fn get(&self, hash: u64, text: &str, size: u32, bold: S) -> Option { self.entries.get(&hash).filter(|e| e.text == text && e.size == size && e.bold == bold).map(|e| e.width) } /// Estimates retained strings and table capacity with slack for buckets, using saturation. /// This is a retention policy, not a strict allocator or process-memory bound. fn retained_bytes(&self) -> usize { - self.strings.saturating_add(self.entries.capacity().saturating_mul(2 * std::mem::size_of::<(u64, Entry)>())) + self.strings.saturating_add(self.entries.capacity().saturating_mul(2 * std::mem::size_of::<(u64, Entry)>())) } /// Replaces a colliding entry and accounts for its old string before checking the budget. /// Over-budget shards release their capacity rather than retaining an empty large table. - fn insert(&mut self, hash: u64, text: &str, size: u32, bold: bool, width: f32) { + fn insert(&mut self, hash: u64, text: &str, size: u32, bold: S, width: f32) { let text = text.to_owned(); self.strings = self.strings.saturating_add(text.capacity()); if let Some(old) = self.entries.insert(hash, Entry { text, size, bold, width }) { @@ -53,16 +59,21 @@ impl Widths { /// Sixteen independent shards retain at most 16 × the per-shard estimate after insertion. /// Misses measure without a lock, so concurrent misses may repeat work but cannot mix results. -#[derive(Default)] -pub(super) struct Cache { - shards: [Mutex; 16], +pub(super) struct Cache { + shards: [Mutex>; 16], +} + +impl Default for Cache { + fn default() -> Self { + Cache { shards: std::array::from_fn(|_| Mutex::default()) } + } } -impl Cache { +impl Cache { /// Returns a matching width or calls `uncached` once, preserving its exact floating-point result. /// Oversized text and non-finite sizes bypass hashing, locking and retention. Poisoned locks /// are recovered: entries are only installed after the caller has measured successfully. - pub(super) fn measure(&self, text: &str, size: f32, bold: bool, uncached: impl FnOnce() -> f32) -> f32 { + pub(super) fn measure(&self, text: &str, size: f32, bold: S, uncached: impl FnOnce() -> f32) -> f32 { if text.len() > MAX_TEXT_BYTES || !size.is_finite() { return uncached(); } diff --git a/crates/render/src/tiles.rs b/crates/render/src/tiles.rs index 66e2d7755..fa1b03047 100644 --- a/crates/render/src/tiles.rs +++ b/crates/render/src/tiles.rs @@ -78,6 +78,7 @@ pub(crate) fn tile_label(l: rustgis_tiles::Label) -> Option<(PlacedLabel, f64)> framed: false, italic: l.italic, spacing, + font: None, line_align, angle: l.angle, collide: l.collide, diff --git a/crates/ui-egui/src/labelfonts.rs b/crates/ui-egui/src/labelfonts.rs new file mode 100644 index 000000000..4e55b9263 --- /dev/null +++ b/crates/ui-egui/src/labelfonts.rs @@ -0,0 +1,81 @@ +//! Label fonts in the map view: a label class's installed family (`LabelClass::font`), loaded into +//! egui when a label first asks for it, as the export draws it. The face is the one the renderer +//! measured the label in (`families::pick`), so a label sits alike on screen and in an export. +//! Until egui has it (fonts set during a frame take effect on the next), the label draws in the +//! interface font; a later reinstall of the interface fonts drops it, and it is loaded again. + +use std::collections::HashMap; +use std::sync::Arc; + +use egui::{FontData, FontFamily}; +use rustgis_engine::render::families; + +/// Fonts asked for, by egui family name: the pass each was set in, or `None` when its file +/// couldn't be read (not asked for again). +#[derive(Clone, Default)] +struct Requests(HashMap>); + +/// Fonts asked for this pass, not yet set. +#[derive(Clone, Default)] +struct Pending(Vec<(String, &'static [u8], u32, bool)>); + +/// The egui family a label in installed family `font` draws in, `bold` and `italic`, and whether +/// egui slants it (the family has no italic face). `None` for the interface font: no family, one +/// not installed, or one not loaded yet (asked for here, so a later frame has it). +pub(crate) fn family(ctx: &egui::Context, font: Option, bold: bool, italic: bool) -> Option<(FontFamily, bool)> { + let fam = families::installed_now()?.get(usize::from(font?))?; + let (face, slanted) = families::pick(fam, bold, italic)?; + let heavier = families::heavier(face, bold); + let key = format!("label-font:{}#{}{}", face.path.display(), face.index, if heavier { ":bold" } else { "" }); + let family = FontFamily::Name(key.as_str().into()); + if ctx.fonts(|f| f.definitions().families.contains_key(&family)) { + return Some((family, slanted)); + } + let id = egui::Id::unique("label-fonts"); + let pass = ctx.cumulative_pass_nr(); + let asked = ctx.data_mut(|d| d.get_temp_mut_or_default::(id).0.get(&key).copied()); + match asked { + // Its file couldn't be read. + Some(None) => return None, + // Set a pass ago: it takes effect on the next. Set before that and gone: the interface + // fonts were installed over it. + Some(Some(p)) if pass <= p + 1 => return None, + _ => {} + } + let bytes = families::bytes(face); + ctx.data_mut(|d| { + d.get_temp_mut_or_default::(id).0.insert(key.clone(), bytes.map(|_| pass)); + if let Some(b) = bytes { + d.get_temp_mut_or_default::(id).0.push((key, b, face.index, heavier)); + } + }); + // A frame to set it, and one more drawn in it. + ctx.request_repaint(); + None +} + +/// Sets the label fonts the last pass asked for, in one go, each with the interface fonts after it +/// for the characters it lacks. Called once a frame, before the labels are drawn and after the +/// interface fonts are installed. +pub(crate) fn install_pending(ctx: &egui::Context) { + let id = egui::Id::unique("label-fonts"); + let pending = ctx.data_mut(|d| std::mem::take(&mut d.get_temp_mut_or_default::(id).0)); + if pending.is_empty() { + return; + } + let mut defs = ctx.fonts(|f| f.definitions().clone()); + let fallback: Vec = defs.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); + for (key, bytes, index, heavier) in pending { + let mut data = FontData::from_static(bytes); + data.index = index; + if heavier { + let weight = egui::epaint::text::VariationCoords::new([(b"wght", families::BOLD_WEIGHT)]); + data = data.tweak(egui::FontTweak { coords: weight, ..Default::default() }); + } + defs.font_data.insert(key.clone(), Arc::new(data)); + let mut list = vec![key.clone()]; + list.extend(fallback.iter().cloned()); + defs.families.insert(FontFamily::Name(key.as_str().into()), list); + } + ctx.set_fonts(defs); +} diff --git a/crates/ui-egui/src/labelpane.rs b/crates/ui-egui/src/labelpane.rs index ea82c1bf6..d82bfbd50 100644 --- a/crates/ui-egui/src/labelpane.rs +++ b/crates/ui-egui/src/labelpane.rs @@ -6,6 +6,7 @@ use egui::{RichText, Ui, vec2}; use rustgis_engine::map::symbology::{DEFAULT_LABEL_CLASS, LABEL_SIZE, MAX_LABEL_CLASSES}; use rustgis_engine::map::{LabelPlacement, Layer, Rgba}; +use rustgis_engine::render::families; use serde_json::{Value, json}; use crate::RustGisApp; @@ -162,6 +163,8 @@ fn sql(app: &mut RustGisApp, ui: &mut Ui, l: &Layer, t: &Tokens) { fn text_symbol(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { section(ui, "Text Symbol", |ui| { grid(ui, "symbol", |ui| { + font(app, ui, l); + ui.end_row(); let input_label = ui.label(tl!("Size")); // The whole range `labels.style` accepts (#457); the map follows a drag and letting // go runs the command once, so a drag is one undo step. @@ -208,6 +211,58 @@ fn text_symbol(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { }); } +/// The most installed families the font list shows at once (a machine can have thousands); the +/// rest are found by typing part of a name. +const MAX_FONTS_SHOWN: usize = 200; + +/// The installed family the labels are drawn in: the label font, or one picked from the +/// installed families (found by typing part of the name). A family this machine lacks, from a +/// project made on another, is shown as missing and drawn in the label font. +fn font(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { + let input_label = ui.label(tl!("Font")); + // The installed fonts are indexed on a thread of their own; until then there's none to pick. + let Some(installed) = families::installed_now() else { + families::warm_up(); + ui.add_enabled(false, egui::Button::new(tl!("Reading the installed fonts…"))).labelled_by(input_label.id); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); + return; + }; + let cur = l.labels.default.font.as_deref(); + let shown = match cur { + None => tl!("Label font").to_string(), + Some(name) if families::find(name).is_some() => name.to_string(), + Some(name) => crate::i18n::fmt("{name} (not installed)", &[("name", name)]), + }; + let search_id = egui::Id::unique(("label_pane_font_search", l.id)); + egui::ComboBox::from_id_salt("label_pane_font") + .selected_text(shown) + .width(180.0) + .height(320.0) + .show_ui(ui, |ui| { + let mut search = ui.data_mut(|d| d.get_temp::(search_id)).unwrap_or_default(); + if ui.add(egui::TextEdit::singleline(&mut search).hint_text(tl!("Search fonts"))).changed() { + ui.data_mut(|d| d.insert_temp(search_id, search.clone())); + } + if ui.selectable_label(cur.is_none(), tl!("Label font")).clicked() { + run(app, "labels.style", json!({"font": ""})); + } + let search = search.to_lowercase(); + let mut matching = installed.iter().filter(|f| f.listed && (search.is_empty() || f.name.to_lowercase().contains(&search))); + for f in matching.by_ref().take(MAX_FONTS_SHOWN) { + let on = cur.is_some_and(|c| c.eq_ignore_ascii_case(&f.name)); + if ui.selectable_label(on, &f.name).clicked() && !on { + run(app, "labels.style", json!({"font": f.name})); + } + } + let more = matching.count(); + if more > 0 { + ui.weak(crate::i18n::fmt("{count} more: type part of a name", &[("count", &more.to_string())])); + } + }) + .response + .labelled_by(input_label.id); +} + fn position(app: &mut RustGisApp, ui: &mut Ui, l: &Layer) { section(ui, "Position", |ui| { let cur = l.labels.default.placement; @@ -424,7 +479,7 @@ mod tests { use egui::accesskit::Role; let mut h = harness(with_cities()); h.run(); - for label in ["Field", "Out Beyond", "In Beyond", "Closest", "Farthest"] { + for label in ["Field", "Font", "Out Beyond", "In Beyond", "Closest", "Farthest"] { assert!(h.query_by_role_and_label(Role::ComboBox, label).is_some(), "missing input: {label}"); } assert!(h.query_by_role_and_label(Role::SpinButton, "Size").is_some()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0f30e5b93..905c3c8a4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,7 @@ mod globe_drape; mod graphicformat; pub mod icons; mod keymap; +mod labelfonts; mod labelpane; mod layoutview; #[cfg(not(target_arch = "wasm32"))] @@ -836,6 +837,8 @@ pub struct RustGisApp { fonts_failed: Vec, /// Fonts were installed this frame: `set_fonts` only takes effect on the next one. fonts_pending: bool, + /// Whether the installed font families were indexed by a frame (see `families::warm_up`). + label_fonts_indexed: bool, /// The language the interface is drawn in. It trails the chosen one until a font for its /// script is active, so switching to Chinese never draws a frame of missing-glyph boxes. shown_lang: i18n::Lang, @@ -890,6 +893,8 @@ impl RustGisApp { } pub fn new(session: Session) -> Self { + // The installed fonts a label can be drawn in, indexed while the app starts. + rustgis_engine::render::families::warm_up(); let mut s = session; if s.active_layer.is_none() { s.active_layer = s.map().layers.first().map(|l| l.id); @@ -940,6 +945,7 @@ impl RustGisApp { fonts_requested: Vec::new(), fonts_failed: Vec::new(), fonts_pending: false, + label_fonts_indexed: false, shown_lang: i18n::Lang::EN, seen_project: None, } @@ -3439,6 +3445,24 @@ impl RustGisApp { self.fonts_pending = true; self.map.invalidate(); } + // The label fonts the last frame's labels asked for (after the interface fonts, which + // would replace them). Not in a frame that set the interface fonts: those take effect on + // the next, and setting fonts now, from the definitions in effect, would undo them. + if self.fonts_pending { + ctx.request_repaint(); + } else { + labelfonts::install_pending(ctx); + } + // Labels in a named family draw in the label font until the installed fonts are + // indexed (`families::warm_up`); then the map is drawn again in theirs. + if !self.label_fonts_indexed { + if rustgis_engine::render::families::installed_now().is_some() { + self.label_fonts_indexed = true; + self.map.invalidate(); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + } // Files from the async picker or browser drops. let arrived: Vec<(String, Vec)> = match &self.services.inbox { Some(inbox) => std::mem::take(&mut *inbox.lock().unwrap_or_else(|e| e.into_inner())), diff --git a/crates/ui-egui/src/mapview.rs b/crates/ui-egui/src/mapview.rs index 4cf746a4a..48c8aa564 100644 --- a/crates/ui-egui/src/mapview.rs +++ b/crates/ui-egui/src/mapview.rs @@ -1171,15 +1171,26 @@ fn tex_rect(app: &RustGisApp, cam: Camera, rect: Rect, globe_view: bool) -> Rect Rect::from_min_max(to_screen(0.0, 0.0), to_screen(tw, th)) } -/// Map text laid out as the map view draws it: its font, and a text graphic's style (#535), its -/// lines aligned in their block, slanted (egui's own slant, as the fonts have no italic face) -/// and letter-spaced. The one layout for drawing a label and for picking or dragging a text -/// graphic, so they agree. -fn label_job(text: &str, size: f32, bold: bool, italic: bool, spacing: f32, align: TextAlign) -> egui::text::LayoutJob { - let font_id = if bold { semibold(size * 1.33) } else { regular(size * 1.33) }; +/// Map text laid out as the map view draws it: its font (a label class's installed `family`, as +/// [`crate::labelfonts::family`] gives it, and whether egui slants it), and a text graphic's +/// style (#535), its lines aligned in their block, slanted (egui's own slant, as the interface +/// fonts have no italic face) and letter-spaced. The one layout for drawing a label and for +/// picking or dragging a text graphic, so they agree. +fn label_job( + text: &str, + size: f32, + bold: bool, + italic: bool, + spacing: f32, + align: TextAlign, + family: Option<(egui::FontFamily, bool)>, +) -> egui::text::LayoutJob { + let (font_id, italics) = match family { + Some((family, slanted)) => (egui::FontId::new(size * 1.33, family), slanted), + None => (if bold { semibold(size * 1.33) } else { regular(size * 1.33) }, italic), + }; // Letter spacing in points, scaled as the size is. - let format = - egui::TextFormat { font_id, color: Color32::WHITE, italics: italic, extra_letter_spacing: spacing * 1.33, ..Default::default() }; + let format = egui::TextFormat { font_id, color: Color32::WHITE, italics, extra_letter_spacing: spacing * 1.33, ..Default::default() }; let mut job = egui::text::LayoutJob::single_section(text.to_owned(), format); job.halign = match align { TextAlign::Left => egui::Align::LEFT, @@ -1216,7 +1227,8 @@ fn draw_label(p: &egui::Painter, l: &PlacedLabel, at: Pos2) { /// Draws a label (not a curved one) at `at`: its halo when `halo`, its text when `text`. fn draw_label_ink(p: &egui::Painter, l: &PlacedLabel, at: Pos2, halo: bool, text: bool) { - let galley = p.layout_job(label_job(&l.text, l.size, l.bold, l.italic, l.spacing, l.line_align)); + let family = crate::labelfonts::family(p.ctx(), l.font, l.bold, l.italic); + let galley = p.layout_job(label_job(&l.text, l.size, l.bold, l.italic, l.spacing, l.line_align, family)); // Text set beside an icon is lined up by its edge, with egui's own measure of its width. let anchor = if l.align < -0.25 { Align2::LEFT_CENTER @@ -1336,6 +1348,7 @@ fn text_shown( graphics::italic(ds, row), graphics::letter_spacing(ds, row), graphics::align(ds, row), + None, ); Some((at, job)) } diff --git a/docs/user-guide/cartography.md b/docs/user-guide/cartography.md index 4244d4082..59407176e 100644 --- a/docs/user-guide/cartography.md +++ b/docs/user-guide/cartography.md @@ -247,6 +247,13 @@ Turn labels on per layer (View › Labeling › Label, or the Style tab), then: - **Label field and text**: the field the labels show (`labels.field={"layer": "Cities", "field": "name"}`), and the text symbol's size (4–72 points), colour, halo, bold and italic (`labels.style={"layer": "Cities", "size": 12, "color": "#5A1E0A", "halo": true}`). +- **Font**: the labels' font family, any installed on the computer (Labeling pane › Text 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 + its name, so a project keeps it on a computer that lacks it, drawing its labels in the label + font there (the pane shows it as not installed), and in the browser, which reads no installed + fonts (`labels.style={"layer": "Rivers", "font": "Liberation Serif"}`; `labels.fonts` lists the + installed families, `{"font": ""}` goes back to the label font). - **SQL**: label only the features that match a SQL clause (the language of definition queries), such as `pop_max > 5000000`. In the Labeling pane, Class › **SQL** › **New expression**: write the clause (the field and operator buttons add to it), **Check** says From f5215c2a43ec063948a980db2dd79d66cf2d0b57 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Sat, 24 Oct 2026 21:08:00 +0200 Subject: [PATCH 120/297] feat(map): open QGIS projects as maps and apply QGIS layer styles (#825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(map): open QGIS projects as maps and apply QGIS layer styles `project.importQgis` opens a QGIS project (.qgs, or a .qgz archive) as a new map: its layers in the layer tree's order, from their files (relative paths from the project's folder), CSV points, XYZ tiles or WMS, hidden where unchecked; the project's coordinate system and saved view (its layers' extent when that shows none of them); a blank basemap and no graticule. `style.importQgis` gives a layer a .qml style. One undo step each (`Session::run_part` runs the commands an import is made of). What carries over (`rustgis_map::qgis`, reading both the