Skip to content

Commit 70dca7b

Browse files
authored
Merge pull request #183 from CU-ESIIL/codex/update-default-camera-settings-in-v.plot
Default front-right, zoomed-out camera for v.plot with override and tests
2 parents 01bbc1c + 17397a6 commit 70dca7b

File tree

5 files changed

+103
-1
lines changed

5 files changed

+103
-1
lines changed

docs/viz/cube_viewer.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@ pipe(ndvi) | v.plot()
4444
- The viewer renders into a canvas inside the notebook output. The same HTML
4545
can be opened standalone for debugging.
4646

47+
## Customizing the view
48+
49+
Pass a Plotly-style camera dict to `v.plot()` to set the initial view. This is
50+
useful for forcing a front-right swing or zooming out when sharing links.
51+
52+
```python
53+
from cubedynamics import pipe, verbs as v
54+
55+
plot = pipe(ndvi) | v.plot(
56+
camera=dict(eye=dict(x=2.0, y=1.5, z=1.2)),
57+
)
58+
```
59+
4760
## Saving and opening as HTML
4861

4962
Use the returned `CubePlot` to save a standalone HTML file for easier

src/cubedynamics/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def plot(
107107
time_dim: str | None = "time",
108108
cmap: str = "viridis",
109109
clim: tuple[float, float] | None = None,
110+
camera: dict | None = None,
110111
**kwargs,
111112
):
112113
"""Plot a cube without explicitly building a pipe chain.
@@ -123,6 +124,8 @@ def plot(
123124
clim : tuple of float, optional
124125
Value limits passed through to the plotting verb for consistent color
125126
scaling.
127+
camera : dict, optional
128+
Plotly-style camera configuration for the initial cube view.
126129
**kwargs : Any
127130
Forwarded to :func:`cubedynamics.verbs.plot.plot` for advanced layout
128131
control.
@@ -159,6 +162,7 @@ def plot(
159162
time_dim=time_dim,
160163
cmap=cmap,
161164
clim=clim,
165+
camera=camera,
162166
**kwargs,
163167
)
164168

src/cubedynamics/plotting/cube_plot.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import logging
1818
import html
19+
import math
1920
import os
2021
import string
2122

@@ -258,6 +259,53 @@ class CoordCube:
258259
time_format: str = "%Y-%m-%d"
259260

260261

262+
DEFAULT_CAMERA: Dict[str, Dict[str, float]] = {
263+
"eye": {"x": 1.8, "y": 1.35, "z": 1.15},
264+
"up": {"x": 0.0, "y": 0.0, "z": 1.0},
265+
"center": {"x": 0.0, "y": 0.0, "z": 0.0},
266+
}
267+
_PLOTLY_DEFAULT_EYE = {"x": 1.25, "y": 1.25, "z": 1.25}
268+
269+
270+
def resolve_camera(camera: Optional[Dict[str, Any]]) -> Dict[str, Dict[str, float]]:
271+
merged = {
272+
"eye": dict(DEFAULT_CAMERA["eye"]),
273+
"up": dict(DEFAULT_CAMERA["up"]),
274+
"center": dict(DEFAULT_CAMERA["center"]),
275+
}
276+
if not camera:
277+
return merged
278+
for key in ("eye", "up", "center"):
279+
values = camera.get(key)
280+
if isinstance(values, dict):
281+
for axis in ("x", "y", "z"):
282+
if axis in values:
283+
merged[key][axis] = float(values[axis])
284+
return merged
285+
286+
287+
def plotly_camera_to_coord(camera: Optional[Dict[str, Any]]) -> CoordCube:
288+
resolved = resolve_camera(camera)
289+
eye = resolved["eye"]
290+
x = float(eye.get("x", 0.0))
291+
y = float(eye.get("y", 0.0))
292+
z = float(eye.get("z", 0.0))
293+
294+
azim = math.degrees(math.atan2(y, x)) if (x or y) else 0.0
295+
horiz = math.hypot(x, y)
296+
elev = math.degrees(math.atan2(z, horiz)) if (horiz or z) else 0.0
297+
298+
ref = math.sqrt(
299+
_PLOTLY_DEFAULT_EYE["x"] ** 2
300+
+ _PLOTLY_DEFAULT_EYE["y"] ** 2
301+
+ _PLOTLY_DEFAULT_EYE["z"] ** 2
302+
)
303+
mag = math.sqrt(x**2 + y**2 + z**2) or ref
304+
zoom = mag / ref if ref else 1.0
305+
306+
return CoordCube(elev=elev, azim=azim, zoom=zoom)
307+
308+
261309
@dataclass
262310
class CubeAnnotation:
263311
"""Simple annotation container for planes/text anchored to cube axes."""
@@ -453,6 +501,7 @@ class CubePlot(metaclass=_CubePlotMeta):
453501
show_progress: bool = True
454502
progress_style: str = "bar"
455503
coord: CoordCube = field(default_factory=CoordCube)
504+
camera: Optional[Dict[str, Any]] = None
456505
annotations: List[CubeAnnotation] = field(default_factory=list)
457506
out_html: str = "cube_da.html"
458507
facet: Optional[CubeFacet] = None
@@ -486,6 +535,8 @@ def __post_init__(self) -> None:
486535
self.alpha_scale = ScaleAlphaContinuous()
487536
if self.caption is None and self.fig_title is not None:
488537
self.caption = {"title": self.fig_title}
538+
if self.camera is not None:
539+
self.coord = plotly_camera_to_coord(self.camera)
489540
if isinstance(self.data, xr.DataArray):
490541
self.axis_meta = self.axis_meta or self._build_axis_meta(self.data)
491542

src/cubedynamics/verbs/plot.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919

2020
import xarray as xr
2121

22-
from cubedynamics.plotting.cube_plot import CubePlot, ScaleFillContinuous
22+
from cubedynamics.plotting.cube_plot import (
23+
DEFAULT_CAMERA,
24+
CubePlot,
25+
ScaleFillContinuous,
26+
plotly_camera_to_coord,
27+
resolve_camera,
28+
)
2329
from cubedynamics.streaming import VirtualCube
2430
from cubedynamics.utils import _infer_time_y_x_dims
2531
from ..piping import Verb
@@ -39,6 +45,7 @@ class PlotOptions:
3945
thin_time_factor: int = 4
4046
time_dim: str | None = None
4147
clim: tuple[float, float] | None = None
48+
camera: dict | None = None
4249
fig_id: int | None = None
4350
fig_title: str | None = None
4451
fig_text: str | None = None
@@ -54,6 +61,7 @@ def plot(
5461
thin_time_factor: int = 4,
5562
time_dim: str | None = None,
5663
clim: tuple[float, float] | None = None,
64+
camera: dict | None = None,
5765
fig_id: int | None = None,
5866
fig_title: str | None = None,
5967
fig_text: str | None = None,
@@ -70,6 +78,7 @@ def plot(
7078
thin_time_factor: int = 4,
7179
time_dim: str | None = None,
7280
clim: tuple[float, float] | None = None,
81+
camera: dict | None = None,
7382
fig_id: int | None = None,
7483
fig_title: str | None = None,
7584
fig_text: str | None = None,
@@ -86,6 +95,7 @@ def plot(
8695
thin_time_factor: int = 4,
8796
time_dim: str | None = None,
8897
clim: tuple[float, float] | None = None,
98+
camera: dict | None = None,
8999
fig_id: int | None = None,
90100
fig_title: str | None = None,
91101
fig_text: str | None = None,
@@ -116,6 +126,9 @@ def plot(
116126
Name of the temporal dimension. Inferred when not provided.
117127
clim : tuple of float, optional
118128
Color limits for the continuous scale.
129+
camera : dict, optional
130+
Plotly-style camera configuration used to set the initial cube view.
131+
When omitted, a front-right, zoomed-out default is applied.
119132
fig_id, fig_title, fig_text : optional
120133
Caption metadata used by the viewer export helpers.
121134
@@ -158,6 +171,7 @@ def plot(
158171
thin_time_factor=thin_time_factor,
159172
time_dim=time_dim,
160173
clim=clim,
174+
camera=camera,
161175
fig_id=fig_id,
162176
fig_title=fig_title,
163177
fig_text=fig_text,
@@ -183,6 +197,9 @@ def _plot(value: xr.DataArray | VirtualCube):
183197
if opts.fig_id is not None or opts.fig_title is not None or opts.fig_text is not None:
184198
caption_payload = {"id": opts.fig_id, "title": opts.fig_title, "text": opts.fig_text}
185199

200+
camera_to_use = resolve_camera(opts.camera or DEFAULT_CAMERA)
201+
coord = plotly_camera_to_coord(camera_to_use)
202+
186203
# 1. Build CubePlot for this cube
187204
cube = CubePlot(
188205
da_value,
@@ -194,6 +211,8 @@ def _plot(value: xr.DataArray | VirtualCube):
194211
time_dim=resolved_time,
195212
fill_scale=ScaleFillContinuous(cmap=opts.cmap, limits=opts.clim),
196213
fig_title=opts.fig_title,
214+
coord=coord,
215+
camera=camera_to_use,
197216
)
198217

199218
# 2. Draw cube

tests/test_plot_verb.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from cubedynamics import verbs as v
55
from cubedynamics.piping import pipe
66
from cubedynamics.plotting import CubePlot
7+
from cubedynamics.plotting.cube_plot import DEFAULT_CAMERA
78

89

910
def _make_tiny_cube():
@@ -30,3 +31,17 @@ def test_plot_does_not_materialize_dask():
3031
result = (pipe(cube) | v.plot()).unwrap()
3132
assert isinstance(result, CubePlot)
3233
assert cube.data.__class__.__name__.lower().startswith("array")
34+
35+
36+
def test_plot_default_camera():
37+
cube = _make_tiny_cube()
38+
result = (pipe(cube) | v.plot()).unwrap()
39+
assert result.camera == DEFAULT_CAMERA
40+
assert result.camera["eye"]["x"] > 0
41+
42+
43+
def test_plot_camera_override():
44+
cube = _make_tiny_cube()
45+
custom_camera = {"eye": {"x": 2.0, "y": 1.5, "z": 1.2}}
46+
result = (pipe(cube) | v.plot(camera=custom_camera)).unwrap()
47+
assert result.camera["eye"] == custom_camera["eye"]

0 commit comments

Comments
 (0)