gri-plot 0.2.4__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. {gri_plot-0.2.4 → gri_plot-0.2.5}/CONTRIBUTING.md +8 -4
  2. {gri_plot-0.2.4 → gri_plot-0.2.5}/PKG-INFO +2 -1
  3. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/__init__.py +5 -0
  4. gri_plot-0.2.5/gri_plot/_heatmap_image.py +132 -0
  5. gri_plot-0.2.5/gri_plot/basemap.py +307 -0
  6. gri_plot-0.2.5/gri_plot/data/natural_earth_boundaries.npz +0 -0
  7. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/figure3d.py +2 -2
  8. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/figure_map.py +188 -17
  9. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/observables/los.py +1 -1
  10. gri_plot-0.2.5/gri_plot/polylines.py +60 -0
  11. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/scatter_map.py +44 -22
  12. {gri_plot-0.2.4 → gri_plot-0.2.5}/pyproject.toml +26 -5
  13. gri_plot-0.2.5/scripts/build_basemap_data.py +241 -0
  14. gri_plot-0.2.5/test/test_basemap.py +169 -0
  15. gri_plot-0.2.5/test/test_heatmap_image.py +137 -0
  16. gri_plot-0.2.5/test/test_polylines.py +52 -0
  17. gri_plot-0.2.4/.docs_other_projects.md +0 -85
  18. gri_plot-0.2.4/.gitlab-ci-deps.yml +0 -7
  19. gri_plot-0.2.4/.gitlab-ci.yml +0 -95
  20. gri_plot-0.2.4/.init_venv.sh +0 -87
  21. gri_plot-0.2.4/.pre-commit-config.yaml +0 -40
  22. gri_plot-0.2.4/.pre-commit-unskipped.py +0 -38
  23. gri_plot-0.2.4/.python-version +0 -1
  24. gri_plot-0.2.4/.ruff.toml +0 -60
  25. gri_plot-0.2.4/.vscode/settings.json +0 -15
  26. gri_plot-0.2.4/notes.txt +0 -3
  27. gri_plot-0.2.4/report.xml +0 -1
  28. gri_plot-0.2.4/uv.lock +0 -590
  29. {gri_plot-0.2.4 → gri_plot-0.2.5}/.gitignore +0 -0
  30. {gri_plot-0.2.4 → gri_plot-0.2.5}/LICENSE +0 -0
  31. {gri_plot-0.2.4 → gri_plot-0.2.5}/README.md +0 -0
  32. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/frames.py +0 -0
  33. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/observables/__init__.py +0 -0
  34. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/observables/aoa.py +0 -0
  35. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/observables/range_sphere.py +0 -0
  36. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/plot_ellipse.py +0 -0
  37. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/py.typed +0 -0
  38. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/scatter.py +0 -0
  39. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/__init__.py +0 -0
  40. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/cone.py +0 -0
  41. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/cylinder.py +0 -0
  42. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/ellipse.py +0 -0
  43. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/ellipsoid.py +0 -0
  44. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/meshgen.py +0 -0
  45. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/sphere.py +0 -0
  46. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/surfaces/__init__.py +0 -0
  47. {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/surfaces/mesh.py +0 -0
  48. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/__init__.py +0 -0
  49. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_figure3d.py +0 -0
  50. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_figure_map.py +0 -0
  51. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_frames.py +0 -0
  52. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_observables/__init__.py +0 -0
  53. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_observables/test_aoa.py +0 -0
  54. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_observables/test_los.py +0 -0
  55. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_observables/test_range_sphere.py +0 -0
  56. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_plot_ellipse.py +0 -0
  57. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_plot_scattermap.py +0 -0
  58. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_scatter.py +0 -0
  59. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/__init__.py +0 -0
  60. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_cone.py +0 -0
  61. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_cylinder.py +0 -0
  62. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_ellipse.py +0 -0
  63. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_ellipsoid.py +0 -0
  64. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_sphere.py +0 -0
  65. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_surfaces/__init__.py +0 -0
  66. {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_surfaces/test_mesh.py +0 -0
@@ -54,12 +54,12 @@ ruff check # lint
54
54
  ruff format # format
55
55
  ```
56
56
 
57
- ### Pyright
57
+ ### ty
58
58
 
59
- [Pyright](https://microsoft.github.io/pyright/#/) for static type checking:
59
+ [ty](https://docs.astral.sh/ty/) for static type checking:
60
60
 
61
61
  ```bash
62
- pyright
62
+ ty check
63
63
  ```
64
64
 
65
65
  ### Pre-commit
@@ -110,4 +110,8 @@ by Astral and add to your `settings.json`:
110
110
  }
111
111
  ```
112
112
 
113
- Pyright is included in VS Code's Pylance extension by default.
113
+ For type checking in VS Code, install the
114
+ [ty extension](https://marketplace.visualstudio.com/items?itemName=astral-sh.ty)
115
+ by Astral. To avoid conflicts with Pylance, either let ty disable the Python
116
+ language server (the default) or keep Pylance and set
117
+ `"ty.disableLanguageServices": true` to use ty for diagnostics only.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gri-plot
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: 3D geolocation visualization and 2D plotting utilities built on Plotly
5
5
  Project-URL: Homepage, https://geosolresearch.com
6
6
  Project-URL: Repository, https://gitlab.com/geosol-foss/python/gri-plot
@@ -19,6 +19,7 @@ Classifier: Topic :: Scientific/Engineering :: Visualization
19
19
  Requires-Python: >=3.12
20
20
  Requires-Dist: gri-utils>=0.3.3
21
21
  Requires-Dist: numpy>=2.3.3
22
+ Requires-Dist: pillow>=11.0.0
22
23
  Requires-Dist: plotly>=6.3.0
23
24
  Description-Content-Type: text/markdown
24
25
 
@@ -16,6 +16,7 @@ Main components:
16
16
 
17
17
  # 2D plotting
18
18
  # 3D plotting infrastructure
19
+ from .basemap import BasemapStyle, add_offline_basemap
19
20
  from .figure3d import DEFAULT_COLORS, Figure3D, plot_surfaces
20
21
  from .figure_map import FigureMap, MapStyle
21
22
  from .frames import Bounds, Frame, FrameTransformer
@@ -27,6 +28,7 @@ from .observables import (
27
28
  RangeSphere,
28
29
  )
29
30
  from .plot_ellipse import plot_ellipse
31
+ from .polylines import split_at_antimeridian
30
32
  from .scatter import scatter
31
33
  from .scatter_map import scatter_map
32
34
 
@@ -47,6 +49,7 @@ from .surfaces.mesh import grid_to_mesh, vertices_to_mesh3d
47
49
  __all__ = [
48
50
  "DEFAULT_COLORS",
49
51
  "AoaSurface",
52
+ "BasemapStyle",
50
53
  "Bounds",
51
54
  "Cone",
52
55
  "Cylinder",
@@ -61,6 +64,7 @@ __all__ = [
61
64
  "MapStyle",
62
65
  "RangeSphere",
63
66
  "Sphere",
67
+ "add_offline_basemap",
64
68
  "cone_mesh",
65
69
  "cylinder_mesh",
66
70
  "ellipsoid_mesh",
@@ -72,5 +76,6 @@ __all__ = [
72
76
  "scatter",
73
77
  "scatter_map",
74
78
  "sphere_mesh",
79
+ "split_at_antimeridian",
75
80
  "vertices_to_mesh3d",
76
81
  ]
@@ -0,0 +1,132 @@
1
+ """PNG heatmap image generation for map-figure overlays.
2
+
3
+ Plotly's ``Scattermap`` cannot host a ``go.Heatmap`` trace because heatmaps
4
+ live in a Cartesian plot area, not a map. The workaround this module
5
+ implements is to encode the scalar field as an RGBA PNG (base64 data URI)
6
+ and add it to ``layout.map.layers`` as an ``sourcetype="image"`` entry.
7
+
8
+ The work this module does:
9
+
10
+ * colorscale sampling into a 256-entry lookup table;
11
+ * a Mercator pre-resample of the lat axis so the image aligns with the
12
+ basemap at non-equatorial latitudes (the image-layer is stretched
13
+ linearly in Mercator y; our grid is linear in geodetic latitude);
14
+ * a Gaussian blur on the alpha channel so the footprint mask's hard
15
+ per-cell edges fade smoothly at the boundary.
16
+
17
+ All functions here are private helpers for ``FigureMap.add_heatmap_image``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ import io
24
+ import math
25
+ from typing import TYPE_CHECKING
26
+
27
+ import numpy as np
28
+ import plotly.colors as pc
29
+ from PIL import Image, ImageFilter
30
+
31
+ if TYPE_CHECKING:
32
+ from numpy.typing import NDArray
33
+
34
+ LUT_SIZE = 256
35
+
36
+
37
+ def _colorscale_lut(name: str, n: int = LUT_SIZE) -> NDArray[np.uint8]:
38
+ """Sample a named Plotly colorscale into an (n, 3) uint8 RGB lookup table."""
39
+ samples = pc.sample_colorscale(name, np.linspace(0.0, 1.0, n))
40
+ rows = [
41
+ [int(c) for c in s.removeprefix("rgb(").rstrip(")").split(",")] for s in samples
42
+ ]
43
+ return np.array(rows, dtype=np.uint8)
44
+
45
+
46
+ def _mercator_remap(
47
+ values: NDArray[np.floating],
48
+ lat_axis: NDArray[np.floating],
49
+ ) -> NDArray[np.floating]:
50
+ """Resample a (M, N) grid so rows are linearly spaced in Mercator y.
51
+
52
+ The input ``values`` are assumed to be on a grid whose row i has
53
+ latitude ``lat_axis[i]`` in degrees (linear in geodetic latitude).
54
+ Returns an array of the same shape whose row i corresponds to a
55
+ latitude linearly spaced in Mercator y between the endpoints of
56
+ ``lat_axis``. This is what the Plotly image layer expects when its
57
+ ``coordinates`` corners are geodetic lat/lon.
58
+ """
59
+ m = len(lat_axis)
60
+ y_min = math.log(math.tan(math.pi / 4.0 + math.radians(float(lat_axis[0])) / 2.0))
61
+ y_max = math.log(math.tan(math.pi / 4.0 + math.radians(float(lat_axis[-1])) / 2.0))
62
+ target_y = np.linspace(y_min, y_max, m)
63
+ target_lat = np.degrees(2.0 * (np.arctan(np.exp(target_y)) - math.pi / 4.0))
64
+ src_idx = np.interp(target_lat, lat_axis, np.arange(m, dtype=np.float64))
65
+ src_lo = np.floor(src_idx).astype(np.int64)
66
+ src_hi = np.minimum(src_lo + 1, m - 1)
67
+ frac = (src_idx - src_lo)[:, None]
68
+ return (1.0 - frac) * values[src_lo] + frac * values[src_hi]
69
+
70
+
71
+ def values_to_png_data_uri( # noqa: PLR0913
72
+ values: NDArray[np.floating],
73
+ lat_axis: NDArray[np.floating],
74
+ colorscale: str,
75
+ *,
76
+ alpha: float,
77
+ symmetric: bool,
78
+ blur_px: float,
79
+ value_range: tuple[float, float] | None,
80
+ ) -> tuple[str, float, float]:
81
+ """Render a lat/lon-gridded scalar field to a PNG data URI.
82
+
83
+ Args:
84
+ values: Scalar field, shape (M, N). NaN cells are rendered
85
+ transparent.
86
+ lat_axis: Latitude per row in degrees, shape (M,). Used both to
87
+ pick the input-row colormap and to Mercator-resample before
88
+ encoding.
89
+ colorscale: Plotly colorscale name.
90
+ alpha: Opacity (0-1) for finite cells.
91
+ symmetric: When True and the values span zero, expand the color
92
+ range to be symmetric about zero. Useful for diverging
93
+ colorscales on signed fields (TDOA-dot).
94
+ blur_px: Gaussian blur radius (pixels) applied to the alpha
95
+ channel only. Softens the per-cell stair-step at the mask
96
+ edge without blurring the color data. Set to 0 to disable.
97
+ value_range: Explicit (vmin, vmax) override. Takes precedence
98
+ over ``symmetric`` and the implicit min/max.
99
+
100
+ Returns:
101
+ Tuple of (data URI string, vmin, vmax).
102
+ """
103
+ v = np.asarray(values, dtype=np.float64)
104
+ if value_range is not None:
105
+ vmin, vmax = value_range
106
+ else:
107
+ vmin, vmax = float(np.nanmin(v)), float(np.nanmax(v))
108
+ if symmetric and vmin < 0.0 < vmax:
109
+ vabs = max(abs(vmin), abs(vmax))
110
+ vmin, vmax = -vabs, vabs
111
+
112
+ v_remap = _mercator_remap(v, np.asarray(lat_axis, dtype=np.float64))
113
+ finite = np.isfinite(v_remap)
114
+ v_safe = np.where(finite, v_remap, vmin)
115
+ denom = vmax - vmin if vmax > vmin else 1.0
116
+ norm = np.clip((v_safe - vmin) / denom, 0.0, 1.0)
117
+ idx = (norm * (LUT_SIZE - 1)).astype(np.int32)
118
+
119
+ rgb = _colorscale_lut(colorscale)[idx]
120
+ a = np.where(finite, int(alpha * 255), 0).astype(np.uint8)
121
+ rgba = np.dstack([rgb, a])[::-1, :, :] # flip rows so lat increases upward
122
+
123
+ img = Image.fromarray(rgba, mode="RGBA")
124
+ if blur_px > 0:
125
+ r_ch, g_ch, b_ch, a_ch = img.split()
126
+ a_ch = a_ch.filter(ImageFilter.GaussianBlur(radius=blur_px))
127
+ img = Image.merge("RGBA", (r_ch, g_ch, b_ch, a_ch))
128
+
129
+ buf = io.BytesIO()
130
+ img.save(buf, format="PNG")
131
+ uri = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
132
+ return uri, vmin, vmax
@@ -0,0 +1,307 @@
1
+ """Offline Natural Earth basemap for Plotly figures.
2
+
3
+ Provides political boundaries (admin-0 countries, admin-1 states/provinces)
4
+ from bundled Natural Earth data, with no network access at render time.
5
+ Intended as a fallback for environments where online tile providers (CARTO,
6
+ ArcGIS, OSM) are unreachable.
7
+
8
+ Example::
9
+
10
+ import plotly.graph_objects as go
11
+ from gri_plot import add_offline_basemap, BasemapStyle
12
+
13
+ fig = go.Figure()
14
+ fig.add_trace(go.Scattermap(lat=[40.7], lon=[-74.0], mode="markers"))
15
+ add_offline_basemap(fig, style=BasemapStyle.DARK)
16
+ fig.show()
17
+
18
+ The bundled data lives in ``gri_plot/data/natural_earth_boundaries.npz`` and
19
+ is generated by ``scripts/build_basemap_data.py``. Admin-0 is Natural Earth
20
+ 1:50m; admin-1 is 1:10m decimated via Douglas-Peucker for compactness.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import base64
26
+ import enum
27
+ import functools
28
+ import io
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import TYPE_CHECKING
32
+
33
+ import numpy as np
34
+ import plotly.graph_objects as go
35
+ from PIL import Image
36
+
37
+ if TYPE_CHECKING:
38
+ from numpy.typing import NDArray
39
+
40
+
41
+ _DATA_PATH = Path(__file__).parent / "data" / "natural_earth_boundaries.npz"
42
+ SCATTERMAP = "scattermap"
43
+ SCATTERGEO = "scattergeo"
44
+ _VALID_BACKENDS = (SCATTERMAP, SCATTERGEO)
45
+
46
+
47
+ class BasemapStyle(enum.Enum):
48
+ """Color style for the offline basemap.
49
+
50
+ The ``_COUNTRIES`` variants draw only admin-0 (country) boundaries for a
51
+ less busy look; the plain variants also draw admin-1 (state/province).
52
+
53
+ Attributes:
54
+ DARK: Dark background with light country + state borders.
55
+ LIGHT: Light background with dark country + state borders.
56
+ DARK_COUNTRIES: Dark background with country borders only.
57
+ LIGHT_COUNTRIES: Light background with country borders only.
58
+ """
59
+
60
+ DARK = "dark"
61
+ LIGHT = "light"
62
+ DARK_COUNTRIES = "dark_countries"
63
+ LIGHT_COUNTRIES = "light_countries"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class _Palette:
68
+ """Color palette shared across a color family (dark or light)."""
69
+
70
+ background: str
71
+ admin0_color: str
72
+ admin1_color: str
73
+ admin0_width: float
74
+ admin1_width: float
75
+
76
+
77
+ _DARK_PALETTE = _Palette(
78
+ background="#14181c",
79
+ admin0_color="rgba(140, 155, 170, 0.55)",
80
+ admin1_color="rgba(90, 100, 115, 0.35)",
81
+ admin0_width=0.8,
82
+ admin1_width=0.4,
83
+ )
84
+
85
+ _LIGHT_PALETTE = _Palette(
86
+ background="#f2f2f2",
87
+ admin0_color="rgba(40, 40, 40, 0.75)",
88
+ admin1_color="rgba(110, 110, 110, 0.45)",
89
+ admin0_width=0.8,
90
+ admin1_width=0.4,
91
+ )
92
+
93
+ # Map each style to (palette, include_admin1). Decoupling lets the color
94
+ # family and the "detail level" vary independently.
95
+ _STYLE_SPEC: dict[BasemapStyle, tuple[_Palette, bool]] = {
96
+ BasemapStyle.DARK: (_DARK_PALETTE, True),
97
+ BasemapStyle.LIGHT: (_LIGHT_PALETTE, True),
98
+ BasemapStyle.DARK_COUNTRIES: (_DARK_PALETTE, False),
99
+ BasemapStyle.LIGHT_COUNTRIES: (_LIGHT_PALETTE, False),
100
+ }
101
+
102
+
103
+ @functools.lru_cache(maxsize=1)
104
+ def _load_boundaries() -> dict[str, NDArray[np.float32]]:
105
+ """Load bundled Natural Earth boundary arrays from NPZ.
106
+
107
+ Cached so repeated calls share one in-memory copy.
108
+
109
+ Returns:
110
+ Mapping of array names (admin0_lon, admin0_lat, admin1_lon,
111
+ admin1_lat) to float32 arrays with NaN separators between rings.
112
+
113
+ Raises:
114
+ FileNotFoundError: If the bundled NPZ is missing from the wheel.
115
+ """
116
+ if not _DATA_PATH.is_file():
117
+ msg = (
118
+ f"offline basemap data not found at {_DATA_PATH}; regenerate with "
119
+ "scripts/build_basemap_data.py"
120
+ )
121
+ raise FileNotFoundError(msg)
122
+ with np.load(_DATA_PATH) as data:
123
+ return {k: data[k].copy() for k in data.files}
124
+
125
+
126
+ @functools.lru_cache(maxsize=8)
127
+ def _solid_color_png_data_uri(hex_color: str) -> str:
128
+ """Encode a 1x1 solid-color PNG as a base64 data URI.
129
+
130
+ Used as the "background" pseudo-tile under Scattermap figures, since
131
+ Plotly has no offline dark map_style.
132
+
133
+ Args:
134
+ hex_color: Color as ``#RRGGBB``.
135
+
136
+ Returns:
137
+ A ``data:image/png;base64,...`` URI.
138
+ """
139
+ h = hex_color.lstrip("#")
140
+ rgb = (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
141
+ buf = io.BytesIO()
142
+ Image.new("RGB", (1, 1), rgb).save(buf, format="PNG")
143
+ return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
144
+
145
+
146
+ def _world_fill_layer(hex_color: str) -> dict:
147
+ """Build a Plotly map image layer that fills the world with a solid color.
148
+
149
+ Mercator projections clip near the poles; we extend slightly beyond the
150
+ safe bound so there is no sliver of default white visible at high latitudes.
151
+ """
152
+ return {
153
+ "below": "traces",
154
+ "sourcetype": "image",
155
+ "source": _solid_color_png_data_uri(hex_color),
156
+ "coordinates": [
157
+ [-180.0, 89.0],
158
+ [180.0, 89.0],
159
+ [180.0, -89.0],
160
+ [-180.0, -89.0],
161
+ ],
162
+ }
163
+
164
+
165
+ def _make_boundary_trace( # noqa: PLR0913
166
+ lon: NDArray[np.float32],
167
+ lat: NDArray[np.float32],
168
+ *,
169
+ color: str,
170
+ width: float,
171
+ backend: str,
172
+ name: str,
173
+ ) -> go.Scattermap | go.Scattergeo:
174
+ """Build a single boundary polyline trace for the chosen backend."""
175
+ kwargs = {
176
+ "lon": lon,
177
+ "lat": lat,
178
+ "mode": "lines",
179
+ "line": {"color": color, "width": width},
180
+ "hoverinfo": "skip",
181
+ "showlegend": False,
182
+ "name": name,
183
+ }
184
+ if backend == SCATTERGEO:
185
+ return go.Scattergeo(**kwargs)
186
+ return go.Scattermap(**kwargs)
187
+
188
+
189
+ def add_offline_basemap( # noqa: PLR0913
190
+ fig: go.Figure,
191
+ *,
192
+ style: BasemapStyle = BasemapStyle.DARK,
193
+ backend: str = SCATTERMAP,
194
+ admin0_color: str | None = None,
195
+ admin1_color: str | None = None,
196
+ admin0_width: float | None = None,
197
+ admin1_width: float | None = None,
198
+ ) -> go.Figure:
199
+ """Add offline Natural Earth boundaries to a Plotly figure in-place.
200
+
201
+ Inserts admin-1 (state/province) then admin-0 (country) border traces
202
+ at the front of ``fig.data`` so any existing data traces render above
203
+ the basemap. For the Scattermap backend, also installs a solid-color
204
+ world image layer to provide the background color (Plotly's built-in
205
+ dark map styles require online tile access).
206
+
207
+ Args:
208
+ fig: Target figure; modified in place.
209
+ style: Color style (see :class:`BasemapStyle`). Determines the
210
+ background color and the palette defaults for border colors and
211
+ widths.
212
+ backend: Which Plotly trace family to use. Must match the traces
213
+ already on ``fig``: use ``"scattermap"`` for Scattermap-based
214
+ figures, ``"scattergeo"`` for Scattergeo-based figures.
215
+ admin0_color: Override the style's default country border color.
216
+ Any Plotly color string (``"#rrggbb"``, ``"rgba(r, g, b, a)"``,
217
+ named color). ``None`` uses the style default.
218
+ admin1_color: Override the style's default state/province border
219
+ color. Ignored when ``style`` is a ``*_COUNTRIES`` variant since
220
+ admin-1 is not drawn in that case.
221
+ admin0_width: Override the country border line width in pixels.
222
+ admin1_width: Override the state/province border line width.
223
+
224
+ Returns:
225
+ The same figure, for chaining.
226
+
227
+ Raises:
228
+ ValueError: If backend is not recognized.
229
+ FileNotFoundError: If the bundled basemap NPZ is missing.
230
+ """
231
+ if backend not in _VALID_BACKENDS:
232
+ msg = f"backend must be one of {_VALID_BACKENDS}, got {backend!r}"
233
+ raise ValueError(msg)
234
+
235
+ data = _load_boundaries()
236
+ palette, include_admin1 = _STYLE_SPEC[style]
237
+ a0_color = admin0_color if admin0_color is not None else palette.admin0_color
238
+ a1_color = admin1_color if admin1_color is not None else palette.admin1_color
239
+ a0_width = admin0_width if admin0_width is not None else palette.admin0_width
240
+ a1_width = admin1_width if admin1_width is not None else palette.admin1_width
241
+
242
+ # admin-1 first so admin-0 draws on top for emphasis at shared borders.
243
+ border_traces: list[go.Scattermap | go.Scattergeo] = []
244
+ if include_admin1:
245
+ border_traces.append(
246
+ _make_boundary_trace(
247
+ data["admin1_lon"],
248
+ data["admin1_lat"],
249
+ color=a1_color,
250
+ width=a1_width,
251
+ backend=backend,
252
+ name="basemap_admin1",
253
+ ),
254
+ )
255
+ border_traces.append(
256
+ _make_boundary_trace(
257
+ data["admin0_lon"],
258
+ data["admin0_lat"],
259
+ color=a0_color,
260
+ width=a0_width,
261
+ backend=backend,
262
+ name="basemap_admin0",
263
+ ),
264
+ )
265
+
266
+ # Append then reorder so basemap renders under any existing traces.
267
+ # Plotly only permits fig.data reassignment for permutations of itself,
268
+ # so we add first and then permute.
269
+ n_existing = len(fig.data)
270
+ fig.add_traces(border_traces)
271
+ n_borders = len(border_traces)
272
+ new_order = (
273
+ *range(n_existing, n_existing + n_borders),
274
+ *range(n_existing),
275
+ )
276
+ fig.data = tuple(fig.data[i] for i in new_order)
277
+
278
+ if backend == SCATTERMAP:
279
+ existing_layers = list(fig.layout.map.layers or [])
280
+ fig.update_layout(map_style="white-bg")
281
+ # Direct assignment rather than update_layout(map_layers=...); the
282
+ # latter does an element-wise merge with the existing tuple, which
283
+ # turns any pre-existing layer (e.g. an add_heatmap_image overlay)
284
+ # at index 0 into a mash-up of itself and the new world_fill.
285
+ fig.layout.map.layers = (
286
+ _world_fill_layer(palette.background),
287
+ *existing_layers,
288
+ )
289
+ else:
290
+ fig.update_layout(
291
+ geo={
292
+ "bgcolor": palette.background,
293
+ "showland": False,
294
+ "showocean": False,
295
+ "showcoastlines": False,
296
+ "showcountries": False,
297
+ "showsubunits": False,
298
+ "showframe": False,
299
+ },
300
+ )
301
+ return fig
302
+
303
+
304
+ __all__ = [
305
+ "BasemapStyle",
306
+ "add_offline_basemap",
307
+ ]
@@ -217,7 +217,7 @@ class Figure3D:
217
217
  }
218
218
 
219
219
  if labels:
220
- trace_kwargs["text"] = list(labels)
220
+ trace_kwargs["text"] = list(labels) # ty: ignore[invalid-assignment]
221
221
  trace_kwargs["textposition"] = "top center"
222
222
 
223
223
  trace_kwargs.update(kwargs)
@@ -267,7 +267,7 @@ class Figure3D:
267
267
 
268
268
  if name is not None:
269
269
  trace_kwargs["name"] = name
270
- trace_kwargs["showlegend"] = True
270
+ trace_kwargs["showlegend"] = True # ty: ignore[invalid-assignment]
271
271
 
272
272
  trace_kwargs.update(kwargs)
273
273
  self._traces.append(go.Scatter3d(**trace_kwargs))