diff --git a/plugins/plotly-express/docs/choropleth-map.md b/plugins/plotly-express/docs/choropleth-map.md new file mode 100644 index 000000000..4e9885e22 --- /dev/null +++ b/plugins/plotly-express/docs/choropleth-map.md @@ -0,0 +1,114 @@ +# Choropleth Map + +A choropleth map plot is a geographic visualization that shades regions of a tile-based map according to a data value. It uses MapLibre map tiles, providing rich geographic context with zoom and pan, making it ideal for thematic maps that need detailed underlying imagery. + +Choropleth map plots are appropriate when the dataset contains values associated with regions defined by GeoJSON. For a simpler tile-free projection, use [`choropleth`](./choropleth.md). For point-based map visualizations, see [`scatter_map`](./scatter-map.md). + +## What are choropleth map plots useful for? + +- **Regional comparisons with map context**: They are excellent for comparing a single quantitative value across geographic regions while keeping detailed map imagery underneath. +- **Interactive exploration**: Users can zoom and pan to inspect specific regions in detail. +- **Live, region-level data**: Because the figure updates as the underlying Deephaven table ticks, choropleth map plots can reflect changing aggregate values per region in real time. + +## Examples + +### A basic choropleth map with custom GeoJSON + +`choropleth_map` requires GeoJSON to define the regions. Use `featureidkey` to point at the property in the GeoJSON that matches the values in the `locations` column. + +```python order=choropleth_map_plot,election_table +import deephaven.plot.express as dx +from plotly import express as px + +# Load the election dataset (ticking by default) +election_table = dx.data.election() + +# plotly ships matching geojson for the election dataset +geojson = px.data.election_geojson() + +# Color districts by votes for one candidate; updates live as the table ticks +choropleth_map_plot = dx.choropleth_map( + election_table, + locations="District", + geojson=geojson, + featureidkey="properties.district", + color="Joly", + zoom=9, + center={"lat": 45.55, "lon": -73.7}, +) +``` + +### Customize the color scale + +Change the color scale using the `color_continuous_scale` argument and constrain it with `range_color`. + +```python order=choropleth_map_plot,election_table +import deephaven.plot.express as dx +from plotly import express as px + +election_table = dx.data.election() +geojson = px.data.election_geojson() + +choropleth_map_plot = dx.choropleth_map( + election_table, + locations="District", + geojson=geojson, + featureidkey="properties.district", + color="Joly", + color_continuous_scale=["yellow", "orange", "red"], + zoom=9, + center={"lat": 45.55, "lon": -73.7}, +) +``` + +### Adjust opacity to show map detail + +Lower the `opacity` so the underlying map tiles remain visible through the colored regions. + +```python order=choropleth_map_plot,election_table +import deephaven.plot.express as dx +from plotly import express as px + +election_table = dx.data.election() +geojson = px.data.election_geojson() + +choropleth_map_plot = dx.choropleth_map( + election_table, + locations="District", + geojson=geojson, + featureidkey="properties.district", + color="Joly", + opacity=0.5, + zoom=9, + center={"lat": 45.55, "lon": -73.7}, +) +``` + +### Change map style + +Use different base map styles with the `map_style` argument. The default style depends on the theme. + +```python order=choropleth_map_plot,election_table +import deephaven.plot.express as dx +from plotly import express as px + +election_table = dx.data.election() +geojson = px.data.election_geojson() + +choropleth_map_plot = dx.choropleth_map( + election_table, + locations="District", + geojson=geojson, + featureidkey="properties.district", + color="Joly", + map_style="open-street-map", + zoom=9, + center={"lat": 45.55, "lon": -73.7}, +) +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.plot.express.choropleth_map +``` diff --git a/plugins/plotly-express/docs/choropleth.md b/plugins/plotly-express/docs/choropleth.md new file mode 100644 index 000000000..2acf7bba6 --- /dev/null +++ b/plugins/plotly-express/docs/choropleth.md @@ -0,0 +1,102 @@ +# Choropleth + +A choropleth plot is a geographic visualization that shades regions of a map according to a data value. It uses a basic geographic projection without map tiles, making it ideal for thematic maps where the focus is on regional values rather than detailed geographic context. + +Choropleth plots are appropriate when the dataset contains values associated with named geographic regions (countries, states, custom polygons defined by GeoJSON). For tile-based maps with zoom and pan over detailed map imagery, use [`choropleth_map`](./choropleth-map.md). For point-based geographic visualizations, see [`scatter_geo`](./scatter-geo.md). + +## What are choropleth plots useful for? + +- **Regional comparisons**: They are excellent for comparing a single quantitative value across many geographic regions at a glance. +- **Thematic mapping**: Choropleth plots provide a clear visual encoding for variables like population, election results, or any per-region statistic. +- **Live, region-level data**: Because the figure updates as the underlying Deephaven table ticks, choropleth plots can reflect changing aggregate values per region in real time. + +## Examples + +### A basic choropleth using built-in country names + +When the `locations` column contains values that match a built-in `locationmode` (`'ISO-3'`, `'USA-states'`, or `'country names'`), no GeoJSON is needed. + +```python order=choropleth_plot,gapminder_table +import deephaven.plot.express as dx + +# Load the gapminder dataset (ticking by default) +gapminder_table = dx.data.gapminder() + +# Color each country by life expectancy using the built-in country geometry +choropleth_plot = dx.choropleth( + gapminder_table, + locations="Country", + locationmode="country names", + color="LifeExp", + projection="natural earth", +) +``` + +### Choropleth with custom GeoJSON + +Pass GeoJSON directly to render arbitrary regions. Use `featureidkey` to point at the property in the GeoJSON that matches the values in the `locations` column. + +```python order=choropleth_plot,election_table +import deephaven.plot.express as dx +from plotly import express as px + +# Load the election dataset (ticking by default) +election_table = dx.data.election() + +# plotly ships matching geojson for the election dataset +geojson = px.data.election_geojson() + +# Color districts by votes for one candidate; updates live as the table ticks +choropleth_plot = dx.choropleth( + election_table, + locations="District", + geojson=geojson, + featureidkey="properties.district", + color="Joly", + fitbounds="locations", +) +``` + +### Customize the color scale + +Change the color scale using the `color_continuous_scale` argument and constrain it with `range_color`. + +```python order=choropleth_plot,gapminder_table +import deephaven.plot.express as dx + +gapminder_table = dx.data.gapminder() + +choropleth_plot = dx.choropleth( + gapminder_table, + locations="Country", + locationmode="country names", + color="LifeExp", + color_continuous_scale=["yellow", "orange", "red"], + range_color=[40, 85], +) +``` + +### Change projection and scope + +Use the `projection` argument to switch the map projection (for example `"orthographic"` for a globe view), and `scope` to focus on a region such as `"europe"` or `"north america"`. + +```python order=choropleth_plot,gapminder_table +import deephaven.plot.express as dx + +gapminder_table = dx.data.gapminder() + +choropleth_plot = dx.choropleth( + gapminder_table, + locations="Country", + locationmode="country names", + color="LifeExp", + projection="orthographic", + scope="world", +) +``` + +## API Reference + +```{eval-rst} +.. dhautofunction:: deephaven.plot.express.choropleth +``` diff --git a/plugins/plotly-express/docs/sidebar.json b/plugins/plotly-express/docs/sidebar.json index 6d16dbe1c..edbf3539d 100644 --- a/plugins/plotly-express/docs/sidebar.json +++ b/plugins/plotly-express/docs/sidebar.json @@ -29,6 +29,14 @@ "label": "Candlestick", "path": "candlestick.md" }, + { + "label": "Choropleth", + "path": "choropleth.md" + }, + { + "label": "Choropleth Map", + "path": "choropleth-map.md" + }, { "label": "Density Heatmap", "path": "density_heatmap.md" diff --git a/plugins/plotly-express/src/deephaven/plot/express/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/__init__.py index 24ec4fbdf..a249e5acd 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/__init__.py +++ b/plugins/plotly-express/src/deephaven/plot/express/__init__.py @@ -46,6 +46,9 @@ scatter_map, density_map, line_map, + choropleth, + choropleth_map, + choropleth_mapbox, ) from .data import data_generators diff --git a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py index f0e2c7c63..548d0b6fc 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py +++ b/plugins/plotly-express/src/deephaven/plot/express/deephaven_figure/generate.py @@ -1077,6 +1077,26 @@ def generate_figure( data_cols, custom_call_args, table, start_index ) + # Some trace types (choropleth, choropleth_map, choropleth_mapbox) store + # color values in the trace's `z` field rather than `marker.color`. The + # default `color` -> `marker/color` override produces a path the JS client + # cannot walk (TypeError: Cannot set properties of undefined). Rewrite the + # mapping keys for those trace types here so the figure stays in sync as + # the underlying live table updates. + _CHOROPLETH_TRACE_TYPES = {"choropleth", "choroplethmap", "choroplethmapbox"} + for offset, trace in enumerate(px_fig.data): + if getattr(trace, "type", None) not in _CHOROPLETH_TRACE_TYPES: + continue + idx = offset + if idx < len(data_mapping._data_mapping): # noqa: SLF001 + var_col = data_mapping._data_mapping[idx] # noqa: SLF001 + if "marker/color" in var_col: + var_col["z"] = var_col.pop("marker/color") + if idx < len(hover_mapping): + hov = hover_mapping[idx] + if "marker/color" in hov: + hov["z"] = hov.pop("marker/color") + types = get_hovertext_types(data_cols) hover_text, legend_title = create_hover_and_axis_titles( diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/__init__.py b/plugins/plotly-express/src/deephaven/plot/express/plots/__init__.py index c83c7e779..b2fcaa3e5 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/__init__.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/__init__.py @@ -17,6 +17,9 @@ scatter_map, density_map, line_map, + choropleth, + choropleth_map, + choropleth_mapbox, ) from .heatmap import density_heatmap from .indicator import indicator diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py b/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py index a7eec4b2b..e5b09bb21 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/_private_utils.py @@ -434,12 +434,22 @@ def process_args( # Set a default center to prevent px from auto-centering based on data # which breaks the initial view for map plots since the data may be null initially # Auto centering on the server side is also a bad idea since the data can change, - # so that should be done on the client side if desired - center = { - "lat": 0, - "lon": 0, - } - render_args["args"]["center"] = center + # so that should be done on the client side if desired. + # Skip for geo-based plots (e.g. `choropleth`) since the geo subplot uses + # `scope`/`projection` and a forced {lat:0, lon:0} center conflicts with + # projections like `albers usa`, which crashes the JS renderer. + is_geo_plot = ( + "scope" in render_args["args"] + or "projection" in render_args["args"] + or "locationmode" in render_args["args"] + ) + if not is_geo_plot: + center = { + "lat": 0, + "lon": 0, + } + if center is not None: + render_args["args"]["center"] = center orig_process_args = args_copy(render_args) orig_process_func = lambda **local_args: create_deephaven_figure(**local_args)[0] diff --git a/plugins/plotly-express/src/deephaven/plot/express/plots/maps.py b/plugins/plotly-express/src/deephaven/plot/express/plots/maps.py index ece19a736..c2f50b50f 100644 --- a/plugins/plotly-express/src/deephaven/plot/express/plots/maps.py +++ b/plugins/plotly-express/src/deephaven/plot/express/plots/maps.py @@ -694,3 +694,188 @@ def density_map( args = locals() return process_args(args, set(), px_func=px.density_map) + + +def choropleth( + table: TableLike, + locations: str | None = None, + locationmode: str | None = None, + geojson: str | dict | None = None, + featureidkey: str = "id", + color: str | None = None, + hover_name: str | None = None, + labels: dict[str, str] | None = None, + color_discrete_sequence: list[str] | None = None, + color_discrete_map: dict[str | tuple[str], str] | None = None, + color_continuous_scale: list[str] | None = None, + range_color: list[float] | None = None, + color_continuous_midpoint: float | None = None, + projection: str | None = None, + scope: str | None = None, + center: MapCenter | None = None, + fitbounds: bool | str = False, + basemap_visible: bool | None = None, + title: str | None = None, + template: str | None = None, + unsafe_update_figure: Callable = default_callback, + on_click: Callable | None = None, + on_select: Callable | None = None, + on_deselect: Callable | None = None, + on_hover: Callable | None = None, + on_unhover: Callable | None = None, + on_relayout: Callable | None = None, + on_legend_click: Callable | None = None, +) -> DeephavenFigure: + """ + Create a choropleth plot + + Args: + table: A table to pull data from. + locations: A column name to use for location values. + These map to predefined geographic regions when used with locationmode, + or to features in the geojson argument. + locationmode: A location mode to use. + One of 'ISO-3', 'USA-states', or 'country names'. + These map locations to predefined geographic regions. + geojson: GeoJSON data to use for geographic regions. + featureidkey: The feature ID key to use for geographic regions. + For example, 'properties.district' for a geojson with district properties. + color: A column name with values that determine the color of each region. + The values are used on a continuous color scale. + hover_name: A column that contains names to bold in the hover tooltip. + labels: A dictionary of labels mapping columns to new labels. + color_discrete_sequence: A list of colors to sequentially apply when + color is a non-numeric (categorical) column. + color_discrete_map: If dict, the keys should be strings of the column values which + map to colors. Used when color is categorical. + color_continuous_scale: A list of colors for a continuous scale + range_color: A list of two numbers that form the endpoints of the color axis + color_continuous_midpoint: A number that is the midpoint of the color axis + projection: The projection type to use. + Default depends on scope. + One of 'equirectangular', 'mercator', 'orthographic', 'natural earth', + 'kavrayskiy7', 'miller', 'robinson', 'eckert4', 'azimuthal equal area', + 'azimuthal equidistant', 'conic equal area', 'conic conformal', + 'conic equidistant', 'gnomonic', 'stereographic', 'mollweide', 'hammer', + 'transverse mercator', 'albers usa', 'winkel tripel', 'aitoff', or + 'sinusoidal' + scope: The scope of the map. + Default of 'world', but forced to 'usa' if projection is 'albers usa' + One of 'world', 'usa', 'europe', 'asia', 'africa', 'north america', or + 'south america' + center: A dictionary of center coordinates. + The keys should be 'lat' and 'lon' and the values should be floats + that represent the lat and lon of the center of the map. + fitbounds: One of False, 'locations', or 'geojson' + If 'locations' or 'geojson', the map will zoom to the extent of the + locations or geojson bounds respectively. + basemap_visible: If True, the basemap layer is visible. + title: The title of the chart + template: The template for the chart. + unsafe_update_figure: An update function that takes a plotly figure + as an argument and optionally returns a plotly figure. If a figure is + not returned, the plotly figure passed will be assumed to be the return + value. Used to add any custom changes to the underlying plotly figure. + Note that the existing data traces should not be removed. This may lead + to unexpected behavior if traces are modified in a way that break data + mappings. + + Returns: + DeephavenFigure: A DeephavenFigure that contains the choropleth figure + """ + args = locals() + + return process_args(args, set(), px_func=px.choropleth) + + +def choropleth_map( + table: TableLike, + locations: str | None = None, + geojson: str | dict | None = None, + featureidkey: str = "id", + color: str | None = None, + hover_name: str | None = None, + labels: dict[str, str] | None = None, + color_discrete_sequence: list[str] | None = None, + color_discrete_map: dict[str | tuple[str], str] | None = None, + color_continuous_scale: list[str] | None = None, + range_color: list[float] | None = None, + color_continuous_midpoint: float | None = None, + opacity: float | None = None, + zoom: float | None = 0, + center: MapCenter | None = None, + map_style: str | None = None, + title: str | None = None, + template: str | None = None, + unsafe_update_figure: Callable = default_callback, + on_click: Callable | None = None, + on_select: Callable | None = None, + on_deselect: Callable | None = None, + on_hover: Callable | None = None, + on_unhover: Callable | None = None, + on_relayout: Callable | None = None, + on_legend_click: Callable | None = None, +) -> DeephavenFigure: + """ + Create a choropleth_map plot + + Args: + table: A table to pull data from. + locations: A column name to use for location values. + These map to features in the geojson argument. + geojson: GeoJSON data to use for geographic regions. + featureidkey: The feature ID key to use for geographic regions. + For example, 'properties.district' for a geojson with district properties. + color: A column name with values that determine the color of each region. + The values are used on a continuous color scale. + hover_name: A column that contains names to bold in the hover tooltip. + labels: A dictionary of labels mapping columns to new labels. + color_discrete_sequence: A list of colors to sequentially apply when + color is a non-numeric (categorical) column. + color_discrete_map: If dict, the keys should be strings of the column values which + map to colors. Used when color is categorical. + color_continuous_scale: A list of colors for a continuous scale + range_color: A list of two numbers that form the endpoints of the color axis + color_continuous_midpoint: A number that is the midpoint of the color axis + opacity: Opacity to apply to all regions. 0 is completely transparent + and 1 is completely opaque. + zoom: The zoom level of the map. 0 is the whole world, and higher values zoom in closer. + center: A dictionary of center coordinates. + The keys should be 'lat' and 'lon' and the values should be floats + that represent the lat and lon of the center of the map. + map_style: The style of the map. Defaults to None, which uses the theme's default. + If a str, one of 'basic', 'carto-darkmatter', 'carto-darkmatter-nolabels', 'carto-positron', + 'carto-positron-nolabels', 'carto-voyager', 'carto-voyager-nolabels', 'dark', 'light', + 'open-street-map', 'outdoors', 'satellite', 'satellite-streets', 'streets', 'white-bg'. + title: The title of the chart + template: The template for the chart. + unsafe_update_figure: An update function that takes a plotly figure + as an argument and optionally returns a plotly figure. If a figure is + not returned, the plotly figure passed will be assumed to be the return + value. Used to add any custom changes to the underlying plotly figure. + Note that the existing data traces should not be removed. This may lead + to unexpected behavior if traces are modified in a way that break data + mappings. + + Returns: + DeephavenFigure: A DeephavenFigure that contains the choropleth_map figure + """ + args = locals() + + return process_args(args, set(), px_func=px.choropleth_map) + + +def choropleth_mapbox(*args, **kwargs) -> DeephavenFigure: + """ + Deprecated function. Use choropleth_map instead. + """ + warnings.warn( + "choropleth_mapbox is deprecated and will be removed in a future release. Use choropleth_map instead.", + DeprecationWarning, + stacklevel=2, + ) + + if "style_mapbox" in kwargs: + kwargs["map_style"] = kwargs.pop("style_mapbox") + + return choropleth_map(*args, **kwargs) diff --git a/plugins/plotly-express/test/deephaven/plot/express/plots/test_maps.py b/plugins/plotly-express/test/deephaven/plot/express/plots/test_maps.py index d55f8a5b3..c58f7c8af 100644 --- a/plugins/plotly-express/test/deephaven/plot/express/plots/test_maps.py +++ b/plugins/plotly-express/test/deephaven/plot/express/plots/test_maps.py @@ -1,4 +1,5 @@ import unittest +import warnings from ..BaseTest import BaseTestCase, PLOTLY_NULL_INT @@ -6,13 +7,17 @@ class MapTestCase(BaseTestCase): def setUp(self) -> None: from deephaven import new_table - from deephaven.column import int_col + from deephaven.column import int_col, string_col self.source = new_table( [ int_col("lat", [1, 2, 2, 3, 3, 3, 4, 4, 5]), int_col("lon", [1, 2, 2, 3, 3, 3, 4, 4, 5]), int_col("z", [1, 2, 2, 3, 3, 3, 4, 4, 5]), + string_col( + "loc", + ["USA", "CAN", "MEX", "BRA", "ARG", "GBR", "FRA", "DEU", "JPN"], + ), ] ) @@ -45,7 +50,6 @@ def test_basic_scatter_geo(self): expected_layout = { "geo": { - "center": {"lat": 0, "lon": 0}, "domain": {"x": [0.0, 1.0], "y": [0.0, 1.0]}, "fitbounds": False, }, @@ -126,7 +130,6 @@ def test_basic_line_geo(self): expected_layout = { "geo": { - "center": {"lat": 0, "lon": 0}, "domain": {"x": [0.0, 1.0], "y": [0.0, 1.0]}, "fitbounds": False, }, @@ -234,5 +237,92 @@ def test_basic_density_map(self): self.assertEqual(plotly["layout"], expected_layout) +class ChoroplethTestCase(BaseTestCase): + def setUp(self) -> None: + from deephaven import new_table + from deephaven.column import int_col, string_col + + self.source = new_table( + [ + int_col("z", [1, 2, 2, 3, 3, 3, 4, 4, 5]), + string_col( + "loc", + ["USA", "CAN", "MEX", "BRA", "ARG", "GBR", "FRA", "DEU", "JPN"], + ), + ] + ) + + def test_basic_choropleth(self): + import src.deephaven.plot.express as dx + + chart = dx.choropleth( + self.source, + locations="loc", + locationmode="ISO-3", + color="z", + ).to_dict(self.exporter) + plotly = chart["plotly"] + + self.assertEqual(len(plotly["data"]), 1) + trace = plotly["data"][0] + self.assertEqual(trace["type"], "choropleth") + self.assertEqual(trace["locationmode"], "ISO-3") + self.assertEqual(trace["featureidkey"], "id") + self.assertEqual(trace["geo"], "geo") + self.assertEqual(trace["coloraxis"], "coloraxis") + # locations and z column data is a placeholder array filled by the live table + self.assertIn("locations", trace) + self.assertIn("z", trace) + + layout = plotly["layout"] + self.assertIn("geo", layout) + self.assertIn("coloraxis", layout) + self.assertEqual(layout["geo"]["fitbounds"], False) + + def test_basic_choropleth_map(self): + import src.deephaven.plot.express as dx + + chart = dx.choropleth_map( + self.source, + locations="loc", + color="z", + zoom=3, + ).to_dict(self.exporter) + plotly = chart["plotly"] + + self.assertEqual(len(plotly["data"]), 1) + trace = plotly["data"][0] + self.assertEqual(trace["type"], "choroplethmap") + self.assertEqual(trace["featureidkey"], "id") + self.assertEqual(trace["subplot"], "map") + self.assertEqual(trace["coloraxis"], "coloraxis") + self.assertIn("locations", trace) + self.assertIn("z", trace) + + layout = plotly["layout"] + self.assertIn("map", layout) + self.assertEqual(layout["map"]["zoom"], 3) + self.assertIn("coloraxis", layout) + + def test_choropleth_mapbox_deprecated(self): + import src.deephaven.plot.express as dx + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + chart = dx.choropleth_mapbox( + self.source, + locations="loc", + color="z", + ).to_dict(self.exporter) + + self.assertTrue( + any(issubclass(w.category, DeprecationWarning) for w in caught), + "choropleth_mapbox should emit a DeprecationWarning", + ) + + trace = chart["plotly"]["data"][0] + self.assertEqual(trace["type"], "choroplethmap") + + if __name__ == "__main__": unittest.main() diff --git a/tests/app.d/express.py b/tests/app.d/express.py index 9f98c814e..c755a165a 100644 --- a/tests/app.d/express.py +++ b/tests/app.d/express.py @@ -138,6 +138,43 @@ ohlc_source, x="Timestamp", open="Open", high="High", low="Low", close="Close" ) +# Choropleth (SVG, deterministic for snapshot) +choropleth_source = new_table( + [ + string_col("State", ["NY", "CA", "TX", "FL", "WA"]), + double_col("Population", [19.5, 39.0, 29.0, 21.5, 7.7]), + ] +) +choropleth_fig = dx.choropleth( + choropleth_source, + locations="State", + locationmode="USA-states", + color="Population", + scope="usa", +) + +# Ticking choropleth — verifies live-table updates flow through to the chart. +# Only the values change; the set of states is fixed so the trace is stable. +choropleth_ticking_source = ( + time_table("PT0.5S") + .update_view( + [ + "idx = (int)(ii % 5)", + "State = (new String[]{`NY`,`CA`,`TX`,`FL`,`WA`})[idx]", + "Population = (double)((ii % 100) + 1)", + ] + ) + .last_by("State") + .view(["State", "Population"]) +) +choropleth_ticking_fig = dx.choropleth( + choropleth_ticking_source, + locations="State", + locationmode="USA-states", + color="Population", + scope="usa", +) + # Add titles to subplots titles_fig = dx.make_subplots( dx.scatter(express_source, x="Values", y="Values2"), diff --git a/tests/express.spec.ts b/tests/express.spec.ts index 7584d10e9..1f842311f 100644 --- a/tests/express.spec.ts +++ b/tests/express.spec.ts @@ -105,13 +105,53 @@ test('Candlestick chart loads', async ({ page }) => { }); test('Titles fig loads', async ({ page }) => { - await gotoPage(page, ''); - await openPanel(page, 'titles_fig', '.js-plotly-plot'); - await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); + await gotoPage(page, ''); + await openPanel(page, 'titles_fig', '.js-plotly-plot'); + await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); }); test('Subplots fig loads', async ({ page }) => { await gotoPage(page, ''); await openPanel(page, 'keep_subplot_titles_fig', '.js-plotly-plot'); await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); -}); \ No newline at end of file +}); + +test('Choropleth loads', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'choropleth_fig', '.js-plotly-plot'); + // The choropleth trace renders an SVG per trace. + await expect( + page.locator('.iris-chart-panel').locator('g.choropleth').first() + ).toBeVisible(); + await expect(page.locator('.iris-chart-panel')).toHaveScreenshot(); +}); + +test('Choropleth ticking updates live', async ({ page }) => { + await gotoPage(page, ''); + await openPanel(page, 'choropleth_ticking_fig', '.js-plotly-plot'); + + const plot = page.locator('.iris-chart-panel').locator('.js-plotly-plot'); + await expect(plot).toBeVisible(); + await expect( + page.locator('.iris-chart-panel').locator('g.choropleth').first() + ).toBeVisible(); + + // Snapshot the trace's z values, wait for a couple of ticks, and verify + // they have changed — this proves the live table is driving the chart. + const readZ = async () => + plot.evaluate((el: HTMLElement) => { + const data = (el as unknown as { data?: Array<{ z?: number[] }> }).data; + return data?.[0]?.z?.slice() ?? null; + }); + + const initialZ = await readZ(); + expect(initialZ).not.toBeNull(); + expect(initialZ?.length).toBe(5); + + await expect + .poll(async () => readZ(), { + message: 'z values should change as the table ticks', + timeout: 5000, + }) + .not.toEqual(initialZ); +});