From 16765adc147fda5871170b51bf94048e69e61cb5 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 30 Sep 2026 16:32:09 +0800 Subject: [PATCH 1/2] Size map points by a number, and show the numbers on hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A map said where, never how much: every point was the same 9 px dot, and its tooltip gave a name and coordinates to five decimals but not the value the map was about. A dashboard's `map` can now take `size = requests` — area follows the value, or `size_scale = "log"` for values spanning orders of magnitude — with a two-circle key in the corner to read sizes back. Points draw largest first so a small one is never buried, and the pointer picks the one on top. A value that cannot sit on the scale is drawn smallest and counted in the map's notes. The tooltip now leads with the numbers: `tooltip = [place, requests, ips]` lists exactly those columns; without it, the size column and the other numeric columns come first and the coordinates after, in the results chart too. `check` validates the new attributes, with --database that `size` is numeric and every tooltip column exists; the language server completes and documents them. Co-Authored-By: Claude Opus 5.5 (1M context) --- skills/ducklocal/SKILL.md | 4 +- src/i18n.rs | 6 + src/spec/complete.rs | 45 ++++- src/spec/lsp.rs | 5 +- src/spec/mod.rs | 3 + src/spec/model.rs | 166 ++++++++++++++++- src/spec/prepare.rs | 35 +++- src/ui/chart.rs | 6 + src/ui/geo.rs | 382 +++++++++++++++++++++++++++++++++++--- tests/lsp.rs | 15 +- 10 files changed, 625 insertions(+), 42 deletions(-) diff --git a/skills/ducklocal/SKILL.md b/skills/ducklocal/SKILL.md index 1515d1b..ee99bbf 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`, `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`. +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, an optional numeric `size` column (points' area follows the value; `size_scale = "log"` for values spanning orders of magnitude) and an optional `tooltip` list of the columns to show on hover (default: the size column and other numbers, then the coordinates); for example `plot "stations" { type = "map" query = query.stations lat = geo_lat lng = geo_lng color = type size = passengers tooltip = [name, passengers] }`. 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`/`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. +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`/`size`/`tooltip` column against the columns the queries actually return (a non-numeric `y` is an error outside `table`, and so is a non-numeric `lat`, `lng` or `size`). 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/i18n.rs b/src/i18n.rs index fdf1644..b85db06 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -484,6 +484,12 @@ static STRINGS: &[(&str, &str, &str)] = &[ // ── src/ui/geo.rs ─────────────────────────────────────────────────── ("chart.map.title", "地图({} / {})", "Map of {} / {}"), ("chart.map.colored_by", "按 {} 着色", "Colored by {}"), + ("chart.map.sized_by", "按 {} 定大小", "Sized by {}"), + ( + "chart.map.notice.unsized", + "{} 个点没有可用的大小值,按最小绘制", + "{} points have no usable size value; drawn smallest", + ), ("chart.map.other", "其他", "Other"), ("chart.map.row", "第 {} 行", "Row {}"), ("chart.map.no_label", "(结果中没有名称列)", "(no name column in the result)"), diff --git a/src/spec/complete.rs b/src/spec/complete.rs index 494921b..1dc8138 100644 --- a/src/spec/complete.rs +++ b/src/spec/complete.rs @@ -14,8 +14,20 @@ 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"]; +pub(crate) const PLOT_ATTRS: [&str; 12] = [ + "type", + "query", + "x", + "y", + "series", + "title", + "lat", + "lng", + "color", + "size", + "size_scale", + "tooltip", +]; /// One thing that could be inserted at the cursor. #[derive(Debug, Clone, PartialEq)] @@ -81,13 +93,23 @@ pub(crate) fn complete(source: &str, line: usize, col: usize) -> Vec .collect(); } - if let Some(quoted) = after_type_equals(&before) { - return model::PLOT_TYPES + // The two attributes whose value is one of a fixed set of strings. + let choices = [ + ("type", model::PLOT_TYPES, "plot type"), + ("size_scale", model::SIZE_SCALES, "size scale"), + ]; + if let Some((quoted, values, detail)) = choices + .iter() + .find_map(|(attr, values, detail)| { + after_attr_equals(&before, attr).map(|quoted| (quoted, *values, *detail)) + }) + { + return values .iter() .map(|ty| Completion { label: ty.to_string(), kind: CompletionKind::Value, - detail: Some("plot type".to_string()), + detail: Some(detail.to_string()), // Inside an open quote the bare word; outside one, bring the // quotes — the value is a string either way. insert_text: if quoted { @@ -176,7 +198,7 @@ fn after_query_dot(before: &str) -> bool { /// The cursor follows `type =`, optionally inside an opened quote. The answer /// is whether the quote is there, so the insert text knows to bring its own. -fn after_type_equals(before: &str) -> Option { +fn after_attr_equals(before: &str, attr: &str) -> Option { let before = before.trim_end(); let ident_len = trailing_ident_len(before); let head = &before[..before.len() - ident_len]; @@ -185,7 +207,7 @@ fn after_type_equals(before: &str) -> Option { None => (head, false), }; let head = head.trim_end().strip_suffix('=')?; - (head.trim() == "type").then_some(quoted) + (head.trim() == attr).then_some(quoted) } /// Length of the ASCII identifier a line ends in — the partial word being @@ -338,6 +360,15 @@ mod tests { assert_eq!(labels(&candidates), ["sql"]); } + #[test] + fn size_scales_after_size_scale_equals() { + let candidates = complete("plot \"p\" {\n size_scale = \n}", 2, 16); + assert_eq!(labels(&candidates), model::SIZE_SCALES); + // Outside a quote the value comes quoted: it is a string. + assert_eq!(candidates[1].insert_text, "\"log\""); + assert_eq!(candidates[0].detail.as_deref(), Some("size scale")); + } + #[test] fn plot_types_after_type_equals() { // Inside an opened quote the insert text is the bare word. diff --git a/src/spec/lsp.rs b/src/spec/lsp.rs index 18a3192..050b012 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`; a `map` takes `lat`, `lng` and `color` instead of `x`, `y` and `series`."), + "plot" => format!("**plot \"{name}\"**\n\nA named plot: `type`, `query`, `x` and `y`, plus optional `series` and `title`; a `map` takes `lat`, `lng` and optional `color`, `size`, `size_scale` and `tooltip` instead of `x`, `y` and `series`."), _ => return None, }, Hit::Ref { segments, .. } => { @@ -470,6 +470,9 @@ fn attr_doc(name: &str) -> Option<&'static str> { "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.", + "size" => "**size**\n\nOptional, for a `map`: a numeric column its points are sized by — the area follows the value, so twice the value is twice the ink. A key in the corner reads sizes back.", + "size_scale" => "**size_scale**\n\nOptional, with `size`: `\"sqrt\"` (the default, area follows value) or `\"log\"`, for values spanning orders of magnitude.", + "tooltip" => "**tooltip**\n\nOptional, for a `map`: the columns its tooltip lists, in order — `tooltip = [place, requests, ips]`. Left out, it lists the size column and the other numbers, then the coordinates.", "title" => "**title**\n\nOptional: the plot's title.", _ => return None, }) diff --git a/src/spec/mod.rs b/src/spec/mod.rs index 5e15f0e..6688603 100644 --- a/src/spec/mod.rs +++ b/src/spec/mod.rs @@ -171,6 +171,9 @@ pub fn check(args: &[OsString]) -> Result { "lat": p.lat, "lng": p.lng, "color": p.color, + "size": p.size, + "size_scale": p.size_scale, + "tooltip": p.tooltip, "title": p.title, "line": p.line, })).collect::>(), diff --git a/src/spec/model.rs b/src/spec/model.rs index 894ffef..3c7944a 100644 --- a/src/spec/model.rs +++ b/src/spec/model.rs @@ -9,7 +9,8 @@ //! * 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`; +//! optionally `color`, `size` (with `size_scale`, `"sqrt"` or `"log"`) and +//! `tooltip`, a list of the columns its tooltip shows; //! * `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)"`). @@ -28,7 +29,10 @@ use super::syntax::{self, Attr, Block, File, RefSite, Value}; 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"]; +const MAP_ATTRS: &[&str] = &["lat", "lng", "color", "size", "size_scale", "tooltip"]; + +/// How a map's `size` may scale its points. +pub(crate) const SIZE_SCALES: &[&str] = &["sqrt", "log"]; #[derive(Debug, Clone)] pub(crate) struct Spec { @@ -68,6 +72,12 @@ pub(crate) struct Plot { pub lng: Option, /// The column a `map` colors its points by; `None` picks one. pub color: Option, + /// The numeric column a `map` sizes its points by, and the scale: + /// `"sqrt"` (the default, area follows value) or `"log"`. + pub size: Option, + pub size_scale: Option, + /// The columns a `map`'s tooltip lists; `None` picks the numbers. + pub tooltip: Option>, /// Line and column of the block's kind keyword. pub line: usize, pub col: usize, @@ -374,6 +384,9 @@ fn plot(block: &Block, diagnostics: &mut Vec) -> Option { let mut lat = None; let mut lng = None; let mut color = None; + let mut size = None; + let mut size_scale = None; + let mut tooltip = None; let mut seen: HashSet<&str> = HashSet::new(); for attr in &block.attrs { if !seen.insert(attr.name.as_str()) { @@ -413,10 +426,23 @@ fn plot(block: &Block, diagnostics: &mut Vec) -> Option { "lat" => lat = column(attr, diagnostics), "lng" => lng = column(attr, diagnostics), "color" => color = column(attr, diagnostics), + "size" => size = column(attr, diagnostics), + "size_scale" => match string(attr, diagnostics) { + Some(text) if SIZE_SCALES.contains(&text.as_str()) => size_scale = Some(text), + Some(text) => diagnostics.push(Diagnostic::at_attr( + attr, + format!( + "Unknown size_scale: {text:?}; one of {}", + SIZE_SCALES.join(", ") + ), + )), + None => {} + }, + "tooltip" => tooltip = columns(attr, diagnostics), other => diagnostics.push(Diagnostic::at_attr( attr, format!( - "A plot block holds type, query, x, y, series, title, and for a map lat, lng, color; unknown attribute: {other}" + "A plot block holds type, query, x, y, series, title, and for a map lat, lng, color, size, size_scale, tooltip; unknown attribute: {other}" ), )), } @@ -441,6 +467,12 @@ fn plot(block: &Block, diagnostics: &mut Vec) -> Option { if let Some(message) = misplaced { diagnostics.push(Diagnostic::at_attr(attr, message)); } + if name == "size_scale" && size.is_none() && is_map { + diagnostics.push(Diagnostic::at_attr( + attr, + "size_scale says how size scales the points; set size to a numeric column too", + )); + } } let required: &[&str] = if is_map { &["type", "query"] } else { &["type", "query", "x"] }; for &name in required { @@ -472,6 +504,9 @@ fn plot(block: &Block, diagnostics: &mut Vec) -> Option { lat, lng, color, + size, + size_scale, + tooltip, line: block.line, col: block.col, span: block.kind_span, @@ -479,6 +514,39 @@ fn plot(block: &Block, diagnostics: &mut Vec) -> Option { }) } +/// A list of column names: `[place, requests, "Unique IPs"]`, not empty. +fn columns(attr: &Attr, diagnostics: &mut Vec) -> Option> { + let Value::List(items) = &attr.value else { + diagnostics.push(Diagnostic::at_attr( + attr, + format!("{} is a list of columns: [a, b, \"C d\"]", attr.name), + )); + return None; + }; + let mut names = Vec::with_capacity(items.len()); + for item in items { + match item { + Value::Ref(segments) if segments.len() == 1 => names.push(segments[0].clone()), + Value::Str(text) => names.push(text.clone()), + _ => { + diagnostics.push(Diagnostic::at_attr( + attr, + format!("{} lists columns: identifiers or strings", attr.name), + )); + return None; + } + } + } + if names.is_empty() { + diagnostics.push(Diagnostic::at_attr( + attr, + format!("{} lists at least one column; leave it out for the default", attr.name), + )); + return None; + } + Some(names) +} + /// A column name, written as a bare identifier or — for the names SQL quoting /// exists for — a string. fn column(attr: &Attr, diagnostics: &mut Vec) -> Option { @@ -585,8 +653,15 @@ pub(crate) fn check_columns(spec: &Spec, columns: &ColumnLookup) -> Vec Vec Vec = diagnostics.iter().map(|d| d.message.as_str()).collect(); + let has = |needle: &str| messages.iter().any(|m| m.contains(needle)); + assert!(has("Unknown size_scale: \"cubic\""), "{messages:?}"); + assert!(has("set size to a numeric column too"), "{messages:?}"); + assert!(has("lists at least one column"), "{messages:?}"); + assert!(has("tooltip is a list of columns"), "{messages:?}"); + assert!(has("size is for map plots"), "{messages:?}"); + } + + #[test] + fn size_and_tooltip_columns_must_exist_and_size_must_be_a_number() { + let spec = validate_ok( + r#" +query "q" { sql = "SELECT 1" } +plot "m" { + type = "map" + query = query.q + lat = lat + lng = lng + size = place + tooltip = [place, nope] +} +"#, + ); + let columns = |_: &str| { + Ok(vec![ + ("lat".to_string(), "DOUBLE".to_string()), + ("lng".to_string(), "DOUBLE".to_string()), + ("place".to_string(), "VARCHAR".to_string()), + ]) + }; + let diagnostics = check_columns(&spec, &columns); + let messages: Vec<&str> = diagnostics.iter().map(|d| d.message.as_str()).collect(); + assert!( + messages.iter().any(|m| m.contains("size = \"place\" (VARCHAR), which is not numeric")), + "{messages:?}" + ); + assert!( + messages.iter().any(|m| m.contains("tooltip = \"nope\"")), + "{messages:?}" + ); + } } diff --git a/src/spec/prepare.rs b/src/spec/prepare.rs index c3aca21..fea6124 100644 --- a/src/spec/prepare.rs +++ b/src/spec/prepare.rs @@ -25,7 +25,7 @@ use crate::i18n::trf; use crate::query::QueryResult; 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}; +use crate::ui::geo::{is_lat_name, is_lng_name, GeoData, MapStyle, SizeScale}; /// A chart is at most a couple of thousand pixels wide; past this, points are /// bucket-averaged, as in the results chart. @@ -138,7 +138,35 @@ pub(crate) fn prepare(plot: &Plot, result: Option<&Result>) Some(Err(name)) => return base(Some(missing_column(name, result))), None => None, }; - let geo = GeoData::from_columns(result, lat_ix, lng_ix, color_ix); + let size_ix = match plot.size.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 tooltip = match &plot.tooltip { + Some(names) => { + let mut ixs = Vec::with_capacity(names.len()); + for name in names { + match column(name) { + Some(ix) => ixs.push(ix), + None => return base(Some(missing_column(name, result))), + } + } + Some(ixs) + } + None => None, + }; + let scale = plot + .size_scale + .as_deref() + .and_then(SizeScale::parse) + .unwrap_or_default(); + let style = MapStyle { + color: color_ix, + size: size_ix.map(|ix| (ix, scale)), + tooltip, + }; + let geo = GeoData::from_columns(result, lat_ix, lng_ix, &style); return PreparedPlot { label_name: format!( "{} / {}", @@ -395,6 +423,9 @@ mod tests { lat: None, lng: None, color: None, + size: None, + size_scale: None, + tooltip: None, line: 1, col: 1, span: (0, 4), diff --git a/src/ui/chart.rs b/src/ui/chart.rs index b7a550c..e7ff08e 100644 --- a/src/ui/chart.rs +++ b/src/ui/chart.rs @@ -556,6 +556,12 @@ pub(crate) fn map_notes(geo: &GeoData, cx: &App) -> (Option, Vec<(Hsla, if let Some(name) = &geo.category_name { notices.push(trf("chart.map.colored_by", &[name])); } + if let Some(name) = &geo.size_name { + notices.push(trf("chart.map.sized_by", &[name])); + } + if geo.size_missing > 0 { + notices.push(trf("chart.map.notice.unsized", &[&geo.size_missing.to_string()])); + } let legend = geo .categories .iter() diff --git a/src/ui/geo.rs b/src/ui/geo.rs index 80eed8e..9f362b7 100644 --- a/src/ui/geo.rs +++ b/src/ui/geo.rs @@ -6,7 +6,8 @@ //! 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 //! cardinality text column is present (a `type`, a `country`), points are -//! colored by it, so the map says something beyond "where". +//! colored by it, so the map says something beyond "where"; a dashboard can +//! also size them by a number (`MapStyle`), so it says "how much" too. //! //! Like the other charts, detection and projection happen once per result set //! (`GeoData::detect`); a frame re-derives only the viewport transform, which @@ -41,6 +42,12 @@ const MAX_MERCATOR_LAT: f64 = 85.051_128_78; /// single street does not fill the plot at an absurd scale. const MIN_SPAN: f64 = 0.01; const DOT_SIZE: f32 = 9.; +/// A sized point's diameter at the largest value, and the least any point +/// gets — below this a dot stops being findable, let alone hoverable. +const MAX_SIZED: f32 = 40.; +const MIN_SIZED: f32 = 5.; +/// Tooltip rows of measures a map shows unasked. +const DEFAULT_MEASURES: usize = 4; const HOVER_DOT_SIZE: f32 = 12.; const HOVER_HALO: f32 = 20.; /// How close, in pixels, the cursor must be to a point to hover it. @@ -63,6 +70,78 @@ pub(crate) struct GeoPoint { y: f64, label: SharedString, category: Option, + /// The point's diameter in pixels. + diameter: f32, + /// The cells of `GeoData::measures`, in order, as the query returned + /// them: a tooltip shows the number, not a rounding of it. + measures: Vec, +} + +/// How a sized map turns a value into a point's area. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SizeScale { + /// Area in proportion to the value: what a reader's eye compares. + #[default] + Sqrt, + /// By order of magnitude, for values spanning several: one heavy row + /// would otherwise shrink every other point to the minimum. + Log, +} + +impl SizeScale { + pub fn parse(name: &str) -> Option { + match name { + "sqrt" => Some(Self::Sqrt), + "log" => Some(Self::Log), + _ => None, + } + } + + /// The diameter for `value` on a map whose usable values run + /// `min..=max`, or `None` when the value cannot be placed on the scale. + fn diameter(self, value: f64, min: f64, max: f64) -> Option { + match self { + Self::Sqrt if value >= 0. && max > 0. => { + Some((MAX_SIZED * (value / max).sqrt() as f32).max(MIN_SIZED)) + } + Self::Log if value > 0. => { + let t = if max > min { + (value.ln() - min.ln()) / (max.ln() - min.ln()) + } else { + 1. + }; + Some(MIN_SIZED + (MAX_SIZED - MIN_SIZED) * t as f32) + } + _ => None, + } + } + + fn usable(self, value: f64) -> bool { + match self { + Self::Sqrt => value >= 0., + Self::Log => value > 0., + } + } +} + +/// What a dashboard's `map` plot asks of the points beyond placing them. +#[derive(Clone, Debug, Default)] +pub struct MapStyle { + /// The column to color by; `None` picks one. + pub color: Option, + /// The numeric column to size by, and how. + pub size: Option<(usize, SizeScale)>, + /// The columns the tooltip lists, in order; `None` lists the size column + /// and the other numbers, then the coordinates. + pub tooltip: Option>, +} + +/// The size key's reference values and the scale they sit on. +#[derive(Debug)] +struct SizeKey { + scale: SizeScale, + min: f64, + max: f64, } /// Everything the map needs, derived from a result set once. @@ -83,6 +162,17 @@ pub struct GeoData { pub dropped: usize, /// How many rows had valid coordinates, when more than were plotted. pub capped_from: Option, + /// The column points are sized by. + pub size_name: Option, + size_key: Option, + /// Points whose size value was missing, or off its scale (negative, or + /// not positive on a log scale): drawn at the smallest size. + pub size_missing: usize, + /// The tooltip's measure rows: column names, matching each point's + /// `measures`. + measures: Vec, + /// Whether the tooltip ends with the coordinates. + show_coordinates: bool, } impl GeoData { @@ -101,22 +191,22 @@ impl GeoData { if valid.is_empty() || valid.len() * 2 < result.rows.len() { return None; } - Some(Self::build(result, lat_ix, lng_ix, valid, None)) + Some(Self::build(result, lat_ix, lng_ix, valid, &MapStyle::default())) } /// 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. + /// longitude columns, and the `style` its other attributes ask for. + /// 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, + style: &MapStyle, ) -> Self { let valid = valid_coordinates(result, lat_ix, lng_ix); - Self::build(result, lat_ix, lng_ix, valid, color_ix) + Self::build(result, lat_ix, lng_ix, valid, style) } fn build( @@ -124,19 +214,46 @@ impl GeoData { lat_ix: usize, lng_ix: usize, valid: Vec<(usize, f64, f64)>, - color_ix: Option, + style: &MapStyle, ) -> 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 = match color_ix { + let category = match style.color { Some(column) => category_of(result, &valid, column), None => category_column(result, &valid, label_ix), }; - let points: Vec = valid + // The scale's ends come from the values that can sit on it. + let size_key = style.size.and_then(|(column, scale)| { + let (min, max) = valid + .iter() + .filter_map(|(ix, _, _)| parse_number(result.rows[*ix].get(column)?)) + .filter(|value| scale.usable(*value)) + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| { + (lo.min(v), hi.max(v)) + }); + (min <= max).then_some((column, SizeKey { scale, min, max })) + }); + let mut size_missing = 0; + + let measure_ixs: Vec = match &style.tooltip { + Some(columns) => columns.clone(), + None => { + let size_ix = style.size.map(|(column, _)| column); + let category_ix = category.as_ref().map(|c| c.column); + let others = (0..result.columns.len()).filter(|&ix| { + result.columns[ix].kind == ColumnKind::Numeric + && ![Some(lat_ix), Some(lng_ix), size_ix, category_ix, label_ix] + .contains(&Some(ix)) + }); + size_ix.into_iter().chain(others).take(DEFAULT_MEASURES).collect() + } + }; + + let mut points: Vec = valid .into_iter() .take(MAX_GEO_POINTS) .map(|(ix, lat, lng)| { @@ -148,6 +265,26 @@ impl GeoData { let category = category .as_ref() .and_then(|c| row.get(c.column).map(|v| c.slot(v))); + let diameter = match (&size_key, style.size) { + (Some((column, key)), _) => row + .get(*column) + .and_then(|cell| parse_number(cell)) + .and_then(|value| key.scale.diameter(value, key.min, key.max)) + .unwrap_or_else(|| { + size_missing += 1; + MIN_SIZED + }), + // Asked for, but no row had a usable value. + (None, Some(_)) => { + size_missing += 1; + MIN_SIZED + } + (None, None) => DOT_SIZE, + }; + let measures = measure_ixs + .iter() + .map(|ix| row.get(*ix).cloned().unwrap_or_default().into()) + .collect(); GeoPoint { lat, lng, @@ -155,9 +292,16 @@ impl GeoData { y: mercator_y(lat), label, category, + diameter, + measures, } }) .collect(); + // Largest first, so a small point is never buried under a big one + // and stays reachable by the pointer. + if style.size.is_some() { + points.sort_by(|a, b| b.diameter.total_cmp(&a.diameter)); + } let extent = points.iter().fold( ( @@ -189,6 +333,14 @@ impl GeoData { extent, dropped, capped_from, + size_name: style.size.map(|(column, _)| result.columns[column].name.clone()), + size_key: size_key.map(|(_, key)| key), + size_missing, + measures: measure_ixs + .iter() + .map(|ix| result.columns[*ix].name.clone()) + .collect(), + show_coordinates: style.tooltip.is_none(), } } @@ -636,16 +788,21 @@ impl Plot for GeoPlot { for p in self.data.points.iter() { let (x, y) = view.project(p.x, p.y); let color = self.color_of(p, cx); - let origin = bounds.origin + point(px(x - DOT_SIZE / 2.), px(y - DOT_SIZE / 2.)); + let d = p.diameter; + let origin = bounds.origin + point(px(x - d / 2.), px(y - d / 2.)); window.paint_quad(quad( - Bounds::new(origin, size(px(DOT_SIZE), px(DOT_SIZE))), - px(DOT_SIZE / 2.), + Bounds::new(origin, size(px(d), px(d))), + px(d / 2.), Background::from(color), px(1.), ring, BorderStyle::default(), )); } + + if let Some(key) = &self.data.size_key { + paint_size_key(key, &view, bounds, window, cx); + } } fn id(&self) -> Option { @@ -660,18 +817,33 @@ impl Plot for GeoPlot { ) -> Option { let view = Viewport::fit(self.data.extent, bounds.size); let (cx_, cy_) = (position.x.as_f32(), position.y.as_f32()); - // Later points paint on top, so on a tie the last one is the one seen. + // A point is under the pointer inside its own circle, or near a small + // one. Of those, the one painted last — on top, and for a sized map + // the smallest — is the one seen; closeness breaks the rest. let (index, (x, y), _) = self .data .points .iter() .enumerate() - .map(|(ix, p)| { + .filter_map(|(ix, p)| { let (x, y) = view.project(p.x, p.y); - (ix, (x, y), (x - cx_).powi(2) + (y - cy_).powi(2)) + let d2 = (x - cx_).powi(2) + (y - cy_).powi(2); + let reach = (p.diameter / 2.).max(HIT_RADIUS); + (d2 <= reach * reach).then_some((ix, (x, y), d2)) }) - .filter(|(_, _, d2)| *d2 <= HIT_RADIUS * HIT_RADIUS) - .min_by(|a, b| a.2.total_cmp(&b.2).then(b.0.cmp(&a.0)))?; + .max_by(|a, b| { + let inside = |(ix, _, d2): &(usize, (f32, f32), f32)| { + let r = self.data.points[*ix].diameter / 2.; + *d2 <= r * r + }; + inside(a) + .cmp(&inside(b)) + .then(if inside(a) && inside(b) { + a.0.cmp(&b.0) + } else { + b.2.total_cmp(&a.2).then(a.0.cmp(&b.0)) + }) + })?; let at = point(px(x), px(y)); Some(TooltipState::new(index, at, vec![at])) } @@ -700,9 +872,15 @@ impl Plot for GeoPlot { let value = self.data.categories.get(slot).cloned().unwrap_or_default(); tooltip = tooltip.row(color, name.clone(), value); } - tooltip = tooltip - .plain_row(self.data.lat_name.clone(), format!("{:.5}", p.lat)) - .plain_row(self.data.lng_name.clone(), format!("{:.5}", p.lng)); + // The numbers the map is about come first; where it is, after. + for (name, value) in self.data.measures.iter().zip(&p.measures) { + tooltip = tooltip.plain_row(name.clone(), value.clone()); + } + if self.data.show_coordinates { + tooltip = tooltip + .plain_row(self.data.lat_name.clone(), format!("{:.5}", p.lat)) + .plain_row(self.data.lng_name.clone(), format!("{:.5}", p.lng)); + } if self.data.label_name.is_none() { // Without a name column the title is the row number; say so. tooltip = tooltip.plain_row(tr("chart.map.no_label"), ""); @@ -711,13 +889,75 @@ impl Plot for GeoPlot { } } +/// The size key: the largest value's circle and a smaller reference inside +/// it, bottom-aligned in the plot area's lower-left corner, each labelled — +/// how a reader turns a circle back into a number. +fn paint_size_key( + key: &SizeKey, + view: &Viewport, + bounds: Bounds, + window: &mut Window, + cx: &mut App, +) { + let theme = cx.theme(); + let (muted, background) = (theme.muted_foreground, theme.background); + let small = match key.scale { + // A quarter of the largest value draws half its diameter: the pair + // shows that area, not width, carries the value. + SizeScale::Sqrt => key.max / 4., + SizeScale::Log => key.min, + }; + let entries: Vec<(f64, f32)> = [key.max, small] + .into_iter() + .filter(|v| *v > 0. || key.scale == SizeScale::Sqrt) + .filter_map(|v| key.scale.diameter(v, key.min, key.max).map(|d| (v, d))) + .collect(); + let Some(&(_, largest)) = entries.first() else { + return; + }; + let pad = 8.; + let label_width = 64.; + let panel = Bounds::new( + bounds.origin + + point( + px(view.area.origin.x + 6.), + px(view.area.origin.y + view.area.size.height - largest - pad * 2. - 6.), + ), + size(px(largest + label_width + pad * 2.), px(largest + pad * 2.)), + ); + window.paint_quad(fill(panel, background.opacity(0.85)).corner_radii(px(4.))); + let bottom = panel.origin.y + px(pad + largest); + let center_x = panel.origin.x + px(pad + largest / 2.); + let mut labels = Vec::new(); + for (value, d) in &entries { + let origin = point(center_x - px(d / 2.), bottom - px(*d)); + window.paint_quad(quad( + Bounds::new(origin, size(px(*d), px(*d))), + px(d / 2.), + Background::from(gpui_kit::transparent_black()), + px(1.), + muted, + BorderStyle::default(), + )); + labels.push(Text::new( + crate::ui::chart::format_value(*value), + point( + center_x + px(largest / 2. + 6.) - bounds.origin.x, + bottom - px(*d) - bounds.origin.y - px(1.), + ), + muted, + )); + } + PlotLabel::new(labels).paint(&bounds, window, cx); +} + #[cfg(test)] mod tests { // Deliberately not `use super::*`: that pulls in `gpui_kit::*`, whose // `test` macro shadows the built-in `#[test]`. use super::{ format_degrees, inverse_mercator_y, is_lat_name, is_lng_name, mercator_y, nice_step, - GeoData, MAX_CATEGORIES, + GeoData, MapStyle, SizeScale, DOT_SIZE, MAX_CATEGORIES, MAX_SIZED, MIN_SIZED, }; use crate::query::{ColumnKind, ColumnMeta, QueryResult}; @@ -841,4 +1081,100 @@ mod tests { assert_eq!(format_degrees(-4.5, 0.5, 'E', 'W'), "4.5°W"); assert_eq!(format_degrees(0.0, 0.5, 'E', 'W'), "0°"); } + + fn traffic() -> QueryResult { + QueryResult { + columns: Vec::from([ + column("place", ColumnKind::Text), + column("lat", ColumnKind::Numeric), + column("lng", ColumnKind::Numeric), + column("requests", ColumnKind::Numeric), + column("ips", ColumnKind::Numeric), + ]), + rows: [ + ["Hong Kong", "22.3", "114.2", "836", "40"], + ["Tokyo", "35.7", "139.7", "209", "12"], + ["Singapore", "1.35", "103.8", "0", "1"], + ["Nowhere", "10.0", "10.0", "NULL", "0"], + ] + .into_iter() + .map(|r| r.into_iter().map(String::from).collect()) + .collect(), + elapsed_ms: 0, + truncated: false, + } + } + + fn sized(scale: SizeScale, tooltip: Option>) -> GeoData { + GeoData::from_columns( + &traffic(), + 1, + 2, + &MapStyle { + color: None, + size: Some((3, scale)), + tooltip, + }, + ) + } + + fn diameter_of(geo: &GeoData, label: &str) -> f32 { + geo.points.iter().find(|p| p.label == label).unwrap().diameter + } + + #[test] + fn area_follows_the_value() { + let geo = sized(SizeScale::Sqrt, None); + let (hk, tokyo) = (diameter_of(&geo, "Hong Kong"), diameter_of(&geo, "Tokyo")); + assert_eq!(hk, MAX_SIZED); + // A quarter of the value is a quarter of the area: half the diameter. + let ratio = (tokyo / hk) as f64; + assert!((ratio - (209f64 / 836.).sqrt()).abs() < 1e-3, "{ratio}"); + // Zero is on the scale, just too small to see unclamped. + assert_eq!(diameter_of(&geo, "Singapore"), MIN_SIZED); + // A missing value is drawn smallest, and counted. + assert_eq!(diameter_of(&geo, "Nowhere"), MIN_SIZED); + assert_eq!(geo.size_missing, 1); + assert_eq!(geo.size_name.as_deref(), Some("requests")); + } + + #[test] + fn a_log_scale_spreads_orders_of_magnitude_and_rejects_zero() { + let geo = sized(SizeScale::Log, None); + // Smallest positive value at the minimum, largest at the maximum. + assert_eq!(diameter_of(&geo, "Tokyo"), MIN_SIZED); + assert_eq!(diameter_of(&geo, "Hong Kong"), MAX_SIZED); + // Zero has no logarithm: it and the missing value are counted. + assert_eq!(geo.size_missing, 2); + } + + #[test] + fn big_points_paint_first_so_small_ones_stay_on_top() { + let geo = sized(SizeScale::Sqrt, None); + let diameters: Vec = geo.points.iter().map(|p| p.diameter).collect(); + assert!(diameters.windows(2).all(|w| w[0] >= w[1]), "{diameters:?}"); + } + + #[test] + fn the_tooltip_leads_with_the_numbers() { + // Unasked: the size column, then the other numbers, then where. + let geo = sized(SizeScale::Sqrt, None); + assert_eq!(geo.measures, ["requests", "ips"]); + assert!(geo.show_coordinates); + let hk = geo.points.iter().find(|p| p.label == "Hong Kong").unwrap(); + assert_eq!(hk.measures, ["836", "40"]); + + // Asked: exactly those columns, in that order, and no coordinates. + let geo = sized(SizeScale::Sqrt, Some(vec![4, 0])); + assert_eq!(geo.measures, ["ips", "place"]); + assert!(!geo.show_coordinates); + } + + #[test] + fn an_unsized_map_keeps_its_dots_and_still_shows_numbers() { + let geo = GeoData::detect(&traffic()).unwrap(); + assert!(geo.points.iter().all(|p| p.diameter == DOT_SIZE)); + assert_eq!(geo.size_name, None); + assert_eq!(geo.measures, ["requests", "ips"]); + } } diff --git a/tests/lsp.rs b/tests/lsp.rs index 4cb7e87..334c19a 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -330,7 +330,20 @@ fn completion_inside_a_plot_offers_its_attributes() { .collect(); assert_eq!( labels, - ["type", "query", "x", "y", "series", "title", "lat", "lng", "color"], + [ + "type", + "query", + "x", + "y", + "series", + "title", + "lat", + "lng", + "color", + "size", + "size_scale", + "tooltip" + ], "{response}" ); assert_eq!(items[0]["kind"], 10, "property: {response}"); From 4a3f86ce683ab9aeeb8c22e147fa9031adedd595 Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 30 Sep 2026 16:33:32 +0800 Subject: [PATCH 2/2] Leave the map's header to the base-map change Both touched the same lines; the size encoding is documented where it lives, on MapStyle. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/ui/geo.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ui/geo.rs b/src/ui/geo.rs index 9f362b7..682da10 100644 --- a/src/ui/geo.rs +++ b/src/ui/geo.rs @@ -6,8 +6,7 @@ //! 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 //! cardinality text column is present (a `type`, a `country`), points are -//! colored by it, so the map says something beyond "where"; a dashboard can -//! also size them by a number (`MapStyle`), so it says "how much" too. +//! colored by it, so the map says something beyond "where". //! //! Like the other charts, detection and projection happen once per result set //! (`GeoData::detect`); a frame re-derives only the viewport transform, which