From ae81a58fde5cb314c5b5d64dbc447e35f6f429f5 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 30 Sep 2026 15:25:21 +0800 Subject: [PATCH 1/3] Draw maps over OpenStreetMap tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points on a bare graticule said where, but not where that is. A map chart now lays OpenStreetMap raster tiles under its points, in the Web Mercator projection the points already use, at the zoom closest to the view's scale. The fetching follows the OSM tile policy: an identifying User-Agent, two downloads at a time, newest view first, tiles kept on disk for a week and reused, stale ones drawn when the network is gone, and the attribution on the map. Decoded tiles are capped and evicted least recently drawn, and released from the GPU atlas as they go. Offline, or with the `map_tiles` setting `off`, the map draws as before. `map_tile_url` points it at another {z}/{x}/{y} server. This is the first thing the app fetches on its own; the README now says what leaves the machine — the area being viewed, never a row. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 2 +- README.zh-CN.md | 2 +- src/ui/geo.rs | 85 ++++++++-- src/ui/mod.rs | 1 + src/ui/tiles.rs | 408 ++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 489 insertions(+), 13 deletions(-) create mode 100644 src/ui/tiles.rs diff --git a/Cargo.lock b/Cargo.lock index a83da54..051ab3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2240,6 +2240,7 @@ dependencies = [ "gpui-kit", "gpui-shell", "hmac 0.12.1", + "image", "lsp-server", "lsp-types", "quick-xml 0.36.2", diff --git a/Cargo.toml b/Cargo.toml index 0297eef..b5ac9f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,9 @@ hmac = "0.12" sha2 = "0.10" quick-xml = { version = "0.36", features = ["serialize"] } unicode-width = "0.2" +# Map tiles: the same `image` GPUI decodes with, only the formats tile +# servers send. +image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } [dev-dependencies] rust_xlsxwriter = "0.99" diff --git a/README.md b/README.md index 42eca69..65d82d0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ducklocal warehouse.duckdb # or an existing DuckDB database Each CSV, TSV, Parquet, JSON, or Excel file becomes queryable as the window opens. You can also drag files or folders onto the window, or pick them from the file dialog — and they stay registered, so the next launch starts with the same workspace. One open request attaches at most 256 files. -Your data stays on your machine: nothing is uploaded, and there is no account. +Your data stays on your machine: nothing is uploaded, and there is no account. The one thing the app fetches on its own is the OpenStreetMap base map under a map chart — the tile requests reveal the area being viewed, never your rows. ## Features diff --git a/README.zh-CN.md b/README.zh-CN.md index b707360..55f3b27 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,7 +19,7 @@ ducklocal warehouse.duckdb # 或已有的 DuckDB 数据库 每个 CSV / TSV / Parquet / JSON / Excel 文件在窗口打开时即可查询。也可以把文件或文件夹拖进窗口,或用文件对话框选择;它们会被记住,下次启动直接回到同一个工作区。一次打开请求最多挂载 256 个文件。 -数据只留在这台机器上:不上传、不需要账号。 +数据只留在这台机器上:不上传、不需要账号。应用唯一会主动联网获取的是地图图表下方的 OpenStreetMap 底图——瓦片请求会暴露正在查看的区域,但绝不包含你的数据行。 ## 功能 diff --git a/src/ui/geo.rs b/src/ui/geo.rs index 80eed8e..0c67e3e 100644 --- a/src/ui/geo.rs +++ b/src/ui/geo.rs @@ -1,10 +1,12 @@ //! Map mode of the chart tab: a result with a latitude and a longitude column //! plots each row as a point on a Web Mercator projection, fitted to the -//! points' extent, over a graticule labelled in degrees. +//! points' extent, over OpenStreetMap tiles and a graticule labelled in +//! degrees. //! -//! There is no base map: tiles would need the network, and an embedded -//! coastline would say nothing at the city scale most station, store or -//! sensor tables live at. The points themselves draw the shape. When a low +//! The base map comes from the network (`tiles`); an embedded coastline would +//! say nothing at the city scale most station, store or sensor tables live +//! at. Offline, or with tiles turned off, the points draw the shape on their +//! own over the graticule. When a low //! cardinality text column is present (a `type`, a `country`), points are //! colored by it, so the map says something beyond "where". //! @@ -433,6 +435,16 @@ fn fold_category(column: usize, sorted: Vec<(String, usize)>) -> Category { } } +/// A latitude's projected ordinate, for the tile layer's tests. +#[cfg(test)] +pub(crate) fn project_lat(lat: f64) -> f64 { + mercator_y(lat) +} + +/// Most tiles a map asks for at once; a bigger plot drops a zoom level +/// rather than filling the policy's queue with one view. +const MAX_TILES: usize = 48; + fn mercator_y(lat: f64) -> f64 { let phi = lat.clamp(-MAX_MERCATOR_LAT, MAX_MERCATOR_LAT).to_radians(); (FRAC_PI_4 + phi / 2.).tan().ln().to_degrees() @@ -579,6 +591,8 @@ impl Plot for GeoPlot { theme.background, ); + let dark = theme.mode.is_dark(); + // The plot area reads as a map sheet: a faint fill and a frame. window.paint_quad(quad( area, @@ -589,8 +603,40 @@ impl Plot for GeoPlot { BorderStyle::default(), )); + // The base map, when tiles are on and have arrived. Whatever has not + // arrived yet leaves the sheet showing through. + let base_map = crate::ui::tiles::enabled(cx); + let mut drew_tiles = false; + if base_map { + let mut z = crate::ui::tiles::zoom_for(view.scale); + let mut wanted = crate::ui::tiles::covering(view.visible(), z); + while wanted.len() > MAX_TILES && z > 0 { + z -= 1; + wanted = crate::ui::tiles::covering(view.visible(), z); + } + for (key, image) in crate::ui::tiles::visible(&wanted, window, cx) { + let (x0, x1, y0, y1) = crate::ui::tiles::extent_of(key); + let (left, top) = view.project(x0, y1); + let (right, bottom) = view.project(x1, y0); + let tile = Bounds::from_corners( + bounds.origin + point(px(left), px(top)), + bounds.origin + point(px(right), px(bottom)), + ); + drew_tiles |= window + .paint_image(area, tile, Corners::all(px(4.)), image, 0, false) + .is_ok(); + } + // Tiles are drawn for daylight; in the dark theme a veil of the + // background keeps them from glaring behind the points. + if drew_tiles && dark { + window.paint_quad(fill(area, background.opacity(0.35)).corner_radii(px(4.))); + } + } + let theme = cx.theme(); + // Graticule: meridians at round longitudes, parallels at round - // latitudes (unevenly spaced, as Mercator spaces them). + // latitudes (unevenly spaced, as Mercator spaces them). Over a base + // map the streets are the reference; only the labels stay. let (vx0, vx1, vy0, vy1) = view.visible(); let lng_step = nice_step(vx1 - vx0, view.area.size.width as f64); let (lat0, lat1) = (inverse_mercator_y(vy0), inverse_mercator_y(vy1)); @@ -601,12 +647,14 @@ impl Plot for GeoPlot { let parallels: Vec<(f32, f64)> = steps_within(lat0.max(-85.), lat1.min(85.), lat_step) .map(|lat| (view.project(0., mercator_y(lat)).1, lat)) .collect(); - Grid::new() - .x(meridians.iter().map(|(x, _)| px(x - view.area.origin.x))) - .y(parallels.iter().map(|(y, _)| px(y - view.area.origin.y))) - .stroke(grid) - .dash_array(&[px(4.), px(2.)]) - .paint(&area, window); + if !drew_tiles { + Grid::new() + .x(meridians.iter().map(|(x, _)| px(x - view.area.origin.x))) + .y(parallels.iter().map(|(y, _)| px(y - view.area.origin.y))) + .stroke(grid) + .dash_array(&[px(4.), px(2.)]) + .paint(&area, window); + } let mut labels: Vec = meridians .iter() @@ -627,6 +675,21 @@ impl Plot for GeoPlot { ) .align(TextAlign::Right) })); + // The tile policy asks for the credit on the map itself, whenever its + // tiles are shown. + if drew_tiles { + labels.push( + Text::new( + crate::ui::tiles::ATTRIBUTION, + point( + px(view.area.origin.x + view.area.size.width - 6.), + px(view.area.origin.y + view.area.size.height - TEXT_SIZE - 6.), + ), + if dark { theme.foreground } else { muted }, + ) + .align(TextAlign::Right), + ); + } PlotLabel::new(labels).paint(&bounds, window, cx); // One quad per point, ringed in the background color so overlapping diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d472956..027db1d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,6 +8,7 @@ pub mod scale; pub mod setup_dialog; pub mod sidebar; pub mod status_bar; +pub mod tiles; pub mod title_bar; pub mod workspace; diff --git a/src/ui/tiles.rs b/src/ui/tiles.rs new file mode 100644 index 0000000..a25d2e2 --- /dev/null +++ b/src/ui/tiles.rs @@ -0,0 +1,408 @@ +//! The base map under a map chart: OpenStreetMap raster tiles, fetched on +//! demand, kept on disk, and drawn in the same Web Mercator projection the +//! points use. +//! +//! This is the one place the app reaches the network on its own. What leaves +//! the machine is which tiles are wanted — roughly the area and zoom being +//! looked at — never a row of data. `map_tiles = off` in the settings turns it +//! off, and the map falls back to its graticule, as it does offline. +//! +//! The OpenStreetMap Foundation's tile policy is what shapes the fetching: an +//! identifying User-Agent, at most two requests at a time, tiles cached for a +//! week and reused, never a bulk prefetch, and the attribution on the map. +//! `map_tile_url` points at another `{z}/{x}/{y}` server for heavier use. + +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant, SystemTime}; + +use gpui_kit::*; +use image::Frame; + +pub(crate) const ATTRIBUTION: &str = "© OpenStreetMap contributors"; +const DEFAULT_TEMPLATE: &str = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; +/// Tiles are 256 px squares at every zoom. +pub(crate) const TILE_SIZE: f64 = 256.; +/// The deepest zoom the default server renders. +pub(crate) const MAX_ZOOM: u8 = 19; +/// The policy's limit on parallel downloads. +const MAX_IN_FLIGHT: usize = 2; +/// Requests waiting beyond this are ones the view has already moved past. +const MAX_QUEUED: usize = 64; +/// Decoded tiles kept for drawing: 256 × 256 × 4 bytes each, so ~50 MB. +const MAX_READY: usize = 200; +/// How long a tile on disk is used without asking again: the policy's floor. +const FRESH_FOR: Duration = Duration::from_secs(7 * 24 * 60 * 60); +/// How long a failed tile waits before it is tried again. +const RETRY_AFTER: Duration = Duration::from_secs(30); +const MAX_TILE_BYTES: u64 = 2 * 1024 * 1024; + +/// One tile of the square Web Mercator world: `2^z` tiles a side at zoom `z`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct TileKey { + pub z: u8, + pub x: u32, + pub y: u32, +} + +enum Entry { + Queued, + Loading, + Ready { image: Arc, used: u64 }, + Failed(Instant), +} + +struct Config { + enabled: bool, + template: String, +} + +impl Config { + fn load() -> Self { + let setting = |key| crate::history::get_setting(key).ok().flatten(); + let enabled = setting("map_tiles").is_none_or(|value| value != "off"); + let template = setting("map_tile_url") + .filter(|url| url.contains("{z}") && url.contains("{x}") && url.contains("{y}")) + .unwrap_or_else(|| DEFAULT_TEMPLATE.to_string()); + Self { enabled, template } + } +} + +#[derive(Default)] +struct Tiles { + config: Option, + entries: HashMap, + queue: VecDeque, + in_flight: usize, + /// Bumped once per `visible` call, so eviction knows what was drawn last. + frame: u64, +} + +impl Global for Tiles {} + +/// Whether the base map is on: it is, unless the `map_tiles` setting is `off`. +pub(crate) fn enabled(cx: &mut App) -> bool { + let tiles = cx.default_global::(); + tiles.config.get_or_insert_with(Config::load).enabled +} + +/// The tiles among `wanted` that are ready to draw. The rest are asked for; +/// the windows repaint as each arrives. +pub(crate) fn visible( + wanted: &[TileKey], + window: &mut Window, + cx: &mut App, +) -> Vec<(TileKey, Arc)> { + let now = Instant::now(); + let tiles = cx.default_global::(); + tiles.config.get_or_insert_with(Config::load); + tiles.frame += 1; + let frame = tiles.frame; + let mut ready = Vec::new(); + for key in wanted { + match tiles.entries.get_mut(key) { + Some(Entry::Ready { image, used }) => { + *used = frame; + ready.push((*key, image.clone())); + } + Some(Entry::Queued | Entry::Loading) => {} + Some(Entry::Failed(at)) if now.duration_since(*at) < RETRY_AFTER => {} + _ => { + tiles.entries.insert(*key, Entry::Queued); + tiles.queue.push_back(*key); + } + } + } + // The oldest requests are for views already scrolled or resized away. + while tiles.queue.len() > MAX_QUEUED { + if let Some(stale) = tiles.queue.pop_front() { + tiles.entries.remove(&stale); + } + } + let evicted = evict(tiles, frame); + for image in evicted { + cx.drop_image(image, Some(window)); + } + pump(cx); + ready +} + +/// Drop the least recently drawn tiles past `MAX_READY`, never one drawn in +/// this frame. Returns their images, for the atlas to release. +fn evict(tiles: &mut Tiles, frame: u64) -> Vec> { + let mut ready: Vec<(u64, TileKey)> = tiles + .entries + .iter() + .filter_map(|(key, entry)| match entry { + Entry::Ready { used, .. } if *used != frame => Some((*used, *key)), + _ => None, + }) + .collect(); + let total = ready.len() + + tiles + .entries + .values() + .filter(|entry| matches!(entry, Entry::Ready { used, .. } if *used == frame)) + .count(); + if total <= MAX_READY { + return Vec::new(); + } + ready.sort_unstable_by_key(|(used, _)| *used); + ready + .into_iter() + .take(total - MAX_READY) + .filter_map(|(_, key)| match tiles.entries.remove(&key) { + Some(Entry::Ready { image, .. }) => Some(image), + _ => None, + }) + .collect() +} + +/// Start downloads while there is room: the newest request first, since it +/// is the view on screen now. +fn pump(cx: &mut App) { + loop { + let tiles = cx.global_mut::(); + if tiles.in_flight >= MAX_IN_FLIGHT { + return; + } + let Some(key) = tiles.queue.pop_back() else { + return; + }; + tiles.entries.insert(key, Entry::Loading); + tiles.in_flight += 1; + let template = tiles + .config + .as_ref() + .map_or(DEFAULT_TEMPLATE, |config| config.template.as_str()) + .to_string(); + let task = cx + .background_executor() + .spawn(async move { load(&template, key) }); + cx.spawn(async move |cx| { + let result = task.await; + cx.update(|cx| { + let tiles = cx.global_mut::(); + tiles.in_flight -= 1; + let frame = tiles.frame; + match result { + Ok(image) => { + tiles + .entries + .insert(key, Entry::Ready { image, used: frame }); + } + Err(error) => { + tracing::debug!("map tile {key:?}: {error:#}"); + tiles.entries.insert(key, Entry::Failed(Instant::now())); + } + } + pump(cx); + cx.refresh_windows(); + }); + }) + .detach(); + } +} + +/// A tile from the disk cache while it is fresh, from the server otherwise, +/// and from a stale copy when the server cannot be reached. Blocking. +fn load(template: &str, key: TileKey) -> anyhow::Result> { + let path = cache_path(template, key); + let cached = path.as_ref().and_then(|path| { + let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?; + let age = SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + Some((std::fs::read(path).ok()?, age < FRESH_FOR)) + }); + let bytes = match cached { + Some((bytes, true)) => bytes, + stale => match fetch(template, key) { + Ok(bytes) => { + if let Some(path) = &path { + store(path, &bytes); + } + bytes + } + Err(error) => match stale { + Some((bytes, false)) => bytes, + _ => return Err(error), + }, + }, + }; + decode(&bytes) +} + +fn fetch(template: &str, key: TileKey) -> anyhow::Result> { + static AGENT: OnceLock = OnceLock::new(); + let agent = AGENT.get_or_init(|| { + ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(15))) + .user_agent(concat!( + "DuckLocal/", + env!("CARGO_PKG_VERSION"), + " (+https://ducklocal.app)" + )) + .build() + .into() + }); + let mut response = agent.get(&tile_url(template, key)).call()?; + Ok(response + .body_mut() + .with_config() + .limit(MAX_TILE_BYTES) + .read_to_vec()?) +} + +pub(crate) fn tile_url(template: &str, key: TileKey) -> String { + template + .replace("{z}", &key.z.to_string()) + .replace("{x}", &key.x.to_string()) + .replace("{y}", &key.y.to_string()) +} + +/// `/tiles////`: one directory per tile server, so a +/// changed `map_tile_url` never draws the old server's tiles. +fn cache_path(template: &str, key: TileKey) -> Option { + let dirs = directories::ProjectDirs::from("", "", "DuckLocal")?; + Some( + dirs.cache_dir() + .join("tiles") + .join(format!("{:016x}", fnv1a(template.as_bytes()))) + .join(key.z.to_string()) + .join(key.x.to_string()) + .join(key.y.to_string()), + ) +} + +/// Written whole and renamed into place, so a crash never leaves half a tile +/// to be read back as fresh. A cache that cannot be written only costs a +/// download next time. +fn store(path: &std::path::Path, bytes: &[u8]) { + let Some(parent) = path.parent() else { return }; + let temporary = path.with_extension(format!("part{}", std::process::id())); + let written = std::fs::create_dir_all(parent) + .and_then(|_| std::fs::write(&temporary, bytes)) + .and_then(|_| std::fs::rename(&temporary, path)); + if written.is_err() { + let _ = std::fs::remove_file(&temporary); + } +} + +fn fnv1a(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }) +} + +/// PNG or JPEG bytes to the BGRA frame GPUI draws. +fn decode(bytes: &[u8]) -> anyhow::Result> { + let mut pixels = image::load_from_memory(bytes)?.into_rgba8(); + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Ok(Arc::new(RenderImage::new([Frame::new(pixels)]))) +} + +/// The zoom whose tiles draw closest to their own size at `scale` pixels per +/// projected degree — never enlarged, which blurs, so at most one level finer +/// than the view needs. +pub(crate) fn zoom_for(scale: f64) -> u8 { + // The world is 360 projected degrees, and `TILE_SIZE × 2^z` pixels wide. + let level = (scale * 360. / TILE_SIZE).log2().ceil(); + level.clamp(0., MAX_ZOOM as f64) as u8 +} + +/// The tiles covering projected `(min_x, max_x, min_y, max_y)` at zoom `z`, +/// clipped to the world. +pub(crate) fn covering(extent: (f64, f64, f64, f64), z: u8) -> Vec { + let n = 1u32 << z; + let per_tile = 360. / n as f64; + let (x0, x1, y0, y1) = extent; + let column = |x: f64| (((x + 180.) / per_tile).floor().max(0.) as u32).min(n - 1); + // Tile rows count down from the top, y = +180. + let row = |y: f64| (((180. - y) / per_tile).floor().max(0.) as u32).min(n - 1); + let (c0, c1) = (column(x0.max(-180.)), column(x1.min(180.))); + let (r0, r1) = (row(y1.min(180.)), row(y0.max(-180.))); + let mut keys = Vec::new(); + for y in r0..=r1 { + for x in c0..=c1 { + keys.push(TileKey { z, x, y }); + } + } + keys +} + +/// A tile's projected extent: `(min_x, max_x, min_y, max_y)`. +pub(crate) fn extent_of(key: TileKey) -> (f64, f64, f64, f64) { + let per_tile = 360. / (1u32 << key.z) as f64; + let x0 = key.x as f64 * per_tile - 180.; + let y1 = 180. - key.y as f64 * per_tile; + (x0, x0 + per_tile, y1 - per_tile, y1) +} + +#[cfg(test)] +mod tests { + use super::{covering, extent_of, tile_url, zoom_for, TileKey, DEFAULT_TEMPLATE, MAX_ZOOM}; + + #[test] + fn the_world_is_one_tile_at_zoom_zero() { + assert_eq!( + covering((-180., 180., -180., 180.), 0), + vec![TileKey { z: 0, x: 0, y: 0 }] + ); + assert_eq!( + extent_of(TileKey { z: 0, x: 0, y: 0 }), + (-180., 180., -180., 180.) + ); + } + + #[test] + fn tiles_count_rows_from_the_top() { + // Amsterdam, 4.9°E 52.37°N, is tile 131/84 at zoom 8. + let y = crate::ui::geo::project_lat(52.37); + let keys = covering((4.9, 4.9, y, y), 8); + assert_eq!( + keys, + vec![TileKey { + z: 8, + x: 131, + y: 84 + }] + ); + let (x0, x1, y0, y1) = extent_of(keys[0]); + assert!(x0 <= 4.9 && 4.9 < x1 && y0 <= y && y < y1); + } + + #[test] + fn a_view_past_the_edge_of_the_world_is_clipped() { + let keys = covering((-500., 500., -500., 500.), 1); + assert_eq!(keys.len(), 4); + } + + #[test] + fn zoom_follows_the_scale_and_never_enlarges_a_tile() { + // One tile across 360 degrees at 256 px is zoom 0 exactly. + assert_eq!(zoom_for(256. / 360.), 0); + // A little more detail than zoom 0 gives needs zoom 1. + assert_eq!(zoom_for(300. / 360.), 1); + assert_eq!(zoom_for(1e12), MAX_ZOOM); + assert_eq!(zoom_for(1e-6), 0); + } + + #[test] + fn a_tile_url_fills_the_template() { + assert_eq!( + tile_url( + DEFAULT_TEMPLATE, + TileKey { + z: 8, + x: 131, + y: 84 + } + ), + "https://tile.openstreetmap.org/8/131/84.png" + ); + } +} From a9e8328408fad37e692b29b6ee867769f906ab8e Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 30 Sep 2026 16:25:07 +0800 Subject: [PATCH 2/3] Keep the online base map off until someone turns it on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tiles are the one thing the app would fetch on its own, and what they reveal — the area being viewed — is the user's to give away, not a default. A tool that runs inside a company, or as an agent's built-in, should not reach the public OSM servers unasked either; their policy is for light, deliberate use. View → Online base map (OpenStreetMap) turns it on and remembers the choice as `map_tiles`; unset reads as off. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- README.zh-CN.md | 2 +- src/i18n.rs | 5 +++++ src/ui/mod.rs | 10 ++++++++++ src/ui/tiles.rs | 23 ++++++++++++++++++----- 5 files changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 65d82d0..ed6133e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ducklocal warehouse.duckdb # or an existing DuckDB database Each CSV, TSV, Parquet, JSON, or Excel file becomes queryable as the window opens. You can also drag files or folders onto the window, or pick them from the file dialog — and they stay registered, so the next launch starts with the same workspace. One open request attaches at most 256 files. -Your data stays on your machine: nothing is uploaded, and there is no account. The one thing the app fetches on its own is the OpenStreetMap base map under a map chart — the tile requests reveal the area being viewed, never your rows. +Your data stays on your machine: nothing is uploaded, and there is no account. Map charts can draw an OpenStreetMap base map under their points, off until you turn it on (View → Online base map): its tile requests reveal the area being viewed, never your rows. ## Features diff --git a/README.zh-CN.md b/README.zh-CN.md index 55f3b27..9565a9a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -19,7 +19,7 @@ ducklocal warehouse.duckdb # 或已有的 DuckDB 数据库 每个 CSV / TSV / Parquet / JSON / Excel 文件在窗口打开时即可查询。也可以把文件或文件夹拖进窗口,或用文件对话框选择;它们会被记住,下次启动直接回到同一个工作区。一次打开请求最多挂载 256 个文件。 -数据只留在这台机器上:不上传、不需要账号。应用唯一会主动联网获取的是地图图表下方的 OpenStreetMap 底图——瓦片请求会暴露正在查看的区域,但绝不包含你的数据行。 +数据只留在这台机器上:不上传、不需要账号。地图图表可以在点下方绘制 OpenStreetMap 底图,默认关闭,需在「视图 → 在线底图」中开启:瓦片请求会暴露正在查看的区域,但绝不包含你的数据行。 ## 功能 diff --git a/src/i18n.rs b/src/i18n.rs index fdf1644..26c5151 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -404,6 +404,11 @@ static STRINGS: &[(&str, &str, &str)] = &[ ("menu.zoom_in", "放大界面", "Larger interface"), ("menu.zoom_out", "缩小界面", "Smaller interface"), ("menu.zoom_reset", "默认大小", "Default size"), + ( + "menu.base_map", + "在线底图(OpenStreetMap)", + "Online base map (OpenStreetMap)", + ), ("workspace.stop.tooltip", "中断正在运行的查询", "Interrupt the running query"), ("workspace.format", "格式化", "Format"), ("workspace.format.tooltip", "格式化当前 SQL", "Format current SQL"), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 027db1d..1b64a83 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -34,6 +34,7 @@ gpui_kit::actions!( CloseTab, OpenData, OpenSetup, + ToggleBaseMap, Quit ] ); @@ -73,6 +74,11 @@ pub fn init(cx: &mut App) { cx.on_action(|_: &ZoomReset, cx| scale::set(scale::UiSize::Default, cx)); cx.on_action(|_: &StopQuery, _| crate::db::interrupt()); cx.on_action(|_: &Quit, cx| cx.quit()); + cx.on_action(|_: &ToggleBaseMap, cx| { + tiles::toggle(cx); + // The item's check mark is part of the menu, built once. + set_menus(cx); + }); // After the bindings: the menu bar reads each item's shortcut from the // keymap at the moment it is built. set_menus(cx); @@ -88,6 +94,7 @@ pub fn set_menus(cx: &mut App) { items, disabled: false, }; + let base_map = tiles::enabled(cx); cx.set_menus(vec![ Menu { name: "DuckLocal".into(), @@ -135,6 +142,9 @@ pub fn set_menus(cx: &mut App) { MenuItem::action(tr("menu.zoom_in"), ZoomIn), MenuItem::action(tr("menu.zoom_out"), ZoomOut), MenuItem::action(tr("menu.zoom_reset"), ZoomReset), + MenuItem::Separator, + MenuItem::action(tr("menu.base_map"), ToggleBaseMap) + .checked(base_map), ], ), ]); diff --git a/src/ui/tiles.rs b/src/ui/tiles.rs index a25d2e2..a407086 100644 --- a/src/ui/tiles.rs +++ b/src/ui/tiles.rs @@ -2,10 +2,11 @@ //! demand, kept on disk, and drawn in the same Web Mercator projection the //! points use. //! -//! This is the one place the app reaches the network on its own. What leaves +//! It is off until someone turns it on (View → Online base map), because it +//! is the one place the app would reach the network on its own: what leaves //! the machine is which tiles are wanted — roughly the area and zoom being -//! looked at — never a row of data. `map_tiles = off` in the settings turns it -//! off, and the map falls back to its graticule, as it does offline. +//! looked at — never a row of data. Off, or offline, the map draws over its +//! graticule. The choice is the `map_tiles` setting, `on` or `off`. //! //! The OpenStreetMap Foundation's tile policy is what shapes the fetching: an //! identifying User-Agent, at most two requests at a time, tiles cached for a @@ -61,7 +62,7 @@ struct Config { impl Config { fn load() -> Self { let setting = |key| crate::history::get_setting(key).ok().flatten(); - let enabled = setting("map_tiles").is_none_or(|value| value != "off"); + let enabled = setting("map_tiles").is_some_and(|value| value == "on"); let template = setting("map_tile_url") .filter(|url| url.contains("{z}") && url.contains("{x}") && url.contains("{y}")) .unwrap_or_else(|| DEFAULT_TEMPLATE.to_string()); @@ -81,12 +82,24 @@ struct Tiles { impl Global for Tiles {} -/// Whether the base map is on: it is, unless the `map_tiles` setting is `off`. +/// Whether the base map is on: only once someone has turned it on. pub(crate) fn enabled(cx: &mut App) -> bool { let tiles = cx.default_global::(); tiles.config.get_or_insert_with(Config::load).enabled } +/// Turn the base map on or off, remember the choice, and redraw every map. +pub(crate) fn toggle(cx: &mut App) { + let tiles = cx.default_global::(); + let config = tiles.config.get_or_insert_with(Config::load); + config.enabled = !config.enabled; + let value = if config.enabled { "on" } else { "off" }; + if let Err(error) = crate::history::set_setting("map_tiles", value) { + tracing::warn!("map_tiles not saved: {error:#}"); + } + cx.refresh_windows(); +} + /// The tiles among `wanted` that are ready to draw. The rest are asked for; /// the windows repaint as each arrives. pub(crate) fn visible( From 08c2ea491805db53a1fc6fba981ff895044f70fa Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 30 Sep 2026 16:33:27 +0800 Subject: [PATCH 3/3] Say in the map's header that its tiles are opt-in Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/geo.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/ui/geo.rs b/src/ui/geo.rs index 0c67e3e..7aea717 100644 --- a/src/ui/geo.rs +++ b/src/ui/geo.rs @@ -1,12 +1,11 @@ //! Map mode of the chart tab: a result with a latitude and a longitude column //! plots each row as a point on a Web Mercator projection, fitted to the -//! points' extent, over OpenStreetMap tiles and a graticule labelled in -//! degrees. +//! points' extent, over a graticule labelled in degrees — and OpenStreetMap +//! tiles, once someone turns them on (`tiles`). //! -//! The base map comes from the network (`tiles`); an embedded coastline would -//! say nothing at the city scale most station, store or sensor tables live -//! at. Offline, or with tiles turned off, the points draw the shape on their -//! own over the graticule. When a low +//! Without tiles the points draw the shape on their own; an embedded +//! coastline would say nothing at the city scale most station, store or +//! sensor tables live at. When a low //! cardinality text column is present (a `type`, a `country`), points are //! colored by it, so the map says something beyond "where". //!