diff --git a/.github/scripts/build-book-examples.sh b/.github/scripts/build-book-examples.sh index 115c7f08..17f01776 100755 --- a/.github/scripts/build-book-examples.sh +++ b/.github/scripts/build-book-examples.sh @@ -40,6 +40,7 @@ BOOK_EXAMPLES=( "basic_charts" "custom_controls" "financial_charts" + "maps" "scientific_charts" "shapes" "static_export" diff --git a/CHANGELOG.md b/CHANGELOG.md index 04dc0511..6a9958d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ 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` +- [[#NNN](https://github.com/plotly/plotly.rs/pull/NNN)] Add `LayoutGeo` `fitbounds` (`GeoFitBounds`) and `resolution` (`GeoResolution`, 1:110M/1:50M base-layer detail) options - [[#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..92322cf7 --- /dev/null +++ b/docs/book/src/recipes/maps/choropleth_maps.md @@ -0,0 +1,47 @@ +# 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 `to_inline_html` method is used to produce the html plots displayed in this +page. The rendered maps require an internet connection (the MapLibre basemap and, +for the second example, the remote GeoJSON are fetched in the browser). + +## Choropleth on a geo subplot + +```rust,no_run +{{#include ../../../../../examples/maps/src/main.rs:choropleth}} +``` + +{{#include ../../../../../examples/maps/output/inline_choropleth.html}} + +## Choropleth on a MapLibre map subplot + +```rust,no_run +{{#include ../../../../../examples/maps/src/main.rs:choropleth_map}} +``` + +{{#include ../../../../../examples/maps/output/inline_choropleth_map.html}} 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..9aaa8675 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, GeoResolution, LayoutGeo, LayoutMap, MapStyle, Mapbox, MapboxStyle, + Projection, Rotation, + }, + Choropleth, ChoroplethMap, Configuration, DensityMapbox, Layout, Plot, ScatterGeo, + ScatterMapbox, }; use plotly_utils::write_example_to_html; @@ -35,15 +40,38 @@ fn scatter_geo(show: bool, file_name: &str) { use csv; use reqwest; - // Download and parse the CSV + // Download and parse the CSV. If the fetch fails (e.g. no network during a + // book/CI build), warn and skip this example rather than panicking. let url = "https://raw.githubusercontent.com/plotly/datasets/master/globe_contours.csv"; - let req = reqwest::blocking::get(url).unwrap().text().unwrap(); + let req = match reqwest::blocking::get(url) + .and_then(|resp| resp.error_for_status()) + .and_then(|resp| resp.text()) + { + Ok(body) => body, + Err(err) => { + eprintln!("warning: skipping scatter_geo example; failed to fetch {url}: {err}"); + return; + } + }; let mut rdr = csv::Reader::from_reader(req.as_bytes()); - let headers = rdr.headers().unwrap().clone(); + let headers = match rdr.headers() { + Ok(headers) => headers.clone(), + Err(err) => { + eprintln!("warning: skipping scatter_geo example; failed to read CSV headers: {err}"); + return; + } + }; let mut rows = vec![]; for result in rdr.records() { - let record = result.unwrap(); - rows.push(record); + match result { + Ok(record) => rows.push(record), + Err(err) => { + eprintln!( + "warning: skipping scatter_geo example; failed to parse CSV record: {err}" + ); + return; + } + } } // Color scale @@ -65,23 +93,26 @@ fn scatter_geo(show: bool, file_name: &str) { for i in 0..scl.len() { let lat_head = format!("lat-{}", i + 1); let lon_head = format!("lon-{}", i + 1); + let (lat_idx, lon_idx) = match ( + headers.iter().position(|h| h == lat_head), + headers.iter().position(|h| h == lon_head), + ) { + (Some(lat_idx), Some(lon_idx)) => (lat_idx, lon_idx), + _ => { + eprintln!( + "warning: skipping scatter_geo example; missing expected columns \ + {lat_head}/{lon_head}" + ); + return; + } + }; let lat: Vec = rows .iter() - .map(|row| { - row.get(headers.iter().position(|h| h == lat_head).unwrap()) - .unwrap() - .parse() - .unwrap_or(f64::NAN) - }) + .map(|row| row.get(lat_idx).unwrap_or("").parse().unwrap_or(f64::NAN)) .collect(); let lon: Vec = rows .iter() - .map(|row| { - row.get(headers.iter().position(|h| h == lon_head).unwrap()) - .unwrap() - .parse() - .unwrap_or(f64::NAN) - }) + .map(|row| row.get(lon_idx).unwrap_or("").parse().unwrap_or(f64::NAN)) .collect(); all_lats.push(lat); all_lons.push(lon); @@ -152,9 +183,83 @@ 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) + .resolution(GeoResolution::OneOverFiftyMillion), + ); + + 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/geo.rs b/plotly/src/layout/geo.rs index 03a31d93..071be2c0 100644 --- a/plotly/src/layout/geo.rs +++ b/plotly/src/layout/geo.rs @@ -4,8 +4,61 @@ use serde::Serialize; use crate::color::Color; use crate::layout::{Axis, Center, Projection}; -#[derive(Serialize, Clone, Debug, FieldSetter)] +/// Determines how a `geo` subplot's view is auto-computed to fit the plotted +/// data. The default is [`GeoFitBounds::False`] (equivalent to Plotly's +/// `false`), which uses the configured center/projection/axes as-is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GeoFitBounds { + /// Equivalent to Plotly's `false`: use the configured + /// center/projection/axes as-is. Serializes as the boolean `false`, so it + /// can override a non-default value coming from a template or a previously + /// composed layout. + False, + /// Frame the subplot to the union of the traces' location geometries. + Locations, + /// Frame the subplot to the bounds of the traces' GeoJSON. + GeoJson, +} + +impl Serialize for GeoFitBounds { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::False => serializer.serialize_bool(false), + Self::Locations => serializer.serialize_str("locations"), + Self::GeoJson => serializer.serialize_str("geojson"), + } + } +} + +/// Sets the resolution of the base layers. Higher detail (smaller scale +/// denominator) means more accurate coastlines and borders at the cost of a +/// larger payload. The default is +/// [`GeoResolution::OneOverOneHundredTenMillion`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GeoResolution { + /// 1:110,000,000 scale (lower detail, default). Serializes as `110`. + OneOverOneHundredTenMillion, + /// 1:50,000,000 scale (higher detail). Serializes as `50`. + OneOverFiftyMillion, +} + +impl Serialize for GeoResolution { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::OneOverOneHundredTenMillion => serializer.serialize_u16(110), + Self::OneOverFiftyMillion => serializer.serialize_u16(50), + } + } +} +#[serde_with::skip_serializing_none] +#[derive(Serialize, Clone, Debug, FieldSetter)] pub struct LayoutGeo { /// Sets the latitude and longitude of the center of the map. center: Option
, @@ -15,6 +68,8 @@ pub struct LayoutGeo { /// Sets the projection of the map #[field_setter(default = "Projection::new().projection_type(ProjectionType::Orthographic)")] projection: Option, + /// Sets the background color of the subplot. + bgcolor: Option>, /// If to show the ocean or not #[field_setter(default = "Some(true)")] showocean: Option, @@ -31,10 +86,24 @@ pub struct LayoutGeo { lakecolor: Option>, /// If to show countries (borders) or not showcountries: Option, + /// Sets the scope of the basemap to a geographic region. Accepted values: + /// `"world"`, `"usa"`, `"europe"`, `"asia"`, `"africa"`, + /// `"north america"`, `"south america"`. Default is `"world"`. + /// Setting `"usa"` restricts the basemap (land, country borders) to the + /// United States, preventing neighbouring land (e.g. British Columbia) + /// from bleeding into the albers-usa composite projection canvas. + scope: Option, + /// Sets the resolution of the base layers (coastlines, land, borders). + resolution: Option, /// Configures the longitude axis lonaxis: Option, /// Configures the latitude axis lataxis: Option, + /// Auto-frames the subplot to the plotted data (e.g. `Locations` fits the + /// view to the trace's filled region geometries). Overrides + /// `center`/`lonaxis`/`lataxis` ranges when set. + #[serde(rename = "fitbounds")] + fitbounds: Option, // Sets the coastline stroke width (in px). #[field_setter(default = "Some(1)")] coastlinewidth: Option, @@ -64,6 +133,7 @@ mod tests { .projection_type(ProjectionType::Mercator) .rotation(Rotation::new().lat(1.0).lon(2.0).roll(4.0)), ) + .bgcolor(Rgb::new(17, 17, 17)) .showocean(true) .oceancolor(Rgb::new(0, 255, 255)) .showland(true) @@ -71,6 +141,7 @@ mod tests { .showlakes(false) .lakecolor(Rgb::new(50, 50, 200)) .showcountries(true) + .resolution(GeoResolution::OneOverFiftyMillion) .lonaxis(Axis::new().title("Longitude")) .lataxis(Axis::new().title("Latitude")) .coastlinewidth(2); @@ -79,6 +150,7 @@ mod tests { "center": {"lat": 10.0, "lon": 20.0}, "zoom": 5, "projection": {"type": "mercator", "rotation": {"lat": 1.0, "lon": 2.0, "roll": 4.0}}, + "bgcolor": "rgb(17, 17, 17)", "showocean": true, "oceancolor": "rgb(0, 255, 255)", "showland": true, @@ -86,10 +158,37 @@ mod tests { "showlakes": false, "lakecolor": "rgb(50, 50, 200)", "showcountries": true, + "resolution": 50, "lataxis": { "title": { "text": "Latitude" } }, "lonaxis": { "title": { "text": "Longitude" } }, "coastlinewidth": 2 }); assert_eq!(to_value(geo).unwrap(), expected); } + + #[test] + fn serialize_geo_fitbounds() { + let geo = LayoutGeo::new().fitbounds(GeoFitBounds::Locations); + assert_eq!(to_value(geo).unwrap(), json!({ "fitbounds": "locations" })); + assert_eq!(to_value(GeoFitBounds::GeoJson).unwrap(), json!("geojson")); + assert_eq!(to_value(GeoFitBounds::False).unwrap(), json!(false)); + + let geo = LayoutGeo::new().fitbounds(GeoFitBounds::False); + assert_eq!(to_value(geo).unwrap(), json!({ "fitbounds": false })); + } + + #[test] + fn serialize_geo_resolution() { + assert_eq!( + to_value(GeoResolution::OneOverOneHundredTenMillion).unwrap(), + json!(110) + ); + assert_eq!( + to_value(GeoResolution::OneOverFiftyMillion).unwrap(), + json!(50) + ); + + let geo = LayoutGeo::new().resolution(GeoResolution::OneOverOneHundredTenMillion); + assert_eq!(to_value(geo).unwrap(), json!({ "resolution": 110 })); + } } diff --git a/plotly/src/layout/map.rs b/plotly/src/layout/map.rs new file mode 100644 index 00000000..0243aa2e --- /dev/null +++ b/plotly/src/layout/map.rs @@ -0,0 +1,132 @@ +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 { + Basic, + #[serde(rename = "carto-darkmatter")] + CartoDarkMatter, + #[serde(rename = "carto-darkmatter-nolabels")] + CartoDarkMatterNoLabels, + CartoPositron, + #[serde(rename = "carto-positron-nolabels")] + CartoPositronNoLabels, + CartoVoyager, + #[serde(rename = "carto-voyager-nolabels")] + CartoVoyagerNoLabels, + Dark, + Light, + OpenStreetMap, + Outdoors, + Satellite, + SatelliteStreets, + Streets, + WhiteBg, +} + +/// 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::Basic).unwrap(), json!("basic")); + assert_eq!(to_value(MapStyle::CartoDarkMatter).unwrap(), json!("carto-darkmatter")); + assert_eq!(to_value(MapStyle::CartoDarkMatterNoLabels).unwrap(), json!("carto-darkmatter-nolabels")); + assert_eq!(to_value(MapStyle::CartoPositron).unwrap(), json!("carto-positron")); + assert_eq!(to_value(MapStyle::CartoPositronNoLabels).unwrap(), json!("carto-positron-nolabels")); + assert_eq!(to_value(MapStyle::CartoVoyager).unwrap(), json!("carto-voyager")); + assert_eq!(to_value(MapStyle::CartoVoyagerNoLabels).unwrap(), json!("carto-voyager-nolabels")); + assert_eq!(to_value(MapStyle::OpenStreetMap).unwrap(), json!("open-street-map")); + assert_eq!(to_value(MapStyle::WhiteBg).unwrap(), json!("white-bg")); + 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..1aef0bb0 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; @@ -37,9 +38,10 @@ pub use self::axis::{ SelectorButton, SelectorStep, SliderRangeMode, SpikeMode, SpikeSnap, StepMode, TickLabelPosition, TicksDirection, TicksPosition, UnifiedHoverTitle, ZeroLineLayer, }; -pub use self::geo::LayoutGeo; +pub use self::geo::{GeoFitBounds, GeoResolution, 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