diff --git a/Cargo.lock b/Cargo.lock
index b33daa9..a83da54 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3173,7 +3173,7 @@ dependencies = [
[[package]]
name = "gpui-base"
version = "0.7.0"
-source = "git+https://github.com/longbridge/gpui-kit?tag=v0.7.0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=0c830f4d257e69fdd17200650533ab4ca9a40cc0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -3212,7 +3212,7 @@ dependencies = [
[[package]]
name = "gpui-component"
version = "0.7.0"
-source = "git+https://github.com/longbridge/gpui-kit?tag=v0.7.0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=0c830f4d257e69fdd17200650533ab4ca9a40cc0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
dependencies = [
"anyhow",
"chrono",
@@ -3259,7 +3259,7 @@ dependencies = [
[[package]]
name = "gpui-component-macros"
version = "0.7.0"
-source = "git+https://github.com/longbridge/gpui-kit?tag=v0.7.0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=0c830f4d257e69fdd17200650533ab4ca9a40cc0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
dependencies = [
"proc-macro-crate",
"proc-macro2",
@@ -3270,7 +3270,7 @@ dependencies = [
[[package]]
name = "gpui-component-shell"
version = "0.7.0"
-source = "git+https://github.com/longbridge/gpui-kit?tag=v0.7.0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=0c830f4d257e69fdd17200650533ab4ca9a40cc0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
dependencies = [
"gpui-component",
"gpui-shell",
@@ -3279,7 +3279,7 @@ dependencies = [
[[package]]
name = "gpui-fps"
version = "0.7.0"
-source = "git+https://github.com/longbridge/gpui-kit?tag=v0.7.0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=0c830f4d257e69fdd17200650533ab4ca9a40cc0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
dependencies = [
"core-graphics 0.24.0",
"gpui-pre",
@@ -3296,7 +3296,7 @@ dependencies = [
[[package]]
name = "gpui-kit"
version = "0.7.0"
-source = "git+https://github.com/longbridge/gpui-kit?tag=v0.7.0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=0c830f4d257e69fdd17200650533ab4ca9a40cc0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
dependencies = [
"gpui-base",
"gpui-component",
@@ -3309,7 +3309,7 @@ dependencies = [
[[package]]
name = "gpui-kit-assets"
version = "0.7.0"
-source = "git+https://github.com/longbridge/gpui-kit?tag=v0.7.0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=0c830f4d257e69fdd17200650533ab4ca9a40cc0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
dependencies = [
"anyhow",
"gpui-pre",
@@ -3832,7 +3832,7 @@ checksum = "829258540ea51bb9ea4a71686beffe5c99586901ba9f39944d06e4a180ab63fe"
[[package]]
name = "gpui-shell"
version = "0.7.0"
-source = "git+https://github.com/longbridge/gpui-kit?tag=v0.7.0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=0c830f4d257e69fdd17200650533ab4ca9a40cc0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
dependencies = [
"anyhow",
"async-channel",
@@ -4222,7 +4222,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.62.2",
+ "windows-core 0.57.0",
]
[[package]]
@@ -7437,7 +7437,7 @@ dependencies = [
[[package]]
name = "rquickjs"
version = "0.12.9"
-source = "git+https://github.com/longbridge/gpui-kit?tag=v0.7.0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
+source = "git+https://github.com/longbridge/gpui-kit?rev=0c830f4d257e69fdd17200650533ab4ca9a40cc0#0c830f4d257e69fdd17200650533ab4ca9a40cc0"
dependencies = [
"quickjs-jit",
]
@@ -10005,7 +10005,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.48.0",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index ea19ded..0297eef 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,9 +17,9 @@ categories = ["database", "gui"]
# `gpui-base` in the build.
# `tree-sitter-sql`: the SQL editor and the SQL in `.dash` heredocs are the only
# code DuckLocal highlights, so it links that one grammar rather than the set.
-gpui-kit = { git = "https://github.com/longbridge/gpui-kit", tag = "v0.7.0", features = ["tree-sitter-sql"] }
-gpui-shell = { git = "https://github.com/longbridge/gpui-kit", tag = "v0.7.0" }
-gpui-component-shell = { git = "https://github.com/longbridge/gpui-kit", tag = "v0.7.0" }
+gpui-kit = { git = "https://github.com/longbridge/gpui-kit", rev = "0c830f4d257e69fdd17200650533ab4ca9a40cc0", features = ["tree-sitter-sql"] }
+gpui-shell = { git = "https://github.com/longbridge/gpui-kit", rev = "0c830f4d257e69fdd17200650533ab4ca9a40cc0" }
+gpui-component-shell = { git = "https://github.com/longbridge/gpui-kit", rev = "0c830f4d257e69fdd17200650533ab4ca9a40cc0" }
duckdb = { version = "1", features = ["bundled", "json", "parquet"] }
calamine = { version = "0.36", features = ["dates"] }
smol = "2"
@@ -45,7 +45,7 @@ rust_xlsxwriter = "0.99"
# `gpui-shell` scripts run on the QuickJS fork the toolkit pins; the crates.io
# `rquickjs` would be a second, incompatible copy of the same crate.
[patch.crates-io]
-rquickjs = { git = "https://github.com/longbridge/gpui-kit", tag = "v0.7.0" }
+rquickjs = { git = "https://github.com/longbridge/gpui-kit", rev = "0c830f4d257e69fdd17200650533ab4ca9a40cc0" }
# Fast over small. The opt-level reaches DuckDB's C++ too: libduckdb-sys
# builds it with the `cc` crate, which passes Cargo's level on to the
diff --git a/assets/Info.plist b/assets/Info.plist
index 911a2f0..8a212ae 100644
--- a/assets/Info.plist
+++ b/assets/Info.plist
@@ -22,5 +22,40 @@
12.0
NSHighResolutionCapable
+ UTExportedTypeDeclarations
+
+
+ UTTypeIdentifier
+ com.ducklocal.dash
+ UTTypeDescription
+ DuckLocal Dashboard Spec
+ UTTypeConformsTo
+
+ public.text
+
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ dash
+
+
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ DuckLocal Dashboard
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Owner
+ LSItemContentTypes
+
+ com.ducklocal.dash
+
+
+
diff --git a/skills/ducklocal/SKILL.md b/skills/ducklocal/SKILL.md
index f863ee4..1515d1b 100644
--- a/skills/ducklocal/SKILL.md
+++ b/skills/ducklocal/SKILL.md
@@ -40,9 +40,9 @@ plot "revenue" {
}
```
-A `query` block holds one `sql` attribute (one read-only statement — SELECT, WITH, FROM, VALUES, SHOW, DESCRIBE, SUMMARIZE or PIVOT — heredoc or string; DDL, DML, COPY, ATTACH and INSTALL are rejected). A `plot` block holds `type` (`line`, `bar`, `area`, `scatter`, `table`), `query` (a `query.name` reference), `x` and `y` (result columns, bare identifiers or quoted strings; `y` optional for `table`), optional `series` and `title`. No functions, conditionals, or interpolation exist. There is a working example at `examples/analysis_app/dashboard.dash`.
+A `query` block holds one `sql` attribute (one read-only statement — SELECT, WITH, FROM, VALUES, SHOW, DESCRIBE, SUMMARIZE or PIVOT — heredoc or string; DDL, DML, COPY, ATTACH and INSTALL are rejected). A `plot` block holds `type` (`line`, `bar`, `area`, `scatter`, `pie`, `map`, `table`), `query` (a `query.name` reference), `x` and `y` (result columns, bare identifiers or quoted strings; `y` optional for `table`), optional `series` and `title`. A `pie` takes `x` as its slices and `y` as their sizes, and no `series`; it folds past eight slices into "other". A `map` takes `lat` and `lng` (degrees) instead of `x`/`y`/`series` — either may be omitted when a numeric column's name says it, such as `geo_lat` or `longitude` — plus an optional `color` column; for example `plot "stations" { type = "map" query = query.stations lat = geo_lat lng = geo_lng color = type }`. No functions, conditionals, or interpolation exist. There is a working example at `examples/analysis_app/dashboard.dash`.
-Always validate before handing a spec over: `ducklocal check dashboard.dash`, or `ducklocal check dashboard.dash --database warehouse.duckdb` to also run every query read-only and verify every `x`/`y`/`series` against the columns the queries actually return (a non-numeric `y` is an error outside `table`). A spec mistake is exit 2 with kind `spec`, one `file:line: message` per diagnostic; a query that fails on the database is exit 1 with kind `sql`, one line per failing query — fix all of them, not just the first. Prefer the `--database` form whenever the database exists: without it nothing runs, so errors that appear only at execution (a cast the build cannot perform, a value that will not convert) go unseen. To see it rendered, open the file in the GUI (`ducklocal dashboard.dash` or drag it onto the window): it becomes a dashboard tab, a resizable vertical stack of the plots with per-plot inline errors.
+Always validate before handing a spec over: `ducklocal check dashboard.dash`, or `ducklocal check dashboard.dash --database warehouse.duckdb` to also run every query read-only and verify every `x`/`y`/`series`/`lat`/`lng`/`color` against the columns the queries actually return (a non-numeric `y` is an error outside `table`, and so is a non-numeric `lat` or `lng`). A spec mistake is exit 2 with kind `spec`, one `file:line: message` per diagnostic; a query that fails on the database is exit 1 with kind `sql`, one line per failing query — fix all of them, not just the first. Prefer the `--database` form whenever the database exists: without it nothing runs, so errors that appear only at execution (a cast the build cannot perform, a value that will not convert) go unseen. To see it rendered, open the file in the GUI (`ducklocal dashboard.dash` or drag it onto the window): it becomes a dashboard tab, a resizable vertical stack of the plots with per-plot inline errors.
## Authoring an app
diff --git a/src/app.rs b/src/app.rs
index b1b0404..25c99bd 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -8,6 +8,54 @@ use gpui_kit::component::resizable::{h_resizable, resizable_panel};
use gpui_kit::component::{v_flex, ActiveTheme, Sizable, WindowExt};
use gpui_kit::prelude::FluentBuilder;
use gpui_kit::*;
+use std::sync::{Mutex, PoisonError};
+use std::time::Duration;
+
+/// Documents Finder asks the app to open — "Open With", a double-clicked
+/// `.dash`, a drop on the Dock icon — while it runs or as it launches. The
+/// platform's openURLs callback gets no App context, so it queues the paths
+/// here and the root view drains them once a frame is up.
+static FINDER_OPENS: Mutex> = Mutex::new(Vec::new());
+
+/// Queue one URL from the platform's openURLs callback. Only `file://` URLs
+/// are documents; anything else was not meant for us.
+pub(crate) fn queue_finder_open(url: &str) {
+ if let Some(path) = file_url_path(url) {
+ FINDER_OPENS
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner)
+ .push(path);
+ }
+}
+
+/// The path a `file://` URL points at. Percent-escapes are UTF-8; the
+/// authority is empty or `localhost` for a local file.
+fn file_url_path(url: &str) -> Option {
+ fn hex(byte: u8) -> Option {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ b'A'..=b'F' => Some(byte - b'A' + 10),
+ _ => None,
+ }
+ }
+
+ let authority_and_path = url.strip_prefix("file://")?;
+ let path_start = authority_and_path.find('/')?;
+ let encoded = &authority_and_path[path_start..];
+ let mut decoded = Vec::with_capacity(encoded.len());
+ let mut bytes = encoded.bytes();
+ while let Some(byte) = bytes.next() {
+ if byte == b'%' {
+ let hi = hex(bytes.next()?)?;
+ let lo = hex(bytes.next()?)?;
+ decoded.push(hi << 4 | lo);
+ } else {
+ decoded.push(byte);
+ }
+ }
+ String::from_utf8(decoded).ok()
+}
use crate::analysis::apps;
use crate::i18n::{tr, trf};
@@ -134,6 +182,31 @@ impl DuckLocalApp {
})
.detach();
+ // Finder opens queue from the moment the platform callback is
+ // registered — possibly before this view exists — so drain on a slow
+ // poll and open them like a drop on the window. The task ends with
+ // the window: `update_in` fails once the view is gone.
+ cx.spawn_in(window, async move |this, cx| {
+ loop {
+ smol::Timer::after(Duration::from_millis(200)).await;
+ let paths: Vec = std::mem::take(
+ &mut *FINDER_OPENS
+ .lock()
+ .unwrap_or_else(PoisonError::into_inner),
+ );
+ if paths.is_empty() {
+ continue;
+ }
+ if this
+ .update_in(cx, |this, window, cx| this.open_external(paths, window, cx))
+ .is_err()
+ {
+ break;
+ }
+ }
+ })
+ .detach();
+
Self {
state,
title_bar,
@@ -143,15 +216,11 @@ impl DuckLocalApp {
}
}
- /// Paths dropped on the window: an app directory opens an app tab, a
- /// `.dash` file a dashboard tab, and everything else is the same request
- /// the command line and the pickers make.
- fn drop_paths(&mut self, paths: &ExternalPaths, window: &mut Window, cx: &mut Context) {
- let requested: Vec = paths
- .paths()
- .iter()
- .map(|path| path.to_string_lossy().to_string())
- .collect();
+ /// Paths from outside the app — a drop on the window, or a document
+ /// Finder opens with it: an app directory opens an app tab, a `.dash`
+ /// file a dashboard tab, and everything else is the same request the
+ /// command line and the pickers make.
+ fn open_external(&mut self, requested: Vec, window: &mut Window, cx: &mut Context) {
let (directories, rest) = apps::split_paths(&requested);
for directory in directories {
self.workspace
@@ -166,6 +235,15 @@ impl DuckLocalApp {
open_paths(self.state.clone(), data, window, cx);
}
}
+
+ fn drop_paths(&mut self, paths: &ExternalPaths, window: &mut Window, cx: &mut Context) {
+ let requested: Vec = paths
+ .paths()
+ .iter()
+ .map(|path| path.to_string_lossy().to_string())
+ .collect();
+ self.open_external(requested, window, cx);
+ }
}
impl Render for DuckLocalApp {
@@ -231,3 +309,34 @@ impl Render for DuckLocalApp {
.child(self.status_bar.clone())
}
}
+
+#[cfg(test)]
+mod tests {
+ // Deliberately not `use super::*`: that pulls in `gpui_kit::*`, whose
+ // `test` macro shadows the built-in `#[test]`.
+ use super::file_url_path;
+
+ #[test]
+ fn file_urls_decode_to_paths() {
+ assert_eq!(
+ file_url_path("file:///Users/admin/aiops2_overview.dash"),
+ Some("/Users/admin/aiops2_overview.dash".to_string())
+ );
+ // Spaces and CJK arrive percent-encoded.
+ assert_eq!(
+ file_url_path("file:///Users/admin/My%20Reports/%E6%97%A5%E6%8A%A5.dash"),
+ Some("/Users/admin/My Reports/日报.dash".to_string())
+ );
+ assert_eq!(
+ file_url_path("file://localhost/Users/admin/x.dash"),
+ Some("/Users/admin/x.dash".to_string())
+ );
+ }
+
+ #[test]
+ fn non_file_urls_are_not_documents() {
+ assert_eq!(file_url_path("https://example.com/x.dash"), None);
+ assert_eq!(file_url_path("file://"), None);
+ assert_eq!(file_url_path("file:///bad%zz"), None);
+ }
+}
diff --git a/src/i18n.rs b/src/i18n.rs
index 3396d06..fdf1644 100644
--- a/src/i18n.rs
+++ b/src/i18n.rs
@@ -739,6 +739,11 @@ static STRINGS: &[(&str, &str, &str)] = &[
"查询结果中没有列 {}。可用的列:{}",
"The query result has no column {}. Available columns: {}",
),
+ (
+ "dashboard.map_no_coordinates",
+ "地图需要经纬度列:请用 lat 和 lng 指定。查询返回的列:{}",
+ "A map needs coordinate columns: name them with lat and lng. The query returns: {}",
+ ),
(
"dashboard.empty",
"这个 spec 没有声明任何 plot 块。",
diff --git a/src/main.rs b/src/main.rs
index 6f9cf15..535c7f1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -58,9 +58,16 @@ fn main() {
.filter(|arg| !arg.starts_with("-psn_"))
.collect();
- gpui_kit::application()
- .with_assets(assets::AppAssets)
- .run(move |cx| {
+ let application = gpui_kit::application().with_assets(assets::AppAssets);
+ // Finder's "Open With" arrives here as file:// URLs — on launch and on
+ // every later open — and the callback gets no App context, so the paths
+ // are queued for the root view to drain (app.rs).
+ application.on_open_urls(|urls| {
+ for url in urls {
+ crate::app::queue_finder_open(&url);
+ }
+ });
+ application.run(move |cx| {
gpui_kit::init(cx);
ui::init(cx);
Theme::change(ThemeMode::Light, None, cx);
diff --git a/src/spec/complete.rs b/src/spec/complete.rs
index 6fe084a..494921b 100644
--- a/src/spec/complete.rs
+++ b/src/spec/complete.rs
@@ -12,6 +12,11 @@
use super::model;
+/// Every attribute a plot block may hold, in the order completion offers
+/// them: the common ones first, a map's own after.
+pub(crate) const PLOT_ATTRS: [&str; 9] =
+ ["type", "query", "x", "y", "series", "title", "lat", "lng", "color"];
+
/// One thing that could be inserted at the cursor.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Completion {
@@ -95,7 +100,7 @@ pub(crate) fn complete(source: &str, line: usize, col: usize) -> Vec
}
match block_at(source, &lines, line, &before) {
- Some(kind) if kind == "plot" => ["type", "query", "x", "y", "series", "title"]
+ Some(kind) if kind == "plot" => PLOT_ATTRS
.into_iter()
.map(|name| attribute(name, "plot"))
.collect(),
@@ -322,7 +327,7 @@ mod tests {
let candidates = complete("plot \"p\" {\n \n}", 2, 3);
assert_eq!(
labels(&candidates),
- ["type", "query", "x", "y", "series", "title"]
+ super::PLOT_ATTRS
);
assert!(candidates
.iter()
@@ -337,7 +342,7 @@ mod tests {
fn plot_types_after_type_equals() {
// Inside an opened quote the insert text is the bare word.
let candidates = complete("plot \"p\" {\n type = \"li\n}", 2, 12);
- assert_eq!(labels(&candidates), ["line", "bar", "area", "scatter", "table"]);
+ assert_eq!(labels(&candidates), model::PLOT_TYPES);
assert!(candidates
.iter()
.all(|c| c.kind == CompletionKind::Value));
@@ -377,7 +382,7 @@ mod tests {
let source = "query \"q1\" {\n sql = \"SELECT 1\"\n\nplot \"p\" {\n t\n";
assert_eq!(
labels(&complete(source, 5, 3)),
- ["type", "query", "x", "y", "series", "title"]
+ super::PLOT_ATTRS
);
}
diff --git a/src/spec/highlight.rs b/src/spec/highlight.rs
index 9e231fe..3f6b1b8 100644
--- a/src/spec/highlight.rs
+++ b/src/spec/highlight.rs
@@ -29,7 +29,7 @@ use gpui_kit::{Context, HighlightStyle, SharedString, Window};
pub const LANGUAGE: &str = "dash";
/// The plot types, coloured as constants: they are the language's own words.
-const PLOT_TYPES: &[&str] = &["line", "bar", "area", "scatter", "table"];
+use super::model::PLOT_TYPES;
/// What the scanner found: `.dash` tokens by highlight name, and the byte
/// ranges of heredoc bodies, which are SQL.
diff --git a/src/spec/lsp.rs b/src/spec/lsp.rs
index 47f2c87..18a3192 100644
--- a/src/spec/lsp.rs
+++ b/src/spec/lsp.rs
@@ -407,7 +407,7 @@ fn hover_at(source: &str, position: Position) -> Option {
Hit::Attr { name, .. } => attr_doc(&name)?.to_string(),
Hit::Block { kind, name, .. } => match kind.as_str() {
"query" => format!("**query \"{name}\"**\n\nA named query: one `sql` attribute holding one statement, as a heredoc or a string."),
- "plot" => format!("**plot \"{name}\"**\n\nA named plot: `type`, `query`, `x` and `y`, plus optional `series` and `title`."),
+ "plot" => format!("**plot \"{name}\"**\n\nA named plot: `type`, `query`, `x` and `y`, plus optional `series` and `title`; a `map` takes `lat`, `lng` and `color` instead of `x`, `y` and `series`."),
_ => return None,
},
Hit::Ref { segments, .. } => {
@@ -462,11 +462,14 @@ fn validate(source: &str) -> Option {
fn attr_doc(name: &str) -> Option<&'static str> {
Some(match name {
"sql" => "**sql**\n\nThe query's one statement, as a heredoc or a string. A query block holds only this.",
- "type" => "**type**\n\nThe plot's kind: one of `line`, `bar`, `area`, `scatter`, `table`.",
+ "type" => "**type**\n\nThe plot's kind: one of `line`, `bar`, `area`, `scatter`, `pie`, `map`, `table`.",
"query" => "**query**\n\nThe query block this plot draws: `query = query.some_name`.",
- "x" => "**x**\n\nA column of the query's result, as a bare identifier or a quoted string.",
- "y" => "**y**\n\nThe numeric column the plot draws; optional when the type is `table`.",
- "series" => "**series**\n\nOptional: the column that splits the plot into one line or bar group per value.",
+ "x" => "**x**\n\nA column of the query's result, as a bare identifier or a quoted string; a `pie`'s slices. Not for a `map`.",
+ "y" => "**y**\n\nThe numeric column the plot draws; a `pie`'s slice sizes. Optional for `table`, not for a `map`.",
+ "series" => "**series**\n\nOptional: the column that splits the plot into one line or bar group per value. Not for a `pie` or a `map`.",
+ "lat" => "**lat**\n\nA `map`'s latitude column, in degrees. Optional when a column's name says it (`lat`, `geo_lat`, `latitude`).",
+ "lng" => "**lng**\n\nA `map`'s longitude column, in degrees. Optional when a column's name says it (`lng`, `lon`, `longitude`).",
+ "color" => "**color**\n\nOptional, for a `map`: the column its points are colored by; the five most common values get a color, the rest share one.",
"title" => "**title**\n\nOptional: the plot's title.",
_ => return None,
})
diff --git a/src/spec/mod.rs b/src/spec/mod.rs
index 9791b0e..5e15f0e 100644
--- a/src/spec/mod.rs
+++ b/src/spec/mod.rs
@@ -168,6 +168,9 @@ pub fn check(args: &[OsString]) -> Result {
"x": p.x,
"y": p.y,
"series": p.series,
+ "lat": p.lat,
+ "lng": p.lng,
+ "color": p.color,
"title": p.title,
"line": p.line,
})).collect::>(),
diff --git a/src/spec/model.rs b/src/spec/model.rs
index db7a2de..894ffef 100644
--- a/src/spec/model.rs
+++ b/src/spec/model.rs
@@ -6,6 +6,10 @@
//! * a `query` block holds one `sql` attribute — one statement, nothing else;
//! * a `plot` block holds `type`, `query`, `x`, and (unless a table) `y`,
//! plus an optional `series` and `title`;
+//! * a `pie` takes `x` (the slices) and `y` (their sizes), and no `series`;
+//! * a `map` takes `lat` and `lng` instead of `x` and `y` — either may be left
+//! out when a column's name says what it is (`geo_lat`, `longitude`) — and
+//! an optional `color`;
//! * `query = query.latency` names a query block that exists;
//! * `x`, `y`, `series` name result columns — as bare identifiers when the
//! column allows it, as strings when it does not (`"Revenue (USD)"`).
@@ -19,8 +23,12 @@ use std::collections::HashSet;
use super::syntax::{self, Attr, Block, File, RefSite, Value};
/// The plot types the format knows. Each is a promise the renderer can keep:
-/// x/y for the continuous ones, anything tabular for `table`.
-pub(crate) const PLOT_TYPES: &[&str] = &["line", "bar", "area", "scatter", "table"];
+/// x/y for the continuous ones and `pie`, anything tabular for `table`,
+/// coordinates for `map`.
+pub(crate) const PLOT_TYPES: &[&str] = &["line", "bar", "area", "scatter", "pie", "map", "table"];
+
+/// The attributes only a `map` takes.
+const MAP_ATTRS: &[&str] = &["lat", "lng", "color"];
#[derive(Debug, Clone)]
pub(crate) struct Spec {
@@ -55,6 +63,11 @@ pub(crate) struct Plot {
pub y: Option,
pub series: Option,
pub title: Option,
+ /// A `map`'s coordinate columns; `None` finds them by name.
+ pub lat: Option,
+ pub lng: Option,
+ /// The column a `map` colors its points by; `None` picks one.
+ pub color: Option,
/// Line and column of the block's kind keyword.
pub line: usize,
pub col: usize,
@@ -358,6 +371,9 @@ fn plot(block: &Block, diagnostics: &mut Vec) -> Option {
let mut y = None;
let mut series = None;
let mut title = None;
+ let mut lat = None;
+ let mut lng = None;
+ let mut color = None;
let mut seen: HashSet<&str> = HashSet::new();
for attr in &block.attrs {
if !seen.insert(attr.name.as_str()) {
@@ -394,15 +410,40 @@ fn plot(block: &Block, diagnostics: &mut Vec) -> Option {
"y" => y = column(attr, diagnostics),
"series" => series = column(attr, diagnostics),
"title" => title = string(attr, diagnostics),
+ "lat" => lat = column(attr, diagnostics),
+ "lng" => lng = column(attr, diagnostics),
+ "color" => color = column(attr, diagnostics),
other => diagnostics.push(Diagnostic::at_attr(
attr,
format!(
- "A plot block holds type, query, x, y, series, title; unknown attribute: {other}"
+ "A plot block holds type, query, x, y, series, title, and for a map lat, lng, color; unknown attribute: {other}"
),
)),
}
}
- for name in ["type", "query", "x"] {
+ let kind_name = kind.clone().unwrap_or_default();
+ let is_map = kind_name == "map";
+ // Attributes the type has no use for are mistakes, not no-ops: a map's
+ // `x` or a line's `lat` says the author expects something that will not
+ // happen.
+ for attr in &block.attrs {
+ let name = attr.name.as_str();
+ let misplaced = if is_map {
+ matches!(name, "x" | "y" | "series")
+ .then(|| format!("A map plot places points by lat and lng; it takes no {name}"))
+ } else if !kind_name.is_empty() && MAP_ATTRS.contains(&name) {
+ Some(format!("{name} is for map plots; this plot is a {kind_name}"))
+ } else if kind_name == "pie" && name == "series" {
+ Some("A pie draws one series; it takes no series".to_string())
+ } else {
+ None
+ };
+ if let Some(message) = misplaced {
+ diagnostics.push(Diagnostic::at_attr(attr, message));
+ }
+ }
+ let required: &[&str] = if is_map { &["type", "query"] } else { &["type", "query", "x"] };
+ for &name in required {
if !seen.contains(name) {
diagnostics.push(Diagnostic::at_block(
block,
@@ -414,7 +455,7 @@ fn plot(block: &Block, diagnostics: &mut Vec) -> Option {
// is only for attributes never written. y is required once the type is
// known to need one.
let kind = kind.unwrap_or_default();
- if !kind.is_empty() && kind != "table" && y.is_none() {
+ if !kind.is_empty() && kind != "table" && kind != "map" && y.is_none() {
diagnostics.push(Diagnostic::at_block(
block,
format!("plot block {:?} requires y", block.name),
@@ -428,6 +469,9 @@ fn plot(block: &Block, diagnostics: &mut Vec) -> Option {
y,
series,
title,
+ lat,
+ lng,
+ color,
line: block.line,
col: block.col,
span: block.kind_span,
@@ -500,10 +544,47 @@ pub(crate) fn check_columns(spec: &Spec, columns: &ColumnLookup) -> Vec>()
+ .join(", ")
+ };
+ // A map may leave its coordinates to the column names; if the names
+ // do not say, the plot has nothing to place.
+ let (mut lat, mut lng) = (plot.lat.clone(), plot.lng.clone());
+ if plot.kind == "map" && (lat.is_none() || lng.is_none()) {
+ let (guess_lat, guess_lng) = crate::ui::geo::guess_coordinate_columns(
+ available.iter().map(|(name, _)| name.as_str()),
+ );
+ let guessed = |ix: Option| ix.map(|ix| available[ix].0.clone());
+ lat = lat.or_else(|| guessed(guess_lat));
+ lng = lng.or_else(|| guessed(guess_lng));
+ for (name, found) in [("lat", &lat), ("lng", &lng)] {
+ if found.is_none() {
+ diagnostics.push(Diagnostic {
+ line: plot.line,
+ col: plot.col,
+ span: plot.span,
+ message: format!(
+ "map plot {:?} names no {name}, and no column of query {:?} looks like one; set {name} to one of: {}",
+ plot.name,
+ query.name,
+ returns()
+ ),
+ });
+ }
+ }
+ }
+ let x = (plot.kind != "map").then_some(plot.x.as_str());
for (name, column) in [
- ("x", Some(plot.x.as_str())),
+ ("x", x),
("y", plot.y.as_deref()),
("series", plot.series.as_deref()),
+ ("lat", plot.lat.as_deref()),
+ ("lng", plot.lng.as_deref()),
+ ("color", plot.color.as_deref()),
]
.into_iter()
.filter_map(|(name, column)| column.map(|c| (name, c)))
@@ -526,6 +607,26 @@ pub(crate) fn check_columns(spec: &Spec, columns: &ColumnLookup) -> Vec = diagnostics.iter().map(|d| d.message.as_str()).collect();
assert!(messages.iter().any(|m| m.contains("Duplicate query")), "{messages:?}");
- assert!(messages.iter().any(|m| m.contains("\"pie\"")), "{messages:?}");
+ assert!(messages.iter().any(|m| m.contains("\"donut\"")), "{messages:?}");
assert!(messages.iter().any(|m| m.contains("does not define")), "{messages:?}");
assert!(messages.iter().any(|m| m.contains("requires x")), "{messages:?}");
assert!(messages.iter().any(|m| m.contains("requires y")), "{messages:?}");
@@ -669,7 +770,7 @@ wat "huh" {}
let pie = diagnostics
.iter()
- .find(|d| d.message.contains("\"pie\""))
+ .find(|d| d.message.contains("\"donut\""))
.unwrap();
assert_eq!((pie.line, pie.col), (5, 3));
assert_eq!(&source[pie.span.0..pie.span.1], "type");
@@ -820,4 +921,96 @@ plot "p" { type = "line" query = query.q x = "Order Date" y = revenue }
);
assert_eq!(spec.plots[0].x, "Order Date");
}
+
+ #[test]
+ fn pies_and_maps_validate() {
+ let spec = validate_ok(
+ r#"
+query "q" { sql = "SELECT 1" }
+plot "share" { type = "pie" query = query.q x = channel y = total }
+plot "where" {
+ type = "map"
+ query = query.q
+ lat = geo_lat
+ lng = geo_lng
+ color = type
+}
+plot "guessed" { type = "map" query = query.q }
+"#,
+ );
+ assert_eq!(spec.plots[0].kind, "pie");
+ let map = &spec.plots[1];
+ assert_eq!(map.lat.as_deref(), Some("geo_lat"));
+ assert_eq!(map.lng.as_deref(), Some("geo_lng"));
+ assert_eq!(map.color.as_deref(), Some("type"));
+ // A map needs neither x nor y, and may leave its coordinates to the
+ // column names.
+ assert_eq!(spec.plots[2].lat, None);
+ }
+
+ #[test]
+ fn attributes_a_type_has_no_use_for_are_mistakes() {
+ let diagnostics = validate_err(
+ r#"
+query "q" { sql = "SELECT 1" }
+plot "a" { type = "map" query = query.q x = lng y = lat }
+plot "b" { type = "line" query = query.q x = t y = v lat = geo_lat }
+plot "c" { type = "pie" query = query.q x = t y = v series = s }
+"#,
+ );
+ let messages: Vec<&str> = diagnostics.iter().map(|d| d.message.as_str()).collect();
+ assert!(messages.iter().any(|m| m.contains("takes no x")), "{messages:?}");
+ assert!(messages.iter().any(|m| m.contains("takes no y")), "{messages:?}");
+ assert!(
+ messages.iter().any(|m| m.contains("lat is for map plots")),
+ "{messages:?}"
+ );
+ assert!(
+ messages.iter().any(|m| m.contains("takes no series")),
+ "{messages:?}"
+ );
+ // Each points at the attribute, on its own line.
+ let lat = diagnostics
+ .iter()
+ .find(|d| d.message.contains("lat is for map plots"))
+ .unwrap();
+ assert_eq!(lat.line, 4);
+ }
+
+ #[test]
+ fn a_map_checks_its_coordinates_against_the_query() {
+ let spec = validate_ok(
+ r#"
+query "q" { sql = "SELECT 1" }
+plot "named" { type = "map" query = query.q lat = geo_lat lng = code }
+plot "guessed" { type = "map" query = query.q }
+"#,
+ );
+ let stations = |_: &str| -> Result, String> {
+ Ok(vec![
+ ("code".into(), "VARCHAR".into()),
+ ("geo_lat".into(), "DOUBLE".into()),
+ ("geo_lng".into(), "DOUBLE".into()),
+ ])
+ };
+ let diagnostics = check_columns(&spec, &stations);
+ // A text longitude places nothing; the guessed map finds both.
+ assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
+ assert!(diagnostics[0].message.contains("lng = \"code\""), "{diagnostics:?}");
+
+ let no_coordinates = |_: &str| -> Result, String> {
+ Ok(vec![("code".into(), "VARCHAR".into())])
+ };
+ let diagnostics = check_columns(&spec, &no_coordinates);
+ assert!(
+ diagnostics
+ .iter()
+ .any(|d| d.message.contains("names no lat") && d.message.contains("guessed")),
+ "{diagnostics:?}"
+ );
+ assert!(
+ diagnostics.iter().any(|d| d.message.contains("\"geo_lat\"")),
+ "a named column the query lacks is reported: {diagnostics:?}"
+ );
+ }
}
diff --git a/src/spec/prepare.rs b/src/spec/prepare.rs
index 56cf618..c3aca21 100644
--- a/src/spec/prepare.rs
+++ b/src/spec/prepare.rs
@@ -7,6 +7,11 @@
//! and a half, bars truncated past two hundred — because a chart that wedges
//! the renderer is worse than a chart that says it was merged.
//!
+//! A `pie` is the same matrix with one series, never merged or truncated —
+//! its slices must add up to the whole — then folded into its largest
+//! slices. A `map` skips the matrix: it is the results chart's `GeoData`,
+//! over the columns the spec names.
+//!
//! Everything here is pure and tested as such; the view (`view.rs`) only
//! renders what this prepares.
@@ -18,7 +23,9 @@ use gpui_kit::SharedString;
use super::model::Plot;
use crate::i18n::trf;
use crate::query::QueryResult;
-use crate::ui::chart::{format_value, parse_number};
+use crate::query::ColumnKind;
+use crate::ui::chart::{format_value, parse_number, pie_slices, PieSlice};
+use crate::ui::geo::{is_lat_name, is_lng_name, GeoData};
/// A chart is at most a couple of thousand pixels wide; past this, points are
/// bucket-averaged, as in the results chart.
@@ -55,6 +62,10 @@ pub struct PreparedPlot {
pub label_name: String,
pub series_names: Vec,
pub points: Vec>,
+ /// A `pie`'s slices, largest first.
+ pub pie: Vec>,
+ /// A `map`'s points.
+ pub geo: Option>,
/// What was dropped or merged to keep the plot drawable.
pub notice: Option,
/// Why there is nothing to draw: the query failed, or a column the plot
@@ -75,6 +86,8 @@ pub(crate) fn prepare(plot: &Plot, result: Option<&Result>)
label_name: plot.x.clone(),
series_names: Vec::new(),
points: Vec::new(),
+ pie: Vec::new(),
+ geo: None,
notice: None,
failure,
};
@@ -95,6 +108,46 @@ pub(crate) fn prepare(plot: &Plot, result: Option<&Result>)
.iter()
.position(|column| column.name.eq_ignore_ascii_case(name))
};
+ if plot.kind == "map" {
+ // Named columns are taken at their word; a missing one is found by
+ // name among the numeric columns, as the results chart finds them.
+ let find = |named: &Option, looks_like: fn(&str) -> bool, skip: Option| {
+ match named {
+ Some(name) => column(name).ok_or_else(|| missing_column(name, result)),
+ None => result
+ .columns
+ .iter()
+ .enumerate()
+ .find(|(ix, c)| {
+ Some(*ix) != skip && c.kind == ColumnKind::Numeric && looks_like(&c.name)
+ })
+ .map(|(ix, _)| ix)
+ .ok_or_else(|| missing_coordinates(result)),
+ }
+ };
+ let lat_ix = match find(&plot.lat, is_lat_name, None) {
+ Ok(ix) => ix,
+ Err(message) => return base(Some(message)),
+ };
+ let lng_ix = match find(&plot.lng, is_lng_name, Some(lat_ix)) {
+ Ok(ix) => ix,
+ Err(message) => return base(Some(message)),
+ };
+ let color_ix = match plot.color.as_deref().map(|name| column(name).ok_or(name)) {
+ Some(Ok(ix)) => Some(ix),
+ Some(Err(name)) => return base(Some(missing_column(name, result))),
+ None => None,
+ };
+ let geo = GeoData::from_columns(result, lat_ix, lng_ix, color_ix);
+ return PreparedPlot {
+ label_name: format!(
+ "{} / {}",
+ result.columns[lat_ix].name, result.columns[lng_ix].name
+ ),
+ geo: Some(Arc::new(geo)),
+ ..base(None)
+ };
+ }
let Some(x_ix) = column(&plot.x) else {
return base(Some(missing_column(&plot.x, result)));
};
@@ -214,7 +267,10 @@ pub(crate) fn prepare(plot: &Plot, result: Option<&Result>)
}
let source_points = points.len();
- if plot.kind == "bar" {
+ if plot.kind == "pie" {
+ // Every band stays: the slices fold below, and a merged or dropped
+ // band would make the shares wrong.
+ } else if plot.kind == "bar" {
if source_points > MAX_BARS {
points.truncate(MAX_BARS);
notices.push(trf(
@@ -276,6 +332,16 @@ pub(crate) fn prepare(plot: &Plot, result: Option<&Result>)
point.label = SharedString::from(format_value(first));
}
+ let points: Vec> = points.into_iter().map(Arc::new).collect();
+ let (pie, pie_skipped) = if plot.kind == "pie" {
+ pie_slices(&points)
+ } else {
+ (Vec::new(), 0)
+ };
+ if pie_skipped > 0 {
+ notices.push(trf("chart.pie.notice.skipped", &[&pie_skipped.to_string()]));
+ }
+
PreparedPlot {
name: plot.name.clone(),
title: plot.title.clone().unwrap_or_else(|| plot.name.clone()),
@@ -283,12 +349,25 @@ pub(crate) fn prepare(plot: &Plot, result: Option<&Result>)
query: plot.query.clone(),
label_name: result.columns[x_ix].name.clone(),
series_names,
- points: points.into_iter().map(Arc::new).collect(),
+ points,
+ pie,
+ geo: None,
notice: (!notices.is_empty()).then(|| notices.join(" · ")),
failure: None,
}
}
+/// Why a `map` with no `lat` or `lng` has nothing to place.
+fn missing_coordinates(result: &QueryResult) -> String {
+ let available = result
+ .columns
+ .iter()
+ .map(|column| column.name.as_str())
+ .collect::>()
+ .join(", ");
+ trf("dashboard.map_no_coordinates", &[&available])
+}
+
fn missing_column(name: &str, result: &QueryResult) -> String {
let available = result
.columns
@@ -313,6 +392,9 @@ mod tests {
y: y.map(str::to_string),
series: series.map(str::to_string),
title: None,
+ lat: None,
+ lng: None,
+ color: None,
line: 1,
col: 1,
span: (0, 4),
@@ -462,4 +544,93 @@ mod tests {
assert!(line.points.iter().all(|p| p.values[0] == 1.0));
assert!(line.notice.is_some());
}
+
+ fn map_plot(lat: Option<&str>, lng: Option<&str>, color: Option<&str>) -> Plot {
+ Plot {
+ kind: "map".into(),
+ x: String::new(),
+ y: None,
+ lat: lat.map(str::to_string),
+ lng: lng.map(str::to_string),
+ color: color.map(str::to_string),
+ ..plot("map", None, None)
+ }
+ }
+
+ fn stations() -> QueryResult {
+ QueryResult {
+ columns: vec![
+ column("name", ColumnKind::Text),
+ column("type", ColumnKind::Text),
+ column("geo_lat", ColumnKind::Numeric),
+ column("geo_lng", ColumnKind::Numeric),
+ ],
+ rows: [
+ ["Utrecht", "mega", "52.09", "5.11"],
+ ["Zwolle", "ic", "52.50", "6.09"],
+ ["Aalten", "stop", "NULL", "6.57"],
+ ]
+ .iter()
+ .map(|r| r.iter().map(|c| c.to_string()).collect())
+ .collect(),
+ elapsed_ms: 0,
+ truncated: false,
+ }
+ }
+
+ #[test]
+ fn a_map_places_the_rows_its_columns_locate() {
+ let result = Ok(stations());
+ let named = prepare(
+ &map_plot(Some("geo_lat"), Some("geo_lng"), Some("type")),
+ Some(&result),
+ );
+ assert_eq!(named.failure, None);
+ let geo = named.geo.expect("a map carries its points");
+ assert_eq!(geo.point_count(), 2);
+ assert_eq!(geo.dropped, 1, "a NULL latitude places nothing");
+ assert_eq!(geo.category_name.as_deref(), Some("type"));
+
+ // Left out, the coordinates are found by name.
+ let guessed = prepare(&map_plot(None, None, None), Some(&result));
+ assert_eq!(guessed.geo.map(|g| g.point_count()), Some(2));
+ assert_eq!(guessed.label_name, "geo_lat / geo_lng");
+ }
+
+ #[test]
+ fn a_map_without_coordinates_says_so() {
+ let result = Ok(QueryResult {
+ columns: vec![column("name", ColumnKind::Text)],
+ rows: vec![vec!["x".into()]],
+ elapsed_ms: 0,
+ truncated: false,
+ });
+ let prepared = prepare(&map_plot(None, None, None), Some(&result));
+ assert!(prepared.geo.is_none());
+ assert!(prepared.failure.is_some());
+
+ let prepared = prepare(&map_plot(Some("nope"), Some("geo_lng"), None), Some(&Ok(stations())));
+ assert!(prepared.failure.unwrap().contains("nope"));
+ }
+
+ #[test]
+ fn a_pie_keeps_every_band_and_folds_the_small_ones() {
+ // More bands than a bar chart keeps: a pie must add up to the whole.
+ let rows: Vec> = (1..=300)
+ .map(|ix| vec![format!("c{ix}"), ix.to_string()])
+ .chain([vec!["neg".to_string(), "-1".to_string()]])
+ .collect();
+ let result = Ok(QueryResult {
+ columns: vec![column("x", ColumnKind::Text), column("y", ColumnKind::Numeric)],
+ rows,
+ elapsed_ms: 0,
+ truncated: false,
+ });
+ let pie = prepare(&plot("pie", Some("y"), None), Some(&result));
+ assert_eq!(pie.points.len(), 301, "nothing truncated");
+ assert_eq!(pie.pie.len(), 8, "folded to the largest slices");
+ let total: f64 = pie.pie.iter().map(|s| s.value() as f64).sum();
+ assert_eq!(total, (1..=300).sum::() as f64);
+ assert!(pie.notice.unwrap().contains('1'), "the negative band is reported");
+ }
}
diff --git a/src/spec/view.rs b/src/spec/view.rs
index 2fd635e..257fd5c 100644
--- a/src/spec/view.rs
+++ b/src/spec/view.rs
@@ -56,7 +56,8 @@ use crate::query::{ColumnKind, QueryOutcome, QueryResult};
use crate::spec::complete::{self, CompletionKind};
use crate::spec::model::{self, Spec};
use crate::spec::plot::{GroupedBars, SeriesPlot, x_label_count};
-use crate::ui::chart::format_value;
+use crate::ui::chart::{format_value, legend_row, map_notes, pie_parts};
+use crate::ui::geo::GeoPlot;
use crate::spec::prepare::{prepare, PlotPoint, PreparedPlot};
use crate::ui::completion::starts_with_ignore_case;
use crate::ui::results::fit_column_width;
@@ -302,24 +303,47 @@ impl Dashboard {
window: &mut Window,
cx: &mut Context,
) -> AnyElement {
- let (title, notice, failure, is_table, is_empty) = {
+ let (title, mut notice, failure, is_table, is_empty) = {
let plot = &self.plots[ix];
+ let is_empty = match plot.kind.as_str() {
+ "map" => plot.geo.as_ref().is_none_or(|geo| geo.point_count() == 0),
+ "pie" => plot.pie.is_empty(),
+ _ => plot.points.is_empty(),
+ };
(
plot.title.clone(),
plot.notice.clone(),
plot.failure.clone(),
plot.kind == "table",
- plot.points.is_empty(),
+ is_empty,
)
};
// The axis subtitle the results chart shows: which series, over which
- // x column. Tables and failures have nothing to say.
- let axes = (!is_table && failure.is_none() && !is_empty).then(|| {
+ // x column — for a pie, what the shares are of; for a map, which
+ // columns place the points. Tables and failures have nothing to say.
+ let drawn = !is_table && failure.is_none() && !is_empty;
+ let mut legend = Vec::new();
+ let axes = drawn.then(|| {
let plot = &self.plots[ix];
- trf(
- "chart.title.by",
- &[&plot.series_names.join(", "), &plot.label_name],
- )
+ let series = plot.series_names.join(", ");
+ match plot.kind.as_str() {
+ "map" => {
+ if let Some(geo) = &plot.geo {
+ let (notes, key) = map_notes(geo, cx);
+ notice = match (notice.take(), notes) {
+ (Some(a), Some(b)) => Some(format!("{a} · {b}")),
+ (a, b) => a.or(b),
+ };
+ legend = key;
+ }
+ plot.label_name.clone()
+ }
+ "pie" => {
+ legend = pie_parts(&plot.pie, series.clone().into(), "dashboard-pie-key", cx).1;
+ trf("chart.pie.title", &[&series, &plot.label_name])
+ }
+ _ => trf("chart.title.by", &[&series, &plot.label_name]),
+ }
});
let body = if let Some(message) = failure {
@@ -380,7 +404,8 @@ impl Dashboard {
.text_color(cx.theme().muted_foreground)
.child(axes),
)
- }),
+ })
+ .when(!legend.is_empty(), |this| this.child(legend_row(legend, cx))),
)
.child(div().flex_1().min_h_0().child(body))
.into_any_element()
@@ -773,6 +798,11 @@ fn chart_element(plot_ix: usize, plot: &PreparedPlot, cx: &App) -> AnyElement {
.unwrap_or_else(|| plot.title.clone());
match plot.kind.as_str() {
+ "pie" => pie_parts(&plot.pie, name.into(), named_id(), cx).0,
+ "map" => match &plot.geo {
+ Some(geo) => GeoPlot::new(named_id(), geo.clone()).into_any_element(),
+ None => empty_chart(cx),
+ },
"bar" if single => BarChart::new(plot.points.clone())
.band(|d: &Arc| d.band.clone())
.value(|d: &Arc| d.values[0])
diff --git a/src/ui/chart.rs b/src/ui/chart.rs
index d7eac6d..b7a550c 100644
--- a/src/ui/chart.rs
+++ b/src/ui/chart.rs
@@ -54,6 +54,7 @@ impl ChartKind {
}
/// One pie slice: a band's first-series value, or the fold of the smallest.
+#[derive(Debug)]
pub struct PieSlice {
name: SharedString,
value: f32,
@@ -61,6 +62,13 @@ pub struct PieSlice {
slot: Option,
}
+impl PieSlice {
+ #[cfg(test)]
+ pub(crate) fn value(&self) -> f32 {
+ self.value
+ }
+}
+
/// A chart is at most a couple of thousand pixels wide, so plotting more
/// points than this cannot show more detail — it only multiplies the
/// primitives the renderer has to push. A wide `PIVOT` over a fortnight of
@@ -88,11 +96,11 @@ pub struct ChartData {
is_time_series: bool,
rows: Vec>,
/// The first series as pie slices, largest first.
- pie: Vec>,
+ pie: Vec>,
/// Rows the pie leaves out: a zero, negative or missing value is no slice.
pie_skipped: usize,
/// Set when the result has a latitude and a longitude column.
- geo: Option>,
+ geo: Option>,
/// `name (type)` per column, for the "no numeric column" message. Only
/// populated when there is nothing to plot.
detected: Vec,
@@ -104,7 +112,7 @@ pub struct ChartData {
impl ChartData {
pub fn prepare(result: &QueryResult) -> Self {
- let geo = GeoData::detect(result).map(Rc::new);
+ let geo = GeoData::detect(result).map(Arc::new);
let mut value_ixes: Vec = result
.columns
.iter()
@@ -269,7 +277,7 @@ impl ChartData {
/// The first series as pie slices, largest first, the smallest folded into
/// "other" past `MAX_SLICES`; plus how many rows had no positive value.
-fn pie_slices(rows: &[Arc]) -> (Vec>, usize) {
+pub(crate) fn pie_slices(rows: &[Arc]) -> (Vec>, usize) {
let mut positive: Vec<(SharedString, f64)> = rows
.iter()
.filter(|r| r.values[0] > 0.)
@@ -284,12 +292,12 @@ fn pie_slices(rows: &[Arc]) -> (Vec>, usize) {
positive.len()
};
let rest: f64 = positive[kept..].iter().map(|(_, v)| v).sum();
- let mut slices: Vec> = positive
+ let mut slices: Vec> = positive
.into_iter()
.take(kept)
.enumerate()
.map(|(slot, (name, value))| {
- Rc::new(PieSlice {
+ Arc::new(PieSlice {
name,
value: value as f32,
slot: Some(slot),
@@ -297,7 +305,7 @@ fn pie_slices(rows: &[Arc]) -> (Vec>, usize) {
})
.collect();
if folded {
- slices.push(Rc::new(PieSlice {
+ slices.push(Arc::new(PieSlice {
name: tr("chart.map.other").into(),
value: rest as f32,
slot: None,
@@ -526,23 +534,15 @@ fn chart_frame(
.when_some(notice, |this, notice| {
this.child(div().text_xs().text_color(muted).child(notice))
})
- .when(!legend.is_empty(), |this| {
- this.child(h_flex().flex_wrap().gap_x_3().gap_y_1().children(
- legend.into_iter().map(|(color, name)| {
- h_flex()
- .gap_1()
- .items_center()
- .child(div().size_2().rounded_full().bg(color))
- .child(div().text_xs().text_color(muted).child(name))
- }),
- ))
- }),
+ .when(!legend.is_empty(), |this| this.child(legend_row(legend, cx))),
)
.child(div().flex_1().min_h_0().child(chart))
.into_any_element()
}
-fn render_map(geo: Rc, cx: &App) -> AnyElement {
+/// What a map says under its title: capping, dropped rows, the color
+/// column; and its legend. Shared with dashboards.
+pub(crate) fn map_notes(geo: &GeoData, cx: &App) -> (Option, Vec<(Hsla, SharedString)>) {
let mut notices = Vec::new();
if let Some(total) = geo.capped_from {
notices.push(trf(
@@ -562,20 +562,47 @@ fn render_map(geo: Rc, cx: &App) -> AnyElement {
.enumerate()
.map(|(slot, name)| (geo.category_color(slot, cx), name.clone()))
.collect();
+ ((!notices.is_empty()).then(|| notices.join(" · ")), legend)
+}
+
+fn render_map(geo: Arc, cx: &App) -> AnyElement {
+ let (notice, legend) = map_notes(&geo, cx);
chart_frame(
trf("chart.map.title", &[&geo.lat_name, &geo.lng_name]),
trf("chart.title.point_count", &[&geo.point_count().to_string()]),
- (!notices.is_empty()).then(|| notices.join(" · ")),
+ notice,
legend,
GeoPlot::new("results-chart-map", geo).into_any_element(),
cx,
)
}
-fn render_pie(data: &ChartData, cx: &App) -> AnyElement {
- if data.pie.is_empty() {
- return empty_chart_state(tr("chart.pie.no_positive"), &[], cx);
- }
+/// A key of colored dots and names, wrapping as wide as it is given.
+pub(crate) fn legend_row(legend: Vec<(Hsla, SharedString)>, cx: &App) -> AnyElement {
+ let muted = cx.theme().muted_foreground;
+ h_flex()
+ .flex_wrap()
+ .gap_x_3()
+ .gap_y_1()
+ .children(legend.into_iter().map(|(color, name)| {
+ h_flex()
+ .gap_1()
+ .items_center()
+ .child(div().size_2().rounded_full().bg(color))
+ .child(div().text_xs().text_color(muted).child(name))
+ }))
+ .into_any_element()
+}
+
+/// A pie over `slices` and its legend (each slice with its share), shared by
+/// the results panel and dashboards so both color and fold slices alike.
+/// Also returns the total the shares are of.
+pub(crate) fn pie_parts(
+ slices: &[Arc],
+ series: SharedString,
+ id: impl Into,
+ cx: &App,
+) -> (AnyElement, Vec<(Hsla, SharedString)>, f64) {
let palette = [
cx.theme().chart_1,
cx.theme().chart_2,
@@ -591,9 +618,8 @@ fn render_pie(data: &ChartData, cx: &App) -> AnyElement {
Some(slot) => palette[slot % palette.len()].opacity(0.55),
None => muted.opacity(0.6),
};
- let total: f64 = data.pie.iter().map(|s| s.value as f64).sum();
- let legend = data
- .pie
+ let total: f64 = slices.iter().map(|s| s.value as f64).sum();
+ let legend = slices
.iter()
.map(|s| {
let share = s.value as f64 / total * 100.;
@@ -603,15 +629,24 @@ fn render_pie(data: &ChartData, cx: &App) -> AnyElement {
)
})
.collect();
- let series = data.series_names[0].clone();
- let chart = PieChart::new(data.pie.clone())
- .value(|s: &Rc| s.value)
- .color(move |s: &Rc| color(s))
+ let chart = PieChart::new(slices.to_vec())
+ .value(|s: &Arc| s.value)
+ .color(move |s: &Arc| color(s))
.pad_angle(0.01)
- .tooltip_name(|s: &Rc| s.name.clone())
- .name(series.clone())
- .id("results-chart-pie")
+ .tooltip_name(|s: &Arc| s.name.clone())
+ .name(series)
+ .id(id)
.into_any_element();
+ (chart, legend, total)
+}
+
+fn render_pie(data: &ChartData, cx: &App) -> AnyElement {
+ if data.pie.is_empty() {
+ return empty_chart_state(tr("chart.pie.no_positive"), &[], cx);
+ }
+ let series = data.series_names[0].clone();
+ let (chart, legend, total) =
+ pie_parts(&data.pie, series.clone().into(), "results-chart-pie", cx);
let notice = (data.pie_skipped > 0)
.then(|| trf("chart.pie.notice.skipped", &[&data.pie_skipped.to_string()]));
let notice = match (data.notice.clone(), notice) {
diff --git a/src/ui/geo.rs b/src/ui/geo.rs
index 60649ba..80eed8e 100644
--- a/src/ui/geo.rs
+++ b/src/ui/geo.rs
@@ -14,7 +14,7 @@
use std::collections::HashMap;
use std::f64::consts::FRAC_PI_4;
-use std::rc::Rc;
+use std::sync::Arc;
use gpui_kit::component::plot::label::{Text, TEXT_SIZE};
use gpui_kit::component::plot::tooltip::{Dot, Tooltip, TooltipState};
@@ -53,6 +53,7 @@ const EDGE_PAD: f32 = 8.;
/// leave a wide plot with three meridians across it.
const LINE_SPACING: f64 = 90.;
+#[derive(Debug)]
pub(crate) struct GeoPoint {
lat: f64,
lng: f64,
@@ -65,6 +66,7 @@ pub(crate) struct GeoPoint {
}
/// Everything the map needs, derived from a result set once.
+#[derive(Debug)]
pub struct GeoData {
pub lat_name: String,
pub lng_name: String,
@@ -74,7 +76,7 @@ pub struct GeoData {
/// one is "other", drawn in the muted color.
pub categories: Vec,
pub folded_other: bool,
- points: Rc>,
+ points: Arc>,
/// Projected extent: `(min_x, max_x, min_y, max_y)`.
extent: (f64, f64, f64, f64),
/// Rows whose coordinates were missing or out of range.
@@ -95,27 +97,44 @@ impl GeoData {
let lng_ix = (0..result.columns.len())
.filter(numeric)
.find(|&ix| ix != lat_ix && is_lng_name(&result.columns[ix].name))?;
-
- let valid: Vec<(usize, f64, f64)> = result
- .rows
- .iter()
- .enumerate()
- .filter_map(|(ix, row)| {
- let lat = parse_number(row.get(lat_ix)?)?;
- let lng = parse_number(row.get(lng_ix)?)?;
- ((-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lng))
- .then_some((ix, lat, lng))
- })
- .collect();
+ let valid = valid_coordinates(result, lat_ix, lng_ix);
if valid.is_empty() || valid.len() * 2 < result.rows.len() {
return None;
}
+ Some(Self::build(result, lat_ix, lng_ix, valid, None))
+ }
+
+ /// The map a dashboard's `map` plot names outright: its latitude and
+ /// longitude columns, and optionally the column to color by (`None` picks
+ /// one as `detect` does). Rows without valid coordinates are dropped and
+ /// counted; nothing is second-guessed, so an empty map says the columns
+ /// held no coordinates rather than falling back to something else.
+ pub fn from_columns(
+ result: &QueryResult,
+ lat_ix: usize,
+ lng_ix: usize,
+ color_ix: Option,
+ ) -> Self {
+ let valid = valid_coordinates(result, lat_ix, lng_ix);
+ Self::build(result, lat_ix, lng_ix, valid, color_ix)
+ }
+
+ fn build(
+ result: &QueryResult,
+ lat_ix: usize,
+ lng_ix: usize,
+ valid: Vec<(usize, f64, f64)>,
+ color_ix: Option,
+ ) -> Self {
let dropped = result.rows.len() - valid.len();
let valid_count = valid.len();
let capped_from = (valid_count > MAX_GEO_POINTS).then_some(valid_count);
let label_ix = label_column(result, &[lat_ix, lng_ix]);
- let category = category_column(result, &valid, label_ix);
+ let category = match color_ix {
+ Some(column) => category_of(result, &valid, column),
+ None => category_column(result, &valid, label_ix),
+ };
let points: Vec = valid
.into_iter()
@@ -159,18 +178,18 @@ impl GeoData {
None => (None, Vec::new(), false),
};
- Some(Self {
+ Self {
lat_name: result.columns[lat_ix].name.clone(),
lng_name: result.columns[lng_ix].name.clone(),
label_name: label_ix.map(|ix| result.columns[ix].name.clone()),
category_name,
categories,
folded_other,
- points: Rc::new(points),
+ points: Arc::new(points),
extent,
dropped,
capped_from,
- })
+ }
}
pub fn point_count(&self) -> usize {
@@ -188,6 +207,36 @@ impl GeoData {
}
}
+/// `(row index, latitude, longitude)` for every row whose two cells parse as
+/// coordinates in range.
+fn valid_coordinates(result: &QueryResult, lat_ix: usize, lng_ix: usize) -> Vec<(usize, f64, f64)> {
+ result
+ .rows
+ .iter()
+ .enumerate()
+ .filter_map(|(ix, row)| {
+ let lat = parse_number(row.get(lat_ix)?)?;
+ let lng = parse_number(row.get(lng_ix)?)?;
+ ((-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lng))
+ .then_some((ix, lat, lng))
+ })
+ .collect()
+}
+
+/// The latitude and longitude columns names alone suggest, as `detect` finds
+/// them but without the values to check: what `ducklocal check` can say about
+/// a `map` plot that leaves `lat` or `lng` out.
+pub(crate) fn guess_coordinate_columns<'a>(
+ names: impl Iterator- + Clone,
+) -> (Option, Option) {
+ let lat = names.clone().position(is_lat_name);
+ let lng = names
+ .enumerate()
+ .find(|(ix, name)| Some(*ix) != lat && is_lng_name(name))
+ .map(|(ix, _)| ix);
+ (lat, lng)
+}
+
fn category_color(slot: usize, is_other: bool, cx: &App) -> Hsla {
let theme = cx.theme();
if is_other {
@@ -330,6 +379,32 @@ fn category_column(
}
let (column, sorted) = best?;
+ Some(fold_category(column, sorted))
+}
+
+/// Color by `column`, as a `color` attribute asks: every value it holds, the
+/// most common first, the rest folded into "other" past the palette.
+fn category_of(result: &QueryResult, valid: &[(usize, f64, f64)], column: usize) -> Option {
+ let mut counts: HashMap<&str, usize> = HashMap::new();
+ for &(row_ix, _, _) in valid {
+ if let Some(value) = result.rows[row_ix].get(column) {
+ *counts.entry(value.as_str()).or_default() += 1;
+ }
+ }
+ if counts.is_empty() {
+ return None;
+ }
+ let mut sorted: Vec<(String, usize)> = counts
+ .into_iter()
+ .map(|(k, v)| (k.to_string(), v))
+ .collect();
+ sorted.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
+ Some(fold_category(column, sorted))
+}
+
+/// Legend slots for `sorted` values (most common first): one each up to the
+/// palette's size, or all but the last slot plus "other" past it.
+fn fold_category(column: usize, sorted: Vec<(String, usize)>) -> Category {
let folded = sorted.len() > MAX_CATEGORIES;
let kept = if folded {
MAX_CATEGORIES - 1
@@ -350,12 +425,12 @@ fn category_column(
if folded {
legend.push(tr("chart.map.other").into());
}
- Some(Category {
+ Category {
column,
slots,
legend,
folded,
- })
+ }
}
fn mercator_y(lat: f64) -> f64 {
@@ -459,14 +534,14 @@ impl Viewport {
}
/// The map element. Rebuilt every frame from the cached `GeoData`; holding the
-/// points behind an `Rc` keeps that a refcount bump.
+/// points behind an `Arc` keeps that a refcount bump.
pub(crate) struct GeoPlot {
id: ElementId,
- data: Rc,
+ data: Arc,
}
impl GeoPlot {
- pub(crate) fn new(id: impl Into, data: Rc) -> Self {
+ pub(crate) fn new(id: impl Into, data: Arc) -> Self {
Self {
id: id.into(),
data,
diff --git a/tests/cli.rs b/tests/cli.rs
index 3a62c69..7907c95 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -901,6 +901,47 @@ plot "b" { type = "line" query = query.revenue x = channel y = channel }
assert!(message.contains("bad.dash:5"), "{message}");
assert!(message.contains("bad.dash:6"), "{message}");
+ // A pie and a map check against the same database: the map's
+ // coordinates are found by name, and a text latitude is a mistake.
+ s.success(&[
+ "query",
+ "--database",
+ "data.duckdb",
+ "--read-write",
+ "--sql",
+ "CREATE TABLE stations AS SELECT * FROM (VALUES
+ ('Utrecht', 'NL', 52.09, 5.11), ('Aachen', 'D', 50.77, 6.09)
+ ) t(name, country, geo_lat, geo_lng)",
+ ]);
+ std::fs::write(
+ s.0.join("geo.dash"),
+ r#"
+query "stations" { sql = "SELECT * FROM stations" }
+query "share" {
+ sql = "SELECT channel, sum(amount) AS total FROM usage GROUP BY channel"
+}
+plot "where" { type = "map" query = query.stations color = country }
+plot "share" { type = "pie" query = query.share x = channel y = total }
+"#,
+ )
+ .unwrap();
+ let out = s.object(&["check", "geo.dash", "--database", "data.duckdb"]);
+ assert_eq!(out["plots"][0]["type"], "map");
+ assert_eq!(out["plots"][0]["color"], "country");
+ assert_eq!(out["plots"][1]["type"], "pie");
+ std::fs::write(
+ s.0.join("badgeo.dash"),
+ r#"
+query "stations" { sql = "SELECT * FROM stations" }
+plot "where" { type = "map" query = query.stations lat = name }
+"#,
+ )
+ .unwrap();
+ let error = s.error(&["check", "badgeo.dash", "--database", "data.duckdb"], 2, "spec");
+ let message = error["error"]["message"].as_str().unwrap();
+ assert!(message.contains("lat = \"name\""), "{message}");
+ assert!(message.contains("not numeric"), "{message}");
+
// Everything wrong before a database opens is said without opening one:
// syntax, unknown blocks, dangling references, multi-statement SQL.
s.error(&["check"], 2, "argument");
diff --git a/tests/lsp.rs b/tests/lsp.rs
index 58f8f50..4cb7e87 100644
--- a/tests/lsp.rs
+++ b/tests/lsp.rs
@@ -330,7 +330,7 @@ fn completion_inside_a_plot_offers_its_attributes() {
.collect();
assert_eq!(
labels,
- ["type", "query", "x", "y", "series", "title"],
+ ["type", "query", "x", "y", "series", "title", "lat", "lng", "color"],
"{response}"
);
assert_eq!(items[0]["kind"], 10, "property: {response}");