Skip to content

canvod.viz API Reference

2D and 3D hemispheric visualization tools.

Package

Visualization and plotting utilities for GNSS VOD data.

This package provides 2D and 3D visualization capabilities for hemispherical GNSS grids and VOD data, with both publication-quality (matplotlib) and interactive (plotly) rendering options.

Examples

2D polar visualization::

from canvod.viz import HemisphereVisualizer2D
from canvod.grids import create_hemigrid

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
viz = HemisphereVisualizer2D(grid)
fig, ax = viz.plot_grid_patches(data=vod_data, title="VOD Distribution")

Convenience function::

from canvod.viz import visualize_grid, add_tissot_indicatrix

fig, ax = visualize_grid(grid, data=vod_data, cmap='viridis')
add_tissot_indicatrix(ax, grid, n_sample=5)

3D interactive visualization::

from canvod.viz import HemisphereVisualizer3D

viz3d = HemisphereVisualizer3D(grid)
fig = viz3d.plot_hemisphere_surface(data=vod_data, title="Interactive VOD")
fig.show()

Unified API::

from canvod.viz import HemisphereVisualizer

viz = HemisphereVisualizer(grid)
fig_2d, ax_2d = viz.plot_2d(data=vod_data)
fig_3d = viz.plot_3d(data=vod_data)

HemisphereVisualizer

Unified hemisphere visualizer combining 2D and 3D capabilities.

Provides consistent API for both publication-quality matplotlib plots and interactive plotly visualizations. Handles styling coordination between different rendering backends.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Examples

Create both 2D and 3D visualizations::

from canvod.grids import create_hemigrid
from canvod.viz import HemisphereVisualizer

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
viz = HemisphereVisualizer(grid)

# Publication-quality 2D plot
fig_2d, ax_2d = viz.plot_2d(
    data=vod_data,
    title="VOD Distribution",
    save_path="publication.png"
)

# Interactive 3D plot
fig_3d = viz.plot_3d(
    data=vod_data,
    title="Interactive VOD Explorer"
)
fig_3d.show()

Switch styles easily::

# Publication style
pub_style = create_publication_style()
viz.set_style(pub_style)
fig, ax = viz.plot_2d(data=vod_data)

# Interactive style
int_style = create_interactive_style(dark_mode=True)
viz.set_style(int_style)
fig = viz.plot_3d(data=vod_data)
Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
class HemisphereVisualizer:
    """Unified hemisphere visualizer combining 2D and 3D capabilities.

    Provides consistent API for both publication-quality matplotlib plots
    and interactive plotly visualizations. Handles styling coordination
    between different rendering backends.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    Examples
    --------
    Create both 2D and 3D visualizations::

        from canvod.grids import create_hemigrid
        from canvod.viz import HemisphereVisualizer

        grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
        viz = HemisphereVisualizer(grid)

        # Publication-quality 2D plot
        fig_2d, ax_2d = viz.plot_2d(
            data=vod_data,
            title="VOD Distribution",
            save_path="publication.png"
        )

        # Interactive 3D plot
        fig_3d = viz.plot_3d(
            data=vod_data,
            title="Interactive VOD Explorer"
        )
        fig_3d.show()

    Switch styles easily::

        # Publication style
        pub_style = create_publication_style()
        viz.set_style(pub_style)
        fig, ax = viz.plot_2d(data=vod_data)

        # Interactive style
        int_style = create_interactive_style(dark_mode=True)
        viz.set_style(int_style)
        fig = viz.plot_3d(data=vod_data)

    """

    def __init__(self, grid: HemiGrid) -> None:
        """Initialize unified visualizer.

        Parameters
        ----------
        grid : HemiGrid
            Hemisphere grid to visualize

        """
        self.grid = grid

        # Initialize specialized visualizers
        self.viz_2d = HemisphereVisualizer2D(grid)
        self.viz_3d = HemisphereVisualizer3D(grid)

        # Default styling
        self.style = PlotStyle()

    def set_style(self, style: PlotStyle) -> None:
        """Set unified styling for both 2D and 3D plots.

        Parameters
        ----------
        style : PlotStyle
            Styling configuration

        Examples
        --------
        >>> pub_style = create_publication_style()
        >>> viz.set_style(pub_style)
        >>> fig, ax = viz.plot_2d(data=vod_data)

        """
        self.style = style

    def plot_2d(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        ax: Axes | None = None,
        save_path: Path | str | None = None,
        style: PolarPlotStyle | None = None,
        **kwargs: Any,
    ) -> tuple[Figure, Axes]:
        """Create 2D publication-quality plot.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        ax : matplotlib.axes.Axes, optional
            Existing axes to plot on
        save_path : Path or str, optional
            Save figure to this path
        style : PolarPlotStyle, optional
            Override default 2D style
        **kwargs
            Additional styling parameters

        Returns
        -------
        fig : matplotlib.figure.Figure
            Figure object
        ax : matplotlib.axes.Axes
            Polar axes with plot

        Examples
        --------
        >>> fig, ax = viz.plot_2d(
        ...     data=vod_data,
        ...     title="VOD Distribution",
        ...     cmap='plasma',
        ...     save_path="output.png",
        ...     dpi=300
        ... )

        """
        if style is None:
            style = self.style.to_polar_style()

        if title:
            style.title = title

        return self.viz_2d.plot_grid_patches(
            data=data, style=style, ax=ax, save_path=save_path, **kwargs
        )

    def plot_3d(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        style: PlotStyle | None = None,
        **kwargs: Any,
    ) -> go.Figure:
        """Create 3D interactive plot.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        style : PlotStyle, optional
            Override default 3D style
        **kwargs
            Additional plotly parameters

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive 3D figure

        Examples
        --------
        >>> fig = viz.plot_3d(
        ...     data=vod_data,
        ...     title="Interactive VOD",
        ...     opacity=0.9,
        ...     width=1000,
        ...     height=800
        ... )
        >>> fig.show()
        >>> fig.write_html("interactive.html")

        """
        if style is None:
            style = self.style

        return self.viz_3d.plot_hemisphere_surface(
            data=data, style=style, title=title, **kwargs
        )

    def plot_3d_mesh(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        **kwargs: Any,
    ) -> go.Figure:
        """Create 3D mesh plot showing cell boundaries.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        **kwargs
            Additional plotly parameters

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive mesh figure

        Examples
        --------
        >>> fig = viz.plot_3d_mesh(
        ...     data=vod_data,
        ...     title="VOD Mesh View",
        ...     opacity=0.7
        ... )

        """
        return self.viz_3d.plot_cell_mesh(data=data, title=title, **kwargs)

    def create_comparison_plot(
        self,
        data: np.ndarray | None = None,
        title_2d: str = "2D Polar View",
        title_3d: str = "3D Hemisphere View",
        save_2d: Path | str | None = None,
        save_3d: Path | str | None = None,
    ) -> tuple[tuple[Figure, Axes], go.Figure]:
        """Create both 2D and 3D plots for comparison.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title_2d : str, default "2D Polar View"
            Title for 2D plot
        title_3d : str, default "3D Hemisphere View"
            Title for 3D plot
        save_2d : Path or str, optional
            Save 2D figure to this path
        save_3d : Path or str, optional
            Save 3D figure to this path (HTML)

        Returns
        -------
        plot_2d : tuple of Figure and Axes
            2D matplotlib plot
        plot_3d : plotly.graph_objects.Figure
            3D plotly plot

        Examples
        --------
        >>> (fig_2d, ax_2d), fig_3d = viz.create_comparison_plot(
        ...     data=vod_data,
        ...     save_2d="comparison_2d.png",
        ...     save_3d="comparison_3d.html"
        ... )
        >>> plt.show()  # Show 2D
        >>> fig_3d.show()  # Show 3D

        """
        # Create 2D plot
        fig_2d, ax_2d = self.plot_2d(data=data, title=title_2d, save_path=save_2d)

        # Create 3D plot
        fig_3d = self.plot_3d(data=data, title=title_3d)

        # Save 3D if requested
        if save_3d:
            save_3d = Path(save_3d)
            save_3d.parent.mkdir(parents=True, exist_ok=True)
            fig_3d.write_html(str(save_3d))

        return (fig_2d, ax_2d), fig_3d

    def create_publication_figure(
        self,
        data: np.ndarray | None = None,
        title: str = "Hemispherical Data Distribution",
        save_path: Path | str | None = None,
        dpi: int = 300,
        **kwargs: Any,
    ) -> tuple[Figure, Axes]:
        """Create publication-ready figure with optimal styling.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, default "Hemispherical Data Distribution"
            Plot title
        save_path : Path or str, optional
            Save figure to this path
        dpi : int, default 300
            Resolution in dots per inch
        **kwargs
            Additional styling parameters

        Returns
        -------
        fig : matplotlib.figure.Figure
            Publication-ready figure
        ax : matplotlib.axes.Axes
            Styled polar axes

        Examples
        --------
        >>> fig, ax = viz.create_publication_figure(
        ...     data=vod_data,
        ...     title="VOD Distribution Over Rosalia Site",
        ...     save_path="paper_figure_3.png",
        ...     dpi=600
        ... )

        """
        # Use publication style and convert to PolarPlotStyle
        pub_plot_style = create_publication_style()
        polar_style = pub_plot_style.to_polar_style()
        polar_style.title = title
        polar_style.dpi = dpi

        # Override with kwargs
        for key, value in kwargs.items():
            if hasattr(polar_style, key):
                setattr(polar_style, key, value)

        return self.plot_2d(data=data, style=polar_style, save_path=save_path)

    def create_interactive_explorer(
        self,
        data: np.ndarray | None = None,
        title: str = "Interactive Data Explorer",
        dark_mode: bool = True,
        save_html: Path | str | None = None,
    ) -> go.Figure:
        """Create interactive explorer with optimal settings.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, default "Interactive Data Explorer"
            Plot title
        dark_mode : bool, default True
            Use dark theme
        save_html : Path or str, optional
            Save HTML to this path

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive explorer figure

        Examples
        --------
        >>> fig = viz.create_interactive_explorer(
        ...     data=vod_data,
        ...     title="VOD Explorer",
        ...     dark_mode=True,
        ...     save_html="explorer.html"
        ... )
        >>> fig.show()

        """
        # Use interactive style
        int_style = create_interactive_style(dark_mode=dark_mode)

        fig = self.plot_3d(data=data, title=title, style=int_style)

        # Save if requested
        if save_html:
            save_html = Path(save_html)
            save_html.parent.mkdir(parents=True, exist_ok=True)
            fig.write_html(str(save_html))

        return fig

__init__(grid)

Initialize unified visualizer.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def __init__(self, grid: HemiGrid) -> None:
    """Initialize unified visualizer.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    """
    self.grid = grid

    # Initialize specialized visualizers
    self.viz_2d = HemisphereVisualizer2D(grid)
    self.viz_3d = HemisphereVisualizer3D(grid)

    # Default styling
    self.style = PlotStyle()

set_style(style)

Set unified styling for both 2D and 3D plots.

Parameters

style : PlotStyle Styling configuration

Examples

pub_style = create_publication_style() viz.set_style(pub_style) fig, ax = viz.plot_2d(data=vod_data)

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def set_style(self, style: PlotStyle) -> None:
    """Set unified styling for both 2D and 3D plots.

    Parameters
    ----------
    style : PlotStyle
        Styling configuration

    Examples
    --------
    >>> pub_style = create_publication_style()
    >>> viz.set_style(pub_style)
    >>> fig, ax = viz.plot_2d(data=vod_data)

    """
    self.style = style

plot_2d(data=None, title=None, ax=None, save_path=None, style=None, **kwargs)

Create 2D publication-quality plot.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title ax : matplotlib.axes.Axes, optional Existing axes to plot on save_path : Path or str, optional Save figure to this path style : PolarPlotStyle, optional Override default 2D style **kwargs Additional styling parameters

Returns

fig : matplotlib.figure.Figure Figure object ax : matplotlib.axes.Axes Polar axes with plot

Examples

fig, ax = viz.plot_2d( ... data=vod_data, ... title="VOD Distribution", ... cmap='plasma', ... save_path="output.png", ... dpi=300 ... )

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def plot_2d(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    ax: Axes | None = None,
    save_path: Path | str | None = None,
    style: PolarPlotStyle | None = None,
    **kwargs: Any,
) -> tuple[Figure, Axes]:
    """Create 2D publication-quality plot.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    ax : matplotlib.axes.Axes, optional
        Existing axes to plot on
    save_path : Path or str, optional
        Save figure to this path
    style : PolarPlotStyle, optional
        Override default 2D style
    **kwargs
        Additional styling parameters

    Returns
    -------
    fig : matplotlib.figure.Figure
        Figure object
    ax : matplotlib.axes.Axes
        Polar axes with plot

    Examples
    --------
    >>> fig, ax = viz.plot_2d(
    ...     data=vod_data,
    ...     title="VOD Distribution",
    ...     cmap='plasma',
    ...     save_path="output.png",
    ...     dpi=300
    ... )

    """
    if style is None:
        style = self.style.to_polar_style()

    if title:
        style.title = title

    return self.viz_2d.plot_grid_patches(
        data=data, style=style, ax=ax, save_path=save_path, **kwargs
    )

plot_3d(data=None, title=None, style=None, **kwargs)

Create 3D interactive plot.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title style : PlotStyle, optional Override default 3D style **kwargs Additional plotly parameters

Returns

plotly.graph_objects.Figure Interactive 3D figure

Examples

fig = viz.plot_3d( ... data=vod_data, ... title="Interactive VOD", ... opacity=0.9, ... width=1000, ... height=800 ... ) fig.show() fig.write_html("interactive.html")

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def plot_3d(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    style: PlotStyle | None = None,
    **kwargs: Any,
) -> go.Figure:
    """Create 3D interactive plot.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    style : PlotStyle, optional
        Override default 3D style
    **kwargs
        Additional plotly parameters

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive 3D figure

    Examples
    --------
    >>> fig = viz.plot_3d(
    ...     data=vod_data,
    ...     title="Interactive VOD",
    ...     opacity=0.9,
    ...     width=1000,
    ...     height=800
    ... )
    >>> fig.show()
    >>> fig.write_html("interactive.html")

    """
    if style is None:
        style = self.style

    return self.viz_3d.plot_hemisphere_surface(
        data=data, style=style, title=title, **kwargs
    )

plot_3d_mesh(data=None, title=None, **kwargs)

Create 3D mesh plot showing cell boundaries.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title **kwargs Additional plotly parameters

Returns

plotly.graph_objects.Figure Interactive mesh figure

Examples

fig = viz.plot_3d_mesh( ... data=vod_data, ... title="VOD Mesh View", ... opacity=0.7 ... )

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def plot_3d_mesh(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    **kwargs: Any,
) -> go.Figure:
    """Create 3D mesh plot showing cell boundaries.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    **kwargs
        Additional plotly parameters

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive mesh figure

    Examples
    --------
    >>> fig = viz.plot_3d_mesh(
    ...     data=vod_data,
    ...     title="VOD Mesh View",
    ...     opacity=0.7
    ... )

    """
    return self.viz_3d.plot_cell_mesh(data=data, title=title, **kwargs)

create_comparison_plot(data=None, title_2d='2D Polar View', title_3d='3D Hemisphere View', save_2d=None, save_3d=None)

Create both 2D and 3D plots for comparison.

Parameters

data : np.ndarray, optional Data values per cell title_2d : str, default "2D Polar View" Title for 2D plot title_3d : str, default "3D Hemisphere View" Title for 3D plot save_2d : Path or str, optional Save 2D figure to this path save_3d : Path or str, optional Save 3D figure to this path (HTML)

Returns

plot_2d : tuple of Figure and Axes 2D matplotlib plot plot_3d : plotly.graph_objects.Figure 3D plotly plot

Examples

(fig_2d, ax_2d), fig_3d = viz.create_comparison_plot( ... data=vod_data, ... save_2d="comparison_2d.png", ... save_3d="comparison_3d.html" ... ) plt.show() # Show 2D fig_3d.show() # Show 3D

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def create_comparison_plot(
    self,
    data: np.ndarray | None = None,
    title_2d: str = "2D Polar View",
    title_3d: str = "3D Hemisphere View",
    save_2d: Path | str | None = None,
    save_3d: Path | str | None = None,
) -> tuple[tuple[Figure, Axes], go.Figure]:
    """Create both 2D and 3D plots for comparison.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title_2d : str, default "2D Polar View"
        Title for 2D plot
    title_3d : str, default "3D Hemisphere View"
        Title for 3D plot
    save_2d : Path or str, optional
        Save 2D figure to this path
    save_3d : Path or str, optional
        Save 3D figure to this path (HTML)

    Returns
    -------
    plot_2d : tuple of Figure and Axes
        2D matplotlib plot
    plot_3d : plotly.graph_objects.Figure
        3D plotly plot

    Examples
    --------
    >>> (fig_2d, ax_2d), fig_3d = viz.create_comparison_plot(
    ...     data=vod_data,
    ...     save_2d="comparison_2d.png",
    ...     save_3d="comparison_3d.html"
    ... )
    >>> plt.show()  # Show 2D
    >>> fig_3d.show()  # Show 3D

    """
    # Create 2D plot
    fig_2d, ax_2d = self.plot_2d(data=data, title=title_2d, save_path=save_2d)

    # Create 3D plot
    fig_3d = self.plot_3d(data=data, title=title_3d)

    # Save 3D if requested
    if save_3d:
        save_3d = Path(save_3d)
        save_3d.parent.mkdir(parents=True, exist_ok=True)
        fig_3d.write_html(str(save_3d))

    return (fig_2d, ax_2d), fig_3d

create_publication_figure(data=None, title='Hemispherical Data Distribution', save_path=None, dpi=300, **kwargs)

Create publication-ready figure with optimal styling.

Parameters

data : np.ndarray, optional Data values per cell title : str, default "Hemispherical Data Distribution" Plot title save_path : Path or str, optional Save figure to this path dpi : int, default 300 Resolution in dots per inch **kwargs Additional styling parameters

Returns

fig : matplotlib.figure.Figure Publication-ready figure ax : matplotlib.axes.Axes Styled polar axes

Examples

fig, ax = viz.create_publication_figure( ... data=vod_data, ... title="VOD Distribution Over Rosalia Site", ... save_path="paper_figure_3.png", ... dpi=600 ... )

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
def create_publication_figure(
    self,
    data: np.ndarray | None = None,
    title: str = "Hemispherical Data Distribution",
    save_path: Path | str | None = None,
    dpi: int = 300,
    **kwargs: Any,
) -> tuple[Figure, Axes]:
    """Create publication-ready figure with optimal styling.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, default "Hemispherical Data Distribution"
        Plot title
    save_path : Path or str, optional
        Save figure to this path
    dpi : int, default 300
        Resolution in dots per inch
    **kwargs
        Additional styling parameters

    Returns
    -------
    fig : matplotlib.figure.Figure
        Publication-ready figure
    ax : matplotlib.axes.Axes
        Styled polar axes

    Examples
    --------
    >>> fig, ax = viz.create_publication_figure(
    ...     data=vod_data,
    ...     title="VOD Distribution Over Rosalia Site",
    ...     save_path="paper_figure_3.png",
    ...     dpi=600
    ... )

    """
    # Use publication style and convert to PolarPlotStyle
    pub_plot_style = create_publication_style()
    polar_style = pub_plot_style.to_polar_style()
    polar_style.title = title
    polar_style.dpi = dpi

    # Override with kwargs
    for key, value in kwargs.items():
        if hasattr(polar_style, key):
            setattr(polar_style, key, value)

    return self.plot_2d(data=data, style=polar_style, save_path=save_path)

create_interactive_explorer(data=None, title='Interactive Data Explorer', dark_mode=True, save_html=None)

Create interactive explorer with optimal settings.

Parameters

data : np.ndarray, optional Data values per cell title : str, default "Interactive Data Explorer" Plot title dark_mode : bool, default True Use dark theme save_html : Path or str, optional Save HTML to this path

Returns

plotly.graph_objects.Figure Interactive explorer figure

Examples

fig = viz.create_interactive_explorer( ... data=vod_data, ... title="VOD Explorer", ... dark_mode=True, ... save_html="explorer.html" ... ) fig.show()

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
def create_interactive_explorer(
    self,
    data: np.ndarray | None = None,
    title: str = "Interactive Data Explorer",
    dark_mode: bool = True,
    save_html: Path | str | None = None,
) -> go.Figure:
    """Create interactive explorer with optimal settings.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, default "Interactive Data Explorer"
        Plot title
    dark_mode : bool, default True
        Use dark theme
    save_html : Path or str, optional
        Save HTML to this path

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive explorer figure

    Examples
    --------
    >>> fig = viz.create_interactive_explorer(
    ...     data=vod_data,
    ...     title="VOD Explorer",
    ...     dark_mode=True,
    ...     save_html="explorer.html"
    ... )
    >>> fig.show()

    """
    # Use interactive style
    int_style = create_interactive_style(dark_mode=dark_mode)

    fig = self.plot_3d(data=data, title=title, style=int_style)

    # Save if requested
    if save_html:
        save_html = Path(save_html)
        save_html.parent.mkdir(parents=True, exist_ok=True)
        fig.write_html(str(save_html))

    return fig

HemisphereVisualizer2D

2D hemisphere visualization using matplotlib.

Creates publication-quality polar projection plots of hemispherical grids. Supports multiple grid types and rendering methods.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Examples

from canvod.grids import create_hemigrid from canvod.viz import HemisphereVisualizer2D

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0) viz = HemisphereVisualizer2D(grid) fig, ax = viz.plot_grid_patches(data=vod_data, title="VOD Distribution") plt.savefig("vod_plot.png", dpi=300, bbox_inches='tight')

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
class HemisphereVisualizer2D:
    """2D hemisphere visualization using matplotlib.

    Creates publication-quality polar projection plots of hemispherical grids.
    Supports multiple grid types and rendering methods.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    Examples
    --------
    >>> from canvod.grids import create_hemigrid
    >>> from canvod.viz import HemisphereVisualizer2D
    >>>
    >>> grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
    >>> viz = HemisphereVisualizer2D(grid)
    >>> fig, ax = viz.plot_grid_patches(data=vod_data, title="VOD Distribution")
    >>> plt.savefig("vod_plot.png", dpi=300, bbox_inches='tight')

    """

    def __init__(self, grid: HemiGrid) -> None:
        """Initialize 2D hemisphere visualizer.

        Parameters
        ----------
        grid : HemiGrid
            Hemisphere grid to visualize

        """
        self.grid = grid
        self._patches_cache: list[Polygon] | None = None
        self._cell_indices_cache: np.ndarray | None = None

    def plot_grid_patches(
        self,
        data: np.ndarray | None = None,
        style: PolarPlotStyle | None = None,
        ax: Axes | None = None,
        save_path: Path | str | None = None,
        **style_kwargs: Any,
    ) -> tuple[Figure, Axes]:
        """Plot hemisphere grid as colored patches in polar projection.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell. If None, plots uniform grid.
        style : PolarPlotStyle, optional
            Styling configuration. If None, uses defaults.
        ax : matplotlib.axes.Axes, optional
            Existing polar axes to plot on. If None, creates new figure.
        save_path : Path or str, optional
            If provided, saves figure to this path
        **style_kwargs
            Override individual style parameters

        Returns
        -------
        fig : matplotlib.figure.Figure
            Figure object
        ax : matplotlib.axes.Axes
            Polar axes with plot

        Examples
        --------
        >>> fig, ax = viz.plot_grid_patches(
        ...     data=vod_data,
        ...     title="VOD Distribution",
        ...     cmap='plasma',
        ...     save_path="output.png"
        ... )

        """
        # Initialize style
        if style is None:
            style = PolarPlotStyle(**style_kwargs)
        else:
            # Override style with kwargs
            for key, value in style_kwargs.items():
                if hasattr(style, key):
                    setattr(style, key, value)

        # Create figure if needed
        if ax is None:
            fig, ax = plt.subplots(
                figsize=style.figsize, dpi=style.dpi, subplot_kw={"projection": "polar"}
            )
        else:
            fig = cast("Figure", ax.figure)
        ax_polar = cast("PolarAxes", ax)

        # Get patches for grid
        patches, cell_indices = self._extract_grid_patches()

        # Map data to patches
        patch_data = self._map_data_to_patches(data, cell_indices)

        # Determine color limits
        vmin = style.vmin if style.vmin is not None else np.nanmin(patch_data)
        vmax = style.vmax if style.vmax is not None else np.nanmax(patch_data)

        # Create patch collection
        pc = PatchCollection(
            patches,
            cmap=style.cmap,
            edgecolor=style.edgecolor,
            linewidth=style.linewidth,
            alpha=style.alpha,
        )
        pc.set_array(np.ma.masked_invalid(patch_data))
        pc.set_clim(vmin, vmax)

        # Add to axes
        ax.add_collection(pc)

        # Style polar axes
        self._apply_polar_styling(ax_polar, style)

        # Add colorbar
        cbar = fig.colorbar(
            pc,
            ax=ax,
            shrink=style.colorbar_shrink,
            pad=style.colorbar_pad,
        )
        cbar.set_label(style.colorbar_label, fontsize=style.colorbar_fontsize)

        # Set title
        if style.title:
            ax.set_title(style.title, y=1.08, fontsize=14)

        # Save if requested
        if save_path:
            save_path = Path(save_path)
            save_path.parent.mkdir(parents=True, exist_ok=True)
            fig.savefig(
                save_path,
                dpi=style.dpi,
                bbox_inches="tight",
                facecolor="white",
                edgecolor="none",
            )

        return fig, ax

    def _extract_grid_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract 2D polygon patches from hemispherical grid.

        Returns
        -------
        patches : list of Polygon
            Matplotlib polygon patches
        cell_indices : np.ndarray
            Corresponding cell indices in grid

        """
        # Use cache if available
        if self._patches_cache is not None and self._cell_indices_cache is not None:
            return self._patches_cache, self._cell_indices_cache

        grid_type = self.grid.grid_type.lower()

        _rectangular_types = {"equal_area", "equal_angle", "equirectangular"}
        if grid_type in _rectangular_types:
            patches, indices = self._extract_rectangular_patches()
        elif grid_type == "htm":
            patches, indices = self._extract_htm_patches()
        elif grid_type == "geodesic":
            patches, indices = self._extract_geodesic_patches()
        elif grid_type == "healpix":
            patches, indices = self._extract_healpix_patches()
        elif grid_type == "fibonacci":
            patches, indices = self._extract_fibonacci_patches()
        else:
            raise ValueError(f"Unsupported grid type: {grid_type}")

        # Cache results
        self._patches_cache = patches
        self._cell_indices_cache = indices

        return patches, indices

    def _extract_rectangular_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract patches from rectangular/equal-area grid."""
        patches = []
        cell_indices = []

        # Access the grid DataFrame from GridData
        grid_df = self.grid.grid

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            phi_min = row["phi_min"]
            phi_max = row["phi_max"]
            theta_min = row["theta_min"]
            theta_max = row["theta_max"]

            # Skip cells beyond hemisphere
            if theta_min > np.pi / 2:
                continue

            # Convert to polar coordinates (rho = sin(theta))
            rho_min = np.sin(theta_min)
            rho_max = np.sin(theta_max)

            # Create rectangular patch in polar coordinates
            vertices = np.array(
                [
                    [phi_min, rho_min],
                    [phi_max, rho_min],
                    [phi_max, rho_max],
                    [phi_min, rho_max],
                ]
            )

            patches.append(Polygon(vertices, closed=True))
            cell_indices.append(idx)

        return patches, np.array(cell_indices)

    def _extract_htm_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract triangular patches from HTM grid."""
        patches = []
        cell_indices = []

        # Access the grid DataFrame from GridData
        grid_df = self.grid.grid

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                # HTM stores vertices as columns htm_vertex_0, htm_vertex_1,
                # htm_vertex_2.
                v0 = np.array(row["htm_vertex_0"], dtype=float)
                v1 = np.array(row["htm_vertex_1"], dtype=float)
                v2 = np.array(row["htm_vertex_2"], dtype=float)

                vertices_3d = np.array([v0, v1, v2])
                x, y, z = (
                    vertices_3d[:, 0],
                    vertices_3d[:, 1],
                    vertices_3d[:, 2],
                )

                # Convert to spherical coordinates
                r = np.sqrt(x**2 + y**2 + z**2)
                theta = np.arccos(np.clip(z / r, -1, 1))
                phi = np.arctan2(y, x)
                phi = np.mod(phi, 2 * np.pi)

                # Skip if beyond hemisphere
                if np.all(theta > np.pi / 2):
                    continue

                # Convert to polar coordinates (rho = sin(theta))
                rho = np.sin(theta)
                vertices_2d = np.column_stack([phi, rho])

                patches.append(Polygon(vertices_2d, closed=True))
                cell_indices.append(idx)

            except KeyError, TypeError:
                # Skip cells that don't have proper HTM vertex data
                continue

        return patches, np.array(cell_indices)

    def _extract_geodesic_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract triangular patches from geodesic grid.

        The ``geodesic_vertices`` column stores vertex **indices** into the
        shared ``grid.vertices`` coordinate array (shape ``(n_vertices, 3)``).
        """
        patches = []
        cell_indices = []

        grid_df = self.grid.grid
        shared_vertices = self.grid.vertices  # (n_vertices, 3) or None

        if shared_vertices is None or "geodesic_vertices" not in grid_df.columns:
            # No vertex data — fall back to bounding-box rectangles
            return self._extract_rectangular_patches()

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                v_indices = np.array(row["geodesic_vertices"], dtype=int)
                if len(v_indices) < 3:
                    continue

                # Look up actual 3D coordinates from shared vertex array
                verts_3d = shared_vertices[v_indices]  # (3, 3)
                x, y, z = verts_3d[:, 0], verts_3d[:, 1], verts_3d[:, 2]

                r = np.sqrt(x**2 + y**2 + z**2)
                theta = np.arccos(np.clip(z / r, -1, 1))
                phi = np.arctan2(y, x)
                phi = np.mod(phi, 2 * np.pi)

                if np.all(theta > np.pi / 2):
                    continue

                rho = np.sin(theta)
                vertices_2d = np.column_stack([phi, rho])
                patches.append(Polygon(vertices_2d, closed=True))
                cell_indices.append(idx)

            except IndexError, KeyError, TypeError, ValueError:
                continue

        return patches, np.array(cell_indices)

    def _extract_healpix_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract patches from HEALPix grid via ``healpy.boundaries``."""
        try:
            import healpy as hp
        except ImportError as e:
            raise ImportError(
                "healpy is required for HEALPix 2D visualization. "
                "Install with: pip install healpy"
            ) from e

        patches = []
        cell_indices = []
        grid_df = self.grid.grid
        nside = int(grid_df["healpix_nside"][0])

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            ipix = int(row["healpix_ipix"])
            # boundaries returns shape (3, n_vertices) in Cartesian
            boundary = hp.boundaries(nside, ipix, step=4)
            x, y, z = boundary[0], boundary[1], boundary[2]

            # Skip pixels entirely below horizon
            if np.all(z < -0.01):
                continue

            r = np.sqrt(x**2 + y**2 + z**2)
            theta = np.arccos(np.clip(z / r, -1, 1))
            phi = np.arctan2(y, x)
            phi = np.mod(phi, 2 * np.pi)

            # Keep only vertices in upper hemisphere
            mask = theta <= np.pi / 2 + 0.01
            if not np.any(mask):
                continue

            rho = np.sin(theta)
            vertices_2d = np.column_stack([phi, rho])
            patches.append(Polygon(vertices_2d, closed=True))
            cell_indices.append(idx)

        return patches, np.array(cell_indices)

    def _extract_fibonacci_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract patches from Fibonacci grid using Voronoi regions."""
        patches = []
        cell_indices = []
        grid_df = self.grid.grid
        voronoi = self.grid.voronoi  # scipy.spatial.SphericalVoronoi or None

        if voronoi is None or "voronoi_region" not in grid_df.columns:
            # No Voronoi data — fall back to bounding-box rectangles
            return self._extract_rectangular_patches()

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                region_indices = row["voronoi_region"]
                if region_indices is None or len(region_indices) < 3:
                    continue

                verts_3d = voronoi.vertices[region_indices]
                x, y, z = verts_3d[:, 0], verts_3d[:, 1], verts_3d[:, 2]

                # Skip cells entirely below horizon
                if np.all(z < -0.01):
                    continue

                r = np.sqrt(x**2 + y**2 + z**2)
                theta = np.arccos(np.clip(z / r, -1, 1))
                phi = np.arctan2(y, x)
                phi = np.mod(phi, 2 * np.pi)

                # Vertices are already in polygon winding order from
                # sort_vertices_of_regions() — use directly.
                rho = np.sin(theta)
                vertices_2d = np.column_stack([phi, rho])
                patches.append(Polygon(vertices_2d, closed=True))
                cell_indices.append(idx)

            except IndexError, KeyError, TypeError, ValueError:
                continue

        return patches, np.array(cell_indices)

    def _map_data_to_patches(
        self,
        data: np.ndarray | None,
        cell_indices: np.ndarray,
    ) -> np.ndarray:
        """Map data values to patches.

        Parameters
        ----------
        data : np.ndarray or None
            Data per grid cell
        cell_indices : np.ndarray
            Cell indices corresponding to patches

        Returns
        -------
        np.ndarray
            Data values for each patch

        """
        if data is None:
            return np.ones(len(cell_indices)) * 0.5

        return data[cell_indices]

    def _apply_polar_styling(
        self,
        ax: PolarAxes,
        style: PolarPlotStyle,
    ) -> None:
        """Apply styling to polar axes.

        Parameters
        ----------
        ax : matplotlib.axes.Axes
            Polar axes to style
        style : PolarPlotStyle
            Styling configuration

        """
        # Set rho limits (0 to 1 for hemisphere projection)
        ax.set_ylim(0, 1.0)

        # Configure polar axis orientation
        ax.set_theta_zero_location("N")  # North at top
        ax.set_theta_direction(-1)  # Clockwise (azimuth convention)

        # Add degree labels on radial axis
        if style.show_degree_labels:
            theta_labels = style.theta_labels
            rho_ticks = [np.sin(np.radians(t)) for t in theta_labels]
            ax.set_yticks(rho_ticks)
            ax.set_yticklabels([f"{t}°" for t in theta_labels])

        # Grid styling
        if style.show_grid:
            ax.grid(
                True,
                alpha=style.grid_alpha,
                linestyle=style.grid_linestyle,
                color="gray",
            )
        else:
            ax.grid(False)

__init__(grid)

Initialize 2D hemisphere visualizer.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, grid: HemiGrid) -> None:
    """Initialize 2D hemisphere visualizer.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    """
    self.grid = grid
    self._patches_cache: list[Polygon] | None = None
    self._cell_indices_cache: np.ndarray | None = None

plot_grid_patches(data=None, style=None, ax=None, save_path=None, **style_kwargs)

Plot hemisphere grid as colored patches in polar projection.

Parameters

data : np.ndarray, optional Data values per cell. If None, plots uniform grid. style : PolarPlotStyle, optional Styling configuration. If None, uses defaults. ax : matplotlib.axes.Axes, optional Existing polar axes to plot on. If None, creates new figure. save_path : Path or str, optional If provided, saves figure to this path **style_kwargs Override individual style parameters

Returns

fig : matplotlib.figure.Figure Figure object ax : matplotlib.axes.Axes Polar axes with plot

Examples

fig, ax = viz.plot_grid_patches( ... data=vod_data, ... title="VOD Distribution", ... cmap='plasma', ... save_path="output.png" ... )

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def plot_grid_patches(
    self,
    data: np.ndarray | None = None,
    style: PolarPlotStyle | None = None,
    ax: Axes | None = None,
    save_path: Path | str | None = None,
    **style_kwargs: Any,
) -> tuple[Figure, Axes]:
    """Plot hemisphere grid as colored patches in polar projection.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell. If None, plots uniform grid.
    style : PolarPlotStyle, optional
        Styling configuration. If None, uses defaults.
    ax : matplotlib.axes.Axes, optional
        Existing polar axes to plot on. If None, creates new figure.
    save_path : Path or str, optional
        If provided, saves figure to this path
    **style_kwargs
        Override individual style parameters

    Returns
    -------
    fig : matplotlib.figure.Figure
        Figure object
    ax : matplotlib.axes.Axes
        Polar axes with plot

    Examples
    --------
    >>> fig, ax = viz.plot_grid_patches(
    ...     data=vod_data,
    ...     title="VOD Distribution",
    ...     cmap='plasma',
    ...     save_path="output.png"
    ... )

    """
    # Initialize style
    if style is None:
        style = PolarPlotStyle(**style_kwargs)
    else:
        # Override style with kwargs
        for key, value in style_kwargs.items():
            if hasattr(style, key):
                setattr(style, key, value)

    # Create figure if needed
    if ax is None:
        fig, ax = plt.subplots(
            figsize=style.figsize, dpi=style.dpi, subplot_kw={"projection": "polar"}
        )
    else:
        fig = cast("Figure", ax.figure)
    ax_polar = cast("PolarAxes", ax)

    # Get patches for grid
    patches, cell_indices = self._extract_grid_patches()

    # Map data to patches
    patch_data = self._map_data_to_patches(data, cell_indices)

    # Determine color limits
    vmin = style.vmin if style.vmin is not None else np.nanmin(patch_data)
    vmax = style.vmax if style.vmax is not None else np.nanmax(patch_data)

    # Create patch collection
    pc = PatchCollection(
        patches,
        cmap=style.cmap,
        edgecolor=style.edgecolor,
        linewidth=style.linewidth,
        alpha=style.alpha,
    )
    pc.set_array(np.ma.masked_invalid(patch_data))
    pc.set_clim(vmin, vmax)

    # Add to axes
    ax.add_collection(pc)

    # Style polar axes
    self._apply_polar_styling(ax_polar, style)

    # Add colorbar
    cbar = fig.colorbar(
        pc,
        ax=ax,
        shrink=style.colorbar_shrink,
        pad=style.colorbar_pad,
    )
    cbar.set_label(style.colorbar_label, fontsize=style.colorbar_fontsize)

    # Set title
    if style.title:
        ax.set_title(style.title, y=1.08, fontsize=14)

    # Save if requested
    if save_path:
        save_path = Path(save_path)
        save_path.parent.mkdir(parents=True, exist_ok=True)
        fig.savefig(
            save_path,
            dpi=style.dpi,
            bbox_inches="tight",
            facecolor="white",
            edgecolor="none",
        )

    return fig, ax

HemisphereVisualizer3D

3D hemisphere visualization using plotly.

Creates interactive 3D plots with rotation, zoom, and hover capabilities. Designed for exploratory data analysis and presentations.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Examples

from canvod.grids import create_hemigrid from canvod.viz import HemisphereVisualizer3D

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0) viz = HemisphereVisualizer3D(grid) fig = viz.plot_hemisphere_surface(data=vod_data, title="Interactive VOD") fig.show()

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
class HemisphereVisualizer3D:
    """3D hemisphere visualization using plotly.

    Creates interactive 3D plots with rotation, zoom, and hover capabilities.
    Designed for exploratory data analysis and presentations.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    Examples
    --------
    >>> from canvod.grids import create_hemigrid
    >>> from canvod.viz import HemisphereVisualizer3D
    >>>
    >>> grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
    >>> viz = HemisphereVisualizer3D(grid)
    >>> fig = viz.plot_hemisphere_surface(data=vod_data, title="Interactive VOD")
    >>> fig.show()

    """

    def __init__(self, grid: HemiGrid) -> None:
        """Initialize 3D hemisphere visualizer.

        Parameters
        ----------
        grid : HemiGrid
            Hemisphere grid to visualize

        """
        self.grid = grid

    def plot_hemisphere_surface(
        self,
        data: np.ndarray | None = None,
        style: PlotStyle | None = None,
        title: str | None = None,
        colorscale: str = "Viridis",
        opacity: float = 0.8,
        show_wireframe: bool = True,
        show_colorbar: bool = True,
        width: int = 800,
        height: int = 600,
        **kwargs: Any,
    ) -> go.Figure:
        """Create 3D surface plot on hemisphere with actual cell patches.

        Renders grid cells as colored 3D patches (not just points).

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell. If None, shows grid structure.
        style : PlotStyle, optional
            Styling configuration. If None, uses defaults.
        title : str, optional
            Plot title
        colorscale : str, default 'Viridis'
            Plotly colorscale name
        opacity : float, default 0.8
            Surface opacity (0=transparent, 1=opaque)
        show_wireframe : bool, default True
            Show grid lines on surface
        show_colorbar : bool, default True
            Display colorbar
        width : int, default 800
            Figure width in pixels
        height : int, default 600
            Figure height in pixels
        **kwargs
            Additional plotly trace parameters

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive 3D figure with cell patches

        Examples
        --------
        >>> fig = viz.plot_hemisphere_surface(
        ...     data=vod_data,
        ...     title="VOD Distribution 3D",
        ...     colorscale='Plasma',
        ...     opacity=0.9
        ... )
        >>> fig.write_html("vod_3d.html")

        """
        # Initialize style
        if style is None:
            style = PlotStyle()
            colorscale = colorscale  # Use parameter
        else:
            colorscale = style.colorscale

        # Render grid based on type
        grid_type = self.grid.grid_type.lower()

        if grid_type in ["equal_area", "equal_angle", "equirectangular"]:
            trace = self._render_rectangular_cells(
                data,
                colorscale,
                opacity,
                show_colorbar,
            )
        elif grid_type == "htm":
            trace = self._render_htm_cells(data, colorscale, opacity, show_colorbar)
        elif grid_type == "geodesic":
            trace = self._render_geodesic_cells(
                data, colorscale, opacity, show_colorbar
            )
        elif grid_type == "healpix":
            trace = self._render_healpix_cells(data, colorscale, opacity, show_colorbar)
        elif grid_type == "fibonacci":
            trace = self._render_fibonacci_cells(
                data, colorscale, opacity, show_colorbar
            )
        else:
            # Fallback to scatter for unknown types
            trace = self._render_scatter_fallback(
                data,
                colorscale,
                opacity,
                show_colorbar,
            )

        fig = go.Figure(data=[trace])

        # Apply layout
        layout_config = style.to_plotly_layout() if style else {}
        layout_config.update(
            {
                "title": title or "Hemisphere 3D",
                "scene": dict(
                    aspectmode="data",
                    xaxis=dict(title="East", showbackground=False),
                    yaxis=dict(title="North", showbackground=False),
                    zaxis=dict(title="Up", showbackground=False),
                    bgcolor=layout_config.get("plot_bgcolor", "white"),
                ),
                "width": width,
                "height": height,
                "margin": dict(l=0, r=0, b=0, t=40),
            }
        )

        fig.update_layout(**layout_config)

        return fig

    def _render_rectangular_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d:
        """Render rectangular grid cells as 3D mesh patches."""
        grid_df = self.grid.grid

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []

        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                phi_min, phi_max = row["phi_min"], row["phi_max"]
                theta_min, theta_max = row["theta_min"], row["theta_max"]

                # Skip if beyond hemisphere
                if theta_min > np.pi / 2:
                    continue

                # Create 4 corners of rectangular cell
                phi_corners = [phi_min, phi_max, phi_max, phi_min]
                theta_corners = [theta_min, theta_min, theta_max, theta_max]

                patch_x, patch_y, patch_z = [], [], []
                for phi, theta in zip(phi_corners, theta_corners):
                    # Convert to 3D Cartesian
                    x = np.sin(theta) * np.sin(phi)
                    y = np.sin(theta) * np.cos(phi)
                    z = np.cos(theta)
                    patch_x.append(x)
                    patch_y.append(y)
                    patch_z.append(z)

                all_x.extend(patch_x)
                all_y.extend(patch_y)
                all_z.extend(patch_z)

                # Color value for this cell
                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * 4)

                # Two triangles per rectangle
                all_i.extend([vertex_count, vertex_count])
                all_j.extend([vertex_count + 1, vertex_count + 2])
                all_k.extend([vertex_count + 2, vertex_count + 3])

                vertex_count += 4

            except KeyError, IndexError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name=f"{self.grid.grid_type.title()} Grid",
        )

    def _render_htm_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d:
        """Render HTM triangular cells as 3D mesh."""
        grid_df = self.grid.grid

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []

        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                v0 = np.array(row["htm_vertex_0"], dtype=float)
                v1 = np.array(row["htm_vertex_1"], dtype=float)
                v2 = np.array(row["htm_vertex_2"], dtype=float)

                # Skip if beyond hemisphere
                if np.all([v[2] < 0 for v in [v0, v1, v2]]):
                    continue

                all_x.extend([v0[1], v1[1], v2[1]])
                all_y.extend([v0[0], v1[0], v2[0]])
                all_z.extend([v0[2], v1[2], v2[2]])

                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * 3)

                # One triangle per cell
                all_i.append(vertex_count)
                all_j.append(vertex_count + 1)
                all_k.append(vertex_count + 2)

                vertex_count += 3

            except KeyError, TypeError, ValueError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name=f"{self.grid.grid_type.title()} Grid",
        )

    def _render_geodesic_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d | go.Scatter3d:
        """Render geodesic triangular cells as 3D mesh.

        Reads ``geodesic_vertices`` (3 vertex indices per cell) and looks up
        3D Cartesian coordinates from the shared ``grid.vertices`` array.
        """
        grid_df = self.grid.grid
        shared_vertices = self.grid.vertices

        if shared_vertices is None or "geodesic_vertices" not in grid_df.columns:
            return self._render_scatter_fallback(
                data, colorscale, opacity, show_colorbar
            )

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []
        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                v_indices = np.array(row["geodesic_vertices"], dtype=int)
                if len(v_indices) < 3:
                    continue

                verts = shared_vertices[v_indices]  # (3, 3)

                if np.all(verts[:, 2] < 0):
                    continue

                all_x.extend(verts[:, 1].tolist())
                all_y.extend(verts[:, 0].tolist())
                all_z.extend(verts[:, 2].tolist())

                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * 3)

                all_i.append(vertex_count)
                all_j.append(vertex_count + 1)
                all_k.append(vertex_count + 2)
                vertex_count += 3

            except KeyError, IndexError, TypeError, ValueError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name="Geodesic Grid",
        )

    def _render_healpix_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d | go.Scatter3d:
        """Render HEALPix curvilinear cells as 3D mesh.

        Uses ``healpy.boundaries()`` to obtain true pixel boundaries,
        then fan-triangulates each quadrilateral pixel.
        """
        try:
            import healpy as hp
        except ImportError:
            return self._render_scatter_fallback(
                data, colorscale, opacity, show_colorbar
            )

        grid_df = self.grid.grid
        if "healpix_ipix" not in grid_df.columns:
            return self._render_scatter_fallback(
                data, colorscale, opacity, show_colorbar
            )

        nside = int(grid_df["healpix_nside"][0])
        step = 4  # 4 sub-points per edge → 16 boundary vertices

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []
        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                ipix = int(row["healpix_ipix"])
                boundary = hp.boundaries(nside, ipix, step=step)
                x, y, z = boundary[1], boundary[0], boundary[2]

                if np.all(z < -0.01):
                    continue

                n_verts = len(x)
                all_x.extend(x.tolist())
                all_y.extend(y.tolist())
                all_z.extend(z.tolist())

                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * n_verts)

                # Fan triangulation from first vertex
                for j in range(1, n_verts - 1):
                    all_i.append(vertex_count)
                    all_j.append(vertex_count + j)
                    all_k.append(vertex_count + j + 1)

                vertex_count += n_verts

            except KeyError, IndexError, TypeError, ValueError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name="HEALPix Grid",
        )

    def _render_fibonacci_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d | go.Scatter3d:
        """Render Fibonacci Voronoi cells as 3D mesh.

        Reads ``voronoi_region`` (variable-length vertex index list) and
        looks up 3D coordinates from ``grid.voronoi.vertices``.  The
        vertex indices are already in correct polygon winding order
        (``SphericalVoronoi.sort_vertices_of_regions()`` was called
        during grid construction), so no re-sorting is needed.
        Fan-triangulates each polygon for ``go.Mesh3d``.
        """
        grid_df = self.grid.grid
        voronoi = self.grid.voronoi

        if voronoi is None or "voronoi_region" not in grid_df.columns:
            return self._render_scatter_fallback(
                data, colorscale, opacity, show_colorbar
            )

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []
        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                region_indices = row["voronoi_region"]
                if region_indices is None or len(region_indices) < 3:
                    continue

                verts = voronoi.vertices[region_indices]

                if np.all(verts[:, 2] < -0.01):
                    continue

                # Vertices are already in polygon winding order from
                # sort_vertices_of_regions() — use directly.
                n_verts = len(verts)

                all_x.extend(verts[:, 1].tolist())
                all_y.extend(verts[:, 0].tolist())
                all_z.extend(verts[:, 2].tolist())

                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * n_verts)

                # Fan triangulation from first vertex
                for j in range(1, n_verts - 1):
                    all_i.append(vertex_count)
                    all_j.append(vertex_count + j)
                    all_k.append(vertex_count + j + 1)

                vertex_count += n_verts

            except KeyError, IndexError, TypeError, ValueError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name="Fibonacci Grid",
        )

    def _render_scatter_fallback(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Scatter3d:
        """Fallback scatter plot for unsupported grid types."""
        grid_df = self.grid.grid
        theta = grid_df["theta"].to_numpy()
        phi = grid_df["phi"].to_numpy()

        # Convert to 3D Cartesian coordinates
        x = np.sin(theta) * np.sin(phi)
        y = np.sin(theta) * np.cos(phi)
        z = np.cos(theta)

        # Prepare data values
        if data is None:
            values = np.ones(self.grid.ncells) * 0.5
        else:
            values = data

        # Filter hemisphere only
        hemisphere_mask = theta <= np.pi / 2
        x = x[hemisphere_mask]
        y = y[hemisphere_mask]
        z = z[hemisphere_mask]
        values = values[hemisphere_mask]

        return go.Scatter3d(
            x=x,
            y=y,
            z=z,
            mode="markers",
            marker=dict(
                size=6,
                color=values,
                colorscale=colorscale,
                opacity=opacity,
                colorbar=dict(title="Value") if show_colorbar else None,
                cmin=np.nanmin(values),
                cmax=np.nanmax(values),
            ),
            text=[f"Cell {i}<br>Value: {v:.3f}" for i, v in enumerate(values)],
            hoverinfo="text",
        )

    def plot_hemisphere_scatter(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        colorscale: str = "Viridis",
        marker_size: int | np.ndarray = 6,
        opacity: float = 0.8,
        width: int = 800,
        height: int = 600,
    ) -> go.Figure:
        """Create 3D scatter plot of cell centers.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        colorscale : str, default 'Viridis'
            Plotly colorscale name
        marker_size : int or np.ndarray, default 6
            Marker size (constant or per-point array)
        opacity : float, default 0.8
            Marker opacity
        width : int, default 800
            Figure width
        height : int, default 600
            Figure height

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive scatter plot

        """
        # Note: Now renders as mesh, not scatter points
        # Marker size parameter is ignored
        fig = self.plot_hemisphere_surface(
            data=data,
            title=title,
            colorscale=colorscale,
            opacity=opacity,
            width=width,
            height=height,
        )

        return fig

    def plot_cell_mesh(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        colorscale: str = "Viridis",
        opacity: float = 0.7,
        show_edges: bool = True,
        width: int = 800,
        height: int = 600,
    ) -> go.Figure:
        """Create 3D mesh plot showing cell boundaries.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        colorscale : str, default 'Viridis'
            Plotly colorscale name
        opacity : float, default 0.7
            Mesh opacity
        show_edges : bool, default True
            Show cell edges
        width : int, default 800
            Figure width
        height : int, default 600
            Figure height

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive mesh plot

        Notes
        -----
        This method requires grid cells with vertex information.
        Currently supports HTM and geodesic grids.

        """
        traces = []

        # Prepare data
        if data is None:
            values = np.ones(self.grid.ncells) * 0.5
        else:
            values = data

        grid_df = self.grid.grid
        grid_type = self.grid.grid_type.lower()

        # Check if grid supports mesh rendering
        if grid_type == "htm" and "htm_vertex_0" in grid_df.columns:
            # HTM triangular mesh
            for idx, row in enumerate(grid_df.iter_rows(named=True)):
                try:
                    v0 = np.array(row["htm_vertex_0"], dtype=float)
                    v1 = np.array(row["htm_vertex_1"], dtype=float)
                    v2 = np.array(row["htm_vertex_2"], dtype=float)

                    vertices = np.array([v0, v1, v2])

                    # Check hemisphere
                    z_coords = vertices[:, 2]
                    if np.all(z_coords < 0):
                        continue

                    # Normalize color value
                    color_val = (values[idx] - np.nanmin(values)) / (
                        np.nanmax(values) - np.nanmin(values)
                    )
                    color_rgb = sample_colorscale(colorscale, [color_val])[0]

                    # Create triangle mesh
                    trace = go.Mesh3d(
                        x=vertices[:, 0],
                        y=vertices[:, 1],
                        z=vertices[:, 2],
                        i=[0],
                        j=[1],
                        k=[2],
                        color=color_rgb,
                        opacity=opacity,
                        flatshading=True,
                        showscale=False,
                        hoverinfo="skip",
                    )
                    traces.append(trace)
                except KeyError, TypeError, ValueError:
                    continue
        else:
            # Not supported for this grid type
            raise NotImplementedError(
                f"Cell mesh rendering not implemented for {grid_type} grids"
            )

        # Sample colorscale (no longer needed above, but kept for compatibility)

        # Add colorbar trace
        if values is not None and len(traces) > 0:
            dummy_trace = go.Scatter3d(
                x=[None],
                y=[None],
                z=[None],
                mode="markers",
                marker=dict(
                    size=0.1,
                    color=[np.nanmin(values), np.nanmax(values)],
                    colorscale=colorscale,
                    colorbar=dict(title="Value"),
                ),
                showlegend=False,
                hoverinfo="skip",
            )
            traces.append(dummy_trace)

        fig = go.Figure(data=traces)

        # Update layout
        fig.update_layout(
            title=title or "Hemisphere Mesh 3D",
            scene=dict(
                aspectmode="data",
                xaxis=dict(title="East", showbackground=False),
                yaxis=dict(title="North", showbackground=False),
                zaxis=dict(title="Up", showbackground=False),
            ),
            width=width,
            height=height,
            margin=dict(l=0, r=0, b=0, t=40),
        )

        return fig

    def add_spherical_overlays(
        self,
        fig: go.Figure,
        elevation_rings: list[int] | None = None,
        meridians_deg: list[int] | None = None,
        overlay_color: str = "lightgray",
        line_width: float = 1,
    ) -> go.Figure:
        """Add elevation rings and meridians to 3D plot.

        Parameters
        ----------
        fig : plotly.graph_objects.Figure
            Existing 3D figure to add overlays to
        elevation_rings : list of int, optional
            Elevation angles in degrees. Default: [15, 30, 45, 60, 75, 90]
        meridians_deg : list of int, optional
            Meridian angles in degrees. Default: [0, 45, 90, 135, 180, 225, 270, 315]
        overlay_color : str, default 'lightgray'
            Color for overlay lines
        line_width : float, default 1
            Width of overlay lines

        Returns
        -------
        fig : plotly.graph_objects.Figure
            Modified figure with overlays

        """
        if elevation_rings is None:
            elevation_rings = [15, 30, 45, 60, 75, 90]
        if meridians_deg is None:
            meridians_deg = list(range(0, 360, 45))

        # Elevation rings
        for theta_deg in elevation_rings:
            theta = np.radians(theta_deg)
            phi = np.linspace(0, 2 * np.pi, 200)
            x = np.sin(theta) * np.sin(phi)
            y = np.sin(theta) * np.cos(phi)
            z = np.full_like(phi, np.cos(theta))
            fig.add_trace(
                go.Scatter3d(
                    x=x,
                    y=y,
                    z=z,
                    mode="lines",
                    line=dict(color=overlay_color, width=line_width),
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

        # Meridians
        for phi_deg in meridians_deg:
            phi = np.radians(phi_deg)
            theta = np.linspace(0, np.pi / 2, 100)
            x = np.sin(theta) * np.sin(phi)
            y = np.sin(theta) * np.cos(phi)
            z = np.cos(theta)
            fig.add_trace(
                go.Scatter3d(
                    x=x,
                    y=y,
                    z=z,
                    mode="lines",
                    line=dict(color=overlay_color, width=line_width),
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

        return fig

    def add_custom_axes(
        self,
        fig: go.Figure,
        axis_length: float = 1.2,
        axis_color: str = "black",
        show_labels: bool = True,
    ) -> go.Figure:
        """Add custom coordinate axes with labels.

        Parameters
        ----------
        fig : plotly.graph_objects.Figure
            Existing 3D figure
        axis_length : float, default 1.2
            Length of axis lines
        axis_color : str, default 'black'
            Color for axes
        show_labels : bool, default True
            Show axis labels (E, N, Z)

        Returns
        -------
        fig : plotly.graph_objects.Figure
            Modified figure with custom axes

        """
        # Axis lines
        axes_lines = [
            dict(x=[0, axis_length], y=[0, 0], z=[0, 0]),  # East
            dict(x=[0, 0], y=[0, axis_length], z=[0, 0]),  # North
            dict(x=[0, 0], y=[0, 0], z=[0, axis_length]),  # Up
        ]

        for axis in axes_lines:
            fig.add_trace(
                go.Scatter3d(
                    x=axis["x"],
                    y=axis["y"],
                    z=axis["z"],
                    mode="lines",
                    line=dict(color=axis_color, width=6),
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

        # Arrowheads
        arrow_tip = axis_length + 0.05
        arrow_size = 0.1
        for pos, direction in zip(
            [[arrow_tip, 0, 0], [0, arrow_tip, 0], [0, 0, arrow_tip]],
            [[arrow_size, 0, 0], [0, arrow_size, 0], [0, 0, arrow_size]],
        ):
            fig.add_trace(
                go.Cone(
                    x=[pos[0]],
                    y=[pos[1]],
                    z=[pos[2]],
                    u=[direction[0]],
                    v=[direction[1]],
                    w=[direction[2]],
                    sizemode="absolute",
                    sizeref=arrow_size,
                    anchor="tip",
                    showscale=False,
                    colorscale=[[0, axis_color], [1, axis_color]],
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

        # Labels
        if show_labels:
            label_offset = axis_length + 0.15
            for label in [
                dict(x=label_offset, y=0, z=0, text="E"),
                dict(x=0, y=label_offset, z=0, text="N"),
                dict(x=0, y=0, z=label_offset, text="Z"),
            ]:
                fig.add_trace(
                    go.Scatter3d(
                        x=[label["x"]],
                        y=[label["y"]],
                        z=[label["z"]],
                        mode="text",
                        text=[label["text"]],
                        textfont=dict(size=16, color=axis_color),
                        hoverinfo="skip",
                        showlegend=False,
                    )
                )

        return fig

__init__(grid)

Initialize 3D hemisphere visualizer.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
43
44
45
46
47
48
49
50
51
52
def __init__(self, grid: HemiGrid) -> None:
    """Initialize 3D hemisphere visualizer.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    """
    self.grid = grid

plot_hemisphere_surface(data=None, style=None, title=None, colorscale='Viridis', opacity=0.8, show_wireframe=True, show_colorbar=True, width=800, height=600, **kwargs)

Create 3D surface plot on hemisphere with actual cell patches.

Renders grid cells as colored 3D patches (not just points).

Parameters

data : np.ndarray, optional Data values per cell. If None, shows grid structure. style : PlotStyle, optional Styling configuration. If None, uses defaults. title : str, optional Plot title colorscale : str, default 'Viridis' Plotly colorscale name opacity : float, default 0.8 Surface opacity (0=transparent, 1=opaque) show_wireframe : bool, default True Show grid lines on surface show_colorbar : bool, default True Display colorbar width : int, default 800 Figure width in pixels height : int, default 600 Figure height in pixels **kwargs Additional plotly trace parameters

Returns

plotly.graph_objects.Figure Interactive 3D figure with cell patches

Examples

fig = viz.plot_hemisphere_surface( ... data=vod_data, ... title="VOD Distribution 3D", ... colorscale='Plasma', ... opacity=0.9 ... ) fig.write_html("vod_3d.html")

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def plot_hemisphere_surface(
    self,
    data: np.ndarray | None = None,
    style: PlotStyle | None = None,
    title: str | None = None,
    colorscale: str = "Viridis",
    opacity: float = 0.8,
    show_wireframe: bool = True,
    show_colorbar: bool = True,
    width: int = 800,
    height: int = 600,
    **kwargs: Any,
) -> go.Figure:
    """Create 3D surface plot on hemisphere with actual cell patches.

    Renders grid cells as colored 3D patches (not just points).

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell. If None, shows grid structure.
    style : PlotStyle, optional
        Styling configuration. If None, uses defaults.
    title : str, optional
        Plot title
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    opacity : float, default 0.8
        Surface opacity (0=transparent, 1=opaque)
    show_wireframe : bool, default True
        Show grid lines on surface
    show_colorbar : bool, default True
        Display colorbar
    width : int, default 800
        Figure width in pixels
    height : int, default 600
        Figure height in pixels
    **kwargs
        Additional plotly trace parameters

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive 3D figure with cell patches

    Examples
    --------
    >>> fig = viz.plot_hemisphere_surface(
    ...     data=vod_data,
    ...     title="VOD Distribution 3D",
    ...     colorscale='Plasma',
    ...     opacity=0.9
    ... )
    >>> fig.write_html("vod_3d.html")

    """
    # Initialize style
    if style is None:
        style = PlotStyle()
        colorscale = colorscale  # Use parameter
    else:
        colorscale = style.colorscale

    # Render grid based on type
    grid_type = self.grid.grid_type.lower()

    if grid_type in ["equal_area", "equal_angle", "equirectangular"]:
        trace = self._render_rectangular_cells(
            data,
            colorscale,
            opacity,
            show_colorbar,
        )
    elif grid_type == "htm":
        trace = self._render_htm_cells(data, colorscale, opacity, show_colorbar)
    elif grid_type == "geodesic":
        trace = self._render_geodesic_cells(
            data, colorscale, opacity, show_colorbar
        )
    elif grid_type == "healpix":
        trace = self._render_healpix_cells(data, colorscale, opacity, show_colorbar)
    elif grid_type == "fibonacci":
        trace = self._render_fibonacci_cells(
            data, colorscale, opacity, show_colorbar
        )
    else:
        # Fallback to scatter for unknown types
        trace = self._render_scatter_fallback(
            data,
            colorscale,
            opacity,
            show_colorbar,
        )

    fig = go.Figure(data=[trace])

    # Apply layout
    layout_config = style.to_plotly_layout() if style else {}
    layout_config.update(
        {
            "title": title or "Hemisphere 3D",
            "scene": dict(
                aspectmode="data",
                xaxis=dict(title="East", showbackground=False),
                yaxis=dict(title="North", showbackground=False),
                zaxis=dict(title="Up", showbackground=False),
                bgcolor=layout_config.get("plot_bgcolor", "white"),
            ),
            "width": width,
            "height": height,
            "margin": dict(l=0, r=0, b=0, t=40),
        }
    )

    fig.update_layout(**layout_config)

    return fig

plot_hemisphere_scatter(data=None, title=None, colorscale='Viridis', marker_size=6, opacity=0.8, width=800, height=600)

Create 3D scatter plot of cell centers.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title colorscale : str, default 'Viridis' Plotly colorscale name marker_size : int or np.ndarray, default 6 Marker size (constant or per-point array) opacity : float, default 0.8 Marker opacity width : int, default 800 Figure width height : int, default 600 Figure height

Returns

plotly.graph_objects.Figure Interactive scatter plot

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
def plot_hemisphere_scatter(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    colorscale: str = "Viridis",
    marker_size: int | np.ndarray = 6,
    opacity: float = 0.8,
    width: int = 800,
    height: int = 600,
) -> go.Figure:
    """Create 3D scatter plot of cell centers.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    marker_size : int or np.ndarray, default 6
        Marker size (constant or per-point array)
    opacity : float, default 0.8
        Marker opacity
    width : int, default 800
        Figure width
    height : int, default 600
        Figure height

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive scatter plot

    """
    # Note: Now renders as mesh, not scatter points
    # Marker size parameter is ignored
    fig = self.plot_hemisphere_surface(
        data=data,
        title=title,
        colorscale=colorscale,
        opacity=opacity,
        width=width,
        height=height,
    )

    return fig

plot_cell_mesh(data=None, title=None, colorscale='Viridis', opacity=0.7, show_edges=True, width=800, height=600)

Create 3D mesh plot showing cell boundaries.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title colorscale : str, default 'Viridis' Plotly colorscale name opacity : float, default 0.7 Mesh opacity show_edges : bool, default True Show cell edges width : int, default 800 Figure width height : int, default 600 Figure height

Returns

plotly.graph_objects.Figure Interactive mesh plot

Notes

This method requires grid cells with vertex information. Currently supports HTM and geodesic grids.

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
def plot_cell_mesh(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    colorscale: str = "Viridis",
    opacity: float = 0.7,
    show_edges: bool = True,
    width: int = 800,
    height: int = 600,
) -> go.Figure:
    """Create 3D mesh plot showing cell boundaries.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    opacity : float, default 0.7
        Mesh opacity
    show_edges : bool, default True
        Show cell edges
    width : int, default 800
        Figure width
    height : int, default 600
        Figure height

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive mesh plot

    Notes
    -----
    This method requires grid cells with vertex information.
    Currently supports HTM and geodesic grids.

    """
    traces = []

    # Prepare data
    if data is None:
        values = np.ones(self.grid.ncells) * 0.5
    else:
        values = data

    grid_df = self.grid.grid
    grid_type = self.grid.grid_type.lower()

    # Check if grid supports mesh rendering
    if grid_type == "htm" and "htm_vertex_0" in grid_df.columns:
        # HTM triangular mesh
        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                v0 = np.array(row["htm_vertex_0"], dtype=float)
                v1 = np.array(row["htm_vertex_1"], dtype=float)
                v2 = np.array(row["htm_vertex_2"], dtype=float)

                vertices = np.array([v0, v1, v2])

                # Check hemisphere
                z_coords = vertices[:, 2]
                if np.all(z_coords < 0):
                    continue

                # Normalize color value
                color_val = (values[idx] - np.nanmin(values)) / (
                    np.nanmax(values) - np.nanmin(values)
                )
                color_rgb = sample_colorscale(colorscale, [color_val])[0]

                # Create triangle mesh
                trace = go.Mesh3d(
                    x=vertices[:, 0],
                    y=vertices[:, 1],
                    z=vertices[:, 2],
                    i=[0],
                    j=[1],
                    k=[2],
                    color=color_rgb,
                    opacity=opacity,
                    flatshading=True,
                    showscale=False,
                    hoverinfo="skip",
                )
                traces.append(trace)
            except KeyError, TypeError, ValueError:
                continue
    else:
        # Not supported for this grid type
        raise NotImplementedError(
            f"Cell mesh rendering not implemented for {grid_type} grids"
        )

    # Sample colorscale (no longer needed above, but kept for compatibility)

    # Add colorbar trace
    if values is not None and len(traces) > 0:
        dummy_trace = go.Scatter3d(
            x=[None],
            y=[None],
            z=[None],
            mode="markers",
            marker=dict(
                size=0.1,
                color=[np.nanmin(values), np.nanmax(values)],
                colorscale=colorscale,
                colorbar=dict(title="Value"),
            ),
            showlegend=False,
            hoverinfo="skip",
        )
        traces.append(dummy_trace)

    fig = go.Figure(data=traces)

    # Update layout
    fig.update_layout(
        title=title or "Hemisphere Mesh 3D",
        scene=dict(
            aspectmode="data",
            xaxis=dict(title="East", showbackground=False),
            yaxis=dict(title="North", showbackground=False),
            zaxis=dict(title="Up", showbackground=False),
        ),
        width=width,
        height=height,
        margin=dict(l=0, r=0, b=0, t=40),
    )

    return fig

add_spherical_overlays(fig, elevation_rings=None, meridians_deg=None, overlay_color='lightgray', line_width=1)

Add elevation rings and meridians to 3D plot.

Parameters

fig : plotly.graph_objects.Figure Existing 3D figure to add overlays to elevation_rings : list of int, optional Elevation angles in degrees. Default: [15, 30, 45, 60, 75, 90] meridians_deg : list of int, optional Meridian angles in degrees. Default: [0, 45, 90, 135, 180, 225, 270, 315] overlay_color : str, default 'lightgray' Color for overlay lines line_width : float, default 1 Width of overlay lines

Returns

fig : plotly.graph_objects.Figure Modified figure with overlays

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
def add_spherical_overlays(
    self,
    fig: go.Figure,
    elevation_rings: list[int] | None = None,
    meridians_deg: list[int] | None = None,
    overlay_color: str = "lightgray",
    line_width: float = 1,
) -> go.Figure:
    """Add elevation rings and meridians to 3D plot.

    Parameters
    ----------
    fig : plotly.graph_objects.Figure
        Existing 3D figure to add overlays to
    elevation_rings : list of int, optional
        Elevation angles in degrees. Default: [15, 30, 45, 60, 75, 90]
    meridians_deg : list of int, optional
        Meridian angles in degrees. Default: [0, 45, 90, 135, 180, 225, 270, 315]
    overlay_color : str, default 'lightgray'
        Color for overlay lines
    line_width : float, default 1
        Width of overlay lines

    Returns
    -------
    fig : plotly.graph_objects.Figure
        Modified figure with overlays

    """
    if elevation_rings is None:
        elevation_rings = [15, 30, 45, 60, 75, 90]
    if meridians_deg is None:
        meridians_deg = list(range(0, 360, 45))

    # Elevation rings
    for theta_deg in elevation_rings:
        theta = np.radians(theta_deg)
        phi = np.linspace(0, 2 * np.pi, 200)
        x = np.sin(theta) * np.sin(phi)
        y = np.sin(theta) * np.cos(phi)
        z = np.full_like(phi, np.cos(theta))
        fig.add_trace(
            go.Scatter3d(
                x=x,
                y=y,
                z=z,
                mode="lines",
                line=dict(color=overlay_color, width=line_width),
                hoverinfo="skip",
                showlegend=False,
            )
        )

    # Meridians
    for phi_deg in meridians_deg:
        phi = np.radians(phi_deg)
        theta = np.linspace(0, np.pi / 2, 100)
        x = np.sin(theta) * np.sin(phi)
        y = np.sin(theta) * np.cos(phi)
        z = np.cos(theta)
        fig.add_trace(
            go.Scatter3d(
                x=x,
                y=y,
                z=z,
                mode="lines",
                line=dict(color=overlay_color, width=line_width),
                hoverinfo="skip",
                showlegend=False,
            )
        )

    return fig

add_custom_axes(fig, axis_length=1.2, axis_color='black', show_labels=True)

Add custom coordinate axes with labels.

Parameters

fig : plotly.graph_objects.Figure Existing 3D figure axis_length : float, default 1.2 Length of axis lines axis_color : str, default 'black' Color for axes show_labels : bool, default True Show axis labels (E, N, Z)

Returns

fig : plotly.graph_objects.Figure Modified figure with custom axes

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
def add_custom_axes(
    self,
    fig: go.Figure,
    axis_length: float = 1.2,
    axis_color: str = "black",
    show_labels: bool = True,
) -> go.Figure:
    """Add custom coordinate axes with labels.

    Parameters
    ----------
    fig : plotly.graph_objects.Figure
        Existing 3D figure
    axis_length : float, default 1.2
        Length of axis lines
    axis_color : str, default 'black'
        Color for axes
    show_labels : bool, default True
        Show axis labels (E, N, Z)

    Returns
    -------
    fig : plotly.graph_objects.Figure
        Modified figure with custom axes

    """
    # Axis lines
    axes_lines = [
        dict(x=[0, axis_length], y=[0, 0], z=[0, 0]),  # East
        dict(x=[0, 0], y=[0, axis_length], z=[0, 0]),  # North
        dict(x=[0, 0], y=[0, 0], z=[0, axis_length]),  # Up
    ]

    for axis in axes_lines:
        fig.add_trace(
            go.Scatter3d(
                x=axis["x"],
                y=axis["y"],
                z=axis["z"],
                mode="lines",
                line=dict(color=axis_color, width=6),
                hoverinfo="skip",
                showlegend=False,
            )
        )

    # Arrowheads
    arrow_tip = axis_length + 0.05
    arrow_size = 0.1
    for pos, direction in zip(
        [[arrow_tip, 0, 0], [0, arrow_tip, 0], [0, 0, arrow_tip]],
        [[arrow_size, 0, 0], [0, arrow_size, 0], [0, 0, arrow_size]],
    ):
        fig.add_trace(
            go.Cone(
                x=[pos[0]],
                y=[pos[1]],
                z=[pos[2]],
                u=[direction[0]],
                v=[direction[1]],
                w=[direction[2]],
                sizemode="absolute",
                sizeref=arrow_size,
                anchor="tip",
                showscale=False,
                colorscale=[[0, axis_color], [1, axis_color]],
                hoverinfo="skip",
                showlegend=False,
            )
        )

    # Labels
    if show_labels:
        label_offset = axis_length + 0.15
        for label in [
            dict(x=label_offset, y=0, z=0, text="E"),
            dict(x=0, y=label_offset, z=0, text="N"),
            dict(x=0, y=0, z=label_offset, text="Z"),
        ]:
            fig.add_trace(
                go.Scatter3d(
                    x=[label["x"]],
                    y=[label["y"]],
                    z=[label["z"]],
                    mode="text",
                    text=[label["text"]],
                    textfont=dict(size=16, color=axis_color),
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

    return fig

PlotStyle dataclass

Unified styling configuration for both 2D and 3D plots.

Parameters

colormap : str, default 'viridis' Colormap name (matplotlib or plotly) colorscale : str, default 'Viridis' Plotly colorscale name background_color : str, default 'white' Background color text_color : str, default 'black' Text color grid_color : str, default 'lightgray' Grid line color font_family : str, default 'sans-serif' Font family font_size : int, default 11 Base font size title_size : int, default 14 Title font size label_size : int, default 12 Axis label font size edge_linewidth : float, default 0.5 Edge line width for cells opacity : float, default 0.8 3D surface opacity marker_size : int, default 8 3D marker size line_width : int, default 1 3D line width wireframe_opacity : float, default 0.2 3D wireframe transparency dark_mode : bool, default False Use dark theme

Source code in packages/canvod-viz/src/canvod/viz/styles.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
@dataclass
class PlotStyle:
    """Unified styling configuration for both 2D and 3D plots.

    Parameters
    ----------
    colormap : str, default 'viridis'
        Colormap name (matplotlib or plotly)
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    background_color : str, default 'white'
        Background color
    text_color : str, default 'black'
        Text color
    grid_color : str, default 'lightgray'
        Grid line color
    font_family : str, default 'sans-serif'
        Font family
    font_size : int, default 11
        Base font size
    title_size : int, default 14
        Title font size
    label_size : int, default 12
        Axis label font size
    edge_linewidth : float, default 0.5
        Edge line width for cells
    opacity : float, default 0.8
        3D surface opacity
    marker_size : int, default 8
        3D marker size
    line_width : int, default 1
        3D line width
    wireframe_opacity : float, default 0.2
        3D wireframe transparency
    dark_mode : bool, default False
        Use dark theme

    """

    colormap: str = "viridis"
    colorscale: str = "Viridis"
    background_color: str = "white"
    text_color: str = "black"
    grid_color: str = "lightgray"
    font_family: str = "sans-serif"
    font_size: int = 11
    title_size: int = 14
    label_size: int = 12
    edge_linewidth: float = 0.5
    opacity: float = 0.8
    marker_size: int = 8
    line_width: int = 1
    wireframe_opacity: float = 0.2
    dark_mode: bool = False

    def to_polar_style(self) -> PolarPlotStyle:
        """Convert to PolarPlotStyle for 2D matplotlib plots.

        Returns
        -------
        PolarPlotStyle
            Equivalent 2D styling configuration

        """
        return PolarPlotStyle(
            cmap=self.colormap,
            edgecolor="white" if self.dark_mode else self.text_color,
            linewidth=self.edge_linewidth,
            alpha=1.0,
            colorbar_fontsize=self.font_size,
        )

    def to_plotly_layout(self) -> dict[str, Any]:
        """Convert to plotly layout configuration.

        Returns
        -------
        dict
            Plotly layout settings

        """
        if self.dark_mode:
            return {
                "template": "plotly_dark",
                "paper_bgcolor": "#111111",
                "plot_bgcolor": "#111111",
                "font": {
                    "family": self.font_family,
                    "size": self.font_size,
                    "color": "white",
                },
            }
        return {
            "template": "plotly",
            "paper_bgcolor": self.background_color,
            "plot_bgcolor": self.background_color,
            "font": {
                "family": self.font_family,
                "size": self.font_size,
                "color": self.text_color,
            },
        }

to_polar_style()

Convert to PolarPlotStyle for 2D matplotlib plots.

Returns

PolarPlotStyle Equivalent 2D styling configuration

Source code in packages/canvod-viz/src/canvod/viz/styles.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def to_polar_style(self) -> PolarPlotStyle:
    """Convert to PolarPlotStyle for 2D matplotlib plots.

    Returns
    -------
    PolarPlotStyle
        Equivalent 2D styling configuration

    """
    return PolarPlotStyle(
        cmap=self.colormap,
        edgecolor="white" if self.dark_mode else self.text_color,
        linewidth=self.edge_linewidth,
        alpha=1.0,
        colorbar_fontsize=self.font_size,
    )

to_plotly_layout()

Convert to plotly layout configuration.

Returns

dict Plotly layout settings

Source code in packages/canvod-viz/src/canvod/viz/styles.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def to_plotly_layout(self) -> dict[str, Any]:
    """Convert to plotly layout configuration.

    Returns
    -------
    dict
        Plotly layout settings

    """
    if self.dark_mode:
        return {
            "template": "plotly_dark",
            "paper_bgcolor": "#111111",
            "plot_bgcolor": "#111111",
            "font": {
                "family": self.font_family,
                "size": self.font_size,
                "color": "white",
            },
        }
    return {
        "template": "plotly",
        "paper_bgcolor": self.background_color,
        "plot_bgcolor": self.background_color,
        "font": {
            "family": self.font_family,
            "size": self.font_size,
            "color": self.text_color,
        },
    }

PolarPlotStyle dataclass

Configuration for 2D polar plot styling (matplotlib).

Parameters

cmap : str, default 'viridis' Matplotlib colormap name edgecolor : str, default 'black' Edge color for grid cells linewidth : float, default 0.5 Line width for cell edges alpha : float, default 1.0 Transparency (0=transparent, 1=opaque) vmin : float or None, optional Minimum value for colormap vmax : float or None, optional Maximum value for colormap title : str or None, optional Plot title figsize : tuple of float, default (10, 10) Figure size in inches (width, height) dpi : int, default 100 Dots per inch for figure colorbar_label : str, default 'Value' Label for colorbar colorbar_shrink : float, default 0.8 Colorbar size relative to axis colorbar_pad : float, default 0.1 Space between axis and colorbar colorbar_fontsize : int, default 11 Font size for colorbar label show_grid : bool, default True Show polar grid lines grid_alpha : float, default 0.3 Grid line transparency grid_linestyle : str, default '--' Grid line style show_degree_labels : bool, default True Show degree labels on radial axis theta_labels : list of int, default [0, 30, 60, 90] Elevation angles for labels (degrees)

Source code in packages/canvod-viz/src/canvod/viz/styles.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@dataclass
class PolarPlotStyle:
    """Configuration for 2D polar plot styling (matplotlib).

    Parameters
    ----------
    cmap : str, default 'viridis'
        Matplotlib colormap name
    edgecolor : str, default 'black'
        Edge color for grid cells
    linewidth : float, default 0.5
        Line width for cell edges
    alpha : float, default 1.0
        Transparency (0=transparent, 1=opaque)
    vmin : float or None, optional
        Minimum value for colormap
    vmax : float or None, optional
        Maximum value for colormap
    title : str or None, optional
        Plot title
    figsize : tuple of float, default (10, 10)
        Figure size in inches (width, height)
    dpi : int, default 100
        Dots per inch for figure
    colorbar_label : str, default 'Value'
        Label for colorbar
    colorbar_shrink : float, default 0.8
        Colorbar size relative to axis
    colorbar_pad : float, default 0.1
        Space between axis and colorbar
    colorbar_fontsize : int, default 11
        Font size for colorbar label
    show_grid : bool, default True
        Show polar grid lines
    grid_alpha : float, default 0.3
        Grid line transparency
    grid_linestyle : str, default '--'
        Grid line style
    show_degree_labels : bool, default True
        Show degree labels on radial axis
    theta_labels : list of int, default [0, 30, 60, 90]
        Elevation angles for labels (degrees)

    """

    cmap: str = "viridis"
    edgecolor: str = "black"
    linewidth: float = 0.5
    alpha: float = 1.0
    vmin: float | None = None
    vmax: float | None = None
    title: str | None = None
    figsize: tuple[float, float] = (10, 10)
    dpi: int = 100
    colorbar_label: str = "Value"
    colorbar_shrink: float = 0.8
    colorbar_pad: float = 0.1
    colorbar_fontsize: int = 11
    show_grid: bool = True
    grid_alpha: float = 0.3
    grid_linestyle: str = "--"
    show_degree_labels: bool = True
    theta_labels: list[int] = field(default_factory=lambda: [0, 30, 60, 90])

visualize_grid(grid, data=None, style=None, **kwargs)

Visualize hemispherical grid in 2D polar projection.

Convenience function providing simple interface to 2D visualization.

Parameters

grid : HemiGrid Grid to visualize data : np.ndarray, optional Data values per cell. If None, plots uniform grid. style : PolarPlotStyle, optional Styling configuration. If None, uses defaults. **kwargs Additional style parameter overrides

Returns

fig : matplotlib.figure.Figure Figure object ax : matplotlib.axes.Axes Polar axes object

Examples

from canvod.grids import create_hemigrid from canvod.viz import visualize_grid

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0) fig, ax = visualize_grid(grid, data=vod_data, cmap='viridis') plt.savefig("vod_plot.png", dpi=300)

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
def visualize_grid(
    grid: HemiGrid,
    data: np.ndarray | None = None,
    style: PolarPlotStyle | None = None,
    **kwargs: Any,
) -> tuple[Figure, Axes]:
    """Visualize hemispherical grid in 2D polar projection.

    Convenience function providing simple interface to 2D visualization.

    Parameters
    ----------
    grid : HemiGrid
        Grid to visualize
    data : np.ndarray, optional
        Data values per cell. If None, plots uniform grid.
    style : PolarPlotStyle, optional
        Styling configuration. If None, uses defaults.
    **kwargs
        Additional style parameter overrides

    Returns
    -------
    fig : matplotlib.figure.Figure
        Figure object
    ax : matplotlib.axes.Axes
        Polar axes object

    Examples
    --------
    >>> from canvod.grids import create_hemigrid
    >>> from canvod.viz import visualize_grid
    >>>
    >>> grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
    >>> fig, ax = visualize_grid(grid, data=vod_data, cmap='viridis')
    >>> plt.savefig("vod_plot.png", dpi=300)

    """
    viz = HemisphereVisualizer2D(grid)
    return viz.plot_grid_patches(data=data, style=style, **kwargs)

visualize_grid_3d(grid, data=None, title=None, colorscale='Viridis', add_overlays=False, add_axes=False, **kwargs)

Visualize hemispherical grid in 3D interactive plot.

Convenience function providing simple interface to 3D visualization.

Parameters

grid : HemiGrid Grid to visualize data : np.ndarray, optional Data values per cell title : str, optional Plot title colorscale : str, default 'Viridis' Plotly colorscale name add_overlays : bool, default False Add elevation rings and meridians add_axes : bool, default False Add custom coordinate axes **kwargs Additional parameters passed to plot_hemisphere_surface

Returns

fig : plotly.graph_objects.Figure Interactive 3D figure

Examples

from canvod.grids import create_hemigrid from canvod.viz import visualize_grid_3d

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0) fig = visualize_grid_3d( ... grid, ... data=vod_data, ... title="VOD 3D", ... add_overlays=True ... ) fig.show()

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
def visualize_grid_3d(
    grid: HemiGrid,
    data: np.ndarray | None = None,
    title: str | None = None,
    colorscale: str = "Viridis",
    add_overlays: bool = False,
    add_axes: bool = False,
    **kwargs: Any,
) -> go.Figure:
    """Visualize hemispherical grid in 3D interactive plot.

    Convenience function providing simple interface to 3D visualization.

    Parameters
    ----------
    grid : HemiGrid
        Grid to visualize
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    add_overlays : bool, default False
        Add elevation rings and meridians
    add_axes : bool, default False
        Add custom coordinate axes
    **kwargs
        Additional parameters passed to plot_hemisphere_surface

    Returns
    -------
    fig : plotly.graph_objects.Figure
        Interactive 3D figure

    Examples
    --------
    >>> from canvod.grids import create_hemigrid
    >>> from canvod.viz import visualize_grid_3d
    >>>
    >>> grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
    >>> fig = visualize_grid_3d(
    ...     grid,
    ...     data=vod_data,
    ...     title="VOD 3D",
    ...     add_overlays=True
    ... )
    >>> fig.show()

    """
    viz = HemisphereVisualizer3D(grid)
    fig = viz.plot_hemisphere_surface(
        data=data, title=title, colorscale=colorscale, **kwargs
    )

    if add_overlays:
        viz.add_spherical_overlays(fig)

    if add_axes:
        viz.add_custom_axes(fig)

    return fig

add_tissot_indicatrix(ax, grid, radius_deg=None, n_sample=None, facecolor='gold', alpha=0.6, edgecolor='black', linewidth=0.5)

Add Tissot's indicatrix circles to existing polar plot.

Adds equal-sized circles to visualize grid distortion. In equal-area grids, circles should appear roughly equal-sized. Variation indicates distortion.

Parameters

ax : matplotlib.axes.Axes Existing polar axis to add circles to grid : HemiGrid Grid instance radius_deg : float, optional Angular radius of circles in degrees. If None, auto-calculated as angular_resolution / 8. n_sample : int, optional Subsample cells (use every nth cell) for performance. If None, shows all cells. facecolor : str, default 'gold' Fill color for circles alpha : float, default 0.6 Transparency (0=transparent, 1=opaque) edgecolor : str, default 'black' Edge color for circles linewidth : float, default 0.5 Edge line width

Returns

ax : matplotlib.axes.Axes Modified axis with Tissot circles added

Examples

fig, ax = visualize_grid(grid, data=vod_data) add_tissot_indicatrix(ax, grid, radius_deg=3, n_sample=5) plt.savefig("vod_with_tissot.png")

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
def add_tissot_indicatrix(
    ax: Axes,
    grid: HemiGrid,
    radius_deg: float | None = None,
    n_sample: int | None = None,
    facecolor: str = "gold",
    alpha: float = 0.6,
    edgecolor: str = "black",
    linewidth: float = 0.5,
) -> Axes:
    """Add Tissot's indicatrix circles to existing polar plot.

    Adds equal-sized circles to visualize grid distortion. In equal-area grids,
    circles should appear roughly equal-sized. Variation indicates distortion.

    Parameters
    ----------
    ax : matplotlib.axes.Axes
        Existing polar axis to add circles to
    grid : HemiGrid
        Grid instance
    radius_deg : float, optional
        Angular radius of circles in degrees. If None, auto-calculated
        as angular_resolution / 8.
    n_sample : int, optional
        Subsample cells (use every nth cell) for performance.
        If None, shows all cells.
    facecolor : str, default 'gold'
        Fill color for circles
    alpha : float, default 0.6
        Transparency (0=transparent, 1=opaque)
    edgecolor : str, default 'black'
        Edge color for circles
    linewidth : float, default 0.5
        Edge line width

    Returns
    -------
    ax : matplotlib.axes.Axes
        Modified axis with Tissot circles added

    Examples
    --------
    >>> fig, ax = visualize_grid(grid, data=vod_data)
    >>> add_tissot_indicatrix(ax, grid, radius_deg=3, n_sample=5)
    >>> plt.savefig("vod_with_tissot.png")

    """
    from matplotlib.patches import Ellipse

    # Auto-calculate radius if not provided
    if radius_deg is None:
        if hasattr(grid, "angular_resolution"):
            radius_deg = grid.angular_resolution / 8
        else:
            theta_vals = grid.grid["theta"].to_numpy()
            theta_spacing = np.median(np.diff(np.sort(np.unique(theta_vals))))
            radius_deg = np.rad2deg(theta_spacing) / 8

    radius_rad = np.deg2rad(radius_deg)

    # Generate circle points on sphere
    n_circle_points = 32
    circle_angles = np.linspace(0, 2 * np.pi, n_circle_points, endpoint=False)

    cell_count = 0
    grid_df = grid.grid

    # Different handling for triangular vs rectangular grids
    if grid.grid_type in ["htm", "geodesic"]:
        # For triangular grids: create circles on sphere surface and project
        for i, row in enumerate(grid_df.iter_rows(named=True)):
            if n_sample is not None and i % n_sample != 0:
                continue

            phi_center = row["phi"]
            theta_center = row["theta"]

            if theta_center > np.pi / 2:
                continue

            # Convert cell center to 3D Cartesian
            x_c = np.sin(theta_center) * np.cos(phi_center)
            y_c = np.sin(theta_center) * np.sin(phi_center)
            z_c = np.cos(theta_center)
            center_3d = np.array([x_c, y_c, z_c])

            # Create tangent vectors
            if theta_center < 0.01:
                tangent_1 = np.array([1, 0, 0])
                tangent_2 = np.array([0, 1, 0])
            else:
                tangent_phi = np.array([-np.sin(phi_center), np.cos(phi_center), 0])
                tangent_phi = tangent_phi / np.linalg.norm(tangent_phi)

                tangent_theta = np.array(
                    [
                        np.cos(theta_center) * np.cos(phi_center),
                        np.cos(theta_center) * np.sin(phi_center),
                        -np.sin(theta_center),
                    ]
                )
                tangent_theta = tangent_theta / np.linalg.norm(tangent_theta)

                tangent_1 = tangent_phi
                tangent_2 = tangent_theta

            # Create circle on sphere surface
            circle_3d = []
            for angle in circle_angles:
                offset = radius_rad * (
                    np.cos(angle) * tangent_1 + np.sin(angle) * tangent_2
                )
                point_3d = center_3d + offset
                norm = np.linalg.norm(point_3d)
                if norm > 1e-10:
                    point_3d = point_3d / norm
                circle_3d.append(point_3d)

            circle_3d = np.array(circle_3d)

            # Project to 2D polar coordinates
            x_2d, y_2d, z_2d = circle_3d[:, 0], circle_3d[:, 1], circle_3d[:, 2]
            theta_2d = np.arccos(np.clip(z_2d, -1, 1))
            phi_2d = np.arctan2(y_2d, x_2d)

            # Convert to polar plot coordinates (rho = sin(theta))
            rho_2d = np.sin(theta_2d)
            angle_2d = phi_2d

            vertices_2d = np.column_stack([angle_2d, rho_2d])

            poly = Polygon(
                vertices_2d,
                facecolor=facecolor,
                alpha=alpha,
                edgecolor=edgecolor,
                linewidth=linewidth,
            )
            ax.add_patch(poly)
            cell_count += 1

    else:
        # Rectangular grids: use simple ellipses at grid centers
        for i, row in enumerate(grid_df.iter_rows(named=True)):
            if n_sample is not None and i % n_sample != 0:
                continue

            phi_center = row["phi"]
            theta_center = row["theta"]

            if theta_center <= np.pi / 2:
                # Convert to polar plot coordinates
                rho_center = np.sin(theta_center)

                ell = Ellipse(
                    (phi_center, rho_center),
                    width=2 * radius_rad,
                    height=2 * radius_rad * np.sin(theta_center),  # Scale by projection
                    facecolor=facecolor,
                    alpha=alpha,
                    edgecolor=edgecolor,
                    linewidth=linewidth,
                )
                ax.add_patch(ell)
                cell_count += 1

    # Update title
    current_title = ax.get_title()
    if current_title:
        ax.set_title(f"{current_title} + Tissot ({cell_count} circles)")
    else:
        ax.set_title(f"Tissot's Indicatrix - {grid.grid_type} ({cell_count} circles)")

    return ax

create_publication_style()

Create styling optimized for publication-quality figures.

Returns

PlotStyle Publication-optimized styling configuration

Examples

style = create_publication_style() viz.plot_2d(data=vod_data, style=style)

Source code in packages/canvod-viz/src/canvod/viz/styles.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def create_publication_style() -> PlotStyle:
    """Create styling optimized for publication-quality figures.

    Returns
    -------
    PlotStyle
        Publication-optimized styling configuration

    Examples
    --------
    >>> style = create_publication_style()
    >>> viz.plot_2d(data=vod_data, style=style)

    """
    return PlotStyle(
        colormap="viridis",
        colorscale="Viridis",
        background_color="white",
        text_color="black",
        font_family="sans-serif",
        font_size=12,
        title_size=16,
        label_size=14,
        edge_linewidth=0.3,
        opacity=0.9,
        dark_mode=False,
    )

create_interactive_style(dark_mode=True)

Create styling optimized for interactive exploration.

Parameters

dark_mode : bool, default True Use dark theme for better screen viewing

Returns

PlotStyle Interactive-optimized styling configuration

Examples

style = create_interactive_style(dark_mode=True) viz.plot_3d(data=vod_data, style=style)

Source code in packages/canvod-viz/src/canvod/viz/styles.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def create_interactive_style(dark_mode: bool = True) -> PlotStyle:
    """Create styling optimized for interactive exploration.

    Parameters
    ----------
    dark_mode : bool, default True
        Use dark theme for better screen viewing

    Returns
    -------
    PlotStyle
        Interactive-optimized styling configuration

    Examples
    --------
    >>> style = create_interactive_style(dark_mode=True)
    >>> viz.plot_3d(data=vod_data, style=style)

    """
    return PlotStyle(
        colormap="plasma" if dark_mode else "viridis",
        colorscale="Plasma" if dark_mode else "Viridis",
        background_color="#111111" if dark_mode else "white",
        text_color="white" if dark_mode else "black",
        font_family="Open Sans, sans-serif",
        font_size=11,
        title_size=14,
        label_size=12,
        edge_linewidth=0.5,
        opacity=0.85,
        marker_size=6,
        wireframe_opacity=0.15,
        dark_mode=dark_mode,
    )

Unified Visualizer

Unified hemisphere visualization API combining 2D and 3D capabilities.

Provides a single interface for both matplotlib (publication) and plotly (interactive) plots.

HemisphereVisualizer

Unified hemisphere visualizer combining 2D and 3D capabilities.

Provides consistent API for both publication-quality matplotlib plots and interactive plotly visualizations. Handles styling coordination between different rendering backends.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Examples

Create both 2D and 3D visualizations::

from canvod.grids import create_hemigrid
from canvod.viz import HemisphereVisualizer

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
viz = HemisphereVisualizer(grid)

# Publication-quality 2D plot
fig_2d, ax_2d = viz.plot_2d(
    data=vod_data,
    title="VOD Distribution",
    save_path="publication.png"
)

# Interactive 3D plot
fig_3d = viz.plot_3d(
    data=vod_data,
    title="Interactive VOD Explorer"
)
fig_3d.show()

Switch styles easily::

# Publication style
pub_style = create_publication_style()
viz.set_style(pub_style)
fig, ax = viz.plot_2d(data=vod_data)

# Interactive style
int_style = create_interactive_style(dark_mode=True)
viz.set_style(int_style)
fig = viz.plot_3d(data=vod_data)
Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
class HemisphereVisualizer:
    """Unified hemisphere visualizer combining 2D and 3D capabilities.

    Provides consistent API for both publication-quality matplotlib plots
    and interactive plotly visualizations. Handles styling coordination
    between different rendering backends.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    Examples
    --------
    Create both 2D and 3D visualizations::

        from canvod.grids import create_hemigrid
        from canvod.viz import HemisphereVisualizer

        grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
        viz = HemisphereVisualizer(grid)

        # Publication-quality 2D plot
        fig_2d, ax_2d = viz.plot_2d(
            data=vod_data,
            title="VOD Distribution",
            save_path="publication.png"
        )

        # Interactive 3D plot
        fig_3d = viz.plot_3d(
            data=vod_data,
            title="Interactive VOD Explorer"
        )
        fig_3d.show()

    Switch styles easily::

        # Publication style
        pub_style = create_publication_style()
        viz.set_style(pub_style)
        fig, ax = viz.plot_2d(data=vod_data)

        # Interactive style
        int_style = create_interactive_style(dark_mode=True)
        viz.set_style(int_style)
        fig = viz.plot_3d(data=vod_data)

    """

    def __init__(self, grid: HemiGrid) -> None:
        """Initialize unified visualizer.

        Parameters
        ----------
        grid : HemiGrid
            Hemisphere grid to visualize

        """
        self.grid = grid

        # Initialize specialized visualizers
        self.viz_2d = HemisphereVisualizer2D(grid)
        self.viz_3d = HemisphereVisualizer3D(grid)

        # Default styling
        self.style = PlotStyle()

    def set_style(self, style: PlotStyle) -> None:
        """Set unified styling for both 2D and 3D plots.

        Parameters
        ----------
        style : PlotStyle
            Styling configuration

        Examples
        --------
        >>> pub_style = create_publication_style()
        >>> viz.set_style(pub_style)
        >>> fig, ax = viz.plot_2d(data=vod_data)

        """
        self.style = style

    def plot_2d(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        ax: Axes | None = None,
        save_path: Path | str | None = None,
        style: PolarPlotStyle | None = None,
        **kwargs: Any,
    ) -> tuple[Figure, Axes]:
        """Create 2D publication-quality plot.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        ax : matplotlib.axes.Axes, optional
            Existing axes to plot on
        save_path : Path or str, optional
            Save figure to this path
        style : PolarPlotStyle, optional
            Override default 2D style
        **kwargs
            Additional styling parameters

        Returns
        -------
        fig : matplotlib.figure.Figure
            Figure object
        ax : matplotlib.axes.Axes
            Polar axes with plot

        Examples
        --------
        >>> fig, ax = viz.plot_2d(
        ...     data=vod_data,
        ...     title="VOD Distribution",
        ...     cmap='plasma',
        ...     save_path="output.png",
        ...     dpi=300
        ... )

        """
        if style is None:
            style = self.style.to_polar_style()

        if title:
            style.title = title

        return self.viz_2d.plot_grid_patches(
            data=data, style=style, ax=ax, save_path=save_path, **kwargs
        )

    def plot_3d(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        style: PlotStyle | None = None,
        **kwargs: Any,
    ) -> go.Figure:
        """Create 3D interactive plot.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        style : PlotStyle, optional
            Override default 3D style
        **kwargs
            Additional plotly parameters

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive 3D figure

        Examples
        --------
        >>> fig = viz.plot_3d(
        ...     data=vod_data,
        ...     title="Interactive VOD",
        ...     opacity=0.9,
        ...     width=1000,
        ...     height=800
        ... )
        >>> fig.show()
        >>> fig.write_html("interactive.html")

        """
        if style is None:
            style = self.style

        return self.viz_3d.plot_hemisphere_surface(
            data=data, style=style, title=title, **kwargs
        )

    def plot_3d_mesh(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        **kwargs: Any,
    ) -> go.Figure:
        """Create 3D mesh plot showing cell boundaries.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        **kwargs
            Additional plotly parameters

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive mesh figure

        Examples
        --------
        >>> fig = viz.plot_3d_mesh(
        ...     data=vod_data,
        ...     title="VOD Mesh View",
        ...     opacity=0.7
        ... )

        """
        return self.viz_3d.plot_cell_mesh(data=data, title=title, **kwargs)

    def create_comparison_plot(
        self,
        data: np.ndarray | None = None,
        title_2d: str = "2D Polar View",
        title_3d: str = "3D Hemisphere View",
        save_2d: Path | str | None = None,
        save_3d: Path | str | None = None,
    ) -> tuple[tuple[Figure, Axes], go.Figure]:
        """Create both 2D and 3D plots for comparison.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title_2d : str, default "2D Polar View"
            Title for 2D plot
        title_3d : str, default "3D Hemisphere View"
            Title for 3D plot
        save_2d : Path or str, optional
            Save 2D figure to this path
        save_3d : Path or str, optional
            Save 3D figure to this path (HTML)

        Returns
        -------
        plot_2d : tuple of Figure and Axes
            2D matplotlib plot
        plot_3d : plotly.graph_objects.Figure
            3D plotly plot

        Examples
        --------
        >>> (fig_2d, ax_2d), fig_3d = viz.create_comparison_plot(
        ...     data=vod_data,
        ...     save_2d="comparison_2d.png",
        ...     save_3d="comparison_3d.html"
        ... )
        >>> plt.show()  # Show 2D
        >>> fig_3d.show()  # Show 3D

        """
        # Create 2D plot
        fig_2d, ax_2d = self.plot_2d(data=data, title=title_2d, save_path=save_2d)

        # Create 3D plot
        fig_3d = self.plot_3d(data=data, title=title_3d)

        # Save 3D if requested
        if save_3d:
            save_3d = Path(save_3d)
            save_3d.parent.mkdir(parents=True, exist_ok=True)
            fig_3d.write_html(str(save_3d))

        return (fig_2d, ax_2d), fig_3d

    def create_publication_figure(
        self,
        data: np.ndarray | None = None,
        title: str = "Hemispherical Data Distribution",
        save_path: Path | str | None = None,
        dpi: int = 300,
        **kwargs: Any,
    ) -> tuple[Figure, Axes]:
        """Create publication-ready figure with optimal styling.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, default "Hemispherical Data Distribution"
            Plot title
        save_path : Path or str, optional
            Save figure to this path
        dpi : int, default 300
            Resolution in dots per inch
        **kwargs
            Additional styling parameters

        Returns
        -------
        fig : matplotlib.figure.Figure
            Publication-ready figure
        ax : matplotlib.axes.Axes
            Styled polar axes

        Examples
        --------
        >>> fig, ax = viz.create_publication_figure(
        ...     data=vod_data,
        ...     title="VOD Distribution Over Rosalia Site",
        ...     save_path="paper_figure_3.png",
        ...     dpi=600
        ... )

        """
        # Use publication style and convert to PolarPlotStyle
        pub_plot_style = create_publication_style()
        polar_style = pub_plot_style.to_polar_style()
        polar_style.title = title
        polar_style.dpi = dpi

        # Override with kwargs
        for key, value in kwargs.items():
            if hasattr(polar_style, key):
                setattr(polar_style, key, value)

        return self.plot_2d(data=data, style=polar_style, save_path=save_path)

    def create_interactive_explorer(
        self,
        data: np.ndarray | None = None,
        title: str = "Interactive Data Explorer",
        dark_mode: bool = True,
        save_html: Path | str | None = None,
    ) -> go.Figure:
        """Create interactive explorer with optimal settings.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, default "Interactive Data Explorer"
            Plot title
        dark_mode : bool, default True
            Use dark theme
        save_html : Path or str, optional
            Save HTML to this path

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive explorer figure

        Examples
        --------
        >>> fig = viz.create_interactive_explorer(
        ...     data=vod_data,
        ...     title="VOD Explorer",
        ...     dark_mode=True,
        ...     save_html="explorer.html"
        ... )
        >>> fig.show()

        """
        # Use interactive style
        int_style = create_interactive_style(dark_mode=dark_mode)

        fig = self.plot_3d(data=data, title=title, style=int_style)

        # Save if requested
        if save_html:
            save_html = Path(save_html)
            save_html.parent.mkdir(parents=True, exist_ok=True)
            fig.write_html(str(save_html))

        return fig

__init__(grid)

Initialize unified visualizer.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
def __init__(self, grid: HemiGrid) -> None:
    """Initialize unified visualizer.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    """
    self.grid = grid

    # Initialize specialized visualizers
    self.viz_2d = HemisphereVisualizer2D(grid)
    self.viz_3d = HemisphereVisualizer3D(grid)

    # Default styling
    self.style = PlotStyle()

set_style(style)

Set unified styling for both 2D and 3D plots.

Parameters

style : PlotStyle Styling configuration

Examples

pub_style = create_publication_style() viz.set_style(pub_style) fig, ax = viz.plot_2d(data=vod_data)

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def set_style(self, style: PlotStyle) -> None:
    """Set unified styling for both 2D and 3D plots.

    Parameters
    ----------
    style : PlotStyle
        Styling configuration

    Examples
    --------
    >>> pub_style = create_publication_style()
    >>> viz.set_style(pub_style)
    >>> fig, ax = viz.plot_2d(data=vod_data)

    """
    self.style = style

plot_2d(data=None, title=None, ax=None, save_path=None, style=None, **kwargs)

Create 2D publication-quality plot.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title ax : matplotlib.axes.Axes, optional Existing axes to plot on save_path : Path or str, optional Save figure to this path style : PolarPlotStyle, optional Override default 2D style **kwargs Additional styling parameters

Returns

fig : matplotlib.figure.Figure Figure object ax : matplotlib.axes.Axes Polar axes with plot

Examples

fig, ax = viz.plot_2d( ... data=vod_data, ... title="VOD Distribution", ... cmap='plasma', ... save_path="output.png", ... dpi=300 ... )

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def plot_2d(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    ax: Axes | None = None,
    save_path: Path | str | None = None,
    style: PolarPlotStyle | None = None,
    **kwargs: Any,
) -> tuple[Figure, Axes]:
    """Create 2D publication-quality plot.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    ax : matplotlib.axes.Axes, optional
        Existing axes to plot on
    save_path : Path or str, optional
        Save figure to this path
    style : PolarPlotStyle, optional
        Override default 2D style
    **kwargs
        Additional styling parameters

    Returns
    -------
    fig : matplotlib.figure.Figure
        Figure object
    ax : matplotlib.axes.Axes
        Polar axes with plot

    Examples
    --------
    >>> fig, ax = viz.plot_2d(
    ...     data=vod_data,
    ...     title="VOD Distribution",
    ...     cmap='plasma',
    ...     save_path="output.png",
    ...     dpi=300
    ... )

    """
    if style is None:
        style = self.style.to_polar_style()

    if title:
        style.title = title

    return self.viz_2d.plot_grid_patches(
        data=data, style=style, ax=ax, save_path=save_path, **kwargs
    )

plot_3d(data=None, title=None, style=None, **kwargs)

Create 3D interactive plot.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title style : PlotStyle, optional Override default 3D style **kwargs Additional plotly parameters

Returns

plotly.graph_objects.Figure Interactive 3D figure

Examples

fig = viz.plot_3d( ... data=vod_data, ... title="Interactive VOD", ... opacity=0.9, ... width=1000, ... height=800 ... ) fig.show() fig.write_html("interactive.html")

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def plot_3d(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    style: PlotStyle | None = None,
    **kwargs: Any,
) -> go.Figure:
    """Create 3D interactive plot.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    style : PlotStyle, optional
        Override default 3D style
    **kwargs
        Additional plotly parameters

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive 3D figure

    Examples
    --------
    >>> fig = viz.plot_3d(
    ...     data=vod_data,
    ...     title="Interactive VOD",
    ...     opacity=0.9,
    ...     width=1000,
    ...     height=800
    ... )
    >>> fig.show()
    >>> fig.write_html("interactive.html")

    """
    if style is None:
        style = self.style

    return self.viz_3d.plot_hemisphere_surface(
        data=data, style=style, title=title, **kwargs
    )

plot_3d_mesh(data=None, title=None, **kwargs)

Create 3D mesh plot showing cell boundaries.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title **kwargs Additional plotly parameters

Returns

plotly.graph_objects.Figure Interactive mesh figure

Examples

fig = viz.plot_3d_mesh( ... data=vod_data, ... title="VOD Mesh View", ... opacity=0.7 ... )

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def plot_3d_mesh(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    **kwargs: Any,
) -> go.Figure:
    """Create 3D mesh plot showing cell boundaries.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    **kwargs
        Additional plotly parameters

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive mesh figure

    Examples
    --------
    >>> fig = viz.plot_3d_mesh(
    ...     data=vod_data,
    ...     title="VOD Mesh View",
    ...     opacity=0.7
    ... )

    """
    return self.viz_3d.plot_cell_mesh(data=data, title=title, **kwargs)

create_comparison_plot(data=None, title_2d='2D Polar View', title_3d='3D Hemisphere View', save_2d=None, save_3d=None)

Create both 2D and 3D plots for comparison.

Parameters

data : np.ndarray, optional Data values per cell title_2d : str, default "2D Polar View" Title for 2D plot title_3d : str, default "3D Hemisphere View" Title for 3D plot save_2d : Path or str, optional Save 2D figure to this path save_3d : Path or str, optional Save 3D figure to this path (HTML)

Returns

plot_2d : tuple of Figure and Axes 2D matplotlib plot plot_3d : plotly.graph_objects.Figure 3D plotly plot

Examples

(fig_2d, ax_2d), fig_3d = viz.create_comparison_plot( ... data=vod_data, ... save_2d="comparison_2d.png", ... save_3d="comparison_3d.html" ... ) plt.show() # Show 2D fig_3d.show() # Show 3D

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def create_comparison_plot(
    self,
    data: np.ndarray | None = None,
    title_2d: str = "2D Polar View",
    title_3d: str = "3D Hemisphere View",
    save_2d: Path | str | None = None,
    save_3d: Path | str | None = None,
) -> tuple[tuple[Figure, Axes], go.Figure]:
    """Create both 2D and 3D plots for comparison.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title_2d : str, default "2D Polar View"
        Title for 2D plot
    title_3d : str, default "3D Hemisphere View"
        Title for 3D plot
    save_2d : Path or str, optional
        Save 2D figure to this path
    save_3d : Path or str, optional
        Save 3D figure to this path (HTML)

    Returns
    -------
    plot_2d : tuple of Figure and Axes
        2D matplotlib plot
    plot_3d : plotly.graph_objects.Figure
        3D plotly plot

    Examples
    --------
    >>> (fig_2d, ax_2d), fig_3d = viz.create_comparison_plot(
    ...     data=vod_data,
    ...     save_2d="comparison_2d.png",
    ...     save_3d="comparison_3d.html"
    ... )
    >>> plt.show()  # Show 2D
    >>> fig_3d.show()  # Show 3D

    """
    # Create 2D plot
    fig_2d, ax_2d = self.plot_2d(data=data, title=title_2d, save_path=save_2d)

    # Create 3D plot
    fig_3d = self.plot_3d(data=data, title=title_3d)

    # Save 3D if requested
    if save_3d:
        save_3d = Path(save_3d)
        save_3d.parent.mkdir(parents=True, exist_ok=True)
        fig_3d.write_html(str(save_3d))

    return (fig_2d, ax_2d), fig_3d

create_publication_figure(data=None, title='Hemispherical Data Distribution', save_path=None, dpi=300, **kwargs)

Create publication-ready figure with optimal styling.

Parameters

data : np.ndarray, optional Data values per cell title : str, default "Hemispherical Data Distribution" Plot title save_path : Path or str, optional Save figure to this path dpi : int, default 300 Resolution in dots per inch **kwargs Additional styling parameters

Returns

fig : matplotlib.figure.Figure Publication-ready figure ax : matplotlib.axes.Axes Styled polar axes

Examples

fig, ax = viz.create_publication_figure( ... data=vod_data, ... title="VOD Distribution Over Rosalia Site", ... save_path="paper_figure_3.png", ... dpi=600 ... )

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
def create_publication_figure(
    self,
    data: np.ndarray | None = None,
    title: str = "Hemispherical Data Distribution",
    save_path: Path | str | None = None,
    dpi: int = 300,
    **kwargs: Any,
) -> tuple[Figure, Axes]:
    """Create publication-ready figure with optimal styling.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, default "Hemispherical Data Distribution"
        Plot title
    save_path : Path or str, optional
        Save figure to this path
    dpi : int, default 300
        Resolution in dots per inch
    **kwargs
        Additional styling parameters

    Returns
    -------
    fig : matplotlib.figure.Figure
        Publication-ready figure
    ax : matplotlib.axes.Axes
        Styled polar axes

    Examples
    --------
    >>> fig, ax = viz.create_publication_figure(
    ...     data=vod_data,
    ...     title="VOD Distribution Over Rosalia Site",
    ...     save_path="paper_figure_3.png",
    ...     dpi=600
    ... )

    """
    # Use publication style and convert to PolarPlotStyle
    pub_plot_style = create_publication_style()
    polar_style = pub_plot_style.to_polar_style()
    polar_style.title = title
    polar_style.dpi = dpi

    # Override with kwargs
    for key, value in kwargs.items():
        if hasattr(polar_style, key):
            setattr(polar_style, key, value)

    return self.plot_2d(data=data, style=polar_style, save_path=save_path)

create_interactive_explorer(data=None, title='Interactive Data Explorer', dark_mode=True, save_html=None)

Create interactive explorer with optimal settings.

Parameters

data : np.ndarray, optional Data values per cell title : str, default "Interactive Data Explorer" Plot title dark_mode : bool, default True Use dark theme save_html : Path or str, optional Save HTML to this path

Returns

plotly.graph_objects.Figure Interactive explorer figure

Examples

fig = viz.create_interactive_explorer( ... data=vod_data, ... title="VOD Explorer", ... dark_mode=True, ... save_html="explorer.html" ... ) fig.show()

Source code in packages/canvod-viz/src/canvod/viz/visualizer.py
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
def create_interactive_explorer(
    self,
    data: np.ndarray | None = None,
    title: str = "Interactive Data Explorer",
    dark_mode: bool = True,
    save_html: Path | str | None = None,
) -> go.Figure:
    """Create interactive explorer with optimal settings.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, default "Interactive Data Explorer"
        Plot title
    dark_mode : bool, default True
        Use dark theme
    save_html : Path or str, optional
        Save HTML to this path

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive explorer figure

    Examples
    --------
    >>> fig = viz.create_interactive_explorer(
    ...     data=vod_data,
    ...     title="VOD Explorer",
    ...     dark_mode=True,
    ...     save_html="explorer.html"
    ... )
    >>> fig.show()

    """
    # Use interactive style
    int_style = create_interactive_style(dark_mode=dark_mode)

    fig = self.plot_3d(data=data, title=title, style=int_style)

    # Save if requested
    if save_html:
        save_html = Path(save_html)
        save_html.parent.mkdir(parents=True, exist_ok=True)
        fig.write_html(str(save_html))

    return fig

2D Visualization

2D hemisphere visualization using matplotlib for publication-quality plots.

Provides polar projection plotting of hemispherical grids with various rendering methods.

HemisphereVisualizer2D

2D hemisphere visualization using matplotlib.

Creates publication-quality polar projection plots of hemispherical grids. Supports multiple grid types and rendering methods.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Examples

from canvod.grids import create_hemigrid from canvod.viz import HemisphereVisualizer2D

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0) viz = HemisphereVisualizer2D(grid) fig, ax = viz.plot_grid_patches(data=vod_data, title="VOD Distribution") plt.savefig("vod_plot.png", dpi=300, bbox_inches='tight')

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
class HemisphereVisualizer2D:
    """2D hemisphere visualization using matplotlib.

    Creates publication-quality polar projection plots of hemispherical grids.
    Supports multiple grid types and rendering methods.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    Examples
    --------
    >>> from canvod.grids import create_hemigrid
    >>> from canvod.viz import HemisphereVisualizer2D
    >>>
    >>> grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
    >>> viz = HemisphereVisualizer2D(grid)
    >>> fig, ax = viz.plot_grid_patches(data=vod_data, title="VOD Distribution")
    >>> plt.savefig("vod_plot.png", dpi=300, bbox_inches='tight')

    """

    def __init__(self, grid: HemiGrid) -> None:
        """Initialize 2D hemisphere visualizer.

        Parameters
        ----------
        grid : HemiGrid
            Hemisphere grid to visualize

        """
        self.grid = grid
        self._patches_cache: list[Polygon] | None = None
        self._cell_indices_cache: np.ndarray | None = None

    def plot_grid_patches(
        self,
        data: np.ndarray | None = None,
        style: PolarPlotStyle | None = None,
        ax: Axes | None = None,
        save_path: Path | str | None = None,
        **style_kwargs: Any,
    ) -> tuple[Figure, Axes]:
        """Plot hemisphere grid as colored patches in polar projection.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell. If None, plots uniform grid.
        style : PolarPlotStyle, optional
            Styling configuration. If None, uses defaults.
        ax : matplotlib.axes.Axes, optional
            Existing polar axes to plot on. If None, creates new figure.
        save_path : Path or str, optional
            If provided, saves figure to this path
        **style_kwargs
            Override individual style parameters

        Returns
        -------
        fig : matplotlib.figure.Figure
            Figure object
        ax : matplotlib.axes.Axes
            Polar axes with plot

        Examples
        --------
        >>> fig, ax = viz.plot_grid_patches(
        ...     data=vod_data,
        ...     title="VOD Distribution",
        ...     cmap='plasma',
        ...     save_path="output.png"
        ... )

        """
        # Initialize style
        if style is None:
            style = PolarPlotStyle(**style_kwargs)
        else:
            # Override style with kwargs
            for key, value in style_kwargs.items():
                if hasattr(style, key):
                    setattr(style, key, value)

        # Create figure if needed
        if ax is None:
            fig, ax = plt.subplots(
                figsize=style.figsize, dpi=style.dpi, subplot_kw={"projection": "polar"}
            )
        else:
            fig = cast("Figure", ax.figure)
        ax_polar = cast("PolarAxes", ax)

        # Get patches for grid
        patches, cell_indices = self._extract_grid_patches()

        # Map data to patches
        patch_data = self._map_data_to_patches(data, cell_indices)

        # Determine color limits
        vmin = style.vmin if style.vmin is not None else np.nanmin(patch_data)
        vmax = style.vmax if style.vmax is not None else np.nanmax(patch_data)

        # Create patch collection
        pc = PatchCollection(
            patches,
            cmap=style.cmap,
            edgecolor=style.edgecolor,
            linewidth=style.linewidth,
            alpha=style.alpha,
        )
        pc.set_array(np.ma.masked_invalid(patch_data))
        pc.set_clim(vmin, vmax)

        # Add to axes
        ax.add_collection(pc)

        # Style polar axes
        self._apply_polar_styling(ax_polar, style)

        # Add colorbar
        cbar = fig.colorbar(
            pc,
            ax=ax,
            shrink=style.colorbar_shrink,
            pad=style.colorbar_pad,
        )
        cbar.set_label(style.colorbar_label, fontsize=style.colorbar_fontsize)

        # Set title
        if style.title:
            ax.set_title(style.title, y=1.08, fontsize=14)

        # Save if requested
        if save_path:
            save_path = Path(save_path)
            save_path.parent.mkdir(parents=True, exist_ok=True)
            fig.savefig(
                save_path,
                dpi=style.dpi,
                bbox_inches="tight",
                facecolor="white",
                edgecolor="none",
            )

        return fig, ax

    def _extract_grid_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract 2D polygon patches from hemispherical grid.

        Returns
        -------
        patches : list of Polygon
            Matplotlib polygon patches
        cell_indices : np.ndarray
            Corresponding cell indices in grid

        """
        # Use cache if available
        if self._patches_cache is not None and self._cell_indices_cache is not None:
            return self._patches_cache, self._cell_indices_cache

        grid_type = self.grid.grid_type.lower()

        _rectangular_types = {"equal_area", "equal_angle", "equirectangular"}
        if grid_type in _rectangular_types:
            patches, indices = self._extract_rectangular_patches()
        elif grid_type == "htm":
            patches, indices = self._extract_htm_patches()
        elif grid_type == "geodesic":
            patches, indices = self._extract_geodesic_patches()
        elif grid_type == "healpix":
            patches, indices = self._extract_healpix_patches()
        elif grid_type == "fibonacci":
            patches, indices = self._extract_fibonacci_patches()
        else:
            raise ValueError(f"Unsupported grid type: {grid_type}")

        # Cache results
        self._patches_cache = patches
        self._cell_indices_cache = indices

        return patches, indices

    def _extract_rectangular_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract patches from rectangular/equal-area grid."""
        patches = []
        cell_indices = []

        # Access the grid DataFrame from GridData
        grid_df = self.grid.grid

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            phi_min = row["phi_min"]
            phi_max = row["phi_max"]
            theta_min = row["theta_min"]
            theta_max = row["theta_max"]

            # Skip cells beyond hemisphere
            if theta_min > np.pi / 2:
                continue

            # Convert to polar coordinates (rho = sin(theta))
            rho_min = np.sin(theta_min)
            rho_max = np.sin(theta_max)

            # Create rectangular patch in polar coordinates
            vertices = np.array(
                [
                    [phi_min, rho_min],
                    [phi_max, rho_min],
                    [phi_max, rho_max],
                    [phi_min, rho_max],
                ]
            )

            patches.append(Polygon(vertices, closed=True))
            cell_indices.append(idx)

        return patches, np.array(cell_indices)

    def _extract_htm_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract triangular patches from HTM grid."""
        patches = []
        cell_indices = []

        # Access the grid DataFrame from GridData
        grid_df = self.grid.grid

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                # HTM stores vertices as columns htm_vertex_0, htm_vertex_1,
                # htm_vertex_2.
                v0 = np.array(row["htm_vertex_0"], dtype=float)
                v1 = np.array(row["htm_vertex_1"], dtype=float)
                v2 = np.array(row["htm_vertex_2"], dtype=float)

                vertices_3d = np.array([v0, v1, v2])
                x, y, z = (
                    vertices_3d[:, 0],
                    vertices_3d[:, 1],
                    vertices_3d[:, 2],
                )

                # Convert to spherical coordinates
                r = np.sqrt(x**2 + y**2 + z**2)
                theta = np.arccos(np.clip(z / r, -1, 1))
                phi = np.arctan2(y, x)
                phi = np.mod(phi, 2 * np.pi)

                # Skip if beyond hemisphere
                if np.all(theta > np.pi / 2):
                    continue

                # Convert to polar coordinates (rho = sin(theta))
                rho = np.sin(theta)
                vertices_2d = np.column_stack([phi, rho])

                patches.append(Polygon(vertices_2d, closed=True))
                cell_indices.append(idx)

            except KeyError, TypeError:
                # Skip cells that don't have proper HTM vertex data
                continue

        return patches, np.array(cell_indices)

    def _extract_geodesic_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract triangular patches from geodesic grid.

        The ``geodesic_vertices`` column stores vertex **indices** into the
        shared ``grid.vertices`` coordinate array (shape ``(n_vertices, 3)``).
        """
        patches = []
        cell_indices = []

        grid_df = self.grid.grid
        shared_vertices = self.grid.vertices  # (n_vertices, 3) or None

        if shared_vertices is None or "geodesic_vertices" not in grid_df.columns:
            # No vertex data — fall back to bounding-box rectangles
            return self._extract_rectangular_patches()

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                v_indices = np.array(row["geodesic_vertices"], dtype=int)
                if len(v_indices) < 3:
                    continue

                # Look up actual 3D coordinates from shared vertex array
                verts_3d = shared_vertices[v_indices]  # (3, 3)
                x, y, z = verts_3d[:, 0], verts_3d[:, 1], verts_3d[:, 2]

                r = np.sqrt(x**2 + y**2 + z**2)
                theta = np.arccos(np.clip(z / r, -1, 1))
                phi = np.arctan2(y, x)
                phi = np.mod(phi, 2 * np.pi)

                if np.all(theta > np.pi / 2):
                    continue

                rho = np.sin(theta)
                vertices_2d = np.column_stack([phi, rho])
                patches.append(Polygon(vertices_2d, closed=True))
                cell_indices.append(idx)

            except IndexError, KeyError, TypeError, ValueError:
                continue

        return patches, np.array(cell_indices)

    def _extract_healpix_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract patches from HEALPix grid via ``healpy.boundaries``."""
        try:
            import healpy as hp
        except ImportError as e:
            raise ImportError(
                "healpy is required for HEALPix 2D visualization. "
                "Install with: pip install healpy"
            ) from e

        patches = []
        cell_indices = []
        grid_df = self.grid.grid
        nside = int(grid_df["healpix_nside"][0])

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            ipix = int(row["healpix_ipix"])
            # boundaries returns shape (3, n_vertices) in Cartesian
            boundary = hp.boundaries(nside, ipix, step=4)
            x, y, z = boundary[0], boundary[1], boundary[2]

            # Skip pixels entirely below horizon
            if np.all(z < -0.01):
                continue

            r = np.sqrt(x**2 + y**2 + z**2)
            theta = np.arccos(np.clip(z / r, -1, 1))
            phi = np.arctan2(y, x)
            phi = np.mod(phi, 2 * np.pi)

            # Keep only vertices in upper hemisphere
            mask = theta <= np.pi / 2 + 0.01
            if not np.any(mask):
                continue

            rho = np.sin(theta)
            vertices_2d = np.column_stack([phi, rho])
            patches.append(Polygon(vertices_2d, closed=True))
            cell_indices.append(idx)

        return patches, np.array(cell_indices)

    def _extract_fibonacci_patches(self) -> tuple[list[Polygon], np.ndarray]:
        """Extract patches from Fibonacci grid using Voronoi regions."""
        patches = []
        cell_indices = []
        grid_df = self.grid.grid
        voronoi = self.grid.voronoi  # scipy.spatial.SphericalVoronoi or None

        if voronoi is None or "voronoi_region" not in grid_df.columns:
            # No Voronoi data — fall back to bounding-box rectangles
            return self._extract_rectangular_patches()

        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                region_indices = row["voronoi_region"]
                if region_indices is None or len(region_indices) < 3:
                    continue

                verts_3d = voronoi.vertices[region_indices]
                x, y, z = verts_3d[:, 0], verts_3d[:, 1], verts_3d[:, 2]

                # Skip cells entirely below horizon
                if np.all(z < -0.01):
                    continue

                r = np.sqrt(x**2 + y**2 + z**2)
                theta = np.arccos(np.clip(z / r, -1, 1))
                phi = np.arctan2(y, x)
                phi = np.mod(phi, 2 * np.pi)

                # Vertices are already in polygon winding order from
                # sort_vertices_of_regions() — use directly.
                rho = np.sin(theta)
                vertices_2d = np.column_stack([phi, rho])
                patches.append(Polygon(vertices_2d, closed=True))
                cell_indices.append(idx)

            except IndexError, KeyError, TypeError, ValueError:
                continue

        return patches, np.array(cell_indices)

    def _map_data_to_patches(
        self,
        data: np.ndarray | None,
        cell_indices: np.ndarray,
    ) -> np.ndarray:
        """Map data values to patches.

        Parameters
        ----------
        data : np.ndarray or None
            Data per grid cell
        cell_indices : np.ndarray
            Cell indices corresponding to patches

        Returns
        -------
        np.ndarray
            Data values for each patch

        """
        if data is None:
            return np.ones(len(cell_indices)) * 0.5

        return data[cell_indices]

    def _apply_polar_styling(
        self,
        ax: PolarAxes,
        style: PolarPlotStyle,
    ) -> None:
        """Apply styling to polar axes.

        Parameters
        ----------
        ax : matplotlib.axes.Axes
            Polar axes to style
        style : PolarPlotStyle
            Styling configuration

        """
        # Set rho limits (0 to 1 for hemisphere projection)
        ax.set_ylim(0, 1.0)

        # Configure polar axis orientation
        ax.set_theta_zero_location("N")  # North at top
        ax.set_theta_direction(-1)  # Clockwise (azimuth convention)

        # Add degree labels on radial axis
        if style.show_degree_labels:
            theta_labels = style.theta_labels
            rho_ticks = [np.sin(np.radians(t)) for t in theta_labels]
            ax.set_yticks(rho_ticks)
            ax.set_yticklabels([f"{t}°" for t in theta_labels])

        # Grid styling
        if style.show_grid:
            ax.grid(
                True,
                alpha=style.grid_alpha,
                linestyle=style.grid_linestyle,
                color="gray",
            )
        else:
            ax.grid(False)

__init__(grid)

Initialize 2D hemisphere visualizer.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, grid: HemiGrid) -> None:
    """Initialize 2D hemisphere visualizer.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    """
    self.grid = grid
    self._patches_cache: list[Polygon] | None = None
    self._cell_indices_cache: np.ndarray | None = None

plot_grid_patches(data=None, style=None, ax=None, save_path=None, **style_kwargs)

Plot hemisphere grid as colored patches in polar projection.

Parameters

data : np.ndarray, optional Data values per cell. If None, plots uniform grid. style : PolarPlotStyle, optional Styling configuration. If None, uses defaults. ax : matplotlib.axes.Axes, optional Existing polar axes to plot on. If None, creates new figure. save_path : Path or str, optional If provided, saves figure to this path **style_kwargs Override individual style parameters

Returns

fig : matplotlib.figure.Figure Figure object ax : matplotlib.axes.Axes Polar axes with plot

Examples

fig, ax = viz.plot_grid_patches( ... data=vod_data, ... title="VOD Distribution", ... cmap='plasma', ... save_path="output.png" ... )

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def plot_grid_patches(
    self,
    data: np.ndarray | None = None,
    style: PolarPlotStyle | None = None,
    ax: Axes | None = None,
    save_path: Path | str | None = None,
    **style_kwargs: Any,
) -> tuple[Figure, Axes]:
    """Plot hemisphere grid as colored patches in polar projection.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell. If None, plots uniform grid.
    style : PolarPlotStyle, optional
        Styling configuration. If None, uses defaults.
    ax : matplotlib.axes.Axes, optional
        Existing polar axes to plot on. If None, creates new figure.
    save_path : Path or str, optional
        If provided, saves figure to this path
    **style_kwargs
        Override individual style parameters

    Returns
    -------
    fig : matplotlib.figure.Figure
        Figure object
    ax : matplotlib.axes.Axes
        Polar axes with plot

    Examples
    --------
    >>> fig, ax = viz.plot_grid_patches(
    ...     data=vod_data,
    ...     title="VOD Distribution",
    ...     cmap='plasma',
    ...     save_path="output.png"
    ... )

    """
    # Initialize style
    if style is None:
        style = PolarPlotStyle(**style_kwargs)
    else:
        # Override style with kwargs
        for key, value in style_kwargs.items():
            if hasattr(style, key):
                setattr(style, key, value)

    # Create figure if needed
    if ax is None:
        fig, ax = plt.subplots(
            figsize=style.figsize, dpi=style.dpi, subplot_kw={"projection": "polar"}
        )
    else:
        fig = cast("Figure", ax.figure)
    ax_polar = cast("PolarAxes", ax)

    # Get patches for grid
    patches, cell_indices = self._extract_grid_patches()

    # Map data to patches
    patch_data = self._map_data_to_patches(data, cell_indices)

    # Determine color limits
    vmin = style.vmin if style.vmin is not None else np.nanmin(patch_data)
    vmax = style.vmax if style.vmax is not None else np.nanmax(patch_data)

    # Create patch collection
    pc = PatchCollection(
        patches,
        cmap=style.cmap,
        edgecolor=style.edgecolor,
        linewidth=style.linewidth,
        alpha=style.alpha,
    )
    pc.set_array(np.ma.masked_invalid(patch_data))
    pc.set_clim(vmin, vmax)

    # Add to axes
    ax.add_collection(pc)

    # Style polar axes
    self._apply_polar_styling(ax_polar, style)

    # Add colorbar
    cbar = fig.colorbar(
        pc,
        ax=ax,
        shrink=style.colorbar_shrink,
        pad=style.colorbar_pad,
    )
    cbar.set_label(style.colorbar_label, fontsize=style.colorbar_fontsize)

    # Set title
    if style.title:
        ax.set_title(style.title, y=1.08, fontsize=14)

    # Save if requested
    if save_path:
        save_path = Path(save_path)
        save_path.parent.mkdir(parents=True, exist_ok=True)
        fig.savefig(
            save_path,
            dpi=style.dpi,
            bbox_inches="tight",
            facecolor="white",
            edgecolor="none",
        )

    return fig, ax

visualize_grid(grid, data=None, style=None, **kwargs)

Visualize hemispherical grid in 2D polar projection.

Convenience function providing simple interface to 2D visualization.

Parameters

grid : HemiGrid Grid to visualize data : np.ndarray, optional Data values per cell. If None, plots uniform grid. style : PolarPlotStyle, optional Styling configuration. If None, uses defaults. **kwargs Additional style parameter overrides

Returns

fig : matplotlib.figure.Figure Figure object ax : matplotlib.axes.Axes Polar axes object

Examples

from canvod.grids import create_hemigrid from canvod.viz import visualize_grid

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0) fig, ax = visualize_grid(grid, data=vod_data, cmap='viridis') plt.savefig("vod_plot.png", dpi=300)

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
def visualize_grid(
    grid: HemiGrid,
    data: np.ndarray | None = None,
    style: PolarPlotStyle | None = None,
    **kwargs: Any,
) -> tuple[Figure, Axes]:
    """Visualize hemispherical grid in 2D polar projection.

    Convenience function providing simple interface to 2D visualization.

    Parameters
    ----------
    grid : HemiGrid
        Grid to visualize
    data : np.ndarray, optional
        Data values per cell. If None, plots uniform grid.
    style : PolarPlotStyle, optional
        Styling configuration. If None, uses defaults.
    **kwargs
        Additional style parameter overrides

    Returns
    -------
    fig : matplotlib.figure.Figure
        Figure object
    ax : matplotlib.axes.Axes
        Polar axes object

    Examples
    --------
    >>> from canvod.grids import create_hemigrid
    >>> from canvod.viz import visualize_grid
    >>>
    >>> grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
    >>> fig, ax = visualize_grid(grid, data=vod_data, cmap='viridis')
    >>> plt.savefig("vod_plot.png", dpi=300)

    """
    viz = HemisphereVisualizer2D(grid)
    return viz.plot_grid_patches(data=data, style=style, **kwargs)

add_tissot_indicatrix(ax, grid, radius_deg=None, n_sample=None, facecolor='gold', alpha=0.6, edgecolor='black', linewidth=0.5)

Add Tissot's indicatrix circles to existing polar plot.

Adds equal-sized circles to visualize grid distortion. In equal-area grids, circles should appear roughly equal-sized. Variation indicates distortion.

Parameters

ax : matplotlib.axes.Axes Existing polar axis to add circles to grid : HemiGrid Grid instance radius_deg : float, optional Angular radius of circles in degrees. If None, auto-calculated as angular_resolution / 8. n_sample : int, optional Subsample cells (use every nth cell) for performance. If None, shows all cells. facecolor : str, default 'gold' Fill color for circles alpha : float, default 0.6 Transparency (0=transparent, 1=opaque) edgecolor : str, default 'black' Edge color for circles linewidth : float, default 0.5 Edge line width

Returns

ax : matplotlib.axes.Axes Modified axis with Tissot circles added

Examples

fig, ax = visualize_grid(grid, data=vod_data) add_tissot_indicatrix(ax, grid, radius_deg=3, n_sample=5) plt.savefig("vod_with_tissot.png")

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_2d.py
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
def add_tissot_indicatrix(
    ax: Axes,
    grid: HemiGrid,
    radius_deg: float | None = None,
    n_sample: int | None = None,
    facecolor: str = "gold",
    alpha: float = 0.6,
    edgecolor: str = "black",
    linewidth: float = 0.5,
) -> Axes:
    """Add Tissot's indicatrix circles to existing polar plot.

    Adds equal-sized circles to visualize grid distortion. In equal-area grids,
    circles should appear roughly equal-sized. Variation indicates distortion.

    Parameters
    ----------
    ax : matplotlib.axes.Axes
        Existing polar axis to add circles to
    grid : HemiGrid
        Grid instance
    radius_deg : float, optional
        Angular radius of circles in degrees. If None, auto-calculated
        as angular_resolution / 8.
    n_sample : int, optional
        Subsample cells (use every nth cell) for performance.
        If None, shows all cells.
    facecolor : str, default 'gold'
        Fill color for circles
    alpha : float, default 0.6
        Transparency (0=transparent, 1=opaque)
    edgecolor : str, default 'black'
        Edge color for circles
    linewidth : float, default 0.5
        Edge line width

    Returns
    -------
    ax : matplotlib.axes.Axes
        Modified axis with Tissot circles added

    Examples
    --------
    >>> fig, ax = visualize_grid(grid, data=vod_data)
    >>> add_tissot_indicatrix(ax, grid, radius_deg=3, n_sample=5)
    >>> plt.savefig("vod_with_tissot.png")

    """
    from matplotlib.patches import Ellipse

    # Auto-calculate radius if not provided
    if radius_deg is None:
        if hasattr(grid, "angular_resolution"):
            radius_deg = grid.angular_resolution / 8
        else:
            theta_vals = grid.grid["theta"].to_numpy()
            theta_spacing = np.median(np.diff(np.sort(np.unique(theta_vals))))
            radius_deg = np.rad2deg(theta_spacing) / 8

    radius_rad = np.deg2rad(radius_deg)

    # Generate circle points on sphere
    n_circle_points = 32
    circle_angles = np.linspace(0, 2 * np.pi, n_circle_points, endpoint=False)

    cell_count = 0
    grid_df = grid.grid

    # Different handling for triangular vs rectangular grids
    if grid.grid_type in ["htm", "geodesic"]:
        # For triangular grids: create circles on sphere surface and project
        for i, row in enumerate(grid_df.iter_rows(named=True)):
            if n_sample is not None and i % n_sample != 0:
                continue

            phi_center = row["phi"]
            theta_center = row["theta"]

            if theta_center > np.pi / 2:
                continue

            # Convert cell center to 3D Cartesian
            x_c = np.sin(theta_center) * np.cos(phi_center)
            y_c = np.sin(theta_center) * np.sin(phi_center)
            z_c = np.cos(theta_center)
            center_3d = np.array([x_c, y_c, z_c])

            # Create tangent vectors
            if theta_center < 0.01:
                tangent_1 = np.array([1, 0, 0])
                tangent_2 = np.array([0, 1, 0])
            else:
                tangent_phi = np.array([-np.sin(phi_center), np.cos(phi_center), 0])
                tangent_phi = tangent_phi / np.linalg.norm(tangent_phi)

                tangent_theta = np.array(
                    [
                        np.cos(theta_center) * np.cos(phi_center),
                        np.cos(theta_center) * np.sin(phi_center),
                        -np.sin(theta_center),
                    ]
                )
                tangent_theta = tangent_theta / np.linalg.norm(tangent_theta)

                tangent_1 = tangent_phi
                tangent_2 = tangent_theta

            # Create circle on sphere surface
            circle_3d = []
            for angle in circle_angles:
                offset = radius_rad * (
                    np.cos(angle) * tangent_1 + np.sin(angle) * tangent_2
                )
                point_3d = center_3d + offset
                norm = np.linalg.norm(point_3d)
                if norm > 1e-10:
                    point_3d = point_3d / norm
                circle_3d.append(point_3d)

            circle_3d = np.array(circle_3d)

            # Project to 2D polar coordinates
            x_2d, y_2d, z_2d = circle_3d[:, 0], circle_3d[:, 1], circle_3d[:, 2]
            theta_2d = np.arccos(np.clip(z_2d, -1, 1))
            phi_2d = np.arctan2(y_2d, x_2d)

            # Convert to polar plot coordinates (rho = sin(theta))
            rho_2d = np.sin(theta_2d)
            angle_2d = phi_2d

            vertices_2d = np.column_stack([angle_2d, rho_2d])

            poly = Polygon(
                vertices_2d,
                facecolor=facecolor,
                alpha=alpha,
                edgecolor=edgecolor,
                linewidth=linewidth,
            )
            ax.add_patch(poly)
            cell_count += 1

    else:
        # Rectangular grids: use simple ellipses at grid centers
        for i, row in enumerate(grid_df.iter_rows(named=True)):
            if n_sample is not None and i % n_sample != 0:
                continue

            phi_center = row["phi"]
            theta_center = row["theta"]

            if theta_center <= np.pi / 2:
                # Convert to polar plot coordinates
                rho_center = np.sin(theta_center)

                ell = Ellipse(
                    (phi_center, rho_center),
                    width=2 * radius_rad,
                    height=2 * radius_rad * np.sin(theta_center),  # Scale by projection
                    facecolor=facecolor,
                    alpha=alpha,
                    edgecolor=edgecolor,
                    linewidth=linewidth,
                )
                ax.add_patch(ell)
                cell_count += 1

    # Update title
    current_title = ax.get_title()
    if current_title:
        ax.set_title(f"{current_title} + Tissot ({cell_count} circles)")
    else:
        ax.set_title(f"Tissot's Indicatrix - {grid.grid_type} ({cell_count} circles)")

    return ax

3D Visualization

3D hemisphere visualization using plotly for interactive exploration.

Provides interactive 3D sphere surface plots with zoom, pan, and rotation capabilities.

HemisphereVisualizer3D

3D hemisphere visualization using plotly.

Creates interactive 3D plots with rotation, zoom, and hover capabilities. Designed for exploratory data analysis and presentations.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Examples

from canvod.grids import create_hemigrid from canvod.viz import HemisphereVisualizer3D

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0) viz = HemisphereVisualizer3D(grid) fig = viz.plot_hemisphere_surface(data=vod_data, title="Interactive VOD") fig.show()

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
class HemisphereVisualizer3D:
    """3D hemisphere visualization using plotly.

    Creates interactive 3D plots with rotation, zoom, and hover capabilities.
    Designed for exploratory data analysis and presentations.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    Examples
    --------
    >>> from canvod.grids import create_hemigrid
    >>> from canvod.viz import HemisphereVisualizer3D
    >>>
    >>> grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
    >>> viz = HemisphereVisualizer3D(grid)
    >>> fig = viz.plot_hemisphere_surface(data=vod_data, title="Interactive VOD")
    >>> fig.show()

    """

    def __init__(self, grid: HemiGrid) -> None:
        """Initialize 3D hemisphere visualizer.

        Parameters
        ----------
        grid : HemiGrid
            Hemisphere grid to visualize

        """
        self.grid = grid

    def plot_hemisphere_surface(
        self,
        data: np.ndarray | None = None,
        style: PlotStyle | None = None,
        title: str | None = None,
        colorscale: str = "Viridis",
        opacity: float = 0.8,
        show_wireframe: bool = True,
        show_colorbar: bool = True,
        width: int = 800,
        height: int = 600,
        **kwargs: Any,
    ) -> go.Figure:
        """Create 3D surface plot on hemisphere with actual cell patches.

        Renders grid cells as colored 3D patches (not just points).

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell. If None, shows grid structure.
        style : PlotStyle, optional
            Styling configuration. If None, uses defaults.
        title : str, optional
            Plot title
        colorscale : str, default 'Viridis'
            Plotly colorscale name
        opacity : float, default 0.8
            Surface opacity (0=transparent, 1=opaque)
        show_wireframe : bool, default True
            Show grid lines on surface
        show_colorbar : bool, default True
            Display colorbar
        width : int, default 800
            Figure width in pixels
        height : int, default 600
            Figure height in pixels
        **kwargs
            Additional plotly trace parameters

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive 3D figure with cell patches

        Examples
        --------
        >>> fig = viz.plot_hemisphere_surface(
        ...     data=vod_data,
        ...     title="VOD Distribution 3D",
        ...     colorscale='Plasma',
        ...     opacity=0.9
        ... )
        >>> fig.write_html("vod_3d.html")

        """
        # Initialize style
        if style is None:
            style = PlotStyle()
            colorscale = colorscale  # Use parameter
        else:
            colorscale = style.colorscale

        # Render grid based on type
        grid_type = self.grid.grid_type.lower()

        if grid_type in ["equal_area", "equal_angle", "equirectangular"]:
            trace = self._render_rectangular_cells(
                data,
                colorscale,
                opacity,
                show_colorbar,
            )
        elif grid_type == "htm":
            trace = self._render_htm_cells(data, colorscale, opacity, show_colorbar)
        elif grid_type == "geodesic":
            trace = self._render_geodesic_cells(
                data, colorscale, opacity, show_colorbar
            )
        elif grid_type == "healpix":
            trace = self._render_healpix_cells(data, colorscale, opacity, show_colorbar)
        elif grid_type == "fibonacci":
            trace = self._render_fibonacci_cells(
                data, colorscale, opacity, show_colorbar
            )
        else:
            # Fallback to scatter for unknown types
            trace = self._render_scatter_fallback(
                data,
                colorscale,
                opacity,
                show_colorbar,
            )

        fig = go.Figure(data=[trace])

        # Apply layout
        layout_config = style.to_plotly_layout() if style else {}
        layout_config.update(
            {
                "title": title or "Hemisphere 3D",
                "scene": dict(
                    aspectmode="data",
                    xaxis=dict(title="East", showbackground=False),
                    yaxis=dict(title="North", showbackground=False),
                    zaxis=dict(title="Up", showbackground=False),
                    bgcolor=layout_config.get("plot_bgcolor", "white"),
                ),
                "width": width,
                "height": height,
                "margin": dict(l=0, r=0, b=0, t=40),
            }
        )

        fig.update_layout(**layout_config)

        return fig

    def _render_rectangular_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d:
        """Render rectangular grid cells as 3D mesh patches."""
        grid_df = self.grid.grid

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []

        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                phi_min, phi_max = row["phi_min"], row["phi_max"]
                theta_min, theta_max = row["theta_min"], row["theta_max"]

                # Skip if beyond hemisphere
                if theta_min > np.pi / 2:
                    continue

                # Create 4 corners of rectangular cell
                phi_corners = [phi_min, phi_max, phi_max, phi_min]
                theta_corners = [theta_min, theta_min, theta_max, theta_max]

                patch_x, patch_y, patch_z = [], [], []
                for phi, theta in zip(phi_corners, theta_corners):
                    # Convert to 3D Cartesian
                    x = np.sin(theta) * np.sin(phi)
                    y = np.sin(theta) * np.cos(phi)
                    z = np.cos(theta)
                    patch_x.append(x)
                    patch_y.append(y)
                    patch_z.append(z)

                all_x.extend(patch_x)
                all_y.extend(patch_y)
                all_z.extend(patch_z)

                # Color value for this cell
                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * 4)

                # Two triangles per rectangle
                all_i.extend([vertex_count, vertex_count])
                all_j.extend([vertex_count + 1, vertex_count + 2])
                all_k.extend([vertex_count + 2, vertex_count + 3])

                vertex_count += 4

            except KeyError, IndexError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name=f"{self.grid.grid_type.title()} Grid",
        )

    def _render_htm_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d:
        """Render HTM triangular cells as 3D mesh."""
        grid_df = self.grid.grid

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []

        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                v0 = np.array(row["htm_vertex_0"], dtype=float)
                v1 = np.array(row["htm_vertex_1"], dtype=float)
                v2 = np.array(row["htm_vertex_2"], dtype=float)

                # Skip if beyond hemisphere
                if np.all([v[2] < 0 for v in [v0, v1, v2]]):
                    continue

                all_x.extend([v0[1], v1[1], v2[1]])
                all_y.extend([v0[0], v1[0], v2[0]])
                all_z.extend([v0[2], v1[2], v2[2]])

                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * 3)

                # One triangle per cell
                all_i.append(vertex_count)
                all_j.append(vertex_count + 1)
                all_k.append(vertex_count + 2)

                vertex_count += 3

            except KeyError, TypeError, ValueError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name=f"{self.grid.grid_type.title()} Grid",
        )

    def _render_geodesic_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d | go.Scatter3d:
        """Render geodesic triangular cells as 3D mesh.

        Reads ``geodesic_vertices`` (3 vertex indices per cell) and looks up
        3D Cartesian coordinates from the shared ``grid.vertices`` array.
        """
        grid_df = self.grid.grid
        shared_vertices = self.grid.vertices

        if shared_vertices is None or "geodesic_vertices" not in grid_df.columns:
            return self._render_scatter_fallback(
                data, colorscale, opacity, show_colorbar
            )

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []
        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                v_indices = np.array(row["geodesic_vertices"], dtype=int)
                if len(v_indices) < 3:
                    continue

                verts = shared_vertices[v_indices]  # (3, 3)

                if np.all(verts[:, 2] < 0):
                    continue

                all_x.extend(verts[:, 1].tolist())
                all_y.extend(verts[:, 0].tolist())
                all_z.extend(verts[:, 2].tolist())

                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * 3)

                all_i.append(vertex_count)
                all_j.append(vertex_count + 1)
                all_k.append(vertex_count + 2)
                vertex_count += 3

            except KeyError, IndexError, TypeError, ValueError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name="Geodesic Grid",
        )

    def _render_healpix_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d | go.Scatter3d:
        """Render HEALPix curvilinear cells as 3D mesh.

        Uses ``healpy.boundaries()`` to obtain true pixel boundaries,
        then fan-triangulates each quadrilateral pixel.
        """
        try:
            import healpy as hp
        except ImportError:
            return self._render_scatter_fallback(
                data, colorscale, opacity, show_colorbar
            )

        grid_df = self.grid.grid
        if "healpix_ipix" not in grid_df.columns:
            return self._render_scatter_fallback(
                data, colorscale, opacity, show_colorbar
            )

        nside = int(grid_df["healpix_nside"][0])
        step = 4  # 4 sub-points per edge → 16 boundary vertices

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []
        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                ipix = int(row["healpix_ipix"])
                boundary = hp.boundaries(nside, ipix, step=step)
                x, y, z = boundary[1], boundary[0], boundary[2]

                if np.all(z < -0.01):
                    continue

                n_verts = len(x)
                all_x.extend(x.tolist())
                all_y.extend(y.tolist())
                all_z.extend(z.tolist())

                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * n_verts)

                # Fan triangulation from first vertex
                for j in range(1, n_verts - 1):
                    all_i.append(vertex_count)
                    all_j.append(vertex_count + j)
                    all_k.append(vertex_count + j + 1)

                vertex_count += n_verts

            except KeyError, IndexError, TypeError, ValueError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name="HEALPix Grid",
        )

    def _render_fibonacci_cells(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Mesh3d | go.Scatter3d:
        """Render Fibonacci Voronoi cells as 3D mesh.

        Reads ``voronoi_region`` (variable-length vertex index list) and
        looks up 3D coordinates from ``grid.voronoi.vertices``.  The
        vertex indices are already in correct polygon winding order
        (``SphericalVoronoi.sort_vertices_of_regions()`` was called
        during grid construction), so no re-sorting is needed.
        Fan-triangulates each polygon for ``go.Mesh3d``.
        """
        grid_df = self.grid.grid
        voronoi = self.grid.voronoi

        if voronoi is None or "voronoi_region" not in grid_df.columns:
            return self._render_scatter_fallback(
                data, colorscale, opacity, show_colorbar
            )

        all_x, all_y, all_z = [], [], []
        all_i, all_j, all_k = [], [], []
        all_colors = []
        vertex_count = 0

        for i, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                region_indices = row["voronoi_region"]
                if region_indices is None or len(region_indices) < 3:
                    continue

                verts = voronoi.vertices[region_indices]

                if np.all(verts[:, 2] < -0.01):
                    continue

                # Vertices are already in polygon winding order from
                # sort_vertices_of_regions() — use directly.
                n_verts = len(verts)

                all_x.extend(verts[:, 1].tolist())
                all_y.extend(verts[:, 0].tolist())
                all_z.extend(verts[:, 2].tolist())

                color_val = data[i] if data is not None else 0.5
                all_colors.extend([color_val] * n_verts)

                # Fan triangulation from first vertex
                for j in range(1, n_verts - 1):
                    all_i.append(vertex_count)
                    all_j.append(vertex_count + j)
                    all_k.append(vertex_count + j + 1)

                vertex_count += n_verts

            except KeyError, IndexError, TypeError, ValueError:
                continue

        return go.Mesh3d(
            x=all_x,
            y=all_y,
            z=all_z,
            i=all_i,
            j=all_j,
            k=all_k,
            intensity=all_colors,
            colorscale=colorscale,
            showscale=show_colorbar,
            colorbar=dict(title="Value") if show_colorbar else None,
            opacity=opacity,
            flatshading=True,
            name="Fibonacci Grid",
        )

    def _render_scatter_fallback(
        self,
        data: np.ndarray | None,
        colorscale: str,
        opacity: float,
        show_colorbar: bool,
    ) -> go.Scatter3d:
        """Fallback scatter plot for unsupported grid types."""
        grid_df = self.grid.grid
        theta = grid_df["theta"].to_numpy()
        phi = grid_df["phi"].to_numpy()

        # Convert to 3D Cartesian coordinates
        x = np.sin(theta) * np.sin(phi)
        y = np.sin(theta) * np.cos(phi)
        z = np.cos(theta)

        # Prepare data values
        if data is None:
            values = np.ones(self.grid.ncells) * 0.5
        else:
            values = data

        # Filter hemisphere only
        hemisphere_mask = theta <= np.pi / 2
        x = x[hemisphere_mask]
        y = y[hemisphere_mask]
        z = z[hemisphere_mask]
        values = values[hemisphere_mask]

        return go.Scatter3d(
            x=x,
            y=y,
            z=z,
            mode="markers",
            marker=dict(
                size=6,
                color=values,
                colorscale=colorscale,
                opacity=opacity,
                colorbar=dict(title="Value") if show_colorbar else None,
                cmin=np.nanmin(values),
                cmax=np.nanmax(values),
            ),
            text=[f"Cell {i}<br>Value: {v:.3f}" for i, v in enumerate(values)],
            hoverinfo="text",
        )

    def plot_hemisphere_scatter(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        colorscale: str = "Viridis",
        marker_size: int | np.ndarray = 6,
        opacity: float = 0.8,
        width: int = 800,
        height: int = 600,
    ) -> go.Figure:
        """Create 3D scatter plot of cell centers.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        colorscale : str, default 'Viridis'
            Plotly colorscale name
        marker_size : int or np.ndarray, default 6
            Marker size (constant or per-point array)
        opacity : float, default 0.8
            Marker opacity
        width : int, default 800
            Figure width
        height : int, default 600
            Figure height

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive scatter plot

        """
        # Note: Now renders as mesh, not scatter points
        # Marker size parameter is ignored
        fig = self.plot_hemisphere_surface(
            data=data,
            title=title,
            colorscale=colorscale,
            opacity=opacity,
            width=width,
            height=height,
        )

        return fig

    def plot_cell_mesh(
        self,
        data: np.ndarray | None = None,
        title: str | None = None,
        colorscale: str = "Viridis",
        opacity: float = 0.7,
        show_edges: bool = True,
        width: int = 800,
        height: int = 600,
    ) -> go.Figure:
        """Create 3D mesh plot showing cell boundaries.

        Parameters
        ----------
        data : np.ndarray, optional
            Data values per cell
        title : str, optional
            Plot title
        colorscale : str, default 'Viridis'
            Plotly colorscale name
        opacity : float, default 0.7
            Mesh opacity
        show_edges : bool, default True
            Show cell edges
        width : int, default 800
            Figure width
        height : int, default 600
            Figure height

        Returns
        -------
        plotly.graph_objects.Figure
            Interactive mesh plot

        Notes
        -----
        This method requires grid cells with vertex information.
        Currently supports HTM and geodesic grids.

        """
        traces = []

        # Prepare data
        if data is None:
            values = np.ones(self.grid.ncells) * 0.5
        else:
            values = data

        grid_df = self.grid.grid
        grid_type = self.grid.grid_type.lower()

        # Check if grid supports mesh rendering
        if grid_type == "htm" and "htm_vertex_0" in grid_df.columns:
            # HTM triangular mesh
            for idx, row in enumerate(grid_df.iter_rows(named=True)):
                try:
                    v0 = np.array(row["htm_vertex_0"], dtype=float)
                    v1 = np.array(row["htm_vertex_1"], dtype=float)
                    v2 = np.array(row["htm_vertex_2"], dtype=float)

                    vertices = np.array([v0, v1, v2])

                    # Check hemisphere
                    z_coords = vertices[:, 2]
                    if np.all(z_coords < 0):
                        continue

                    # Normalize color value
                    color_val = (values[idx] - np.nanmin(values)) / (
                        np.nanmax(values) - np.nanmin(values)
                    )
                    color_rgb = sample_colorscale(colorscale, [color_val])[0]

                    # Create triangle mesh
                    trace = go.Mesh3d(
                        x=vertices[:, 0],
                        y=vertices[:, 1],
                        z=vertices[:, 2],
                        i=[0],
                        j=[1],
                        k=[2],
                        color=color_rgb,
                        opacity=opacity,
                        flatshading=True,
                        showscale=False,
                        hoverinfo="skip",
                    )
                    traces.append(trace)
                except KeyError, TypeError, ValueError:
                    continue
        else:
            # Not supported for this grid type
            raise NotImplementedError(
                f"Cell mesh rendering not implemented for {grid_type} grids"
            )

        # Sample colorscale (no longer needed above, but kept for compatibility)

        # Add colorbar trace
        if values is not None and len(traces) > 0:
            dummy_trace = go.Scatter3d(
                x=[None],
                y=[None],
                z=[None],
                mode="markers",
                marker=dict(
                    size=0.1,
                    color=[np.nanmin(values), np.nanmax(values)],
                    colorscale=colorscale,
                    colorbar=dict(title="Value"),
                ),
                showlegend=False,
                hoverinfo="skip",
            )
            traces.append(dummy_trace)

        fig = go.Figure(data=traces)

        # Update layout
        fig.update_layout(
            title=title or "Hemisphere Mesh 3D",
            scene=dict(
                aspectmode="data",
                xaxis=dict(title="East", showbackground=False),
                yaxis=dict(title="North", showbackground=False),
                zaxis=dict(title="Up", showbackground=False),
            ),
            width=width,
            height=height,
            margin=dict(l=0, r=0, b=0, t=40),
        )

        return fig

    def add_spherical_overlays(
        self,
        fig: go.Figure,
        elevation_rings: list[int] | None = None,
        meridians_deg: list[int] | None = None,
        overlay_color: str = "lightgray",
        line_width: float = 1,
    ) -> go.Figure:
        """Add elevation rings and meridians to 3D plot.

        Parameters
        ----------
        fig : plotly.graph_objects.Figure
            Existing 3D figure to add overlays to
        elevation_rings : list of int, optional
            Elevation angles in degrees. Default: [15, 30, 45, 60, 75, 90]
        meridians_deg : list of int, optional
            Meridian angles in degrees. Default: [0, 45, 90, 135, 180, 225, 270, 315]
        overlay_color : str, default 'lightgray'
            Color for overlay lines
        line_width : float, default 1
            Width of overlay lines

        Returns
        -------
        fig : plotly.graph_objects.Figure
            Modified figure with overlays

        """
        if elevation_rings is None:
            elevation_rings = [15, 30, 45, 60, 75, 90]
        if meridians_deg is None:
            meridians_deg = list(range(0, 360, 45))

        # Elevation rings
        for theta_deg in elevation_rings:
            theta = np.radians(theta_deg)
            phi = np.linspace(0, 2 * np.pi, 200)
            x = np.sin(theta) * np.sin(phi)
            y = np.sin(theta) * np.cos(phi)
            z = np.full_like(phi, np.cos(theta))
            fig.add_trace(
                go.Scatter3d(
                    x=x,
                    y=y,
                    z=z,
                    mode="lines",
                    line=dict(color=overlay_color, width=line_width),
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

        # Meridians
        for phi_deg in meridians_deg:
            phi = np.radians(phi_deg)
            theta = np.linspace(0, np.pi / 2, 100)
            x = np.sin(theta) * np.sin(phi)
            y = np.sin(theta) * np.cos(phi)
            z = np.cos(theta)
            fig.add_trace(
                go.Scatter3d(
                    x=x,
                    y=y,
                    z=z,
                    mode="lines",
                    line=dict(color=overlay_color, width=line_width),
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

        return fig

    def add_custom_axes(
        self,
        fig: go.Figure,
        axis_length: float = 1.2,
        axis_color: str = "black",
        show_labels: bool = True,
    ) -> go.Figure:
        """Add custom coordinate axes with labels.

        Parameters
        ----------
        fig : plotly.graph_objects.Figure
            Existing 3D figure
        axis_length : float, default 1.2
            Length of axis lines
        axis_color : str, default 'black'
            Color for axes
        show_labels : bool, default True
            Show axis labels (E, N, Z)

        Returns
        -------
        fig : plotly.graph_objects.Figure
            Modified figure with custom axes

        """
        # Axis lines
        axes_lines = [
            dict(x=[0, axis_length], y=[0, 0], z=[0, 0]),  # East
            dict(x=[0, 0], y=[0, axis_length], z=[0, 0]),  # North
            dict(x=[0, 0], y=[0, 0], z=[0, axis_length]),  # Up
        ]

        for axis in axes_lines:
            fig.add_trace(
                go.Scatter3d(
                    x=axis["x"],
                    y=axis["y"],
                    z=axis["z"],
                    mode="lines",
                    line=dict(color=axis_color, width=6),
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

        # Arrowheads
        arrow_tip = axis_length + 0.05
        arrow_size = 0.1
        for pos, direction in zip(
            [[arrow_tip, 0, 0], [0, arrow_tip, 0], [0, 0, arrow_tip]],
            [[arrow_size, 0, 0], [0, arrow_size, 0], [0, 0, arrow_size]],
        ):
            fig.add_trace(
                go.Cone(
                    x=[pos[0]],
                    y=[pos[1]],
                    z=[pos[2]],
                    u=[direction[0]],
                    v=[direction[1]],
                    w=[direction[2]],
                    sizemode="absolute",
                    sizeref=arrow_size,
                    anchor="tip",
                    showscale=False,
                    colorscale=[[0, axis_color], [1, axis_color]],
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

        # Labels
        if show_labels:
            label_offset = axis_length + 0.15
            for label in [
                dict(x=label_offset, y=0, z=0, text="E"),
                dict(x=0, y=label_offset, z=0, text="N"),
                dict(x=0, y=0, z=label_offset, text="Z"),
            ]:
                fig.add_trace(
                    go.Scatter3d(
                        x=[label["x"]],
                        y=[label["y"]],
                        z=[label["z"]],
                        mode="text",
                        text=[label["text"]],
                        textfont=dict(size=16, color=axis_color),
                        hoverinfo="skip",
                        showlegend=False,
                    )
                )

        return fig

__init__(grid)

Initialize 3D hemisphere visualizer.

Parameters

grid : HemiGrid Hemisphere grid to visualize

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
43
44
45
46
47
48
49
50
51
52
def __init__(self, grid: HemiGrid) -> None:
    """Initialize 3D hemisphere visualizer.

    Parameters
    ----------
    grid : HemiGrid
        Hemisphere grid to visualize

    """
    self.grid = grid

plot_hemisphere_surface(data=None, style=None, title=None, colorscale='Viridis', opacity=0.8, show_wireframe=True, show_colorbar=True, width=800, height=600, **kwargs)

Create 3D surface plot on hemisphere with actual cell patches.

Renders grid cells as colored 3D patches (not just points).

Parameters

data : np.ndarray, optional Data values per cell. If None, shows grid structure. style : PlotStyle, optional Styling configuration. If None, uses defaults. title : str, optional Plot title colorscale : str, default 'Viridis' Plotly colorscale name opacity : float, default 0.8 Surface opacity (0=transparent, 1=opaque) show_wireframe : bool, default True Show grid lines on surface show_colorbar : bool, default True Display colorbar width : int, default 800 Figure width in pixels height : int, default 600 Figure height in pixels **kwargs Additional plotly trace parameters

Returns

plotly.graph_objects.Figure Interactive 3D figure with cell patches

Examples

fig = viz.plot_hemisphere_surface( ... data=vod_data, ... title="VOD Distribution 3D", ... colorscale='Plasma', ... opacity=0.9 ... ) fig.write_html("vod_3d.html")

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def plot_hemisphere_surface(
    self,
    data: np.ndarray | None = None,
    style: PlotStyle | None = None,
    title: str | None = None,
    colorscale: str = "Viridis",
    opacity: float = 0.8,
    show_wireframe: bool = True,
    show_colorbar: bool = True,
    width: int = 800,
    height: int = 600,
    **kwargs: Any,
) -> go.Figure:
    """Create 3D surface plot on hemisphere with actual cell patches.

    Renders grid cells as colored 3D patches (not just points).

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell. If None, shows grid structure.
    style : PlotStyle, optional
        Styling configuration. If None, uses defaults.
    title : str, optional
        Plot title
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    opacity : float, default 0.8
        Surface opacity (0=transparent, 1=opaque)
    show_wireframe : bool, default True
        Show grid lines on surface
    show_colorbar : bool, default True
        Display colorbar
    width : int, default 800
        Figure width in pixels
    height : int, default 600
        Figure height in pixels
    **kwargs
        Additional plotly trace parameters

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive 3D figure with cell patches

    Examples
    --------
    >>> fig = viz.plot_hemisphere_surface(
    ...     data=vod_data,
    ...     title="VOD Distribution 3D",
    ...     colorscale='Plasma',
    ...     opacity=0.9
    ... )
    >>> fig.write_html("vod_3d.html")

    """
    # Initialize style
    if style is None:
        style = PlotStyle()
        colorscale = colorscale  # Use parameter
    else:
        colorscale = style.colorscale

    # Render grid based on type
    grid_type = self.grid.grid_type.lower()

    if grid_type in ["equal_area", "equal_angle", "equirectangular"]:
        trace = self._render_rectangular_cells(
            data,
            colorscale,
            opacity,
            show_colorbar,
        )
    elif grid_type == "htm":
        trace = self._render_htm_cells(data, colorscale, opacity, show_colorbar)
    elif grid_type == "geodesic":
        trace = self._render_geodesic_cells(
            data, colorscale, opacity, show_colorbar
        )
    elif grid_type == "healpix":
        trace = self._render_healpix_cells(data, colorscale, opacity, show_colorbar)
    elif grid_type == "fibonacci":
        trace = self._render_fibonacci_cells(
            data, colorscale, opacity, show_colorbar
        )
    else:
        # Fallback to scatter for unknown types
        trace = self._render_scatter_fallback(
            data,
            colorscale,
            opacity,
            show_colorbar,
        )

    fig = go.Figure(data=[trace])

    # Apply layout
    layout_config = style.to_plotly_layout() if style else {}
    layout_config.update(
        {
            "title": title or "Hemisphere 3D",
            "scene": dict(
                aspectmode="data",
                xaxis=dict(title="East", showbackground=False),
                yaxis=dict(title="North", showbackground=False),
                zaxis=dict(title="Up", showbackground=False),
                bgcolor=layout_config.get("plot_bgcolor", "white"),
            ),
            "width": width,
            "height": height,
            "margin": dict(l=0, r=0, b=0, t=40),
        }
    )

    fig.update_layout(**layout_config)

    return fig

plot_hemisphere_scatter(data=None, title=None, colorscale='Viridis', marker_size=6, opacity=0.8, width=800, height=600)

Create 3D scatter plot of cell centers.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title colorscale : str, default 'Viridis' Plotly colorscale name marker_size : int or np.ndarray, default 6 Marker size (constant or per-point array) opacity : float, default 0.8 Marker opacity width : int, default 800 Figure width height : int, default 600 Figure height

Returns

plotly.graph_objects.Figure Interactive scatter plot

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
def plot_hemisphere_scatter(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    colorscale: str = "Viridis",
    marker_size: int | np.ndarray = 6,
    opacity: float = 0.8,
    width: int = 800,
    height: int = 600,
) -> go.Figure:
    """Create 3D scatter plot of cell centers.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    marker_size : int or np.ndarray, default 6
        Marker size (constant or per-point array)
    opacity : float, default 0.8
        Marker opacity
    width : int, default 800
        Figure width
    height : int, default 600
        Figure height

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive scatter plot

    """
    # Note: Now renders as mesh, not scatter points
    # Marker size parameter is ignored
    fig = self.plot_hemisphere_surface(
        data=data,
        title=title,
        colorscale=colorscale,
        opacity=opacity,
        width=width,
        height=height,
    )

    return fig

plot_cell_mesh(data=None, title=None, colorscale='Viridis', opacity=0.7, show_edges=True, width=800, height=600)

Create 3D mesh plot showing cell boundaries.

Parameters

data : np.ndarray, optional Data values per cell title : str, optional Plot title colorscale : str, default 'Viridis' Plotly colorscale name opacity : float, default 0.7 Mesh opacity show_edges : bool, default True Show cell edges width : int, default 800 Figure width height : int, default 600 Figure height

Returns

plotly.graph_objects.Figure Interactive mesh plot

Notes

This method requires grid cells with vertex information. Currently supports HTM and geodesic grids.

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
def plot_cell_mesh(
    self,
    data: np.ndarray | None = None,
    title: str | None = None,
    colorscale: str = "Viridis",
    opacity: float = 0.7,
    show_edges: bool = True,
    width: int = 800,
    height: int = 600,
) -> go.Figure:
    """Create 3D mesh plot showing cell boundaries.

    Parameters
    ----------
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    opacity : float, default 0.7
        Mesh opacity
    show_edges : bool, default True
        Show cell edges
    width : int, default 800
        Figure width
    height : int, default 600
        Figure height

    Returns
    -------
    plotly.graph_objects.Figure
        Interactive mesh plot

    Notes
    -----
    This method requires grid cells with vertex information.
    Currently supports HTM and geodesic grids.

    """
    traces = []

    # Prepare data
    if data is None:
        values = np.ones(self.grid.ncells) * 0.5
    else:
        values = data

    grid_df = self.grid.grid
    grid_type = self.grid.grid_type.lower()

    # Check if grid supports mesh rendering
    if grid_type == "htm" and "htm_vertex_0" in grid_df.columns:
        # HTM triangular mesh
        for idx, row in enumerate(grid_df.iter_rows(named=True)):
            try:
                v0 = np.array(row["htm_vertex_0"], dtype=float)
                v1 = np.array(row["htm_vertex_1"], dtype=float)
                v2 = np.array(row["htm_vertex_2"], dtype=float)

                vertices = np.array([v0, v1, v2])

                # Check hemisphere
                z_coords = vertices[:, 2]
                if np.all(z_coords < 0):
                    continue

                # Normalize color value
                color_val = (values[idx] - np.nanmin(values)) / (
                    np.nanmax(values) - np.nanmin(values)
                )
                color_rgb = sample_colorscale(colorscale, [color_val])[0]

                # Create triangle mesh
                trace = go.Mesh3d(
                    x=vertices[:, 0],
                    y=vertices[:, 1],
                    z=vertices[:, 2],
                    i=[0],
                    j=[1],
                    k=[2],
                    color=color_rgb,
                    opacity=opacity,
                    flatshading=True,
                    showscale=False,
                    hoverinfo="skip",
                )
                traces.append(trace)
            except KeyError, TypeError, ValueError:
                continue
    else:
        # Not supported for this grid type
        raise NotImplementedError(
            f"Cell mesh rendering not implemented for {grid_type} grids"
        )

    # Sample colorscale (no longer needed above, but kept for compatibility)

    # Add colorbar trace
    if values is not None and len(traces) > 0:
        dummy_trace = go.Scatter3d(
            x=[None],
            y=[None],
            z=[None],
            mode="markers",
            marker=dict(
                size=0.1,
                color=[np.nanmin(values), np.nanmax(values)],
                colorscale=colorscale,
                colorbar=dict(title="Value"),
            ),
            showlegend=False,
            hoverinfo="skip",
        )
        traces.append(dummy_trace)

    fig = go.Figure(data=traces)

    # Update layout
    fig.update_layout(
        title=title or "Hemisphere Mesh 3D",
        scene=dict(
            aspectmode="data",
            xaxis=dict(title="East", showbackground=False),
            yaxis=dict(title="North", showbackground=False),
            zaxis=dict(title="Up", showbackground=False),
        ),
        width=width,
        height=height,
        margin=dict(l=0, r=0, b=0, t=40),
    )

    return fig

add_spherical_overlays(fig, elevation_rings=None, meridians_deg=None, overlay_color='lightgray', line_width=1)

Add elevation rings and meridians to 3D plot.

Parameters

fig : plotly.graph_objects.Figure Existing 3D figure to add overlays to elevation_rings : list of int, optional Elevation angles in degrees. Default: [15, 30, 45, 60, 75, 90] meridians_deg : list of int, optional Meridian angles in degrees. Default: [0, 45, 90, 135, 180, 225, 270, 315] overlay_color : str, default 'lightgray' Color for overlay lines line_width : float, default 1 Width of overlay lines

Returns

fig : plotly.graph_objects.Figure Modified figure with overlays

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
def add_spherical_overlays(
    self,
    fig: go.Figure,
    elevation_rings: list[int] | None = None,
    meridians_deg: list[int] | None = None,
    overlay_color: str = "lightgray",
    line_width: float = 1,
) -> go.Figure:
    """Add elevation rings and meridians to 3D plot.

    Parameters
    ----------
    fig : plotly.graph_objects.Figure
        Existing 3D figure to add overlays to
    elevation_rings : list of int, optional
        Elevation angles in degrees. Default: [15, 30, 45, 60, 75, 90]
    meridians_deg : list of int, optional
        Meridian angles in degrees. Default: [0, 45, 90, 135, 180, 225, 270, 315]
    overlay_color : str, default 'lightgray'
        Color for overlay lines
    line_width : float, default 1
        Width of overlay lines

    Returns
    -------
    fig : plotly.graph_objects.Figure
        Modified figure with overlays

    """
    if elevation_rings is None:
        elevation_rings = [15, 30, 45, 60, 75, 90]
    if meridians_deg is None:
        meridians_deg = list(range(0, 360, 45))

    # Elevation rings
    for theta_deg in elevation_rings:
        theta = np.radians(theta_deg)
        phi = np.linspace(0, 2 * np.pi, 200)
        x = np.sin(theta) * np.sin(phi)
        y = np.sin(theta) * np.cos(phi)
        z = np.full_like(phi, np.cos(theta))
        fig.add_trace(
            go.Scatter3d(
                x=x,
                y=y,
                z=z,
                mode="lines",
                line=dict(color=overlay_color, width=line_width),
                hoverinfo="skip",
                showlegend=False,
            )
        )

    # Meridians
    for phi_deg in meridians_deg:
        phi = np.radians(phi_deg)
        theta = np.linspace(0, np.pi / 2, 100)
        x = np.sin(theta) * np.sin(phi)
        y = np.sin(theta) * np.cos(phi)
        z = np.cos(theta)
        fig.add_trace(
            go.Scatter3d(
                x=x,
                y=y,
                z=z,
                mode="lines",
                line=dict(color=overlay_color, width=line_width),
                hoverinfo="skip",
                showlegend=False,
            )
        )

    return fig

add_custom_axes(fig, axis_length=1.2, axis_color='black', show_labels=True)

Add custom coordinate axes with labels.

Parameters

fig : plotly.graph_objects.Figure Existing 3D figure axis_length : float, default 1.2 Length of axis lines axis_color : str, default 'black' Color for axes show_labels : bool, default True Show axis labels (E, N, Z)

Returns

fig : plotly.graph_objects.Figure Modified figure with custom axes

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
def add_custom_axes(
    self,
    fig: go.Figure,
    axis_length: float = 1.2,
    axis_color: str = "black",
    show_labels: bool = True,
) -> go.Figure:
    """Add custom coordinate axes with labels.

    Parameters
    ----------
    fig : plotly.graph_objects.Figure
        Existing 3D figure
    axis_length : float, default 1.2
        Length of axis lines
    axis_color : str, default 'black'
        Color for axes
    show_labels : bool, default True
        Show axis labels (E, N, Z)

    Returns
    -------
    fig : plotly.graph_objects.Figure
        Modified figure with custom axes

    """
    # Axis lines
    axes_lines = [
        dict(x=[0, axis_length], y=[0, 0], z=[0, 0]),  # East
        dict(x=[0, 0], y=[0, axis_length], z=[0, 0]),  # North
        dict(x=[0, 0], y=[0, 0], z=[0, axis_length]),  # Up
    ]

    for axis in axes_lines:
        fig.add_trace(
            go.Scatter3d(
                x=axis["x"],
                y=axis["y"],
                z=axis["z"],
                mode="lines",
                line=dict(color=axis_color, width=6),
                hoverinfo="skip",
                showlegend=False,
            )
        )

    # Arrowheads
    arrow_tip = axis_length + 0.05
    arrow_size = 0.1
    for pos, direction in zip(
        [[arrow_tip, 0, 0], [0, arrow_tip, 0], [0, 0, arrow_tip]],
        [[arrow_size, 0, 0], [0, arrow_size, 0], [0, 0, arrow_size]],
    ):
        fig.add_trace(
            go.Cone(
                x=[pos[0]],
                y=[pos[1]],
                z=[pos[2]],
                u=[direction[0]],
                v=[direction[1]],
                w=[direction[2]],
                sizemode="absolute",
                sizeref=arrow_size,
                anchor="tip",
                showscale=False,
                colorscale=[[0, axis_color], [1, axis_color]],
                hoverinfo="skip",
                showlegend=False,
            )
        )

    # Labels
    if show_labels:
        label_offset = axis_length + 0.15
        for label in [
            dict(x=label_offset, y=0, z=0, text="E"),
            dict(x=0, y=label_offset, z=0, text="N"),
            dict(x=0, y=0, z=label_offset, text="Z"),
        ]:
            fig.add_trace(
                go.Scatter3d(
                    x=[label["x"]],
                    y=[label["y"]],
                    z=[label["z"]],
                    mode="text",
                    text=[label["text"]],
                    textfont=dict(size=16, color=axis_color),
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

    return fig

visualize_grid_3d(grid, data=None, title=None, colorscale='Viridis', add_overlays=False, add_axes=False, **kwargs)

Visualize hemispherical grid in 3D interactive plot.

Convenience function providing simple interface to 3D visualization.

Parameters

grid : HemiGrid Grid to visualize data : np.ndarray, optional Data values per cell title : str, optional Plot title colorscale : str, default 'Viridis' Plotly colorscale name add_overlays : bool, default False Add elevation rings and meridians add_axes : bool, default False Add custom coordinate axes **kwargs Additional parameters passed to plot_hemisphere_surface

Returns

fig : plotly.graph_objects.Figure Interactive 3D figure

Examples

from canvod.grids import create_hemigrid from canvod.viz import visualize_grid_3d

grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0) fig = visualize_grid_3d( ... grid, ... data=vod_data, ... title="VOD 3D", ... add_overlays=True ... ) fig.show()

Source code in packages/canvod-viz/src/canvod/viz/hemisphere_3d.py
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
def visualize_grid_3d(
    grid: HemiGrid,
    data: np.ndarray | None = None,
    title: str | None = None,
    colorscale: str = "Viridis",
    add_overlays: bool = False,
    add_axes: bool = False,
    **kwargs: Any,
) -> go.Figure:
    """Visualize hemispherical grid in 3D interactive plot.

    Convenience function providing simple interface to 3D visualization.

    Parameters
    ----------
    grid : HemiGrid
        Grid to visualize
    data : np.ndarray, optional
        Data values per cell
    title : str, optional
        Plot title
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    add_overlays : bool, default False
        Add elevation rings and meridians
    add_axes : bool, default False
        Add custom coordinate axes
    **kwargs
        Additional parameters passed to plot_hemisphere_surface

    Returns
    -------
    fig : plotly.graph_objects.Figure
        Interactive 3D figure

    Examples
    --------
    >>> from canvod.grids import create_hemigrid
    >>> from canvod.viz import visualize_grid_3d
    >>>
    >>> grid = create_hemigrid(grid_type='equal_area', angular_resolution=10.0)
    >>> fig = visualize_grid_3d(
    ...     grid,
    ...     data=vod_data,
    ...     title="VOD 3D",
    ...     add_overlays=True
    ... )
    >>> fig.show()

    """
    viz = HemisphereVisualizer3D(grid)
    fig = viz.plot_hemisphere_surface(
        data=data, title=title, colorscale=colorscale, **kwargs
    )

    if add_overlays:
        viz.add_spherical_overlays(fig)

    if add_axes:
        viz.add_custom_axes(fig)

    return fig

Styles

Styling configuration for visualizations.

Provides consistent styling across 2D matplotlib and 3D plotly visualizations, including publication-quality RSE journal style and colorscale utilities.

PolarPlotStyle dataclass

Configuration for 2D polar plot styling (matplotlib).

Parameters

cmap : str, default 'viridis' Matplotlib colormap name edgecolor : str, default 'black' Edge color for grid cells linewidth : float, default 0.5 Line width for cell edges alpha : float, default 1.0 Transparency (0=transparent, 1=opaque) vmin : float or None, optional Minimum value for colormap vmax : float or None, optional Maximum value for colormap title : str or None, optional Plot title figsize : tuple of float, default (10, 10) Figure size in inches (width, height) dpi : int, default 100 Dots per inch for figure colorbar_label : str, default 'Value' Label for colorbar colorbar_shrink : float, default 0.8 Colorbar size relative to axis colorbar_pad : float, default 0.1 Space between axis and colorbar colorbar_fontsize : int, default 11 Font size for colorbar label show_grid : bool, default True Show polar grid lines grid_alpha : float, default 0.3 Grid line transparency grid_linestyle : str, default '--' Grid line style show_degree_labels : bool, default True Show degree labels on radial axis theta_labels : list of int, default [0, 30, 60, 90] Elevation angles for labels (degrees)

Source code in packages/canvod-viz/src/canvod/viz/styles.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@dataclass
class PolarPlotStyle:
    """Configuration for 2D polar plot styling (matplotlib).

    Parameters
    ----------
    cmap : str, default 'viridis'
        Matplotlib colormap name
    edgecolor : str, default 'black'
        Edge color for grid cells
    linewidth : float, default 0.5
        Line width for cell edges
    alpha : float, default 1.0
        Transparency (0=transparent, 1=opaque)
    vmin : float or None, optional
        Minimum value for colormap
    vmax : float or None, optional
        Maximum value for colormap
    title : str or None, optional
        Plot title
    figsize : tuple of float, default (10, 10)
        Figure size in inches (width, height)
    dpi : int, default 100
        Dots per inch for figure
    colorbar_label : str, default 'Value'
        Label for colorbar
    colorbar_shrink : float, default 0.8
        Colorbar size relative to axis
    colorbar_pad : float, default 0.1
        Space between axis and colorbar
    colorbar_fontsize : int, default 11
        Font size for colorbar label
    show_grid : bool, default True
        Show polar grid lines
    grid_alpha : float, default 0.3
        Grid line transparency
    grid_linestyle : str, default '--'
        Grid line style
    show_degree_labels : bool, default True
        Show degree labels on radial axis
    theta_labels : list of int, default [0, 30, 60, 90]
        Elevation angles for labels (degrees)

    """

    cmap: str = "viridis"
    edgecolor: str = "black"
    linewidth: float = 0.5
    alpha: float = 1.0
    vmin: float | None = None
    vmax: float | None = None
    title: str | None = None
    figsize: tuple[float, float] = (10, 10)
    dpi: int = 100
    colorbar_label: str = "Value"
    colorbar_shrink: float = 0.8
    colorbar_pad: float = 0.1
    colorbar_fontsize: int = 11
    show_grid: bool = True
    grid_alpha: float = 0.3
    grid_linestyle: str = "--"
    show_degree_labels: bool = True
    theta_labels: list[int] = field(default_factory=lambda: [0, 30, 60, 90])

PlotStyle dataclass

Unified styling configuration for both 2D and 3D plots.

Parameters

colormap : str, default 'viridis' Colormap name (matplotlib or plotly) colorscale : str, default 'Viridis' Plotly colorscale name background_color : str, default 'white' Background color text_color : str, default 'black' Text color grid_color : str, default 'lightgray' Grid line color font_family : str, default 'sans-serif' Font family font_size : int, default 11 Base font size title_size : int, default 14 Title font size label_size : int, default 12 Axis label font size edge_linewidth : float, default 0.5 Edge line width for cells opacity : float, default 0.8 3D surface opacity marker_size : int, default 8 3D marker size line_width : int, default 1 3D line width wireframe_opacity : float, default 0.2 3D wireframe transparency dark_mode : bool, default False Use dark theme

Source code in packages/canvod-viz/src/canvod/viz/styles.py
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
@dataclass
class PlotStyle:
    """Unified styling configuration for both 2D and 3D plots.

    Parameters
    ----------
    colormap : str, default 'viridis'
        Colormap name (matplotlib or plotly)
    colorscale : str, default 'Viridis'
        Plotly colorscale name
    background_color : str, default 'white'
        Background color
    text_color : str, default 'black'
        Text color
    grid_color : str, default 'lightgray'
        Grid line color
    font_family : str, default 'sans-serif'
        Font family
    font_size : int, default 11
        Base font size
    title_size : int, default 14
        Title font size
    label_size : int, default 12
        Axis label font size
    edge_linewidth : float, default 0.5
        Edge line width for cells
    opacity : float, default 0.8
        3D surface opacity
    marker_size : int, default 8
        3D marker size
    line_width : int, default 1
        3D line width
    wireframe_opacity : float, default 0.2
        3D wireframe transparency
    dark_mode : bool, default False
        Use dark theme

    """

    colormap: str = "viridis"
    colorscale: str = "Viridis"
    background_color: str = "white"
    text_color: str = "black"
    grid_color: str = "lightgray"
    font_family: str = "sans-serif"
    font_size: int = 11
    title_size: int = 14
    label_size: int = 12
    edge_linewidth: float = 0.5
    opacity: float = 0.8
    marker_size: int = 8
    line_width: int = 1
    wireframe_opacity: float = 0.2
    dark_mode: bool = False

    def to_polar_style(self) -> PolarPlotStyle:
        """Convert to PolarPlotStyle for 2D matplotlib plots.

        Returns
        -------
        PolarPlotStyle
            Equivalent 2D styling configuration

        """
        return PolarPlotStyle(
            cmap=self.colormap,
            edgecolor="white" if self.dark_mode else self.text_color,
            linewidth=self.edge_linewidth,
            alpha=1.0,
            colorbar_fontsize=self.font_size,
        )

    def to_plotly_layout(self) -> dict[str, Any]:
        """Convert to plotly layout configuration.

        Returns
        -------
        dict
            Plotly layout settings

        """
        if self.dark_mode:
            return {
                "template": "plotly_dark",
                "paper_bgcolor": "#111111",
                "plot_bgcolor": "#111111",
                "font": {
                    "family": self.font_family,
                    "size": self.font_size,
                    "color": "white",
                },
            }
        return {
            "template": "plotly",
            "paper_bgcolor": self.background_color,
            "plot_bgcolor": self.background_color,
            "font": {
                "family": self.font_family,
                "size": self.font_size,
                "color": self.text_color,
            },
        }

to_polar_style()

Convert to PolarPlotStyle for 2D matplotlib plots.

Returns

PolarPlotStyle Equivalent 2D styling configuration

Source code in packages/canvod-viz/src/canvod/viz/styles.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def to_polar_style(self) -> PolarPlotStyle:
    """Convert to PolarPlotStyle for 2D matplotlib plots.

    Returns
    -------
    PolarPlotStyle
        Equivalent 2D styling configuration

    """
    return PolarPlotStyle(
        cmap=self.colormap,
        edgecolor="white" if self.dark_mode else self.text_color,
        linewidth=self.edge_linewidth,
        alpha=1.0,
        colorbar_fontsize=self.font_size,
    )

to_plotly_layout()

Convert to plotly layout configuration.

Returns

dict Plotly layout settings

Source code in packages/canvod-viz/src/canvod/viz/styles.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def to_plotly_layout(self) -> dict[str, Any]:
    """Convert to plotly layout configuration.

    Returns
    -------
    dict
        Plotly layout settings

    """
    if self.dark_mode:
        return {
            "template": "plotly_dark",
            "paper_bgcolor": "#111111",
            "plot_bgcolor": "#111111",
            "font": {
                "family": self.font_family,
                "size": self.font_size,
                "color": "white",
            },
        }
    return {
        "template": "plotly",
        "paper_bgcolor": self.background_color,
        "plot_bgcolor": self.background_color,
        "font": {
            "family": self.font_family,
            "size": self.font_size,
            "color": self.text_color,
        },
    }

Colorscale dataclass

Unified colorscale that converts between Plotly, matplotlib, and palettable.

Parameters

name : str Colorscale identifier. stops : list of (float, str) Normalized [(position, color), ...] where position is 0–1.

Source code in packages/canvod-viz/src/canvod/viz/styles.py
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
@dataclass
class Colorscale:
    """Unified colorscale that converts between Plotly, matplotlib, and palettable.

    Parameters
    ----------
    name : str
        Colorscale identifier.
    stops : list of (float, str)
        Normalized ``[(position, color), ...]`` where position is 0–1.

    """

    name: str
    stops: list[tuple[float, str]] = field(default_factory=list)

    @classmethod
    def from_matplotlib(cls, cmap_name: str, n_colors: int = 256) -> Colorscale:
        """Create from a matplotlib colormap name."""
        import matplotlib.pyplot as plt

        cmap = plt.get_cmap(cmap_name)
        stops = [
            (
                i / (n_colors - 1),
                f"rgb({int(c[0] * 255)},{int(c[1] * 255)},{int(c[2] * 255)})",
            )
            for i, c in ((j, cmap(j / (n_colors - 1))) for j in range(n_colors))
        ]
        return cls(name=cmap_name, stops=stops)

    @classmethod
    def from_colors(cls, colors: list[str], name: str = "custom") -> Colorscale:
        """Create from a list of color strings (hex, named, or rgb())."""
        n = len(colors)
        stops = [(i / (n - 1), c) for i, c in enumerate(colors)]
        return cls(name=name, stops=stops)

    def to_matplotlib(self, n_colors: int = 256):
        """Convert to a matplotlib ``LinearSegmentedColormap``.

        Returns
        -------
        matplotlib.colors.Colormap

        """
        from matplotlib.colors import LinearSegmentedColormap, to_rgb

        colors = []
        for _pos, color_str in self.stops:
            if color_str.startswith("rgb"):
                match = re.match(r"rgb\((\d+),?\s*(\d+),?\s*(\d+)\)", color_str)
                if match:
                    r, g, b = (int(x) for x in match.groups())
                    colors.append((r / 255, g / 255, b / 255))
                else:
                    raise ValueError(f"Invalid rgb format: {color_str}")
            else:
                colors.append(to_rgb(color_str))
        return LinearSegmentedColormap.from_list(self.name, colors, N=n_colors)

    def to_plotly(self) -> list[tuple[float, str]]:
        """Return Plotly-compatible colorscale list."""
        return list(self.stops)

from_matplotlib(cmap_name, n_colors=256) classmethod

Create from a matplotlib colormap name.

Source code in packages/canvod-viz/src/canvod/viz/styles.py
458
459
460
461
462
463
464
465
466
467
468
469
470
471
@classmethod
def from_matplotlib(cls, cmap_name: str, n_colors: int = 256) -> Colorscale:
    """Create from a matplotlib colormap name."""
    import matplotlib.pyplot as plt

    cmap = plt.get_cmap(cmap_name)
    stops = [
        (
            i / (n_colors - 1),
            f"rgb({int(c[0] * 255)},{int(c[1] * 255)},{int(c[2] * 255)})",
        )
        for i, c in ((j, cmap(j / (n_colors - 1))) for j in range(n_colors))
    ]
    return cls(name=cmap_name, stops=stops)

from_colors(colors, name='custom') classmethod

Create from a list of color strings (hex, named, or rgb()).

Source code in packages/canvod-viz/src/canvod/viz/styles.py
473
474
475
476
477
478
@classmethod
def from_colors(cls, colors: list[str], name: str = "custom") -> Colorscale:
    """Create from a list of color strings (hex, named, or rgb())."""
    n = len(colors)
    stops = [(i / (n - 1), c) for i, c in enumerate(colors)]
    return cls(name=name, stops=stops)

to_matplotlib(n_colors=256)

Convert to a matplotlib LinearSegmentedColormap.

Returns

matplotlib.colors.Colormap

Source code in packages/canvod-viz/src/canvod/viz/styles.py
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
def to_matplotlib(self, n_colors: int = 256):
    """Convert to a matplotlib ``LinearSegmentedColormap``.

    Returns
    -------
    matplotlib.colors.Colormap

    """
    from matplotlib.colors import LinearSegmentedColormap, to_rgb

    colors = []
    for _pos, color_str in self.stops:
        if color_str.startswith("rgb"):
            match = re.match(r"rgb\((\d+),?\s*(\d+),?\s*(\d+)\)", color_str)
            if match:
                r, g, b = (int(x) for x in match.groups())
                colors.append((r / 255, g / 255, b / 255))
            else:
                raise ValueError(f"Invalid rgb format: {color_str}")
        else:
            colors.append(to_rgb(color_str))
    return LinearSegmentedColormap.from_list(self.name, colors, N=n_colors)

to_plotly()

Return Plotly-compatible colorscale list.

Source code in packages/canvod-viz/src/canvod/viz/styles.py
503
504
505
def to_plotly(self) -> list[tuple[float, str]]:
    """Return Plotly-compatible colorscale list."""
    return list(self.stops)

create_publication_style()

Create styling optimized for publication-quality figures.

Returns

PlotStyle Publication-optimized styling configuration

Examples

style = create_publication_style() viz.plot_2d(data=vod_data, style=style)

Source code in packages/canvod-viz/src/canvod/viz/styles.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def create_publication_style() -> PlotStyle:
    """Create styling optimized for publication-quality figures.

    Returns
    -------
    PlotStyle
        Publication-optimized styling configuration

    Examples
    --------
    >>> style = create_publication_style()
    >>> viz.plot_2d(data=vod_data, style=style)

    """
    return PlotStyle(
        colormap="viridis",
        colorscale="Viridis",
        background_color="white",
        text_color="black",
        font_family="sans-serif",
        font_size=12,
        title_size=16,
        label_size=14,
        edge_linewidth=0.3,
        opacity=0.9,
        dark_mode=False,
    )

create_rse_style()

Create styling matching Remote Sensing of Environment journal guidelines.

Returns

PlotStyle RSE-compatible styling with Arial/Helvetica fonts, 300 DPI, inward ticks, and colorblind-friendly color cycle.

Source code in packages/canvod-viz/src/canvod/viz/styles.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def create_rse_style() -> PlotStyle:
    """Create styling matching Remote Sensing of Environment journal guidelines.

    Returns
    -------
    PlotStyle
        RSE-compatible styling with Arial/Helvetica fonts, 300 DPI,
        inward ticks, and colorblind-friendly color cycle.

    """
    return PlotStyle(
        colormap="viridis",
        colorscale="Viridis",
        background_color="white",
        text_color="black",
        font_family="Arial, Helvetica, DejaVu Sans, sans-serif",
        font_size=11,
        title_size=14,
        label_size=12,
        edge_linewidth=1.0,
        opacity=0.9,
        dark_mode=False,
    )

create_interactive_style(dark_mode=True)

Create styling optimized for interactive exploration.

Parameters

dark_mode : bool, default True Use dark theme for better screen viewing

Returns

PlotStyle Interactive-optimized styling configuration

Examples

style = create_interactive_style(dark_mode=True) viz.plot_3d(data=vod_data, style=style)

Source code in packages/canvod-viz/src/canvod/viz/styles.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def create_interactive_style(dark_mode: bool = True) -> PlotStyle:
    """Create styling optimized for interactive exploration.

    Parameters
    ----------
    dark_mode : bool, default True
        Use dark theme for better screen viewing

    Returns
    -------
    PlotStyle
        Interactive-optimized styling configuration

    Examples
    --------
    >>> style = create_interactive_style(dark_mode=True)
    >>> viz.plot_3d(data=vod_data, style=style)

    """
    return PlotStyle(
        colormap="plasma" if dark_mode else "viridis",
        colorscale="Plasma" if dark_mode else "Viridis",
        background_color="#111111" if dark_mode else "white",
        text_color="white" if dark_mode else "black",
        font_family="Open Sans, sans-serif",
        font_size=11,
        title_size=14,
        label_size=12,
        edge_linewidth=0.5,
        opacity=0.85,
        marker_size=6,
        wireframe_opacity=0.15,
        dark_mode=dark_mode,
    )

apply_rse_style()

Apply RSE journal style globally via plt.rcParams.

Returns

dict The applied rcParams dictionary.

Source code in packages/canvod-viz/src/canvod/viz/styles.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def apply_rse_style() -> dict[str, Any]:
    """Apply RSE journal style globally via ``plt.rcParams``.

    Returns
    -------
    dict
        The applied rcParams dictionary.

    """
    import matplotlib.pyplot as plt

    params = _rse_rcparams()
    plt.rcParams.update(params)
    return params

rse_context()

Return a context manager that temporarily applies RSE style.

Usage::

with rse_context():
    fig, ax = plt.subplots()
    ...
Source code in packages/canvod-viz/src/canvod/viz/styles.py
353
354
355
356
357
358
359
360
361
362
363
364
365
def rse_context():
    """Return a context manager that temporarily applies RSE style.

    Usage::

        with rse_context():
            fig, ax = plt.subplots()
            ...

    """
    import matplotlib.pyplot as plt

    return plt.rc_context(_rse_rcparams())

rse_style(func)

Decorator that applies RSE style to a plotting function.

The decorated function is expected to return (fig, axes).

Source code in packages/canvod-viz/src/canvod/viz/styles.py
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def rse_style(func):
    """Decorator that applies RSE style to a plotting function.

    The decorated function is expected to return ``(fig, axes)``.
    """

    @wraps(func)
    def wrapper(*args, **kwargs):
        import matplotlib.pyplot as plt

        with plt.rc_context(_rse_rcparams()):
            fig, axes = func(*args, **kwargs)
            fix_figure_for_dark_mode(fig)
            return fig, axes

    return wrapper

style_colorbar(cbar, label=None)

Apply RSE-compatible styling to a matplotlib colorbar.

Parameters

cbar : matplotlib.colorbar.Colorbar Colorbar instance to style. label : str, optional Label text.

Returns

matplotlib.colorbar.Colorbar The styled colorbar.

Source code in packages/canvod-viz/src/canvod/viz/styles.py
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
def style_colorbar(cbar, label: str | None = None):
    """Apply RSE-compatible styling to a matplotlib colorbar.

    Parameters
    ----------
    cbar : matplotlib.colorbar.Colorbar
        Colorbar instance to style.
    label : str, optional
        Label text.

    Returns
    -------
    matplotlib.colorbar.Colorbar
        The styled colorbar.

    """
    cbar.ax.tick_params(colors="black", labelcolor="black", labelsize=10)
    if label:
        cbar.set_label(label, color="black", size=12)
    for spine in cbar.ax.spines.values():
        spine.set_edgecolor("black")
    return cbar

fix_figure_for_dark_mode(fig, axes=None)

Set explicit white backgrounds so figures render correctly in dark IDEs.

Parameters

fig : matplotlib.figure.Figure Figure to fix. axes : list, optional Specific axes; defaults to all axes in the figure.

Returns

matplotlib.figure.Figure

Source code in packages/canvod-viz/src/canvod/viz/styles.py
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
def fix_figure_for_dark_mode(fig, axes=None):
    """Set explicit white backgrounds so figures render correctly in dark IDEs.

    Parameters
    ----------
    fig : matplotlib.figure.Figure
        Figure to fix.
    axes : list, optional
        Specific axes; defaults to all axes in the figure.

    Returns
    -------
    matplotlib.figure.Figure

    """
    fig.patch.set_facecolor("white")
    fig.patch.set_edgecolor("white")
    if axes is None:
        axes = fig.get_axes()
    for ax in axes:
        ax.set_facecolor("white")
        for spine in ax.spines.values():
            spine.set_color("black")
        ax.tick_params(colors="black", labelcolor="black")
    return fig