gri-plot 0.2.0.post1__py3-none-any.whl

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.
@@ -0,0 +1,293 @@
1
+ """Terrain surface from XYZ grid.
2
+
3
+ This module provides the TerrainSurface class for visualizing terrain.
4
+ """
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+ from scipy.interpolate import RegularGridInterpolator
10
+
11
+ from gri_plot.surfaces import ImplicitShape
12
+ from gri_plot.surfaces.mesh import vertices_to_mesh3d
13
+
14
+ if TYPE_CHECKING:
15
+ import plotly.graph_objects as go
16
+ from numpy.typing import NDArray
17
+
18
+
19
+ class TerrainSurface(ImplicitShape):
20
+ """Terrain surface from a grid of XYZ points.
21
+
22
+ The terrain is defined by a 2D grid of points in 3D space. This can
23
+ be used to visualize terrain elevation data or any sheet-like surface.
24
+
25
+ This is always treated as a surface (is_volume=False) since the target
26
+ is expected to be on the terrain surface, not inside a volume.
27
+
28
+ Attributes:
29
+ vertices: Grid vertices, shape (M, N, 3) or (M*N, 3).
30
+ grid_shape: Shape of the grid (M, N).
31
+ label: Optional label for legends.
32
+ is_volume: Always False for terrain surfaces.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ vertices: NDArray[np.floating],
38
+ grid_shape: tuple[int, int] | None = None,
39
+ label: str | None = None,
40
+ ) -> None:
41
+ """Initialize the terrain surface.
42
+
43
+ Args:
44
+ vertices: Grid vertices. Can be:
45
+ - Shape (M, N, 3): 2D grid of 3D points
46
+ - Shape (M*N, 3): Flattened grid (requires grid_shape)
47
+ grid_shape: Shape of the grid (M, N). Required if vertices is flat.
48
+ label: Optional label for legends.
49
+
50
+ Raises:
51
+ ValueError: If vertices shape is invalid or grid_shape is missing.
52
+ """
53
+ vertices = np.asarray(vertices, dtype=np.float64)
54
+
55
+ if vertices.ndim == 3: # noqa: PLR2004 - 3D grid (M, N, 3)
56
+ self._grid_shape = (vertices.shape[0], vertices.shape[1])
57
+ self._vertices_grid = vertices
58
+ self._vertices = vertices.reshape(-1, 3)
59
+ elif vertices.ndim == 2 and vertices.shape[1] == 3: # noqa: PLR2004 - flat (M*N, 3)
60
+ if grid_shape is None:
61
+ raise ValueError("grid_shape required for flat vertex array")
62
+ self._grid_shape = grid_shape
63
+ self._vertices = vertices
64
+ self._vertices_grid = vertices.reshape(grid_shape[0], grid_shape[1], 3)
65
+ else:
66
+ raise ValueError(
67
+ "vertices must be shape (M, N, 3) or (M*N, 3)",
68
+ )
69
+
70
+ if self._vertices.shape[0] != self._grid_shape[0] * self._grid_shape[1]:
71
+ raise ValueError(
72
+ f"Vertex count {self._vertices.shape[0]} does not match "
73
+ f"grid shape {self._grid_shape}",
74
+ )
75
+
76
+ self._label = label
77
+ self._faces = self._generate_faces()
78
+
79
+ # Set up interpolator for residual_fn
80
+ self._setup_interpolator()
81
+
82
+ def _generate_faces(self) -> NDArray[np.integer]:
83
+ """Generate triangular faces for the grid."""
84
+ m, n = self._grid_shape
85
+ faces = []
86
+
87
+ for i in range(m - 1):
88
+ for j in range(n - 1):
89
+ idx00 = i * n + j
90
+ idx01 = i * n + (j + 1)
91
+ idx10 = (i + 1) * n + j
92
+ idx11 = (i + 1) * n + (j + 1)
93
+
94
+ faces.append([idx00, idx10, idx11])
95
+ faces.append([idx00, idx11, idx01])
96
+
97
+ return np.array(faces, dtype=np.int64)
98
+
99
+ def _setup_interpolator(self) -> None:
100
+ """Set up interpolator for computing residuals.
101
+
102
+ Handles both meshgrid orientations:
103
+ - indexing='xy' (default): x varies along axis 1, y along axis 0
104
+ - indexing='ij': x varies along axis 0, y along axis 1
105
+ """
106
+ # Try to detect orientation by checking which axis has varying x
107
+ x_along_0 = self._vertices_grid[:, 0, 0]
108
+ x_along_1 = self._vertices_grid[0, :, 0]
109
+
110
+ # Check which axis has more variation in x
111
+ x_var_0 = np.ptp(x_along_0) # peak-to-peak along axis 0
112
+ x_var_1 = np.ptp(x_along_1) # peak-to-peak along axis 1
113
+
114
+ if x_var_0 > x_var_1:
115
+ # indexing='ij': x varies along axis 0, y along axis 1
116
+ x_coords = x_along_0
117
+ y_coords = self._vertices_grid[0, :, 1]
118
+ z_values = self._vertices_grid[:, :, 2]
119
+ else:
120
+ # indexing='xy' (default): x varies along axis 1, y along axis 0
121
+ x_coords = x_along_1
122
+ y_coords = self._vertices_grid[:, 0, 1]
123
+ z_values = self._vertices_grid[:, :, 2].T # Transpose to match
124
+
125
+ self._x_coords = x_coords
126
+ self._y_coords = y_coords
127
+
128
+ # Create interpolator for z values
129
+ # fill_value=None enables extrapolation (scipy accepts None despite type stubs)
130
+ self._z_interp = RegularGridInterpolator(
131
+ (x_coords, y_coords),
132
+ z_values,
133
+ method="linear",
134
+ bounds_error=False,
135
+ fill_value=None, # type: ignore[arg-type]
136
+ )
137
+
138
+ @property
139
+ def vertices(self) -> NDArray[np.floating]:
140
+ """Get vertices, shape (M*N, 3)."""
141
+ return self._vertices
142
+
143
+ @property
144
+ def grid_shape(self) -> tuple[int, int]:
145
+ """Get grid shape (M, N)."""
146
+ return self._grid_shape
147
+
148
+ @property
149
+ def label(self) -> str | None:
150
+ """Get the label."""
151
+ return self._label
152
+
153
+ @property
154
+ def is_volume(self) -> bool:
155
+ """Terrain surfaces are not volumes (target on terrain surface)."""
156
+ return False
157
+
158
+ def residual_fn(self, xyz: NDArray[np.floating]) -> NDArray[np.floating]:
159
+ """Compute the terrain residual (vertical distance from surface).
160
+
161
+ The residual is z - z_terrain, so:
162
+ - 0 on the terrain surface
163
+ - Positive above the terrain
164
+ - Negative below the terrain
165
+
166
+ Args:
167
+ xyz: Points to evaluate, shape (..., 3).
168
+
169
+ Returns:
170
+ Residual values, shape (...).
171
+ """
172
+ xyz = np.asarray(xyz)
173
+ original_shape = xyz.shape[:-1]
174
+
175
+ # Flatten for interpolation
176
+ flat_xyz = xyz.reshape(-1, 3)
177
+ xy_points = flat_xyz[:, :2]
178
+
179
+ # Interpolate terrain height at each (x, y)
180
+ z_terrain = self._z_interp(xy_points)
181
+
182
+ # Residual is actual z minus terrain z
183
+ residual = flat_xyz[:, 2] - z_terrain
184
+
185
+ return residual.reshape(original_shape)
186
+
187
+ def get_bounds_xyz(
188
+ self,
189
+ ) -> tuple[NDArray[np.floating], NDArray[np.floating]]:
190
+ """Get bounding box in XYZ coordinates.
191
+
192
+ Returns:
193
+ Tuple of (min_corner, max_corner).
194
+ """
195
+ return self._vertices.min(axis=0), self._vertices.max(axis=0)
196
+
197
+ def to_mesh(
198
+ self,
199
+ resolution: int | None = None, # noqa: ARG002 - unused, mesh is pre-computed
200
+ ) -> tuple[NDArray[np.floating], NDArray[np.integer]]:
201
+ """Return mesh vertices and faces.
202
+
203
+ Args:
204
+ resolution: Unused (mesh is pre-computed from input grid).
205
+
206
+ Returns:
207
+ Tuple of (vertices, faces).
208
+ """
209
+ return self._vertices, self._faces
210
+
211
+ def to_trace(
212
+ self,
213
+ resolution: int | None = None, # noqa: ARG002 - unused but required by ABC
214
+ colorscale: str = "earth",
215
+ **kwargs,
216
+ ) -> go.Mesh3d:
217
+ """Generate a Plotly Mesh3d trace.
218
+
219
+ Args:
220
+ resolution: Unused (included for ABC compatibility).
221
+ colorscale: Color scale for elevation coloring.
222
+ **kwargs: Additional arguments for vertices_to_mesh3d.
223
+
224
+ Returns:
225
+ Plotly Mesh3d trace.
226
+ """
227
+ # Use z-coordinate for intensity if not specified
228
+ if "intensity_fn" not in kwargs and "color" not in kwargs:
229
+ # Default to Z-height intensity (vertices_to_mesh3d does this already)
230
+ kwargs["colorscale"] = colorscale
231
+ kwargs["showscale"] = True
232
+
233
+ return vertices_to_mesh3d(
234
+ self._vertices,
235
+ self._faces,
236
+ name=self._label,
237
+ **kwargs,
238
+ )
239
+
240
+ @classmethod
241
+ def from_xyz_grids(
242
+ cls,
243
+ x: NDArray[np.floating],
244
+ y: NDArray[np.floating],
245
+ z: NDArray[np.floating],
246
+ label: str | None = None,
247
+ ) -> TerrainSurface:
248
+ """Create terrain from separate X, Y, Z grids.
249
+
250
+ Args:
251
+ x: X coordinates, shape (M, N).
252
+ y: Y coordinates, shape (M, N).
253
+ z: Z coordinates, shape (M, N).
254
+ label: Optional label for legends.
255
+
256
+ Returns:
257
+ TerrainSurface instance.
258
+ """
259
+ x = np.asarray(x)
260
+ y = np.asarray(y)
261
+ z = np.asarray(z)
262
+
263
+ vertices = np.stack([x, y, z], axis=-1)
264
+ return cls(vertices, label=label)
265
+
266
+ @classmethod
267
+ def from_elevation_grid(
268
+ cls,
269
+ x_coords: NDArray[np.floating],
270
+ y_coords: NDArray[np.floating],
271
+ elevation: NDArray[np.floating],
272
+ label: str | None = None,
273
+ ) -> TerrainSurface:
274
+ """Create terrain from 1D coordinate arrays and 2D elevation grid.
275
+
276
+ Args:
277
+ x_coords: X coordinates, shape (M,).
278
+ y_coords: Y coordinates, shape (N,).
279
+ elevation: Elevation values, shape (M, N).
280
+ label: Optional label for legends.
281
+
282
+ Returns:
283
+ TerrainSurface instance.
284
+ """
285
+ x_grid, y_grid = np.meshgrid(x_coords, y_coords, indexing="ij")
286
+ return cls.from_xyz_grids(x_grid, y_grid, elevation, label=label)
287
+
288
+ def __repr__(self) -> str:
289
+ """Return string representation."""
290
+ return f"TerrainSurface(grid_shape={self._grid_shape})"
291
+
292
+
293
+ __all__ = ["TerrainSurface"]
@@ -0,0 +1,183 @@
1
+ """2D error ellipse plotting.
2
+
3
+ This module provides the plot_ellipse() convenience function for rendering
4
+ one or more 2D error ellipses as Plotly figures. Designed for visualizing
5
+ geolocation error ellipses from gri-ell's Ell.ellipse output.
6
+ """
7
+
8
+ import math
9
+ from typing import TYPE_CHECKING
10
+
11
+ import plotly.graph_objects as go
12
+
13
+ from .figure3d import DEFAULT_COLORS
14
+
15
+ if TYPE_CHECKING:
16
+ from .shapes.ellipse import Ellipse
17
+
18
+
19
+ def _hex_to_rgba(hex_color: str, alpha: float) -> str:
20
+ """Convert hex color to rgba string.
21
+
22
+ Args:
23
+ hex_color: Hex color string (e.g., "#1f77b4").
24
+ alpha: Opacity value between 0 and 1.
25
+
26
+ Returns:
27
+ RGBA color string (e.g., "rgba(31, 119, 180, 0.25)").
28
+ """
29
+ h = hex_color.lstrip("#")
30
+ r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
31
+ return f"rgba({r}, {g}, {b}, {alpha})"
32
+
33
+
34
+ def _add_ellipse_trace(
35
+ fig: go.Figure,
36
+ ellipse: Ellipse,
37
+ color: str,
38
+ *,
39
+ show_axes: bool,
40
+ single: bool,
41
+ ) -> None:
42
+ """Add traces for a single ellipse to the figure.
43
+
44
+ Args:
45
+ fig: Plotly figure to add traces to.
46
+ ellipse: Ellipse to render.
47
+ color: Hex color for this ellipse.
48
+ show_axes: Whether to draw SMA/SMI annotation lines.
49
+ single: True if this is the only ellipse (affects axis colors).
50
+ """
51
+ east, north = ellipse.boundary()
52
+
53
+ # Filled ellipse boundary
54
+ fig.add_trace(
55
+ go.Scatter(
56
+ x=east,
57
+ y=north,
58
+ mode="lines",
59
+ line={"color": color, "width": 2},
60
+ fill="toself",
61
+ fillcolor=_hex_to_rgba(color, 0.25),
62
+ name=ellipse.label or "Ellipse",
63
+ showlegend=True,
64
+ ),
65
+ )
66
+
67
+ if not show_axes:
68
+ return
69
+
70
+ # SMA / SMI annotation lines from center
71
+ ce, cn = ellipse.center
72
+ sma_rad = math.radians(90.0 - ellipse.ori_deg)
73
+ smi_rad = sma_rad + math.pi / 2
74
+
75
+ # Color convention: single ellipse uses red/orange; multi uses own color
76
+ if single:
77
+ sma_color = DEFAULT_COLORS[3] # red
78
+ smi_color = DEFAULT_COLORS[1] # orange
79
+ else:
80
+ sma_color = color
81
+ smi_color = color
82
+
83
+ for length, angle, axis_label, line_color in [
84
+ (ellipse.sma, sma_rad, f"SMA = {ellipse.sma:.0f} m", sma_color),
85
+ (ellipse.smi, smi_rad, f"SMI = {ellipse.smi:.0f} m", smi_color),
86
+ ]:
87
+ dx = length * math.cos(angle)
88
+ dy = length * math.sin(angle)
89
+ fig.add_trace(
90
+ go.Scatter(
91
+ x=[ce, ce + dx],
92
+ y=[cn, cn + dy],
93
+ mode="lines+text",
94
+ line={"color": line_color, "width": 2, "dash": "dash"},
95
+ text=["", axis_label],
96
+ textposition="top right",
97
+ textfont={"color": line_color, "size": 11},
98
+ showlegend=False,
99
+ ),
100
+ )
101
+
102
+
103
+ def plot_ellipse(
104
+ *ellipses: Ellipse,
105
+ show_axes: bool = True,
106
+ title: str | None = None,
107
+ **kwargs,
108
+ ) -> go.Figure:
109
+ """Plot one or more 2D error ellipses.
110
+
111
+ Creates a Plotly figure with filled ellipses rendered in an East/North
112
+ coordinate frame. Designed for visualizing geolocation error ellipses.
113
+
114
+ Args:
115
+ *ellipses: One or more Ellipse objects to plot.
116
+ show_axes: If True, draw dashed SMA/SMI annotation lines with
117
+ length labels. For a single ellipse, uses red (SMA) and
118
+ orange (SMI). For multiple ellipses, uses each ellipse's
119
+ own color.
120
+ title: Optional figure title.
121
+ **kwargs: Additional arguments passed to fig.update_layout().
122
+
123
+ Returns:
124
+ Plotly Figure object. Call fig.show() to display.
125
+
126
+ Raises:
127
+ ValueError: If no ellipses are provided.
128
+ """
129
+ if not ellipses:
130
+ msg = "At least one Ellipse must be provided"
131
+ raise ValueError(msg)
132
+
133
+ fig = go.Figure()
134
+ single = len(ellipses) == 1
135
+
136
+ for i, ellipse in enumerate(ellipses):
137
+ color = DEFAULT_COLORS[i % len(DEFAULT_COLORS)]
138
+ _add_ellipse_trace(
139
+ fig,
140
+ ellipse,
141
+ color,
142
+ show_axes=show_axes,
143
+ single=single,
144
+ )
145
+
146
+ # Compute auto-padded range from all ellipses
147
+ all_east = []
148
+ all_north = []
149
+ for ellipse in ellipses:
150
+ east, north = ellipse.boundary()
151
+ all_east.extend([east.min(), east.max()])
152
+ all_north.extend([north.min(), north.max()])
153
+
154
+ e_min, e_max = min(all_east), max(all_east)
155
+ n_min, n_max = min(all_north), max(all_north)
156
+ e_span = e_max - e_min
157
+ n_span = n_max - n_min
158
+ pad = max(e_span, n_span) * 0.15
159
+
160
+ layout = {
161
+ "template": "plotly_dark",
162
+ "xaxis_title": "East (m)",
163
+ "yaxis_title": "North (m)",
164
+ "xaxis": {
165
+ "scaleanchor": "y",
166
+ "range": [e_min - pad, e_max + pad],
167
+ },
168
+ "yaxis": {
169
+ "range": [n_min - pad, n_max + pad],
170
+ },
171
+ }
172
+
173
+ if title is not None:
174
+ layout["title"] = title
175
+
176
+ layout.update(kwargs)
177
+ fig.update_layout(**layout)
178
+ return fig
179
+
180
+
181
+ __all__ = [
182
+ "plot_ellipse",
183
+ ]
gri_plot/py.typed ADDED
File without changes
gri_plot/scatter.py ADDED
@@ -0,0 +1,76 @@
1
+ """Simple helper to externalize the plotly scattergram. Make it a one-liner."""
2
+
3
+ from collections.abc import Sequence
4
+
5
+ import numpy as np
6
+ import plotly.graph_objects as go
7
+
8
+
9
+ def scatter(*trace_info: Sequence, **kwargs) -> None:
10
+ """Scattergram with x defined or not. kwargs passed to update_layout.
11
+
12
+ Pass in info like:
13
+ scatter((y,))
14
+ scatter((y1,), (y2,))
15
+ scatter((y1,), (y2,), layout_args)
16
+ scatter((y1, trace_args), (y2,), layout_args)
17
+ scatter((x,y))
18
+ scatter((x1,y1), (x2,y2))
19
+ scatter((x1,y1), (x2,y2), layout_args)
20
+ scatter((x1,y1), (x2,y2, trace_args), layout_args)
21
+
22
+ Args:
23
+ trace_info: sequnces or tuples of sequences. If just single sequences (or
24
+ ndarrays) it plots the sequence as the y. If passed as tuples, uses seq[0]
25
+ for x and seq[1] for y. If a dictionary is passed, it will be passed through
26
+ to scatter as kwargs.
27
+ kwargs: Additional arguments to pass to update_layout like:
28
+ title="Plot Title", yaxis_range=[0,None], xaxis_title="Time (s)"
29
+ """
30
+ traces = _gather_data(trace_info)
31
+
32
+ fig = go.Figure()
33
+ fig.add_traces(traces)
34
+ fig.update_layout(template="plotly_dark", **kwargs)
35
+ fig.show()
36
+
37
+
38
+ def _check_xdef(trace_info: Sequence) -> bool:
39
+ """Verify whether all or none have x defined and have only 2 or 3 args."""
40
+ if len(trace_info) == 0:
41
+ raise ValueError("No data passed in")
42
+ # All inputs are sequences of data
43
+ if any(not isinstance(t, Sequence) for t in trace_info):
44
+ raise ValueError("Inputs must be sequences of data like (y,) or (x,y,kwargs)")
45
+
46
+ x_def = False
47
+ if len(trace_info[0]) > 1 and isinstance(trace_info[0][1], (Sequence, np.ndarray)):
48
+ x_def = True
49
+ for t in trace_info:
50
+ if x_def:
51
+ if len(t) == 1 or not isinstance(t[1], (Sequence, np.ndarray)):
52
+ raise ValueError("All or no traces must have x defined")
53
+ if len(t) > 3: # noqa: PLR2004 - trace is (x, y, kwargs)
54
+ raise ValueError("Extra information passed. Should be (x,y,**args)")
55
+ else:
56
+ if len(t) > 1 and isinstance(t[1], (Sequence | np.ndarray)):
57
+ raise ValueError("All or no traces must have x defined")
58
+ if len(t) > 2: # noqa: PLR2004 - trace is (y, kwargs)
59
+ raise ValueError("Extra information passed. Should be (x,y,**args)")
60
+ return x_def
61
+
62
+
63
+ def _gather_data(trace_info: Sequence) -> list[go.Scatter]:
64
+ """Gather all the traces for the plot."""
65
+ x_def = _check_xdef(trace_info)
66
+ traces: list[go.Scatter] = []
67
+ t_len = 2 if x_def else 1
68
+ for idx, t in enumerate(trace_info):
69
+ trace_args = {} if len(t) == t_len else t[-1]
70
+ if "name" not in trace_args:
71
+ trace_args["name"] = f"D{idx}"
72
+ if x_def:
73
+ traces.append(go.Scatter(x=t[0], y=t[1], **trace_args))
74
+ else:
75
+ traces.append(go.Scatter(y=t[0], **trace_args))
76
+ return traces
@@ -0,0 +1,93 @@
1
+ """Basic lat/lon plot to a scatter_map in plotly."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import plotly.graph_objects as go
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Sequence
9
+
10
+ import numpy as np
11
+
12
+ # TODO lots of cleanup to do here
13
+
14
+
15
+ def scatter_map(*args: Sequence[Sequence[float]] | np.ndarray, **kwargs) -> None:
16
+ """Make a plot from lists of lat/lon.
17
+
18
+ Takes a list of lats and a list of lons as the first two indexes, or a row of lats
19
+ and a row of lons. If your data is in numpy colums, just pass arr.T. If your list is
20
+ lat/lon pairs, then something like np.asarray(arr).T should work.
21
+
22
+ For example, pass in scatter_map((lats1,lons1),(lats2,lons2))
23
+ To get to ((lats,lons)) from ((lat,lon),...), use np.asarray(arr).T
24
+
25
+ Args:
26
+ args(Sequence[Sequence[float]] | np.ndarray): any number of sequences or arrays
27
+ where the first two rows are lat and lon
28
+ kwargs: Any arguments to pass to plotly.express.scater_map like:
29
+ zoom(1-20), height(pixel height of map), width(pixel height of width). Also
30
+ can use a list of marker_sizes(diameters) and marker_colors(plotly colors)
31
+ but must be the same length as *args
32
+ """
33
+ marker_sizes = kwargs.get("marker_sizes")
34
+ marker_colors = kwargs.get("marker_colors")
35
+ fig = go.Figure()
36
+ traces = [
37
+ go.Scattermap(
38
+ lat=arg[0],
39
+ lon=arg[1],
40
+ mode="markers",
41
+ marker={
42
+ "size": marker_sizes[idx] if marker_sizes else None,
43
+ "color": marker_colors[idx] if marker_colors else None,
44
+ "sizemode": "diameter",
45
+ },
46
+ )
47
+ for idx, arg in enumerate(args)
48
+ ]
49
+ fig.add_traces(traces)
50
+ center = _center(*args)
51
+
52
+ # fig.update_layout(map_style="open-street-map")
53
+ # fig.update_layout(map_style="carto-darkmatter")
54
+ # fig.update_layout(map_style="satellite-streets")
55
+ # fig.update_layout(map_style="satellite")
56
+ # fig.update_layout(map_style="streets")
57
+ # fig.update_layout(map_style="carto-voyager")
58
+ # fig.update_layout(map_style="carto-positron")
59
+ # s = "https://gis.apfo.usda.gov/arcgis/rest/services/NAIP/USDA_CONUS_PRIME/ImageServer/tile/{z}/{y}/{x}"
60
+ # s = "https://basemap.nationalmap.gov/arcgis/rest/services/USGSImageryOnly/MapServer/tile/{z}/{y}/{x}"
61
+ s = "https://services.arcgisonline.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
62
+ fig.update_layout(
63
+ template="plotly_dark",
64
+ map_style="white-bg",
65
+ map_layers=[
66
+ {
67
+ "below": "traces",
68
+ "sourcetype": "raster",
69
+ "sourceattribution": "ArcGIS Online",
70
+ "source": [s],
71
+ "maxzoom": 18,
72
+ },
73
+ ],
74
+ map_zoom=kwargs.get("zoom"),
75
+ map_center=center,
76
+ height=kwargs.get("height"),
77
+ width=kwargs.get("width"),
78
+ )
79
+ fig.show()
80
+
81
+
82
+ def _center(*args: Sequence[Sequence[float]] | np.ndarray) -> dict[str, float]:
83
+ """Get the center lat/lon of the data."""
84
+ min_lat = 999
85
+ min_lon = 999
86
+ max_lat = -999
87
+ max_lon = -999
88
+ for arg in args:
89
+ min_lat = min(min_lat, *arg[0])
90
+ max_lat = max(max_lat, *arg[0])
91
+ min_lon = min(min_lon, *arg[1])
92
+ max_lon = max(max_lon, *arg[1])
93
+ return {"lat": (min_lat + max_lat) / 2, "lon": (min_lon + max_lon) / 2}
@@ -0,0 +1,19 @@
1
+ """Geometric shape classes for visualization.
2
+
3
+ This module provides classes for common shapes that can be rendered
4
+ as either parametric meshes, isosurfaces, or 2D boundaries.
5
+ """
6
+
7
+ from .cone import Cone
8
+ from .cylinder import Cylinder
9
+ from .ellipse import Ellipse
10
+ from .ellipsoid import Ellipsoid
11
+ from .sphere import Sphere
12
+
13
+ __all__ = [
14
+ "Cone",
15
+ "Cylinder",
16
+ "Ellipse",
17
+ "Ellipsoid",
18
+ "Sphere",
19
+ ]