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.
- {gri_plot-0.2.4 → gri_plot-0.2.5}/CONTRIBUTING.md +8 -4
- {gri_plot-0.2.4 → gri_plot-0.2.5}/PKG-INFO +2 -1
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/__init__.py +5 -0
- gri_plot-0.2.5/gri_plot/_heatmap_image.py +132 -0
- gri_plot-0.2.5/gri_plot/basemap.py +307 -0
- gri_plot-0.2.5/gri_plot/data/natural_earth_boundaries.npz +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/figure3d.py +2 -2
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/figure_map.py +188 -17
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/observables/los.py +1 -1
- gri_plot-0.2.5/gri_plot/polylines.py +60 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/scatter_map.py +44 -22
- {gri_plot-0.2.4 → gri_plot-0.2.5}/pyproject.toml +26 -5
- gri_plot-0.2.5/scripts/build_basemap_data.py +241 -0
- gri_plot-0.2.5/test/test_basemap.py +169 -0
- gri_plot-0.2.5/test/test_heatmap_image.py +137 -0
- gri_plot-0.2.5/test/test_polylines.py +52 -0
- gri_plot-0.2.4/.docs_other_projects.md +0 -85
- gri_plot-0.2.4/.gitlab-ci-deps.yml +0 -7
- gri_plot-0.2.4/.gitlab-ci.yml +0 -95
- gri_plot-0.2.4/.init_venv.sh +0 -87
- gri_plot-0.2.4/.pre-commit-config.yaml +0 -40
- gri_plot-0.2.4/.pre-commit-unskipped.py +0 -38
- gri_plot-0.2.4/.python-version +0 -1
- gri_plot-0.2.4/.ruff.toml +0 -60
- gri_plot-0.2.4/.vscode/settings.json +0 -15
- gri_plot-0.2.4/notes.txt +0 -3
- gri_plot-0.2.4/report.xml +0 -1
- gri_plot-0.2.4/uv.lock +0 -590
- {gri_plot-0.2.4 → gri_plot-0.2.5}/.gitignore +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/LICENSE +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/README.md +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/frames.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/observables/__init__.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/observables/aoa.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/observables/range_sphere.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/plot_ellipse.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/py.typed +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/scatter.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/__init__.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/cone.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/cylinder.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/ellipse.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/ellipsoid.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/meshgen.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/shapes/sphere.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/surfaces/__init__.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/gri_plot/surfaces/mesh.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/__init__.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_figure3d.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_figure_map.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_frames.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_observables/__init__.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_observables/test_aoa.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_observables/test_los.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_observables/test_range_sphere.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_plot_ellipse.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_plot_scattermap.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_scatter.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/__init__.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_cone.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_cylinder.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_ellipse.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_ellipsoid.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_shapes/test_sphere.py +0 -0
- {gri_plot-0.2.4 → gri_plot-0.2.5}/test/test_surfaces/__init__.py +0 -0
- {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
|
-
###
|
|
57
|
+
### ty
|
|
58
58
|
|
|
59
|
-
[
|
|
59
|
+
[ty](https://docs.astral.sh/ty/) for static type checking:
|
|
60
60
|
|
|
61
61
|
```bash
|
|
62
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
]
|
|
Binary file
|
|
@@ -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))
|