From fadb1d23821d6d0211e92a1c7064001c1d8cf11f Mon Sep 17 00:00:00 2001 From: Joel Natividad <1980690+jqnatividad@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:42:55 -0400 Subject: [PATCH 01/11] feat: add Choropleth and ChoroplethMap trace types Adds choropleth (filled-region) map support, mirroring the existing geo trace pattern: - Choropleth on the geo subplot (LayoutGeo), with a LocationMode enum (ISO-3 / USA-states / country names / geojson-id), a dedicated choropleth::Marker (boundary line + opacity), Selection, and the shared colorscale/colorbar machinery. geojson accepts a URL string or an inline GeoJSON object (serde_json::Value). - ChoroplethMap on the MapLibre `map` subplot (GeoJSON-only). - New LayoutMap / MapStyle / MapBounds for the `map` subplot, wired into Layout (mirrors the existing mapbox subplot). Registers both PlotType variants and top-level/sub-module re-exports, adds serialization + doc tests, runnable examples (examples/maps), and a "Maps / Choropleth Maps" book recipe page. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + docs/book/src/SUMMARY.md | 2 + docs/book/src/recipes/maps.md | 7 + docs/book/src/recipes/maps/choropleth_maps.md | 43 +++ examples/maps/Cargo.toml | 1 + examples/maps/src/main.rs | 80 ++++- plotly/src/common/mod.rs | 2 + plotly/src/layout/map.rs | 125 ++++++++ plotly/src/layout/mod.rs | 3 + plotly/src/lib.rs | 10 +- plotly/src/traces/choropleth.rs | 300 ++++++++++++++++++ plotly/src/traces/choropleth_map.rs | 214 +++++++++++++ plotly/src/traces/mod.rs | 4 + 13 files changed, 784 insertions(+), 8 deletions(-) create mode 100644 docs/book/src/recipes/maps.md create mode 100644 docs/book/src/recipes/maps/choropleth_maps.md create mode 100644 plotly/src/layout/map.rs create mode 100644 plotly/src/traces/choropleth.rs create mode 100644 plotly/src/traces/choropleth_map.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 04dc0511..9996e3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ https://github.com/plotly/plotly.rs/pull/350 - [[#NNN](https://github.com/plotly/plotly.rs/pull/NNN)] Add `Treemap` trace type, with `Tiling`/`PathBar` helpers, a dedicated `treemap::Marker` (`pad`/`corner_radius`/`depth_fade`), and `treemapcolorway`/`extendtreemapcolors` layout options - [[#NNN](https://github.com/plotly/plotly.rs/pull/NNN)] Add `Sunburst` trace type +- [[#NNN](https://github.com/plotly/plotly.rs/pull/NNN)] Add `Choropleth` (geo subplot) and `ChoroplethMap` (MapLibre `map` subplot) trace types, with a `LocationMode` enum and a dedicated `choropleth::Marker`; add the MapLibre `map` subplot via `LayoutMap`/`MapStyle`/`MapBounds` - [[#407](https://github.com/plotly/plotly.rs/issues/407)] Expose plotly.js 3.1–3.6 attributes: - `Layout`: `hoversort`, `hoveranywhere`, `clickanywhere` - `Axis`: `zerolinelayer` (`ZeroLineLayer`), `minorloglabels`, `modebardisable` (`ModeBarDisable`), `ticklabelposition` (`TickLabelPosition`), `unifiedhovertitle` (`UnifiedHoverTitle`), and `ExponentFormat::SIExtended` diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index 8a9b7e7c..866124fd 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -32,6 +32,8 @@ - [Rangebreaks](./recipes/financial_charts/rangebreaks.md) - [3D Charts](./recipes/3dcharts.md) - [Scatter 3D](./recipes/3dcharts/3dcharts.md) + - [Maps](./recipes/maps.md) + - [Choropleth Maps](./recipes/maps/choropleth_maps.md) - [Subplots](./recipes/subplots.md) - [Subplots](./recipes/subplots/subplots.md) - [Multiple Axes](./recipes/subplots/multiple_axes.md) diff --git a/docs/book/src/recipes/maps.md b/docs/book/src/recipes/maps.md new file mode 100644 index 00000000..1fc2c6b3 --- /dev/null +++ b/docs/book/src/recipes/maps.md @@ -0,0 +1,7 @@ +# Maps + +The source code for the following examples can also be found [here](https://github.com/plotly/plotly.rs/tree/main/examples/maps). + +Kind | Link +:---|:----: +Choropleth Maps | [Choropleth Maps](./maps/choropleth_maps.md) diff --git a/docs/book/src/recipes/maps/choropleth_maps.md b/docs/book/src/recipes/maps/choropleth_maps.md new file mode 100644 index 00000000..5e9e6ccb --- /dev/null +++ b/docs/book/src/recipes/maps/choropleth_maps.md @@ -0,0 +1,43 @@ +# Choropleth Maps + +Choropleth maps color geographic regions (countries, states, custom GeoJSON +areas) according to a data value. Two trace types are available: + +- [`Choropleth`](https://docs.rs/plotly/latest/plotly/struct.Choropleth.html) — + drawn on the built-in `geo` subplot + ([`LayoutGeo`](https://docs.rs/plotly/latest/plotly/layout/struct.LayoutGeo.html)). + Regions are matched by `location_mode` (ISO-3 codes, USA state codes, country + names, or a GeoJSON id). +- [`ChoroplethMap`](https://docs.rs/plotly/latest/plotly/struct.ChoroplethMap.html) — + drawn on the MapLibre `map` subplot + ([`LayoutMap`](https://docs.rs/plotly/latest/plotly/layout/struct.LayoutMap.html)). + Regions are always matched against a GeoJSON feature collection via + `feature_id_key`. + +The following imports are used in the examples below: + +```rust,no_run +use plotly::{ + choropleth::{LocationMode, Marker as ChoroplethMarker}, + color::Rgb, + common::{ColorBar, ColorScale, ColorScalePalette, Line}, + layout::{Center, DragMode, LayoutGeo, LayoutMap, MapStyle}, + Choropleth, ChoroplethMap, Configuration, Layout, Plot, +}; +``` + +> The map examples are not built into this book automatically because they fetch +> remote data / basemap tiles at render time. To view them, run +> `cargo run` inside `examples/maps` (set the `show` argument to `true`). + +## Choropleth on a geo subplot + +```rust,no_run +{{#include ../../../../../examples/maps/src/main.rs:choropleth}} +``` + +## Choropleth on a MapLibre map subplot + +```rust,no_run +{{#include ../../../../../examples/maps/src/main.rs:choropleth_map}} +``` diff --git a/examples/maps/Cargo.toml b/examples/maps/Cargo.toml index 2427ea8e..cb3b2b26 100644 --- a/examples/maps/Cargo.toml +++ b/examples/maps/Cargo.toml @@ -9,4 +9,5 @@ plotly = { path = "../../plotly" } plotly_utils = { path = "../plotly_utils" } csv = "1.3" reqwest = { version = "0.11", features = ["blocking"] } +serde_json = "1" diff --git a/examples/maps/src/main.rs b/examples/maps/src/main.rs index 5ab7e8ba..f504c3e2 100644 --- a/examples/maps/src/main.rs +++ b/examples/maps/src/main.rs @@ -1,10 +1,15 @@ #![allow(dead_code)] use plotly::{ + choropleth::{LocationMode, Marker as ChoroplethMarker}, color::Rgb, - common::{Line, Marker, Mode}, - layout::{Axis, Center, DragMode, LayoutGeo, Mapbox, MapboxStyle, Projection, Rotation}, - Configuration, DensityMapbox, Layout, Plot, ScatterGeo, ScatterMapbox, + common::{ColorBar, ColorScale, ColorScalePalette, Line, Marker, Mode}, + layout::{ + Axis, Center, DragMode, LayoutGeo, LayoutMap, MapStyle, Mapbox, MapboxStyle, Projection, + Rotation, + }, + Choropleth, ChoroplethMap, Configuration, DensityMapbox, Layout, Plot, ScatterGeo, + ScatterMapbox, }; use plotly_utils::write_example_to_html; @@ -152,9 +157,78 @@ fn density_mapbox(show: bool, file_name: &str) { } } +/// Classic choropleth on the `geo` subplot, coloring countries by value using +/// ISO-3 country codes. +// ANCHOR: choropleth +fn choropleth(show: bool, file_name: &str) { + let trace = Choropleth::new( + vec![ + "USA", "CAN", "MEX", "BRA", "ARG", "FRA", "DEU", "CHN", "IND", "AUS", + ], + vec![10.0, 8.0, 6.0, 7.0, 4.0, 9.0, 9.5, 12.0, 11.0, 5.0], + ) + .location_mode(LocationMode::Iso3) + .color_scale(ColorScale::Palette(ColorScalePalette::Viridis)) + .color_bar(ColorBar::new().title("Score")) + .marker(ChoroplethMarker::new().line(Line::new().width(0.5).color(Rgb::new(80, 80, 80)))); + + let layout = Layout::new() + .drag_mode(DragMode::Zoom) + .geo(LayoutGeo::new().showcountries(true).showland(true)); + + let mut plot = Plot::new(); + plot.add_trace(trace); + plot.set_layout(layout); + plot.set_configuration(Configuration::default().responsive(true).fill_frame(true)); + + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: choropleth + +/// Choropleth on the MapLibre `map` subplot. Regions are matched against a +/// GeoJSON feature collection (referenced here by URL) via `feature_id_key`. +// ANCHOR: choropleth_map +fn choropleth_map(show: bool, file_name: &str) { + let geojson_url = + "https://raw.githubusercontent.com/python-visualization/folium/main/tests/us-states.json"; + + let trace = ChoroplethMap::new( + vec!["AL", "AK", "AZ", "CA", "NY", "TX"], + vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], + ) + .geojson(serde_json::json!(geojson_url)) + .feature_id_key("id") + .color_scale(ColorScale::Palette(ColorScalePalette::Bluered)) + .show_scale(true) + .marker(ChoroplethMarker::new().opacity(0.7)); + + let layout = Layout::new().drag_mode(DragMode::Zoom).map( + LayoutMap::new() + .style(MapStyle::CartoPositron) + .center(Center::new(38.0, -96.0)) + .zoom(3.0), + ); + + let mut plot = Plot::new(); + plot.add_trace(trace); + plot.set_layout(layout); + plot.set_configuration(Configuration::default().responsive(true).fill_frame(true)); + + let path = write_example_to_html(&plot, file_name); + if show { + plot.show_html(path); + } +} +// ANCHOR_END: choropleth_map + fn main() { // Change false to true on any of these lines to display the example. scatter_mapbox(false, "scatter_mapbox"); scatter_geo(false, "scatter_geo"); density_mapbox(false, "density_mapbox"); + choropleth(false, "choropleth"); + choropleth_map(false, "choropleth_map"); } diff --git a/plotly/src/common/mod.rs b/plotly/src/common/mod.rs index 9ab8786f..513440e5 100644 --- a/plotly/src/common/mod.rs +++ b/plotly/src/common/mod.rs @@ -232,6 +232,8 @@ pub enum PlotType { Pie, Treemap, Sunburst, + Choropleth, + ChoroplethMap, } #[derive(Serialize, Clone, Debug)] diff --git a/plotly/src/layout/map.rs b/plotly/src/layout/map.rs new file mode 100644 index 00000000..e8252240 --- /dev/null +++ b/plotly/src/layout/map.rs @@ -0,0 +1,125 @@ +use plotly_derive::FieldSetter; +use serde::Serialize; + +use super::mapbox::Center; +use crate::common::Domain; +use crate::private::NumOrString; + +/// Sets the style of the MapLibre `map` subplot. +/// +/// Note that the `map` subplot uses MapLibre GL and, unlike the legacy +/// `mapbox` subplot, does not require an access token for the bundled styles. +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum MapStyle { + #[serde(rename = "carto-darkmatter")] + CartoDarkMatter, + CartoPositron, + OpenStreetMap, + StamenTerrain, + StamenToner, + StamenWatercolor, + WhiteBg, + Basic, + Streets, + Outdoors, + Light, + Dark, + Satellite, + SatelliteStreets, +} + +/// Sets the bounds beyond which the `map` subplot cannot be panned. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Clone, Debug, FieldSetter)] +pub struct MapBounds { + west: Option, + east: Option, + south: Option, + north: Option, +} + +impl MapBounds { + pub fn new() -> Self { + Default::default() + } +} + +/// The MapLibre-based `map` subplot, used by traces such as +/// [`ChoroplethMap`](crate::ChoroplethMap) and `scattermap`. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Clone, Debug, FieldSetter)] +pub struct LayoutMap { + /// Sets the bearing angle of the map in degrees counter-clockwise from + /// North. + bearing: Option, + /// Sets the bounds within which the map can be panned. + bounds: Option, + /// Sets the latitude and longitude of the center of the map. + center: Option
, + /// Sets the domain within which the map will be drawn. + domain: Option, + /// Sets the pitch angle of the map in degrees, where `0` means + /// perpendicular to the surface of the map. + pitch: Option, + /// Sets the style of the map. + style: Option, + /// Sets the zoom level of the map. + zoom: Option, + #[serde(rename = "uirevision")] + ui_revision: Option, +} + +impl LayoutMap { + pub fn new() -> Self { + Default::default() + } +} + +#[cfg(test)] +mod tests { + use serde_json::{json, to_value}; + + use super::*; + + #[test] + #[rustfmt::skip] + fn serialize_map_style() { + assert_eq!(to_value(MapStyle::CartoDarkMatter).unwrap(), json!("carto-darkmatter")); + assert_eq!(to_value(MapStyle::CartoPositron).unwrap(), json!("carto-positron")); + assert_eq!(to_value(MapStyle::OpenStreetMap).unwrap(), json!("open-street-map")); + assert_eq!(to_value(MapStyle::StamenTerrain).unwrap(), json!("stamen-terrain")); + assert_eq!(to_value(MapStyle::WhiteBg).unwrap(), json!("white-bg")); + assert_eq!(to_value(MapStyle::Basic).unwrap(), json!("basic")); + assert_eq!(to_value(MapStyle::Satellite).unwrap(), json!("satellite")); + assert_eq!(to_value(MapStyle::SatelliteStreets).unwrap(), json!("satellite-streets")); + } + + #[test] + fn serialize_layout_map() { + let map = LayoutMap::new() + .bearing(30.0) + .bounds( + MapBounds::new() + .west(-10.0) + .east(10.0) + .south(-5.0) + .north(5.0), + ) + .center(Center::new(45.0, -73.0)) + .pitch(15.0) + .style(MapStyle::CartoPositron) + .zoom(4.0); + + let expected = json!({ + "bearing": 30.0, + "bounds": {"west": -10.0, "east": 10.0, "south": -5.0, "north": 5.0}, + "center": {"lat": 45.0, "lon": -73.0}, + "pitch": 15.0, + "style": "carto-positron", + "zoom": 4.0, + }); + + assert_eq!(to_value(map).unwrap(), expected); + } +} diff --git a/plotly/src/layout/mod.rs b/plotly/src/layout/mod.rs index f1f6a681..3151610d 100644 --- a/plotly/src/layout/mod.rs +++ b/plotly/src/layout/mod.rs @@ -17,6 +17,7 @@ mod axis; mod geo; mod grid; mod legend; +mod map; mod mapbox; mod modes; mod polar; @@ -40,6 +41,7 @@ pub use self::axis::{ pub use self::geo::LayoutGeo; pub use self::grid::{GridDomain, GridPattern, GridXSide, GridYSide, LayoutGrid, RowOrder}; pub use self::legend::{GroupClick, ItemClick, ItemSizing, Legend, TraceOrder}; +pub use self::map::{LayoutMap, MapBounds, MapStyle}; pub use self::mapbox::{Center, Mapbox, MapboxStyle}; pub use self::modes::{ AspectMode, BarMode, BarNorm, BoxMode, ClickMode, UniformTextMode, ViolinMode, WaterfallMode, @@ -343,6 +345,7 @@ pub struct LayoutFields { // ternary: Option, scene: Option, geo: Option, + map: Option, polar: Option, annotations: Option>, shapes: Option>, diff --git a/plotly/src/lib.rs b/plotly/src/lib.rs index 1340271b..01c83074 100644 --- a/plotly/src/lib.rs +++ b/plotly/src/lib.rs @@ -60,14 +60,14 @@ pub use layout::Layout; pub use plot::{Plot, Trace, Traces}; // Also provide easy access to modules which contain additional trace-specific types pub use traces::{ - box_plot, contour, heat_map, histogram, image, mesh3d, sankey, scatter, scatter3d, - scatter_mapbox, sunburst, surface, treemap, + box_plot, choropleth, choropleth_map, contour, heat_map, histogram, image, mesh3d, sankey, + scatter, scatter3d, scatter_mapbox, sunburst, surface, treemap, }; // Bring the different trace types into the top-level scope pub use traces::{ - Bar, BoxPlot, Candlestick, Contour, DensityMapbox, HeatMap, Histogram, Image, Mesh3D, Ohlc, - Pie, Sankey, Scatter, Scatter3D, ScatterGeo, ScatterMapbox, ScatterPolar, Sunburst, Surface, - Table, Treemap, + Bar, BoxPlot, Candlestick, Choropleth, ChoroplethMap, Contour, DensityMapbox, HeatMap, + Histogram, Image, Mesh3D, Ohlc, Pie, Sankey, Scatter, Scatter3D, ScatterGeo, ScatterMapbox, + ScatterPolar, Sunburst, Surface, Table, Treemap, }; pub trait Restyle: serde::Serialize {} diff --git a/plotly/src/traces/choropleth.rs b/plotly/src/traces/choropleth.rs new file mode 100644 index 00000000..7ce1813b --- /dev/null +++ b/plotly/src/traces/choropleth.rs @@ -0,0 +1,300 @@ +//! Choropleth trace for the `geo` subplot. + +use plotly_derive::FieldSetter; +use serde::Serialize; +use serde_json::Value; + +use crate::common::{ + ColorBar, ColorScale, Dim, HoverInfo, Label, LegendGroupTitle, Line, PlotType, Visible, +}; +use crate::private::{NumOrString, NumOrStringCollection}; +use crate::Trace; + +/// Determines the set of locations used to match entries in `locations` to +/// regions on the map. +#[derive(Serialize, Clone, Debug, PartialEq)] +pub enum LocationMode { + #[serde(rename = "ISO-3")] + Iso3, + #[serde(rename = "USA-states")] + UsaStates, + #[serde(rename = "country names")] + CountryNames, + #[serde(rename = "geojson-id")] + GeoJsonId, +} + +/// Marker styling for choropleth traces. Unlike the scatter marker, the +/// choropleth marker only exposes the region boundary `line` and per-region +/// `opacity`. +#[serde_with::skip_serializing_none] +#[derive(Serialize, Clone, Debug, FieldSetter)] +pub struct Marker { + /// Sets the line (region boundary) styling. + line: Option, + /// Sets the opacity of the regions. + opacity: Option>, +} + +impl Marker { + pub fn new() -> Self { + Default::default() + } +} + +#[serde_with::skip_serializing_none] +#[derive(Serialize, Clone, Debug, Default)] +pub struct SelectionMarker { + opacity: Option, +} + +/// Styles the regions of `selected`/`unselected` points. +#[derive(Serialize, Clone, Debug, Default)] +pub struct Selection { + marker: SelectionMarker, +} + +impl Selection { + pub fn new() -> Self { + Default::default() + } + + /// Sets the marker opacity of un/selected regions. + pub fn opacity(mut self, opacity: f64) -> Self { + self.marker.opacity = Some(opacity); + self + } +} + +/// Construct a choropleth trace, drawn on the `geo` subplot. +/// +/// # Examples +/// +/// ``` +/// use plotly::Choropleth; +/// use plotly::choropleth::LocationMode; +/// +/// let trace = Choropleth::new(vec!["CAN", "USA", "MEX"], vec![1.0, 2.0, 3.0]) +/// .location_mode(LocationMode::Iso3) +/// .name("countries"); +/// ``` +#[serde_with::skip_serializing_none] +#[derive(Serialize, Clone, Debug, FieldSetter)] +#[field_setter(box_self, kind = "trace")] +pub struct Choropleth +where + Loc: Serialize + Clone, + Z: Serialize + Clone, +{ + #[field_setter(default = "PlotType::Choropleth")] + r#type: PlotType, + /// Sets the trace name. The trace name appears as the legend item and on + /// hover. + name: Option, + /// Determines whether or not this trace is visible. + visible: Option, + /// Determines whether or not an item corresponding to this trace is shown + /// in the legend. + #[serde(rename = "showlegend")] + show_legend: Option, + /// Sets the legend rank for this trace. + #[serde(rename = "legendrank")] + legend_rank: Option, + /// Sets the legend group for this trace. + #[serde(rename = "legendgroup")] + legend_group: Option, + /// Set and style the title to appear for the legend group. + #[serde(rename = "legendgrouptitle")] + legend_group_title: Option, + /// Assigns id labels to each datum. + ids: Option>, + + /// Sets the coordinates via location IDs or names. See `location_mode` for + /// more info. + locations: Option>, + /// Sets the color values, one per location. + z: Option>, + /// Determines the set of locations used to match entries in `locations` to + /// regions on the map. + #[serde(rename = "locationmode")] + location_mode: Option, + /// Sets optional GeoJSON data associated with this trace. Accepts either a + /// URL string pointing to a GeoJSON file, or an inline GeoJSON object. + /// Used with `location_mode` [`LocationMode::GeoJsonId`]. + geojson: Option, + /// Sets the key in GeoJSON features which is used as id to match the items + /// included in the `locations` array. Defaults to `id`. + #[serde(rename = "featureidkey")] + feature_id_key: Option, + + /// Sets the text elements associated with each location. + text: Option>, + /// Sets the hover text elements associated with each location. + #[serde(rename = "hovertext")] + hover_text: Option>, + /// Determines which trace information appears on hover. + #[serde(rename = "hoverinfo")] + hover_info: Option, + /// Template string used for rendering the information that appears on the + /// hover box. + #[serde(rename = "hovertemplate")] + hover_template: Option>, + #[serde(rename = "hovertemplatefallback")] + hover_template_fallback: Option>, + /// Properties of the hover label. + #[serde(rename = "hoverlabel")] + hover_label: Option