From 1dce13586e1a0a0855a3d8a00f76d4c51ea43858 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 16:32:28 -0600 Subject: [PATCH 01/19] fix(analysis): allow data reload --- src/e3sm_siteview/analysis/cell_timechart.py | 3 +++ src/e3sm_siteview/analysis/column_heatmap.py | 3 +++ src/e3sm_siteview/analysis/viz3d.py | 5 ++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/e3sm_siteview/analysis/cell_timechart.py b/src/e3sm_siteview/analysis/cell_timechart.py index 40e8ad1..389422b 100644 --- a/src/e3sm_siteview/analysis/cell_timechart.py +++ b/src/e3sm_siteview/analysis/cell_timechart.py @@ -39,6 +39,9 @@ def __init__(self, server, column_reader): def name(self): return self._id + def refresh_data(self): + self._compute_line_plots() + def _subscribe(self, obj, watch, callback, eager=False, sync=False): self._subscriptions.append(obj.watch(watch, callback, eager=eager, sync=sync)) diff --git a/src/e3sm_siteview/analysis/column_heatmap.py b/src/e3sm_siteview/analysis/column_heatmap.py index b92c179..9b29c56 100644 --- a/src/e3sm_siteview/analysis/column_heatmap.py +++ b/src/e3sm_siteview/analysis/column_heatmap.py @@ -70,6 +70,9 @@ def _time_labels(self): return list(range(tdim.size)) + def refresh_data(self): + self._compute_heatmap() + def _compute_heatmap(self, *_): field = self.ctx.setup.surface_chart.color_by col_id = self.ctx.setup.surface_chart.column diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py index 084221a..f9841c5 100644 --- a/src/e3sm_siteview/analysis/viz3d.py +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -99,6 +99,9 @@ def _proj(self): self._projections.append(proj) return proj + def refresh_data(self): + self._need_render() + @controller.add("reset_camera") def _reset_camera(self): x_rad = math.radians(self.ctx.setup.center[0]) @@ -300,7 +303,7 @@ def unbind_reactivity(self): while self._subscriptions: self._subscriptions.pop()() - def _need_render(self, _): + def _need_render(self, *_): self.view_handler.update() def _on_z_scale_change(self, zscale): From f29dd34a3f1c50e3f394c00be66cc2936a8eb713 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 16:33:07 -0600 Subject: [PATCH 02/19] fix(pages): remove pages --- src/e3sm_siteview/pages/__init__.py | 9 - src/e3sm_siteview/pages/field_selection.py | 29 - src/e3sm_siteview/pages/site_selection.html | 849 -------------------- src/e3sm_siteview/pages/site_selection.py | 29 - src/e3sm_siteview/pages/visualization.py | 50 -- 5 files changed, 966 deletions(-) delete mode 100644 src/e3sm_siteview/pages/__init__.py delete mode 100644 src/e3sm_siteview/pages/field_selection.py delete mode 100644 src/e3sm_siteview/pages/site_selection.html delete mode 100644 src/e3sm_siteview/pages/site_selection.py delete mode 100644 src/e3sm_siteview/pages/visualization.py diff --git a/src/e3sm_siteview/pages/__init__.py b/src/e3sm_siteview/pages/__init__.py deleted file mode 100644 index 09114bd..0000000 --- a/src/e3sm_siteview/pages/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from .field_selection import FieldSelectionPage -from .site_selection import SiteSelectionPage -from .visualization import VisualizationPage - -__all__ = [ - "FieldSelectionPage", - "SiteSelectionPage", - "VisualizationPage", -] diff --git a/src/e3sm_siteview/pages/field_selection.py b/src/e3sm_siteview/pages/field_selection.py deleted file mode 100644 index 89021af..0000000 --- a/src/e3sm_siteview/pages/field_selection.py +++ /dev/null @@ -1,29 +0,0 @@ -from trame.app import TrameApp -from trame.ui.vuetify3 import VAppLayout - -from e3sm_siteview.components.field_selection import FieldSelection - - -class FieldSelectionPage(TrameApp): - def __init__(self, server=None): - super().__init__(server) - self._build_ui() - - def _build_ui(self): - with VAppLayout(self.server) as self.ui: - FieldSelection(self.next) - - def next(self): - self.ctx.pages.viz.activate() - - def activate(self): - self._build_ui() - - -def main(): - app = FieldSelectionPage() - app.server.start() - - -if __name__ == "__main__": - main() diff --git a/src/e3sm_siteview/pages/site_selection.html b/src/e3sm_siteview/pages/site_selection.html deleted file mode 100644 index 11d27b1..0000000 --- a/src/e3sm_siteview/pages/site_selection.html +++ /dev/null @@ -1,849 +0,0 @@ - - - - - - E3SM SiteView — Site Selection - - - - - - - - - -
- - - - - - - - - - - - - - - -
- - - - - - -
- Coordinates -
- - - - - - - - - -
- Region of Interest -
- - - - - - - km - deg - - - - - -
- - -
-
-
-
- Schematic preview (equirectangular, not to scale) -
-
-
-
-
- - - -
- - - - - - - {{ group.label }} - {{ group.key === 'surface' ? '2D' : '3D' }} - - - {{ countSelected(group) }} / {{ group.fields.length - }} - - - - -
- Select all - Clear -
- - - - - - - -
-
-
-
-
-
- - - - -
- - - Back - - Theme - - - -
- - {{ formatCoords(latitude, longitude) }} · {{ radiusValue }} - {{ radiusUnit === 'km' ? 'km' : 'deg' }} radius -
-
- - {{ selectedFieldCount }} field{{ selectedFieldCount === 1 ? - '' : 's' }} selected -
- - - -
-
-
-
- - - - - - Selection payload - - - - -
-{{ requestJson }}
-
-
-
- - - Configuration ready — {{ selectedFieldCount }} field(s) over a {{ - radiusValue }}{{ radiusUnit === 'km' ? 'km' : '°' }} region. - -
-
- - - - - - - diff --git a/src/e3sm_siteview/pages/site_selection.py b/src/e3sm_siteview/pages/site_selection.py deleted file mode 100644 index a37c239..0000000 --- a/src/e3sm_siteview/pages/site_selection.py +++ /dev/null @@ -1,29 +0,0 @@ -from trame.app import TrameApp -from trame.ui.vuetify3 import VAppLayout - -from e3sm_siteview.components.site_selection import SiteSelection - - -class SiteSelectionPage(TrameApp): - def __init__(self, server=None): - super().__init__(server) - self._build_ui() - - def _build_ui(self): - with VAppLayout(self.server) as self.ui: - SiteSelection(self.next) - - def next(self): - self.ctx.pages.fields.activate() - - def activate(self): - self._build_ui() - - -def main(): - app = SiteSelectionPage() - app.server.start() - - -if __name__ == "__main__": - main() diff --git a/src/e3sm_siteview/pages/visualization.py b/src/e3sm_siteview/pages/visualization.py deleted file mode 100644 index 88aa4aa..0000000 --- a/src/e3sm_siteview/pages/visualization.py +++ /dev/null @@ -1,50 +0,0 @@ -from trame.app import TrameApp -from trame.ui.vuetify3 import VAppLayout -from trame.widgets import client, colormaps, dockview, plotly, rca -from trame.widgets import vuetify3 as v3 - -from e3sm_siteview.components import footer - - -class VisualizationPage(TrameApp): - def __init__(self, server=None): - super().__init__(server) - - # Deferred UI initialization - rca.initialize(self.server) - colormaps.initialize(self.server) - plotly.initialize(self.server) - - self._build_ui() - - def _build_ui(self): - with VAppLayout(self.server, fill_height=True) as self.ui: - client.ClientTriggers(mounted=self._load_layout) - with v3.VLayout(): - with v3.VMain(): - dockview.DockView(ctx_name="views_container", theme="Light") - footer.GeneralControls() - - def activate(self): - self._build_ui() - - def _load_layout(self): - list_of_analysis = [a[0] for a in self.ctx.setup.available_analysis] - for viewer in self.ctx.viewers.values(): - viewer.add_analysis(*list_of_analysis) - - -def main(): - from trame.app import get_server # noqa: PLC0415 - - from e3sm_siteview.viewer import create_viewers # noqa: PLC0415 - - server = get_server() - create_viewers(server) - - app = VisualizationPage(server) - app.server.start() - - -if __name__ == "__main__": - main() From 9c85d75ec14ab5ef35edcd007a6159fbd19bd5df Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 16:33:50 -0600 Subject: [PATCH 03/19] fix(site): put map on the same line as form --- src/e3sm_siteview/components/site_selection.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/e3sm_siteview/components/site_selection.py b/src/e3sm_siteview/components/site_selection.py index 636cc28..6f9a815 100644 --- a/src/e3sm_siteview/components/site_selection.py +++ b/src/e3sm_siteview/components/site_selection.py @@ -59,9 +59,9 @@ def _update_radius(self, site_radius, site_radius_unit, site_map_size, **_): self.state.site_marker_diameter = int(0.5 + max(2 * base, 8)) -class SiteSelection(html.Div): +class SiteSelection(v3.VRow): def __init__(self, next_fn, **_): - super().__init__(classes="step-pane pa-6") + super().__init__(classes="pa-6 align-center") self.server.enable_module(module) with self: @@ -163,6 +163,7 @@ def __init__(self, next_fn, **_): click=next_fn, ) + with v3.VCol(): CoordinatePreview() @change("site_selected") From 7f7a20f5a1dffc5983bb5bc9a6b782c45b3efa85 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 16:34:34 -0600 Subject: [PATCH 04/19] fix(data_model): add page/dialog tracking --- src/e3sm_siteview/data_models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py index 9fbe063..549fb4e 100644 --- a/src/e3sm_siteview/data_models.py +++ b/src/e3sm_siteview/data_models.py @@ -96,6 +96,9 @@ class GlobalParameters(dataclass.StateDataModel): readers = dataclass.ServerOnly(set, set) variables_2d = dataclass.Sync(list[Variable], list, has_dataclass=True) variables_3d = dataclass.Sync(list[Variable], list, has_dataclass=True) + # General workflow + active_page = dataclass.Sync(str, "welcome") # welcome, site, fields, viz + data_loaded = dataclass.Sync(bool, False) # Time time_values = dataclass.Sync(list[int], list) time_index = dataclass.Sync(int, 0) @@ -108,6 +111,7 @@ class GlobalParameters(dataclass.StateDataModel): col_ids_str = dataclass.Sync(str, "[]") n_cols = dataclass.Sync(int, 0) # 3D Viz controls + active_viz = dataclass.Sync(list[str], ["volume"]) cloud = dataclass.Sync(CloudControls, has_dataclass=True) surface = dataclass.Sync(ColorByControls, has_dataclass=True) From 4de62ee81190c355502934bed681e93398767d27 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 16:35:40 -0600 Subject: [PATCH 05/19] fix(pages): use dialog and allow back and forth --- src/e3sm_siteview/app.py | 78 +++++++++++-- src/e3sm_siteview/components/footer.py | 154 +++++++++++++++---------- src/e3sm_siteview/viewer.py | 4 + 3 files changed, 163 insertions(+), 73 deletions(-) diff --git a/src/e3sm_siteview/app.py b/src/e3sm_siteview/app.py index 95ac6a0..4cc659f 100644 --- a/src/e3sm_siteview/app.py +++ b/src/e3sm_siteview/app.py @@ -1,16 +1,16 @@ -from types import SimpleNamespace - from trame.app import TrameApp +from trame.ui.vuetify3 import VAppLayout +from trame.widgets import colormaps, dockview, plotly, rca +from trame.widgets import vuetify3 as v3 -from e3sm_siteview import pages from e3sm_siteview.assets import ASSETS +from e3sm_siteview.components import field_selection, footer, site_selection from e3sm_siteview.viewer import create_viewers class E3smSiteView(TrameApp): def __init__(self, server=None): super().__init__(server, client_type="vue3") - # Tab title self.state.trame__title = "E3SM Site View" self.state.trame__favicon = ASSETS.icon @@ -21,11 +21,71 @@ def __init__(self, server=None): create_viewers(self.server) - self.ctx.pages = SimpleNamespace( - fields=pages.FieldSelectionPage(self.server), - viz=pages.VisualizationPage(self.server), - site=pages.SiteSelectionPage(self.server), # Must be last - ) + # Deferred UI initialization + rca.initialize(self.server) + colormaps.initialize(self.server) + plotly.initialize(self.server) + + with VAppLayout(self.server, fill_height=True) as self.ui: + with v3.VLayout(): + with self.ctx.setup.provide_as("controls"): + with v3.VDialog( + absolute=True, + model_value=("controls.active_page === 'welcome'",), + persistent=True, + ): + with v3.VCard(): + v3.VCardTitle("Welcome to SiteView") + v3.VDivider() + + with v3.VDialog( + absolute=True, + model_value=("controls.active_page === 'site'",), + persistent=True, + ): + with v3.VCard(): + v3.VCardTitle("Select your site location and region size") + v3.VDivider() + site_selection.SiteSelection(self._select_region) + + with v3.VDialog( + absolute=True, + persistent=True, + model_value=("controls.active_page === 'fields'",), + ): + with v3.VCard(): + v3.VCardTitle("Select fields to load") + v3.VDivider() + field_selection.FieldSelection(self._load_fields) + + with v3.VMain(): + dockview.DockView(ctx_name="views_container", theme="Light") + footer.GeneralControls() + + def _select_region(self): + if not self.ctx.setup.data_loaded: + self.ctx.setup.active_page = "fields" + else: + self.refresh_analyses_data() + self.ctx.setup.active_page = "viz" + + def _load_fields(self): + if not self.ctx.setup.data_loaded: + self._first_load = False + list_of_analysis = [a[0] for a in self.ctx.setup.available_analysis] + for viewer in self.ctx.viewers.values(): + viewer.add_analysis(*list_of_analysis) + + self.ctx.setup.data_loaded = True + else: + self.refresh_analyses_data() + + self.ctx.setup.active_page = "viz" + + def refresh_analyses_data(self): + for viewer in self.ctx.viewers.values(): + for analysis in viewer.analyses: + analysis.refresh_data() def main(server=None, **kwargs): diff --git a/src/e3sm_siteview/components/footer.py b/src/e3sm_siteview/components/footer.py index 317d9fe..6cbcb03 100644 --- a/src/e3sm_siteview/components/footer.py +++ b/src/e3sm_siteview/components/footer.py @@ -1,3 +1,4 @@ +from trame.widgets import html from trame.widgets import vuetify3 as v3 @@ -7,9 +8,8 @@ def __init__(self): with self, self.ctx.setup.provide_as("controls"): with v3.VBtnToggle( - v_model="controls.active_analysis", + v_model="controls.active_page", color="primary", - multiple=True, density="comfortable", border=True, divided=True, @@ -17,72 +17,98 @@ def __init__(self): mandatory=True, ): v3.VBtn( - v_for="v, i in controls.available_analysis", - key="i", - icon=("v[1]",), - value=("v[0]",), - ) - - with v3.VBtnToggle( - v_model="controls.active_viz", - color="primary", - multiple=True, - density="comfortable", - border=True, - divided=True, - classes="mr-4", - ): - v3.VBtn(icon="mdi-weather-cloudy", value="cloud") - v3.VBtn(icon="mdi-arrow-expand-vertical", value="zscale") - # v3.VBtn(icon="mdi-layers-outline", value="surface") - v3.VBtn(icon="mdi-cube-outline", value="volume") - v3.VBtn(icon="mdi-altimeter", value="hslice") - v3.VBtn(icon="mdi-flip-horizontal", value="vslice") - v3.VBtn(icon="mdi-magnify-scan", value="find_data") - v3.VBtn(icon="mdi-sort", value="column") - v3.VBtn(icon="mdi-map-marker-plus", value="probes") - - with v3.VBtnToggle( - density="comfortable", - border=True, - divided=True, - ): - v3.VBtn( - icon="mdi-step-backward-2", - click="controls.time_index = 0", - ) - v3.VBtn( - icon="mdi-step-backward", - click="controls.time_index > 0 && controls.time_index--", + icon="mdi-map-marker-radius", + value="site", ) v3.VBtn( - icon="mdi-stop", - v_if="controls.time_animating", - click="controls.time_animating = false", + icon="mdi-format-list-checks", + value="fields", ) v3.VBtn( - icon="mdi-play", - v_else=True, - click="controls.time_animating = true", + icon="mdi-lightbulb-on-outline", + value="viz", ) - v3.VBtn( - icon="mdi-step-forward", - click="controls.time_index < controls.time_index_max && controls.time_index++", + + with html.Div( + v_if="controls.data_loaded", classes="d-flex flex-fill align-center" + ): + with v3.VBtnToggle( + v_model="controls.active_analysis", + color="primary", + multiple=True, + density="comfortable", + border=True, + divided=True, + classes="mr-4", + mandatory=True, + ): + v3.VBtn( + v_for="v, i in controls.available_analysis", + key="i", + icon=("v[1]",), + value=("v[0]",), + ) + + with v3.VBtnToggle( + v_model="controls.active_viz", + color="primary", + multiple=True, + density="comfortable", + border=True, + divided=True, + classes="mr-4", + ): + v3.VBtn(icon="mdi-weather-cloudy", value="cloud") + v3.VBtn(icon="mdi-arrow-expand-vertical", value="zscale") + # v3.VBtn(icon="mdi-layers-outline", value="surface") + v3.VBtn(icon="mdi-cube-outline", value="volume") + v3.VBtn(icon="mdi-altimeter", value="hslice") + v3.VBtn(icon="mdi-flip-horizontal", value="vslice") + v3.VBtn(icon="mdi-magnify-scan", value="find_data") + v3.VBtn(icon="mdi-sort", value="column") + v3.VBtn(icon="mdi-map-marker-plus", value="probes") + + with v3.VBtnToggle( + density="comfortable", + border=True, + divided=True, + ): + v3.VBtn( + icon="mdi-step-backward-2", + click="controls.time_index = 0", + ) + v3.VBtn( + icon="mdi-step-backward", + click="controls.time_index > 0 && controls.time_index--", + ) + v3.VBtn( + icon="mdi-stop", + v_if="controls.time_animating", + click="controls.time_animating = false", + ) + v3.VBtn( + icon="mdi-play", + v_else=True, + click="controls.time_animating = true", + ) + v3.VBtn( + icon="mdi-step-forward", + click="controls.time_index < controls.time_index_max && controls.time_index++", + ) + v3.VBtn( + icon="mdi-step-forward-2", + click="controls.time_index = controls.time_index_max", + ) + v3.VLabel( + "{{controls.time_value}}", + style="width: 50px;", + classes="mx-2 d-block text-center", ) - v3.VBtn( - icon="mdi-step-forward-2", - click="controls.time_index = controls.time_index_max", + v3.VSlider( + v_model="controls.time_index", + min=0, + max=("controls.time_index_max",), + step=1, + density="compact", + hide_details=True, ) - v3.VLabel( - "{{controls.time_value}}", - style="width: 50px;", - classes="mx-2 d-block text-center", - ) - v3.VSlider( - v_model="controls.time_index", - min=0, - max=("controls.time_index_max",), - step=1, - density="compact", - hide_details=True, - ) diff --git a/src/e3sm_siteview/viewer.py b/src/e3sm_siteview/viewer.py index 54029fb..63a4e74 100644 --- a/src/e3sm_siteview/viewer.py +++ b/src/e3sm_siteview/viewer.py @@ -143,3 +143,7 @@ def add_analysis(self, *analysis_types): async def _add_panel(self, panel_id, label, template_name): # print("_add_panel", panel_id, label, template_name) self.ctx.views_container.add_panel(panel_id, label, template_name) + + @property + def analyses(self): + return self._analysis.values() From 2e85a2b4ae00f1f00b05f4f45d65dd1de02c0d92 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 16:55:40 -0600 Subject: [PATCH 06/19] fix(welcome): Add welcome page --- src/e3sm_siteview/app.py | 13 ++-- src/e3sm_siteview/components/welcome.py | 81 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 src/e3sm_siteview/components/welcome.py diff --git a/src/e3sm_siteview/app.py b/src/e3sm_siteview/app.py index 4cc659f..f2b7f28 100644 --- a/src/e3sm_siteview/app.py +++ b/src/e3sm_siteview/app.py @@ -4,7 +4,7 @@ from trame.widgets import vuetify3 as v3 from e3sm_siteview.assets import ASSETS -from e3sm_siteview.components import field_selection, footer, site_selection +from e3sm_siteview.components import field_selection, footer, site_selection, welcome from e3sm_siteview.viewer import create_viewers @@ -33,17 +33,17 @@ def __init__(self, server=None): absolute=True, model_value=("controls.active_page === 'welcome'",), persistent=True, + scrollable=True, ): - with v3.VCard(): - v3.VCardTitle("Welcome to SiteView") - v3.VDivider() + welcome.Welcome("controls.active_page = 'site'") with v3.VDialog( absolute=True, model_value=("controls.active_page === 'site'",), persistent=True, + scrollable=True, ): - with v3.VCard(): + with v3.VCard(rounded="lg"): v3.VCardTitle("Select your site location and region size") v3.VDivider() site_selection.SiteSelection(self._select_region) @@ -52,8 +52,9 @@ def __init__(self, server=None): absolute=True, persistent=True, model_value=("controls.active_page === 'fields'",), + scrollable=True, ): - with v3.VCard(): + with v3.VCard(rounded="lg"): v3.VCardTitle("Select fields to load") v3.VDivider() field_selection.FieldSelection(self._load_fields) diff --git a/src/e3sm_siteview/components/welcome.py b/src/e3sm_siteview/components/welcome.py new file mode 100644 index 0000000..3b83455 --- /dev/null +++ b/src/e3sm_siteview/components/welcome.py @@ -0,0 +1,81 @@ +from trame.widgets import html +from trame.widgets import vuetify3 as v3 + +STEPS = [ + { + "title": "Select your site location and region size", + "text": "Pick the site of interest on the map and adjust the radius to define the region of data to extract around it.", + "icon": "mdi-map-marker-radius", + "color": "primary", + }, + { + "title": "Select the set of fields to load", + "text": "Browse or search the available variables and choose the ones relevant to your analysis.", + "icon": "mdi-format-list-checks", + "color": "teal", + }, + { + "title": "Explore the data and gain insight", + "text": "Use the 3D view, column heatmaps and time charts to investigate how fields evolve in space and time.", + "icon": "mdi-chart-box-outline", + "color": "deep-orange", + }, +] + + +class Welcome(v3.VCard): + def __init__(self, next_fn, **_): + super().__init__(rounded="lg") + + with self: + with v3.VCardItem(classes="pa-6 pb-2"): + with v3.Template(v_slot_prepend=True): + with v3.VAvatar(color="primary", size="48"): + v3.VIcon(icon="mdi-map-marker-radius", size="28") + v3.VCardTitle("Welcome to SiteView", classes="text-h5") + v3.VCardSubtitle( + "Explore E3SM simulation data around a site of interest" + ) + with v3.Template(v_slot_append=True): + v3.VBtn( + icon="mdi-close", + variant="text", + density="comfortable", + click=next_fn, + ) + with v3.VCardText(classes="px-6"): + html.P( + "Getting started only takes three steps:", + classes="text-body-1 mb-4", + ) + with v3.VList(lines="three", classes="py-0"): + for idx, step in enumerate(STEPS): + with v3.VListItem(classes="px-0"): + with v3.Template(v_slot_prepend=True): + with v3.VAvatar( + color=step["color"], variant="tonal", classes="mr-2" + ): + html.Span( + f"{idx + 1}", + classes="text-subtitle-1 font-weight-bold", + ) + with v3.VListItemTitle(classes="font-weight-medium"): + v3.VIcon( + icon=step["icon"], + color=step["color"], + size="small", + classes="mr-1", + ) + html.Span(step["title"]) + v3.VListItemSubtitle(step["text"], classes="text-wrap") + + v3.VDivider() + with v3.VCardActions(classes="px-6 py-3"): + v3.VSpacer() + v3.VBtn( + "Getting started", + color="primary", + variant="flat", + append_icon="mdi-chevron-right", + click=next_fn, + ) From 5081069063f9acefd755d530cce2d1c3f85efa86 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Mon, 28 Sep 2026 17:14:28 -0600 Subject: [PATCH 07/19] fix(footer): add tooltip --- src/e3sm_siteview/app.py | 1 + src/e3sm_siteview/components/footer.py | 66 ++++++++++++++++++-------- src/e3sm_siteview/data_models.py | 6 +-- 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/src/e3sm_siteview/app.py b/src/e3sm_siteview/app.py index f2b7f28..be54a58 100644 --- a/src/e3sm_siteview/app.py +++ b/src/e3sm_siteview/app.py @@ -34,6 +34,7 @@ def __init__(self, server=None): model_value=("controls.active_page === 'welcome'",), persistent=True, scrollable=True, + max_width="640", ): welcome.Welcome("controls.active_page = 'site'") diff --git a/src/e3sm_siteview/components/footer.py b/src/e3sm_siteview/components/footer.py index 6cbcb03..83aaee1 100644 --- a/src/e3sm_siteview/components/footer.py +++ b/src/e3sm_siteview/components/footer.py @@ -2,6 +2,10 @@ from trame.widgets import vuetify3 as v3 +def TooltipButton(tooltip, **kwargs): + return v3.VBtn(v_tooltip_top=f"{tooltip}", **kwargs) + + class GeneralControls(v3.VFooter): def __init__(self): super().__init__(app=True) @@ -16,15 +20,18 @@ def __init__(self): classes="mr-4", mandatory=True, ): - v3.VBtn( + TooltipButton( + "'Site selection'", icon="mdi-map-marker-radius", value="site", ) - v3.VBtn( + TooltipButton( + "'Fields selection'", icon="mdi-format-list-checks", value="fields", ) - v3.VBtn( + TooltipButton( + "'Visualization'", icon="mdi-lightbulb-on-outline", value="viz", ) @@ -42,7 +49,8 @@ def __init__(self): classes="mr-4", mandatory=True, ): - v3.VBtn( + TooltipButton( + "v[2]", v_for="v, i in controls.available_analysis", key="i", icon=("v[1]",), @@ -58,44 +66,64 @@ def __init__(self): divided=True, classes="mr-4", ): - v3.VBtn(icon="mdi-weather-cloudy", value="cloud") - v3.VBtn(icon="mdi-arrow-expand-vertical", value="zscale") - # v3.VBtn(icon="mdi-layers-outline", value="surface") - v3.VBtn(icon="mdi-cube-outline", value="volume") - v3.VBtn(icon="mdi-altimeter", value="hslice") - v3.VBtn(icon="mdi-flip-horizontal", value="vslice") - v3.VBtn(icon="mdi-magnify-scan", value="find_data") - v3.VBtn(icon="mdi-sort", value="column") - v3.VBtn(icon="mdi-map-marker-plus", value="probes") + TooltipButton("'Clouds'", icon="mdi-weather-cloudy", value="cloud") + TooltipButton( + "'Vertical scaling'", + icon="mdi-arrow-expand-vertical", + value="zscale", + ) + # TooltipButton("Surface", icon="mdi-layers-outline", value="surface") + TooltipButton( + "'Volume rendering'", icon="mdi-cube-outline", value="volume" + ) + TooltipButton( + "'Horizontal slice'", icon="mdi-altimeter", value="hslice" + ) + TooltipButton( + "'Vertical slice'", icon="mdi-flip-horizontal", value="vslice" + ) + TooltipButton( + "'Find data'", icon="mdi-magnify-scan", value="find_data" + ) + TooltipButton("'Column Cropping'", icon="mdi-sort", value="column") + TooltipButton( + "'Probes'", icon="mdi-map-marker-plus", value="probes" + ) with v3.VBtnToggle( density="comfortable", border=True, divided=True, ): - v3.VBtn( + TooltipButton( + "'First time step'", icon="mdi-step-backward-2", click="controls.time_index = 0", ) - v3.VBtn( + TooltipButton( + "'Previous time step'", icon="mdi-step-backward", click="controls.time_index > 0 && controls.time_index--", ) - v3.VBtn( + TooltipButton( + "'Stop animation'", icon="mdi-stop", v_if="controls.time_animating", click="controls.time_animating = false", ) - v3.VBtn( + TooltipButton( + "'Play animation'", icon="mdi-play", v_else=True, click="controls.time_animating = true", ) - v3.VBtn( + TooltipButton( + "'Next time step'", icon="mdi-step-forward", click="controls.time_index < controls.time_index_max && controls.time_index++", ) - v3.VBtn( + TooltipButton( + "'Last time step'", icon="mdi-step-forward-2", click="controls.time_index = controls.time_index_max", ) diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py index 549fb4e..25364b8 100644 --- a/src/e3sm_siteview/data_models.py +++ b/src/e3sm_siteview/data_models.py @@ -130,9 +130,9 @@ class GlobalParameters(dataclass.StateDataModel): available_analysis = dataclass.Sync( list, [ - ("viz", "mdi-earth"), - ("columnHeatMap", "mdi-view-grid-compact"), - ("cellTimeChart", "mdi-chart-line"), + ("viz", "mdi-earth", "3D view"), + ("columnHeatMap", "mdi-view-grid-compact", "Column heat map"), + ("cellTimeChart", "mdi-chart-line", "Cell time chart"), ], ) From b9ce04f4113a7849850bccfda81a4450561d8923 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 14:52:55 -0600 Subject: [PATCH 08/19] feat(grid): add grid level --- src/e3sm_siteview/analysis/viz3d.py | 67 ++++++++++++++++++ src/e3sm_siteview/io/__init__.py | 4 +- src/e3sm_siteview/io/eam_filters.py | 103 +++++++++++++++++++++++++++- 3 files changed, 171 insertions(+), 3 deletions(-) diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py index f9841c5..59898c1 100644 --- a/src/e3sm_siteview/analysis/viz3d.py +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -21,6 +21,7 @@ from vtkmodules.vtkIOXML import vtkXMLPolyDataReader from vtkmodules.vtkRenderingCore import ( vtkActor, + vtkBillboardTextActor3D, vtkCamera, vtkDataSetMapper, vtkRenderer, @@ -35,6 +36,7 @@ EARTH_RADIUS, EAMColumnVolume, EAMGridLines, + EAMLevelCylinder, EAMProject, ) @@ -205,6 +207,30 @@ def _setup_vtk(self): ) self.colormap_config.register_mapper(self.slice_v_mapper) + # Level cylinder + self.level_cylinder = EAMLevelCylinder() + self.level_cylinder.SetCenter(*self.ctx.setup.center[:2]) + self.level_cylinder.SetRadius(self.ctx.setup.radius_deg) + level_cylinder_mapper = vtkDataSetMapper() + level_cylinder_mapper.ScalarVisibilityOff() + self.level_cylinder_actor = vtkActor(mapper=level_cylinder_mapper) + self.level_cylinder_actor.property.color = (1, 1, 1) + self.level_cylinder_actor.property.line_width = 2 + self.renderer.AddActor(self.level_cylinder_actor) + self.level_cylinder_proj = self._proj() + ( + self.clean_volume + >> self.level_cylinder + >> self.level_cylinder_proj + >> level_cylinder_mapper + ) + + # Level cylinder labels: depth tested billboards (occluded by the + # scene), synced with the projected cylinder right before each render. + self.level_label_actors = [] + self._level_labels_mtime = 0 + renderWindow.AddObserver("StartEvent", self._sync_level_labels) + # Volume self.threshold = vtkThreshold() self.threshold_mapper = vtkDataSetMapper() @@ -257,6 +283,36 @@ def _setup_vtk(self): self._reset_camera() + def _sync_level_labels(self, *_): + self.level_cylinder_proj.Update() + cylinder = self.level_cylinder_proj.GetOutputDataObject(0) + if cylinder.GetMTime() == self._level_labels_mtime: + return + self._level_labels_mtime = cylinder.GetMTime() + + labels = cylinder.GetPointData().GetAbstractArray("labels") + entries = [] + if labels is not None: + for i in range(labels.GetNumberOfValues()): + if text := labels.GetValue(i): + entries.append((text, cylinder.GetPoint(i))) + + while len(self.level_label_actors) < len(entries): + actor = vtkBillboardTextActor3D() + actor.text_property.color = (1, 1, 1) + actor.text_property.font_size = 28 + actor.text_property.justification = 1 # center + actor.text_property.vertical_justification = 1 # center + self.renderer.AddActor(actor) + self.level_label_actors.append(actor) + + for i, actor in enumerate(self.level_label_actors): + if i < len(entries): + actor.input, actor.position = entries[i] + actor.visibility = 1 + else: + actor.visibility = 0 + def _subscribe(self, obj, watch, callback, eager=False, sync=False): self._subscriptions.append(obj.watch(watch, callback, eager=eager, sync=sync)) @@ -293,6 +349,12 @@ def bind_reactivity(self): self._subscribe( self.ctx.setup, ["active_viz"], self._on_visibility_change, eager=True ) + self._subscribe( + self.ctx.setup, + ["center", "radius_deg"], + self._on_region_change, + eager=True, + ) self._subscribe( self.ctx.setup.zscale, ["scale"], self._on_z_scale_change, eager=True ) @@ -312,6 +374,11 @@ def _on_z_scale_change(self, zscale): self.view_handler.update() + def _on_region_change(self, center, radius_deg): + self.level_cylinder.SetCenter(center[0], center[1]) + self.level_cylinder.SetRadius(radius_deg) + self.view_handler.update() + def _on_visibility_change(self, active_viz): has_volume = "volume" in active_viz has_slice = "hslice" in active_viz or "vslice" in active_viz diff --git a/src/e3sm_siteview/io/__init__.py b/src/e3sm_siteview/io/__init__.py index 2892415..a9d7e56 100644 --- a/src/e3sm_siteview/io/__init__.py +++ b/src/e3sm_siteview/io/__init__.py @@ -8,9 +8,9 @@ classes run under pure VTK. Configure via ``Set*`` methods and wire with ``SetInputConnection`` / ``Update``. """ -from .eam_filters import EAMColumnVolume +from .eam_filters import EAMColumnVolume, EAMLevelCylinder from .eam_reader import EAMColumnSource, EAMMeshSource from .projection import EAMProject, EARTH_RADIUS from .earth import CONTINENT_PATH, EAMGridLines -__all__ = ["EAMColumnSource", "EAMColumnVolume", "EAMMeshSource", "EAMProject", "CONTINENT_PATH", "EAMGridLines", "EARTH_RADIUS"] +__all__ = ["EAMColumnSource", "EAMColumnVolume", "EAMLevelCylinder", "EAMMeshSource", "EAMProject", "CONTINENT_PATH", "EAMGridLines", "EARTH_RADIUS"] diff --git a/src/e3sm_siteview/io/eam_filters.py b/src/e3sm_siteview/io/eam_filters.py index 3130766..c0299f5 100644 --- a/src/e3sm_siteview/io/eam_filters.py +++ b/src/e3sm_siteview/io/eam_filters.py @@ -6,7 +6,7 @@ import numpy as np from vtkmodules.util import numpy_support, vtkConstants from vtkmodules.util.vtkAlgorithm import VTKPythonAlgorithmBase -from vtkmodules.vtkCommonCore import vtkPoints +from vtkmodules.vtkCommonCore import vtkPoints, vtkStringArray from vtkmodules.vtkCommonDataModel import ( vtkCellArray, vtkPolyData, @@ -268,3 +268,104 @@ def _RequestDataImpl(self, inInfo, outInfo): ) return 1 + + +class EAMLevelCylinder(VTKPythonAlgorithmBase): + """Wireframe cylinder wrapping the vertical extent of an EAMColumnVolume. + + Input is the (unprojected) column volume: points are (lon, lat, pressure). + Output is an unstructured grid in the same space, so it can go through + EAMProject like the volume: + * a circle of Radius degrees around Center (same lon/lat metric as the + column selection) at the bottom and top of the volume, plus one + circle per vertical split (NumberOfSplits even sections in pressure) + * one extra (cell-less) label point per circle, on the side facing the + equator (south for a northern Center, north for a southern one) at + Radius * (1 + LabelOffset) from the Center + * point data ``labels`` (string) holding the pressure of each circle, + set on its label point + """ + + def __init__(self): + super().__init__( + nInputPorts=1, nOutputPorts=1, outputType="vtkUnstructuredGrid" + ) + self._center = (0.0, 0.0) + self._radius = 1.0 + self._n_splits = 5 + self._label_offset = 0.5 + self._resolution = 64 + + def SetCenter(self, lon, lat): + if (lon, lat) != self._center: + self._center = (lon, lat) + self.Modified() + + def SetRadius(self, value): + if value != self._radius: + self._radius = float(value) + self.Modified() + + def SetNumberOfSplits(self, value): + if value != self._n_splits: + self._n_splits = max(1, int(value)) + self.Modified() + + def SetLabelOffset(self, value): + if value != self._label_offset: + self._label_offset = float(value) + self.Modified() + + def SetResolution(self, value): + if value != self._resolution: + self._resolution = max(3, int(value)) + self.Modified() + + def RequestData(self, request, inInfo, outInfo): + volume = vtkUnstructuredGrid.GetData(inInfo[0], 0) + output = vtkUnstructuredGrid.GetData(outInfo, 0) + output.Initialize() + + if volume is None or volume.GetNumberOfPoints() == 0: + return 1 + + z_min, z_max = volume.GetBounds()[4:6] + z_splits = np.linspace(z_min, z_max, self._n_splits + 1) + + lon, lat = self._center + n_res = self._resolution + angles = np.linspace(0, 2 * np.pi, n_res, endpoint=False) + ring_lon = lon + self._radius * np.cos(angles) + ring_lat = lat + self._radius * np.sin(angles) + + # One ring of n_res points per split, bottom first + n_rings = len(z_splits) + pts = np.empty((n_rings, n_res, 3), dtype=np.float64) + pts[:, :, 0] = ring_lon[None, :] + pts[:, :, 1] = ring_lat[None, :] + pts[:, :, 2] = z_splits[:, None] + + # Label points (no cells), pushed outward on the equator side + label_lat = lat + (-1 if lat >= 0 else 1) * (self._radius + self._label_offset) + label_pts = np.column_stack( + [np.full(n_rings, lon), np.full(n_rings, label_lat), z_splits] + ) + + all_pts = np.vstack([pts.reshape(-1, 3), label_pts]) + points = vtkPoints() + points.SetData(numpy_support.numpy_to_vtk(all_pts, deep=True)) + output.SetPoints(points) + + output.Allocate(n_rings) + for r in range(n_rings): + ring = [r * n_res + i for i in range(n_res)] + output.InsertNextCell(vtkConstants.VTK_POLY_LINE, n_res + 1, [*ring, ring[0]]) + + label_array = vtkStringArray() + label_array.SetName("labels") + label_array.SetNumberOfValues(len(all_pts)) + for r, z in enumerate(z_splits): + label_array.SetValue(n_rings * n_res + r, f"{z:.1f} hPa") + output.GetPointData().AddArray(label_array) + + return 1 From 3e52a02b0672df10685c70836836941e92a8b28a Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 15:10:14 -0600 Subject: [PATCH 09/19] feat(orientation): add orientation axis --- src/e3sm_siteview/analysis/viz3d.py | 66 ++++++++++++++++++++++++++++- src/e3sm_siteview/io/__init__.py | 4 +- src/e3sm_siteview/io/earth.py | 3 ++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py index 59898c1..df47390 100644 --- a/src/e3sm_siteview/analysis/viz3d.py +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -8,25 +8,36 @@ from trame.widgets import colormaps, html, rca from trame.widgets import vuetify3 as v3 from vtkmodules.vtkCommonDataModel import vtkDataObject, vtkPlane +from vtkmodules.vtkCommonMath import vtkMatrix4x4 from vtkmodules.vtkFiltersCore import ( vtk3DLinearGridCrinkleExtractor, + vtkAppendPolyData, vtkFeatureEdges, vtkPolyDataToUnstructuredGrid, vtkThreshold, ) from vtkmodules.vtkFiltersGeneral import vtkCleanUnstructuredGrid from vtkmodules.vtkFiltersGeometry import vtkGeometryFilter -from vtkmodules.vtkFiltersSources import vtkSphereSource +from vtkmodules.vtkFiltersSources import ( + vtkConeSource, + vtkCylinderSource, + vtkSphereSource, + vtkTexturedSphereSource, +) from vtkmodules.vtkInteractionStyle import vtkInteractorStyleSwitch # noqa: F401 +from vtkmodules.vtkInteractionWidgets import vtkOrientationMarkerWidget +from vtkmodules.vtkIOImage import vtkJPEGReader from vtkmodules.vtkIOXML import vtkXMLPolyDataReader from vtkmodules.vtkRenderingCore import ( vtkActor, + vtkAssembly, vtkBillboardTextActor3D, vtkCamera, vtkDataSetMapper, vtkRenderer, vtkRenderWindow, vtkRenderWindowInteractor, + vtkTexture, ) from e3sm_siteview.analysis import ANALYSIS_ID, register_analysis @@ -34,6 +45,7 @@ from e3sm_siteview.io import ( CONTINENT_PATH, EARTH_RADIUS, + EARTH_TEXTURE_PATH, EAMColumnVolume, EAMGridLines, EAMLevelCylinder, @@ -281,6 +293,58 @@ def _setup_vtk(self): self.grid >> self._proj() >> grid_mapper self.renderer.AddActor(grid_actor) + # Orientation marker: textured earth + earth_texture = vtkTexture(interpolate=1, mipmap=1) + vtkJPEGReader(file_name=str(EARTH_TEXTURE_PATH)) >> earth_texture + earth_marker_mapper = vtkDataSetMapper() + ( + vtkTexturedSphereSource(theta_resolution=64, phi_resolution=32) + >> earth_marker_mapper + ) + earth_marker = vtkActor(mapper=earth_marker_mapper, texture=earth_texture) + # Sphere source has poles on Z with lon=-180 on +X; align it with + # EAMProject (north on +Y, lon=0 on +Z, lon=90 on +X) + marker_matrix = vtkMatrix4x4() + marker_matrix.DeepCopy( + ( + *(0, -1, 0, 0), + *(0, 0, 1, 0), + *(-1, 0, 0, 0), + *(0, 0, 0, 1), + ) + ) + earth_marker.user_matrix = marker_matrix + + # Rotation axis: south to north (+Y) with a cone at the north tip + rotation_axis = vtkAppendPolyData() + vtkCylinderSource(radius=0.02, height=1.4, resolution=24) >> rotation_axis + ( + vtkConeSource( + center=(0, 0.7, 0), + direction=(0, 1, 0), + height=0.2, + radius=0.07, + resolution=24, + ) + >> rotation_axis + ) + rotation_axis_mapper = vtkDataSetMapper() + rotation_axis >> rotation_axis_mapper + rotation_axis_actor = vtkActor(mapper=rotation_axis_mapper) + rotation_axis_actor.property.color = (0.9, 0.2, 0.2) + + earth_with_axis = vtkAssembly() + earth_with_axis.AddPart(earth_marker) + earth_with_axis.AddPart(rotation_axis_actor) + + self.orientation_widget = vtkOrientationMarkerWidget( + orientation_marker=earth_with_axis, + interactor=renderWindowInteractor, + viewport=(0.85, 0.85, 1, 1), + ) + self.orientation_widget.EnabledOn() + self.orientation_widget.InteractiveOff() + self._reset_camera() def _sync_level_labels(self, *_): diff --git a/src/e3sm_siteview/io/__init__.py b/src/e3sm_siteview/io/__init__.py index a9d7e56..0a39dba 100644 --- a/src/e3sm_siteview/io/__init__.py +++ b/src/e3sm_siteview/io/__init__.py @@ -11,6 +11,6 @@ from .eam_filters import EAMColumnVolume, EAMLevelCylinder from .eam_reader import EAMColumnSource, EAMMeshSource from .projection import EAMProject, EARTH_RADIUS -from .earth import CONTINENT_PATH, EAMGridLines +from .earth import CONTINENT_PATH, EARTH_TEXTURE_PATH, EAMGridLines -__all__ = ["EAMColumnSource", "EAMColumnVolume", "EAMLevelCylinder", "EAMMeshSource", "EAMProject", "CONTINENT_PATH", "EAMGridLines", "EARTH_RADIUS"] +__all__ = ["EAMColumnSource", "EAMColumnVolume", "EAMLevelCylinder", "EAMMeshSource", "EAMProject", "CONTINENT_PATH", "EARTH_TEXTURE_PATH", "EAMGridLines", "EARTH_RADIUS"] diff --git a/src/e3sm_siteview/io/earth.py b/src/e3sm_siteview/io/earth.py index bc1d01f..e67378f 100644 --- a/src/e3sm_siteview/io/earth.py +++ b/src/e3sm_siteview/io/earth.py @@ -10,6 +10,9 @@ POINTS_PER_LINE = 100 CONTINENT_PATH = Path(__file__).with_name("continents.vtp").resolve() +EARTH_TEXTURE_PATH = ( + Path(__file__).parent.parent / "module" / "serve" / "Equirectangular-projection.jpg" +).resolve() class EAMGridLines(VTKPythonAlgorithmBase): From 28d0ce80bd4b2751027ff0bb9054a90feeb2b70f Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 15:25:54 -0600 Subject: [PATCH 10/19] feat(interaction): improve interaction --- src/e3sm_siteview/analysis/viz3d.py | 127 ++++++++++++++++++++--- src/e3sm_siteview/components/controls.py | 39 +++++++ src/e3sm_siteview/data_models.py | 1 + 3 files changed, 150 insertions(+), 17 deletions(-) diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py index df47390..08f95b5 100644 --- a/src/e3sm_siteview/analysis/viz3d.py +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -6,9 +6,10 @@ from trame.decorators import controller from trame.ui.html import DivLayout from trame.widgets import colormaps, html, rca -from trame.widgets import vuetify3 as v3 +from vtkmodules.vtkCommonCore import vtkMath from vtkmodules.vtkCommonDataModel import vtkDataObject, vtkPlane from vtkmodules.vtkCommonMath import vtkMatrix4x4 +from vtkmodules.vtkCommonTransforms import vtkTransform from vtkmodules.vtkFiltersCore import ( vtk3DLinearGridCrinkleExtractor, vtkAppendPolyData, @@ -24,7 +25,7 @@ vtkSphereSource, vtkTexturedSphereSource, ) -from vtkmodules.vtkInteractionStyle import vtkInteractorStyleSwitch # noqa: F401 +from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera from vtkmodules.vtkInteractionWidgets import vtkOrientationMarkerWidget from vtkmodules.vtkIOImage import vtkJPEGReader from vtkmodules.vtkIOXML import vtkXMLPolyDataReader @@ -56,6 +57,81 @@ CAMERA = vtkCamera() +EARTH_AXIS = (0, 1, 0) + + +def _rotate_camera(camera, angle, axis, center): + """Rotate camera around an axis going through center""" + transform = vtkTransform() + transform.Translate(center) + transform.RotateWXYZ(angle, axis) + transform.Translate(*(-v for v in center)) + camera.position = transform.TransformPoint(camera.position) + camera.focal_point = transform.TransformPoint(camera.focal_point) + + +def create_north_up_interactor_style(get_rotation_center): + """ + Trackball camera style where left-drag rotates the camera around + an axis parallel to the earth rotation axis going through + get_rotation_center() (horizontal motion) and tilts it toward the + poles (vertical motion) while keeping north up. + Middle/right buttons and wheel keep their default pan/zoom behavior. + """ + style = vtkInteractorStyleTrackballCamera() + state = {"rotating": False} + + def on_left_press(*_): + interactor = style.GetInteractor() + style.FindPokedRenderer(*interactor.GetEventPosition()) + if style.GetCurrentRenderer() is None: + return + state["rotating"] = True + + def on_left_release(*_): + state["rotating"] = False + + def on_mouse_move(*_): + if not state["rotating"]: + style.OnMouseMove() + return + + interactor = style.GetInteractor() + renderer = style.GetCurrentRenderer() + camera = renderer.active_camera + x, y = interactor.GetEventPosition() + last_x, last_y = interactor.GetLastEventPosition() + width, height = renderer.GetSize() + center = get_rotation_center() + + # Spin around the earth rotation axis + azimuth = -180.0 * (x - last_x) / max(width, 1) + _rotate_camera(camera, azimuth, EARTH_AXIS, center) + + # Tilt toward the poles without going over them + elevation = 180.0 * (y - last_y) / max(height, 1) + dop = camera.GetDirectionOfProjection() + right = [0, 0, 0] + vtkMath.Cross(dop, EARTH_AXIS, right) + if vtkMath.Normalize(right) > 0: + pole_angle = math.degrees( + math.acos(max(-1.0, min(1.0, vtkMath.Dot(dop, EARTH_AXIS)))) + ) + if 1.0 < pole_angle - elevation < 179.0: + _rotate_camera(camera, elevation, right, center) + + camera.view_up = EARTH_AXIS + camera.OrthogonalizeViewUp() + if style.GetAutoAdjustCameraClippingRange(): + renderer.ResetCameraClippingRange() + interactor.Render() + + style.AddObserver("LeftButtonPressEvent", on_left_press) + style.AddObserver("LeftButtonReleaseEvent", on_left_release) + style.AddObserver("MouseMoveEvent", on_mouse_move) + + return style + class Viz3D(TrameComponent): def __init__(self, server, column_reader): @@ -116,18 +192,36 @@ def _proj(self): def refresh_data(self): self._need_render() - @controller.add("reset_camera") - def _reset_camera(self): + def _data_anchor(self): + """Point on the earth surface at the bottom center of the data""" x_rad = math.radians(self.ctx.setup.center[0]) y_rad = math.radians(self.ctx.setup.center[1]) cos_y_rad = math.cos(y_rad) xs = EARTH_RADIUS * math.sin(x_rad) * cos_y_rad ys = EARTH_RADIUS * math.sin(y_rad) zs = EARTH_RADIUS * math.cos(x_rad) * cos_y_rad - self.renderer.active_camera.focal_point = (xs, ys, zs) - self.renderer.active_camera.position = (xs * 1000, ys * 1000, zs * 1000) - self.renderer.active_camera.view_up = (0, 1, 0) - self.renderer.ResetCamera(self.outline_actor.bounds) + return (xs, ys, zs) + + def _rotation_center(self): + if self.ctx.setup.camera_focus == "earth": + return (0, 0, 0) + return self._data_anchor() + + @controller.add("reset_camera") + def _reset_camera(self): + xs, ys, zs = self._data_anchor() + camera = self.renderer.active_camera + camera.position = (xs * 1000, ys * 1000, zs * 1000) + camera.view_up = EARTH_AXIS + if self.ctx.setup.camera_focus == "earth": + camera.focal_point = (0, 0, 0) + camera.OrthogonalizeViewUp() + r = EARTH_RADIUS + self.renderer.ResetCamera(-r, r, -r, r, -r, r) + else: + camera.focal_point = (xs, ys, zs) + camera.OrthogonalizeViewUp() + self.renderer.ResetCamera(self.outline_actor.bounds) if self.view_handler: self.view_handler.update() @@ -140,7 +234,8 @@ def _setup_vtk(self): renderWindowInteractor = vtkRenderWindowInteractor() renderWindowInteractor.SetRenderWindow(renderWindow) - renderWindowInteractor.GetInteractorStyle().SetCurrentStyleToTrackballCamera() + self.interactor_style = create_north_up_interactor_style(self._rotation_center) + renderWindowInteractor.SetInteractorStyle(self.interactor_style) self.render_window = renderWindow self.renderer = renderer @@ -422,6 +517,7 @@ def bind_reactivity(self): self._subscribe( self.ctx.setup.zscale, ["scale"], self._on_z_scale_change, eager=True ) + self._subscribe(self.ctx.setup, ["camera_focus"], self._on_camera_focus_change) self._subscribe(self.colormap_config, ["mapper_change"], self._need_render) self.ctrl.update_color_range.add(self.colormap_config.update_color_range) @@ -438,6 +534,9 @@ def _on_z_scale_change(self, zscale): self.view_handler.update() + def _on_camera_focus_change(self, *_): + self._reset_camera() + def _on_region_change(self, center, radius_deg): self.level_cylinder.SetCenter(center[0], center[1]) self.level_cylinder.SetRadius(radius_deg) @@ -532,14 +631,8 @@ def _build_ui(self): ) self.ctrl.render.add(self.view_handler.update) - with controls.TopRightFloatControls(): - v3.VBtn( - icon="mdi-crop-free", - classes="rounded", - density="comfortable", - variant="plain", - click=self.ctrl.reset_camera, - ) + with controls.Controls(), controls.TopRightFloatControls(): + controls.CameraFocus(reset_camera=self.ctrl.reset_camera) with controls.Controls(), controls.TopLeftFloatControls(): controls.ZScale() diff --git a/src/e3sm_siteview/components/controls.py b/src/e3sm_siteview/components/controls.py index 2db376e..7656842 100644 --- a/src/e3sm_siteview/components/controls.py +++ b/src/e3sm_siteview/components/controls.py @@ -31,6 +31,45 @@ def __init__(self): super().__init__(style="position:absolute;right:1rem;top:1rem;z-index:100;") +class CameraFocus(html.Div): + def __init__(self, reset_camera): + super().__init__(classes="d-flex flex-column align-center") + is_earth = "controls.camera_focus === 'earth'" + with self: + with v3.VTooltip(text="Reset camera", location="start"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + icon="mdi-crop-free", + classes="rounded", + density="comfortable", + variant="plain", + click=reset_camera, + ) + v3.VDivider() + with v3.VTooltip( + text=( + f"{is_earth} ? 'Earth focus: rotate around earth center' " + ": 'Data focus: rotate around data bottom'", + ), + location="start", + ): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + icon=( + f"{is_earth} ? 'mdi-earth' " + ": 'mdi-image-filter-center-focus'", + ), + classes="rounded", + density="comfortable", + variant="plain", + click=( + f"controls.camera_focus = {is_earth} ? 'data' : 'earth'" + ), + ) + + class BaseToolbar(v3.VCard): def __init__(self, keys, icon): super().__init__( diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py index 25364b8..6457994 100644 --- a/src/e3sm_siteview/data_models.py +++ b/src/e3sm_siteview/data_models.py @@ -111,6 +111,7 @@ class GlobalParameters(dataclass.StateDataModel): col_ids_str = dataclass.Sync(str, "[]") n_cols = dataclass.Sync(int, 0) # 3D Viz controls + camera_focus = dataclass.Sync(str, "data") # data, earth active_viz = dataclass.Sync(list[str], ["volume"]) cloud = dataclass.Sync(CloudControls, has_dataclass=True) From b5a99260556fb419b58612ffb8516d5e056babad Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 15:34:51 -0600 Subject: [PATCH 11/19] feat(region): highlight region on orientation marker --- src/e3sm_siteview/analysis/viz3d.py | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py index 08f95b5..9900b3f 100644 --- a/src/e3sm_siteview/analysis/viz3d.py +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -59,6 +59,11 @@ EARTH_AXIS = (0, 1, 0) +# Orientation marker site cylinder (textured sphere source has radius 0.5) +SITE_MARKER_EARTH_RADIUS = 0.5 +SITE_MARKER_HEIGHT = 0.1 +SITE_MARKER_MIN_RADIUS = 0.03 + def _rotate_camera(camera, angle, axis, center): """Rotate camera around an axis going through center""" @@ -428,9 +433,22 @@ def _setup_vtk(self): rotation_axis_actor = vtkActor(mapper=rotation_axis_mapper) rotation_axis_actor.property.color = (0.9, 0.2, 0.2) + # Selected site: bright cylinder sticking out of the earth surface, + # placed by _update_site_marker() (cylinder source is along +Y) + self.site_marker_source = vtkCylinderSource( + height=SITE_MARKER_HEIGHT, resolution=24 + ) + site_marker_mapper = vtkDataSetMapper() + self.site_marker_source >> site_marker_mapper + self.site_marker_actor = vtkActor(mapper=site_marker_mapper) + self.site_marker_actor.property.color = (1, 0.9, 0) + self.site_marker_actor.property.lighting = 0 + self._update_site_marker() + earth_with_axis = vtkAssembly() earth_with_axis.AddPart(earth_marker) earth_with_axis.AddPart(rotation_axis_actor) + earth_with_axis.AddPart(self.site_marker_actor) self.orientation_widget = vtkOrientationMarkerWidget( orientation_marker=earth_with_axis, @@ -442,6 +460,21 @@ def _setup_vtk(self): self._reset_camera() + def _update_site_marker(self): + """Place the orientation marker site cylinder at the selected region""" + lon, lat = self.ctx.setup.center[:2] + self.site_marker_source.radius = max( + SITE_MARKER_MIN_RADIUS, + SITE_MARKER_EARTH_RADIUS * math.radians(self.ctx.setup.radius_deg), + ) + # Translate along +Y onto the surface, then tilt from the north pole + # down to the site latitude and spin to its longitude (EAMProject frame) + transform = vtkTransform() + transform.RotateY(lon) + transform.RotateX(90 - lat) + transform.Translate(0, SITE_MARKER_EARTH_RADIUS, 0) + self.site_marker_actor.user_transform = transform + def _sync_level_labels(self, *_): self.level_cylinder_proj.Update() cylinder = self.level_cylinder_proj.GetOutputDataObject(0) @@ -540,6 +573,7 @@ def _on_camera_focus_change(self, *_): def _on_region_change(self, center, radius_deg): self.level_cylinder.SetCenter(center[0], center[1]) self.level_cylinder.SetRadius(radius_deg) + self._update_site_marker() self.view_handler.update() def _on_visibility_change(self, active_viz): From fc6f8cb261132c7e8e6aff01f7bfca44377fc1e2 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 15:37:51 -0600 Subject: [PATCH 12/19] fix(earth): make it brighter --- src/e3sm_siteview/analysis/viz3d.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py index 9900b3f..ceee1d2 100644 --- a/src/e3sm_siteview/analysis/viz3d.py +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -402,6 +402,11 @@ def _setup_vtk(self): >> earth_marker_mapper ) earth_marker = vtkActor(mapper=earth_marker_mapper, texture=earth_texture) + # Mostly ambient lighting so the texture stays bright on all sides + # while keeping a bit of shading for the 3D feel + earth_marker.property.ambient = 0.6 + earth_marker.property.diffuse = 0.5 + earth_marker.property.specular = 0 # Sphere source has poles on Z with lon=-180 on +X; align it with # EAMProject (north on +Y, lon=0 on +Z, lon=90 on +X) marker_matrix = vtkMatrix4x4() From cc64086617140ad0ba791018cb5f6de710eec1bf Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 15:50:30 -0600 Subject: [PATCH 13/19] feat(heatmap): add multi-heatmap support --- src/e3sm_siteview/analysis/column_heatmap.py | 157 +++++++++++++------ src/e3sm_siteview/data_models.py | 2 + 2 files changed, 112 insertions(+), 47 deletions(-) diff --git a/src/e3sm_siteview/analysis/column_heatmap.py b/src/e3sm_siteview/analysis/column_heatmap.py index 9b29c56..967140b 100644 --- a/src/e3sm_siteview/analysis/column_heatmap.py +++ b/src/e3sm_siteview/analysis/column_heatmap.py @@ -7,6 +7,7 @@ from trame.ui.html import DivLayout from trame.widgets import html, plotly from trame.widgets import vuetify3 as v3 +from trame_client.encoders.numpy import encode from vtkmodules.util import numpy_support from e3sm_siteview.analysis import ANALYSIS_ID, register_analysis @@ -40,7 +41,9 @@ def _subscribe(self, obj, watch, callback, eager=False, sync=False): def bind_reactivity(self): self._subscribe( - self.ctx.setup.surface_chart, ["color_by", "column"], self._compute_heatmap + self.ctx.setup.surface_chart, + ["color_by", "column", "extra_fields"], + self._compute_heatmap, ) self._subscribe( self.ctx.setup.column, ["altitude_range"], self._compute_heatmap @@ -74,11 +77,17 @@ def refresh_data(self): self._compute_heatmap() def _compute_heatmap(self, *_): - field = self.ctx.setup.surface_chart.color_by + primary = self.ctx.setup.surface_chart.color_by col_id = self.ctx.setup.surface_chart.column altitude_range = self.ctx.setup.column.altitude_range - if field is None: + fields = [primary] if primary else [] + for field in self.ctx.setup.surface_chart.extra_fields: + if field not in fields: + fields.append(field) + + if not fields: + self.ctx.setup.surface_chart.results = [] return col = self.single_column_reader @@ -86,65 +95,78 @@ def _compute_heatmap(self, *_): select_arrays = col.GetProfileVariables() select_arrays.DisableAllArrays() - select_arrays.EnableArray(field) + for field in fields: + select_arrays.EnableArray(field) time_labels = self._time_labels() - series = [] + series = {field: [] for field in fields} levels = None for t in range(len(time_labels)): col.SetSlicing(json.dumps({"time": t})) col.Update() table = col.GetOutputDataObject(0) - array = table.GetColumnByName(field) - profile = numpy_support.vtk_to_numpy(array)[0] # (n_lev,) - series.append(profile[altitude_range[0] : altitude_range[1]]) + for field in fields: + array = table.GetColumnByName(field) + profile = numpy_support.vtk_to_numpy(array)[0] # (n_lev,) + series[field].append(profile[altitude_range[0] : altitude_range[1]]) if levels is None: levels = table.field_data["lev"][0][ altitude_range[0] : altitude_range[1] ] - series = np.array(series) # (time, level) - # fig = px.imshow( - # series.T, - # aspect="auto", - # origin="lower", - # color_continuous_scale="Viridis", - # ) - fig = go.Figure( - data=go.Heatmap( - z=series.T, - x=time_labels, - y=levels, - colorscale="Viridis", - hovertemplate=( - f"time: %{{x}}
lev: %{{y}}
{field}: %{{z}}" - ), - ) - ) # Only label a handful of time steps to keep the axis readable tick_step = max(1, len(time_labels) // 6) tick_vals = time_labels[::tick_step] tick_text = [str(v).replace(" ", "
") for v in tick_vals] - fig.update_layout( - title={"text": field, "x": 0.5, "xanchor": "center"}, - xaxis={ - "title": "time", - "side": "bottom", - "type": "category", - "tickmode": "array", - "tickvals": tick_vals, - "ticktext": tick_text, - "tickangle": 0, - }, - yaxis={"autorange": "reversed"}, - showlegend=False, - margin={"b": 60, "l": 0, "r": 0, "t": 30}, - ) + results = [] + for field in fields: + fig = go.Figure( + data=go.Heatmap( + z=np.array(series[field]).T, # (level, time) + x=time_labels, + y=levels, + colorscale="Viridis", + hovertemplate=( + f"time: %{{x}}
lev: %{{y}}
{field}: %{{z}}" + ), + ) + ) + fig.update_layout( + title={"text": field, "x": 0.5, "xanchor": "center"}, + xaxis={ + "title": "time", + "side": "bottom", + "type": "category", + "tickmode": "array", + "tickvals": tick_vals, + "ticktext": tick_text, + "tickangle": 0, + }, + yaxis={"autorange": "reversed"}, + showlegend=False, + margin={"b": 60, "l": 0, "r": 0, "t": 30}, + ) + results.append( + { + "field": field, + "removable": field != primary, + **encode(fig.to_plotly_json()), + } + ) + + self.ctx.setup.surface_chart.results = results - with self.state: - self.update_figure(fig) + def add_field(self, field): + extra_fields = self.ctx.setup.surface_chart.extra_fields + if field != self.ctx.setup.surface_chart.color_by and field not in extra_fields: + self.ctx.setup.surface_chart.extra_fields = [*extra_fields, field] + + def remove_field(self, field): + self.ctx.setup.surface_chart.extra_fields = [ + f for f in self.ctx.setup.surface_chart.extra_fields if f != field + ] def shift_col_id(self, delta): all_ids = self.ctx.setup.col_ids @@ -160,6 +182,7 @@ def shift_col_id(self, delta): self.ctx.setup.surface_chart.column = all_ids.GetId(0) def _build_ui(self): + plotly.initialize(self.server) with DivLayout(self.server, self.name, classes="h-100") as self.ui: with ( self.ctx.setup.provide_as("global"), @@ -182,6 +205,25 @@ def _build_ui(self): variant="flat", classes="w-100", ) + with v3.VMenu(location="bottom end"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + icon="mdi-plus", + classes="rounded", + density="compact", + ) + with v3.VList(density="compact", max_height="50vh"): + v3.VListItem( + v_for=( + "name in global.variables_3d.filter(v => v.selected).map(v => v.name)" + ".filter(n => n !== global.surface_chart.color_by" + " && !global.surface_chart.extra_fields.includes(n))" + ), + key="name", + title=("name",), + click=(self.add_field, "[name]"), + ) v3.VSpacer() v3.VSelect( prepend_inner_icon="mdi-map-marker-outline", @@ -205,10 +247,31 @@ def _build_ui(self): density="compact", ) - with html.Div(classes="flex-fill pa-2 border-thin"): - self.update_figure = plotly.Figure( - display_mode_bar=("false",) - ).update + with html.Div(classes="flex-fill pa-2 border-thin overflow-auto"): + with v3.VCard( + v_for="(v, i) in global.surface_chart.results", + key="v.field", + variant="flat", + rounded=0, + classes="position-relative", + style=( + "`height: max(300px, (100% - ${5 * (global.surface_chart.results.length - 1)}px) / ${global.surface_chart.results.length});`" + " + (i > 0 ? 'border-top: 1px solid rgba(0, 0, 0, 0.25);margin-top: 5px;padding-top: 5px;' : '')", + ), + ) as container: + container.add_child( + '' + ) + v3.VBtn( + v_if="v.removable", + icon="mdi-close", + size="small", + variant="outlined", + classes="rounded", + density="compact", + style="position:absolute;top:6px;right:6px;z-index:1;", + click=(self.remove_field, "[v.field]"), + ) register_analysis(NAME, ColumnHeatMap) diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py index 6457994..7b4e30c 100644 --- a/src/e3sm_siteview/data_models.py +++ b/src/e3sm_siteview/data_models.py @@ -83,6 +83,8 @@ class VisualizationAnalysis(dataclass.StateDataModel): class DrappedChart(dataclass.StateDataModel): color_by = dataclass.Sync(str) column = dataclass.Sync(int, 0) + extra_fields = dataclass.Sync(list[str], list) + results = dataclass.Sync(list, list) class CellTimeCharts(dataclass.StateDataModel): From 85ed5b4df11210efc8cbac56fa1c5e93b5361d9c Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 16:26:15 -0600 Subject: [PATCH 14/19] fix(line-plot): use proper altitude data --- src/e3sm_siteview/analysis/cell_timechart.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/e3sm_siteview/analysis/cell_timechart.py b/src/e3sm_siteview/analysis/cell_timechart.py index 389422b..8ebf585 100644 --- a/src/e3sm_siteview/analysis/cell_timechart.py +++ b/src/e3sm_siteview/analysis/cell_timechart.py @@ -74,7 +74,7 @@ def _compute_line_plots(self, *_): select_arrays.EnableArray(field) data = {} - for t in range(self.ctx.setup.time_index_max): + for t in range(self.ctx.setup.time_index_max + 1): col.SetSlicing(json.dumps({"time": t})) col.Update() table = col.GetOutputDataObject(0) @@ -86,7 +86,7 @@ def _compute_line_plots(self, *_): n_array[idx][altitude_idx] ) - t_array = np.array(range(self.ctx.setup.time_index_max)) + t_array = np.array(range(self.ctx.setup.time_index_max + 1)) figs = [] for field, cols in data.items(): fig = go.Figure() @@ -173,7 +173,7 @@ def _build_ui(self): ) v3.VSlider( prepend_icon="mdi-altimeter", - v_model="global.slice.altitude", + v_model="global.hslice.altitude", step="1", min=("global.column.altitude_range[0]",), max=("global.column.altitude_range[1]",), From 717e472be4a56d0088efa4d3bfdfa4d9cb50ae59 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 16:53:15 -0600 Subject: [PATCH 15/19] fix(col): use lat/lon for label --- src/e3sm_siteview/analysis/cell_timechart.py | 10 +- src/e3sm_siteview/analysis/column_heatmap.py | 125 +++++++++++-------- src/e3sm_siteview/data_models.py | 26 +++- 3 files changed, 101 insertions(+), 60 deletions(-) diff --git a/src/e3sm_siteview/analysis/cell_timechart.py b/src/e3sm_siteview/analysis/cell_timechart.py index 8ebf585..4b35654 100644 --- a/src/e3sm_siteview/analysis/cell_timechart.py +++ b/src/e3sm_siteview/analysis/cell_timechart.py @@ -86,6 +86,7 @@ def _compute_line_plots(self, *_): n_array[idx][altitude_idx] ) + labels = {item["value"]: item["title"] for item in self.ctx.setup.col_items} t_array = np.array(range(self.ctx.setup.time_index_max + 1)) figs = [] for field, cols in data.items(): @@ -95,7 +96,12 @@ def _compute_line_plots(self, *_): for col, values in cols.items(): array = np.array(values) fig.add_trace( - go.Scatter(x=t_array, y=array, name=str(col), mode="lines") + go.Scatter( + x=t_array, + y=array, + name=labels.get(col, str(col)), + mode="lines", + ) ) y0, y1 = min_max(array, y0, y1) @@ -159,7 +165,7 @@ def _build_ui(self): with v3.VSelect( prepend_inner_icon="mdi-map-marker-outline", v_model="global.line_chart.columns", - items=("JSON.parse(global.col_ids_str)",), + items=("global.col_items",), density="compact", hide_details=True, variant="flat", diff --git a/src/e3sm_siteview/analysis/column_heatmap.py b/src/e3sm_siteview/analysis/column_heatmap.py index 967140b..4755908 100644 --- a/src/e3sm_siteview/analysis/column_heatmap.py +++ b/src/e3sm_siteview/analysis/column_heatmap.py @@ -26,7 +26,8 @@ def __init__(self, server, column_reader): self.single_column_reader = EAMColumnSource() self.single_column_reader.SetDataFileName(self.columns.GetDataFileName()) - self.ctx.setup.surface_chart.column = self.ctx.setup.col_ids.GetId(0) + col_ids = json.loads(self.ctx.setup.col_ids_str) + self.ctx.setup.surface_chart.column = col_ids[0] if col_ids else None self._build_ui() self.bind_reactivity() @@ -120,6 +121,15 @@ def _compute_heatmap(self, *_): tick_vals = time_labels[::tick_step] tick_text = [str(v).replace(" ", "
") for v in tick_vals] + col_label = next( + ( + item["title"] + for item in self.ctx.setup.col_items + if item["value"] == col_id + ), + str(col_id), + ) + results = [] for field in fields: fig = go.Figure( @@ -134,7 +144,7 @@ def _compute_heatmap(self, *_): ) ) fig.update_layout( - title={"text": field, "x": 0.5, "xanchor": "center"}, + title={"text": f"{field} ({col_label})", "x": 0.5, "xanchor": "center"}, xaxis={ "title": "time", "side": "bottom", @@ -169,17 +179,18 @@ def remove_field(self, field): ] def shift_col_id(self, delta): - all_ids = self.ctx.setup.col_ids + all_ids = json.loads(self.ctx.setup.col_ids_str) + if not all_ids: + return + current_col_id = self.ctx.setup.surface_chart.column - num_ids = all_ids.GetNumberOfIds() - for i in range(num_ids): - local_id = all_ids.GetId(i) - if current_col_id == local_id: - target_idx = min(max(0, i + delta), num_ids - 1) - self.ctx.setup.surface_chart.column = all_ids.GetId(target_idx) - return + if current_col_id in all_ids: + i = all_ids.index(current_col_id) + target_idx = min(max(0, i + delta), len(all_ids) - 1) + self.ctx.setup.surface_chart.column = all_ids[target_idx] + return - self.ctx.setup.surface_chart.column = all_ids.GetId(0) + self.ctx.setup.surface_chart.column = all_ids[0] def _build_ui(self): plotly.initialize(self.server) @@ -205,47 +216,57 @@ def _build_ui(self): variant="flat", classes="w-100", ) - with v3.VMenu(location="bottom end"): - with v3.Template(v_slot_activator="{ props }"): - v3.VBtn( - v_bind="props", - icon="mdi-plus", - classes="rounded", - density="compact", - ) - with v3.VList(density="compact", max_height="50vh"): - v3.VListItem( - v_for=( - "name in global.variables_3d.filter(v => v.selected).map(v => v.name)" - ".filter(n => n !== global.surface_chart.color_by" - " && !global.surface_chart.extra_fields.includes(n))" - ), - key="name", - title=("name",), - click=(self.add_field, "[name]"), - ) - v3.VSpacer() - v3.VSelect( - prepend_inner_icon="mdi-map-marker-outline", - v_model="global.surface_chart.column", - items=("JSON.parse(global.col_ids_str)",), - density="compact", - hide_details=True, - variant="flat", - style="width:190px;", - ) - v3.VBtn( - icon="mdi-chevron-left", - click=(self.shift_col_id, "[-1]"), - classes="rounded", - density="compact", - ) - v3.VBtn( - icon="mdi-chevron-right", - click=(self.shift_col_id, "[+1]"), - classes="rounded", - density="compact", - ) + with html.Div(classes="d-flex ga-2"): + with v3.VMenu(location="bottom end"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + icon="mdi-plus", + classes="rounded", + density="compact", + ) + with v3.VList(density="compact", max_height="50vh"): + v3.VListItem( + v_for=( + "name in global.variables_3d.filter(v => v.selected).map(v => v.name)" + ".filter(n => n !== global.surface_chart.color_by" + " && !global.surface_chart.extra_fields.includes(n))" + ), + key="name", + title=("name",), + click=(self.add_field, "[name]"), + ) + + with v3.VMenu(location="bottom end"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + icon="mdi-map-marker-outline", + classes="rounded", + density="compact", + ) + with v3.VList(density="compact", max_height="50vh"): + v3.VListItem( + v_for="item in global.col_items", + key="item.value", + title=("item.title",), + active=( + "item.value === global.surface_chart.column", + ), + click="global.surface_chart.column = item.value", + ) + v3.VBtn( + icon="mdi-chevron-left", + click=(self.shift_col_id, "[-1]"), + classes="rounded", + density="compact", + ) + v3.VBtn( + icon="mdi-chevron-right", + click=(self.shift_col_id, "[+1]"), + classes="rounded", + density="compact", + ) with html.Div(classes="flex-fill pa-2 border-thin overflow-auto"): with v3.VCard( diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py index 7b4e30c..1c7cbcc 100644 --- a/src/e3sm_siteview/data_models.py +++ b/src/e3sm_siteview/data_models.py @@ -12,6 +12,15 @@ from e3sm_siteview.constants import FIELDS_METADATA +def format_lat_lon(lon, lat): + lon = (float(lon) + 180) % 360 - 180 + lat = float(lat) + return ( + f"{abs(lat):.2f}°{'N' if lat >= 0 else 'S'}, " + f"{abs(lon):.2f}°{'E' if lon >= 0 else 'W'}" + ) + + class Variable(dataclass.StateDataModel): name = dataclass.Sync(str) selected = dataclass.Sync(bool, False) @@ -111,6 +120,7 @@ class GlobalParameters(dataclass.StateDataModel): radius_deg = dataclass.Sync(float, 1.0) center = dataclass.Sync(tuple[float, float, float], (0, 0, 0)) col_ids_str = dataclass.Sync(str, "[]") + col_items = dataclass.Sync(list, list) # [{title: "lat, lon", value: col_id}] n_cols = dataclass.Sync(int, 0) # 3D Viz controls camera_focus = dataclass.Sync(str, "data") # data, earth @@ -210,12 +220,16 @@ def _on_animation(self, time_animating): def apply_region(self, radius_deg, center): self.locator.FindPointsWithinRadius(radius_deg, center, self.col_ids) self.col_ids.Sort() - col_id = numpy_support.vtk_to_numpy( - self.locator.GetDataSet().GetPointData().GetArray("col_id") - ) - selected_ids = [ - int(col_id[self.col_ids.GetId(i)]) - for i in range(self.col_ids.GetNumberOfIds()) + dataset = self.locator.GetDataSet() + col_id = numpy_support.vtk_to_numpy(dataset.GetPointData().GetArray("col_id")) + points = numpy_support.vtk_to_numpy(dataset.GetPoints().GetData()) + point_ids = [ + self.col_ids.GetId(i) for i in range(self.col_ids.GetNumberOfIds()) + ] + selected_ids = [int(col_id[pid]) for pid in point_ids] + self.col_items = [ + {"title": format_lat_lon(*points[pid][:2]), "value": int(col_id[pid])} + for pid in point_ids ] self.col_ids_str = json.dumps(selected_ids) self.n_cols = len(selected_ids) From e690b54412c0911ada5385bd2a443e380e94bb3f Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 17:15:13 -0600 Subject: [PATCH 16/19] fix(col): provide feedback in 3D view --- src/e3sm_siteview/analysis/cell_timechart.py | 5 +- src/e3sm_siteview/analysis/column_heatmap.py | 2 +- src/e3sm_siteview/analysis/viz3d.py | 110 ++++++++++++ src/e3sm_siteview/io/__init__.py | 4 +- src/e3sm_siteview/io/eam_filters.py | 173 +++++++++++++++++++ 5 files changed, 290 insertions(+), 4 deletions(-) diff --git a/src/e3sm_siteview/analysis/cell_timechart.py b/src/e3sm_siteview/analysis/cell_timechart.py index 4b35654..0c00976 100644 --- a/src/e3sm_siteview/analysis/cell_timechart.py +++ b/src/e3sm_siteview/analysis/cell_timechart.py @@ -1,6 +1,7 @@ import json import numpy as np +import plotly.colors import plotly.graph_objects as go from trame.app import TrameComponent from trame.ui.html import DivLayout @@ -13,6 +14,7 @@ from e3sm_siteview.io import EAMColumnSource NAME = "cellTimeChart" +COLORS = plotly.colors.qualitative.Plotly def min_max(array, min_value, max_value): @@ -93,7 +95,7 @@ def _compute_line_plots(self, *_): fig = go.Figure() y1 = None y0 = None - for col, values in cols.items(): + for idx, (col, values) in enumerate(cols.items()): array = np.array(values) fig.add_trace( go.Scatter( @@ -101,6 +103,7 @@ def _compute_line_plots(self, *_): y=array, name=labels.get(col, str(col)), mode="lines", + line={"color": COLORS[idx % len(COLORS)]}, ) ) y0, y1 = min_max(array, y0, y1) diff --git a/src/e3sm_siteview/analysis/column_heatmap.py b/src/e3sm_siteview/analysis/column_heatmap.py index 4755908..cae3d79 100644 --- a/src/e3sm_siteview/analysis/column_heatmap.py +++ b/src/e3sm_siteview/analysis/column_heatmap.py @@ -87,7 +87,7 @@ def _compute_heatmap(self, *_): if field not in fields: fields.append(field) - if not fields: + if not fields or col_id is None: self.ctx.setup.surface_chart.results = [] return diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py index ceee1d2..0c1fd39 100644 --- a/src/e3sm_siteview/analysis/viz3d.py +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -1,5 +1,6 @@ import math +import plotly.colors import vtkmodules.vtkRenderingOpenGL2 # noqa: F401 from trame.app import TrameComponent from trame.dataclasses.colormaps import ColormapConfig @@ -47,6 +48,7 @@ CONTINENT_PATH, EARTH_RADIUS, EARTH_TEXTURE_PATH, + EAMColumnMarkers, EAMColumnVolume, EAMGridLines, EAMLevelCylinder, @@ -64,6 +66,15 @@ SITE_MARKER_HEIGHT = 0.1 SITE_MARKER_MIN_RADIUS = 0.03 +# Selected column markers (line chart colors match its plotly traces) +COLUMN_MARKER_AREA_FRACTION = 0.05 +COLUMN_MARKER_SPHERE_SCALE = 2 # sphere radius relative to cylinder radius +COLUMN_MARKER_AMBIENT = 0.5 +HEATMAP_COLUMN_COLOR = (255, 255, 255) +LINE_CHART_COLORS = [ + plotly.colors.hex_to_rgb(c) for c in plotly.colors.qualitative.Plotly +] + def _rotate_camera(camera, angle, axis, center): """Rotate camera around an axis going through center""" @@ -343,6 +354,34 @@ def _setup_vtk(self): self._level_labels_mtime = 0 renderWindow.AddObserver("StartEvent", self._sync_level_labels) + # Selected column markers: cylinders through the columns used by the + # heat map / line chart, plus spheres on the line chart cells + self.column_markers = EAMColumnMarkers() + self.column_markers.SetAreaFraction(COLUMN_MARKER_AREA_FRACTION) + self.column_markers.SetInputConnection(self.volume.GetOutputPort()) + column_cylinders_proj = self._proj() + column_cylinders_proj.SetInputConnection(self.column_markers.GetOutputPort(0)) + column_cylinders_mapper = vtkDataSetMapper() + column_cylinders_mapper.ScalarVisibilityOn() + column_cylinders_mapper.SetScalarModeToUseCellFieldData() + column_cylinders_mapper.SelectColorArray("colors") + column_cylinders_mapper.SetColorModeToDirectScalars() + column_cylinders_proj >> column_cylinders_mapper + column_cylinders_actor = vtkActor(mapper=column_cylinders_mapper) + # Keep the sides bright when looking down the columns + column_cylinders_actor.property.ambient = COLUMN_MARKER_AMBIENT + column_cylinders_actor.property.diffuse = 1 - COLUMN_MARKER_AMBIENT + self.renderer.AddActor(column_cylinders_actor) + + # Column spheres: synced with the projected centers before each render + self.column_spheres_proj = self._proj() + self.column_spheres_proj.SetInputConnection( + self.column_markers.GetOutputPort(1) + ) + self.column_sphere_actors = [] + self._column_spheres_mtime = 0 + renderWindow.AddObserver("StartEvent", self._sync_column_spheres) + # Volume self.threshold = vtkThreshold() self.threshold_mapper = vtkDataSetMapper() @@ -510,6 +549,40 @@ def _sync_level_labels(self, *_): else: actor.visibility = 0 + def _sync_column_spheres(self, *_): + self.column_spheres_proj.Update() + centers = self.column_spheres_proj.GetOutputDataObject(0) + if centers.GetMTime() == self._column_spheres_mtime: + return + self._column_spheres_mtime = centers.GetMTime() + + n_spheres = centers.GetNumberOfPoints() + while len(self.column_sphere_actors) < n_spheres: + mapper = vtkDataSetMapper() + source = vtkSphereSource(theta_resolution=24, phi_resolution=24) + source >> mapper + actor = vtkActor(mapper=mapper) + actor.source = source + actor.property.ambient = COLUMN_MARKER_AMBIENT + actor.property.diffuse = 1 - COLUMN_MARKER_AMBIENT + self.renderer.AddActor(actor) + self.column_sphere_actors.append(actor) + + point_data = centers.GetPointData() + for i, actor in enumerate(self.column_sphere_actors): + if i < n_spheres: + radius_deg = point_data.GetArray("radius").GetValue(i) + actor.source.center = centers.GetPoint(i) + actor.source.radius = ( + COLUMN_MARKER_SPHERE_SCALE * math.radians(radius_deg) * EARTH_RADIUS + ) + actor.property.color = [ + v / 255 for v in point_data.GetArray("colors").GetTuple3(i) + ] + actor.visibility = 1 + else: + actor.visibility = 0 + def _subscribe(self, obj, watch, callback, eager=False, sync=False): self._subscriptions.append(obj.watch(watch, callback, eager=eager, sync=sync)) @@ -556,6 +629,21 @@ def bind_reactivity(self): self.ctx.setup.zscale, ["scale"], self._on_z_scale_change, eager=True ) self._subscribe(self.ctx.setup, ["camera_focus"], self._on_camera_focus_change) + self._subscribe( + self.ctx.setup, ["active_analysis"], self._on_column_markers_change + ) + self._subscribe( + self.ctx.setup.surface_chart, ["column"], self._on_column_markers_change + ) + self._subscribe( + self.ctx.setup.line_chart, ["columns"], self._on_column_markers_change + ) + self._subscribe( + self.ctx.setup.hslice, + ["altitude"], + self._on_column_markers_change, + eager=True, + ) self._subscribe(self.colormap_config, ["mapper_change"], self._need_render) self.ctrl.update_color_range.add(self.colormap_config.update_color_range) @@ -572,6 +660,28 @@ def _on_z_scale_change(self, zscale): self.view_handler.update() + def _on_column_markers_change(self, *_): + active_analysis = self.ctx.setup.active_analysis + line_entries = [] + if "cellTimeChart" in active_analysis: + line_entries = [ + (col_id, LINE_CHART_COLORS[i % len(LINE_CHART_COLORS)]) + for i, col_id in enumerate(self.ctx.setup.line_chart.columns) + ] + + cylinders = list( + line_entries + ) # Edit if only want to see sphere for line locations + heatmap_column = self.ctx.setup.surface_chart.column + if "columnHeatMap" in active_analysis and heatmap_column is not None: + cylinders = [e for e in cylinders if e[0] != heatmap_column] + cylinders.append((heatmap_column, HEATMAP_COLUMN_COLOR)) + + self.column_markers.SetCylinders(cylinders) + self.column_markers.SetSpheres(line_entries) + self.column_markers.SetLevel(self.ctx.setup.hslice.altitude) + self.view_handler.update() + def _on_camera_focus_change(self, *_): self._reset_camera() diff --git a/src/e3sm_siteview/io/__init__.py b/src/e3sm_siteview/io/__init__.py index 0a39dba..80c3018 100644 --- a/src/e3sm_siteview/io/__init__.py +++ b/src/e3sm_siteview/io/__init__.py @@ -8,9 +8,9 @@ classes run under pure VTK. Configure via ``Set*`` methods and wire with ``SetInputConnection`` / ``Update``. """ -from .eam_filters import EAMColumnVolume, EAMLevelCylinder +from .eam_filters import EAMColumnMarkers, EAMColumnVolume, EAMLevelCylinder from .eam_reader import EAMColumnSource, EAMMeshSource from .projection import EAMProject, EARTH_RADIUS from .earth import CONTINENT_PATH, EARTH_TEXTURE_PATH, EAMGridLines -__all__ = ["EAMColumnSource", "EAMColumnVolume", "EAMLevelCylinder", "EAMMeshSource", "EAMProject", "CONTINENT_PATH", "EARTH_TEXTURE_PATH", "EAMGridLines", "EARTH_RADIUS"] +__all__ = ["EAMColumnMarkers", "EAMColumnSource", "EAMColumnVolume", "EAMLevelCylinder", "EAMMeshSource", "EAMProject", "CONTINENT_PATH", "EARTH_TEXTURE_PATH", "EAMGridLines", "EARTH_RADIUS"] diff --git a/src/e3sm_siteview/io/eam_filters.py b/src/e3sm_siteview/io/eam_filters.py index c0299f5..f957cc8 100644 --- a/src/e3sm_siteview/io/eam_filters.py +++ b/src/e3sm_siteview/io/eam_filters.py @@ -369,3 +369,176 @@ def RequestData(self, request, inInfo, outInfo): output.GetPointData().AddArray(label_array) return 1 + + +class EAMColumnMarkers(VTKPythonAlgorithmBase): + """Markers for a set of columns of an EAMColumnVolume. + + Input is the (unprojected) column volume: points are (lon, lat, pressure). + Outputs are unstructured grids in the same space, so they can go through + EAMProject like the volume: + * port 0: one vertical cylinder per column set with SetCylinders(), + centered on the column and spanning its vertical extent, with a + footprint of AreaFraction of the column cell surface + * port 1: one vertex per column set with SetSpheres(), at the center of + the column cell at level Level, with point data ``radius`` holding the + matching cylinder radius (degrees of latitude) + Both carry a ``colors`` (RGB unsigned char) array, on cells for port 0 and + on points for port 1. + """ + + def __init__(self): + super().__init__( + nInputPorts=1, nOutputPorts=2, outputType="vtkUnstructuredGrid" + ) + self._cylinders = [] # [(col_id, (r, g, b)), ...] + self._spheres = [] # [(col_id, (r, g, b)), ...] + self._level = 0 + self._area_fraction = 0.1 + self._resolution = 32 + + def SetCylinders(self, entries): + entries = [(int(c), tuple(rgb)) for c, rgb in entries] + if entries != self._cylinders: + self._cylinders = entries + self.Modified() + + def SetSpheres(self, entries): + entries = [(int(c), tuple(rgb)) for c, rgb in entries] + if entries != self._spheres: + self._spheres = entries + self.Modified() + + def SetLevel(self, value): + if int(value) != self._level: + self._level = int(value) + self.Modified() + + def SetAreaFraction(self, value): + if float(value) != self._area_fraction: + self._area_fraction = float(value) + self.Modified() + + def SetResolution(self, value): + if value != self._resolution: + self._resolution = max(3, int(value)) + self.Modified() + + @staticmethod + def _colors_array(colors): + array = numpy_support.numpy_to_vtk( + np.asarray(colors, dtype=np.uint8).reshape(-1, 3), + deep=True, + array_type=vtkConstants.VTK_UNSIGNED_CHAR, + ) + array.SetName("colors") + return array + + def _footprint(self, corners): + """(center lon, center lat, radius lon, radius lat) in degrees""" + lon, lat = corners[:, 0], corners[:, 1] + center_lon, center_lat = lon.mean(), lat.mean() + area_deg = 0.5 * abs( + np.dot(lon, np.roll(lat, -1)) - np.dot(lat, np.roll(lon, -1)) + ) + # Keep the footprint circular on the sphere + cos_lat = max(np.cos(np.radians(center_lat)), 1e-6) + r_lat = np.sqrt(self._area_fraction * area_deg * cos_lat / np.pi) + return center_lon, center_lat, r_lat / cos_lat, r_lat + + def RequestData(self, request, inInfo, outInfo): + volume = vtkUnstructuredGrid.GetData(inInfo[0], 0) + out_cylinders = vtkUnstructuredGrid.GetData(outInfo, 0) + out_spheres = vtkUnstructuredGrid.GetData(outInfo, 1) + out_cylinders.Initialize() + out_spheres.Initialize() + out_cylinders.SetPoints(vtkPoints()) + out_spheres.SetPoints(vtkPoints()) + + if volume is None or volume.GetNumberOfCells() == 0: + return 1 + + points = numpy_support.vtk_to_numpy(volume.GetPoints().GetData()) + hexes = numpy_support.vtk_to_numpy( + volume.GetCells().GetConnectivityArray() + ).reshape(-1, 8) + cell_col_ids = numpy_support.vtk_to_numpy( + volume.GetCellData().GetArray("col_id") + ) + cell_levels = numpy_support.vtk_to_numpy(volume.GetCellData().GetArray("level")) + + # Cylinders + n_res = self._resolution + angles = np.linspace(0, 2 * np.pi, n_res, endpoint=False) + cyl_pts, cyl_colors = [], [] + out_cylinders.Allocate(len(self._cylinders) * (n_res + 2)) + for col_id, rgb in self._cylinders: + col_cells = hexes[cell_col_ids == col_id] + if len(col_cells) == 0: + continue + col_pts = points[col_cells.reshape(-1)] + lon, lat, r_lon, r_lat = self._footprint(points[col_cells[0, :4]]) + z_min, z_max = col_pts[:, 2].min(), col_pts[:, 2].max() + + offset = len(cyl_pts) * 2 * n_res + ring = np.column_stack( + [lon + r_lon * np.cos(angles), lat + r_lat * np.sin(angles)] + ) + cyl_pts.append( + np.vstack( + [ + np.column_stack([ring, np.full(n_res, z_min)]), + np.column_stack([ring, np.full(n_res, z_max)]), + ] + ) + ) + for i in range(n_res): + j = (i + 1) % n_res + out_cylinders.InsertNextCell( + vtkConstants.VTK_QUAD, + 4, + [offset + i, offset + j, offset + n_res + j, offset + n_res + i], + ) + out_cylinders.InsertNextCell( + vtkConstants.VTK_POLYGON, n_res, [offset + i for i in range(n_res)] + ) + out_cylinders.InsertNextCell( + vtkConstants.VTK_POLYGON, + n_res, + [offset + n_res + i for i in range(n_res)], + ) + cyl_colors.extend([rgb] * (n_res + 2)) + + if cyl_pts: + vpoints = vtkPoints() + vpoints.SetData(numpy_support.numpy_to_vtk(np.vstack(cyl_pts), deep=True)) + out_cylinders.SetPoints(vpoints) + out_cylinders.GetCellData().AddArray(self._colors_array(cyl_colors)) + + # Spheres + sphere_pts, sphere_colors, sphere_radius = [], [], [] + for col_id, rgb in self._spheres: + mask = (cell_col_ids == col_id) & (cell_levels == self._level) + if not mask.any(): + continue + cell = hexes[mask][0] + *_, r_lat = self._footprint(points[cell[:4]]) + sphere_pts.append(points[cell].mean(axis=0)) + sphere_colors.append(rgb) + sphere_radius.append(r_lat) + + if sphere_pts: + vpoints = vtkPoints() + vpoints.SetData( + numpy_support.numpy_to_vtk(np.vstack(sphere_pts), deep=True) + ) + out_spheres.SetPoints(vpoints) + out_spheres.Allocate(len(sphere_pts)) + for i in range(len(sphere_pts)): + out_spheres.InsertNextCell(vtkConstants.VTK_VERTEX, 1, [i]) + out_spheres.GetPointData().AddArray(self._colors_array(sphere_colors)) + radius = numpy_support.numpy_to_vtk(np.asarray(sphere_radius), deep=True) + radius.SetName("radius") + out_spheres.GetPointData().AddArray(radius) + + return 1 From 5850f89f389eb1517905f4a45a37344d52c9ff04 Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 17:26:20 -0600 Subject: [PATCH 17/19] fix(col): sorted column menus --- src/e3sm_siteview/analysis/column_heatmap.py | 9 +++++++-- src/e3sm_siteview/data_models.py | 12 +++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/e3sm_siteview/analysis/column_heatmap.py b/src/e3sm_siteview/analysis/column_heatmap.py index cae3d79..b926535 100644 --- a/src/e3sm_siteview/analysis/column_heatmap.py +++ b/src/e3sm_siteview/analysis/column_heatmap.py @@ -26,8 +26,7 @@ def __init__(self, server, column_reader): self.single_column_reader = EAMColumnSource() self.single_column_reader.SetDataFileName(self.columns.GetDataFileName()) - col_ids = json.loads(self.ctx.setup.col_ids_str) - self.ctx.setup.surface_chart.column = col_ids[0] if col_ids else None + self._select_center_column() self._build_ui() self.bind_reactivity() @@ -50,11 +49,17 @@ def bind_reactivity(self): self.ctx.setup.column, ["altitude_range"], self._compute_heatmap ) self._subscribe(self.ctx.setup.volume, ["color_by"], self._sync_color_by) + self._subscribe(self.ctx.setup, ["col_ids_str"], self._select_center_column) def unbind_reactivity(self): while self._subscriptions: self._subscriptions.pop()() + def _select_center_column(self, *_): + # The column closest to the region center comes first + col_ids = json.loads(self.ctx.setup.col_ids_str) + self.ctx.setup.surface_chart.column = col_ids[0] if col_ids else None + def _sync_color_by(self, color_by): self.ctx.setup.surface_chart.color_by = color_by diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py index 1c7cbcc..6557306 100644 --- a/src/e3sm_siteview/data_models.py +++ b/src/e3sm_siteview/data_models.py @@ -12,8 +12,12 @@ from e3sm_siteview.constants import FIELDS_METADATA +def normalize_lon(lon): + return (float(lon) + 180) % 360 - 180 + + def format_lat_lon(lon, lat): - lon = (float(lon) + 180) % 360 - 180 + lon = normalize_lon(lon) lat = float(lat) return ( f"{abs(lat):.2f}°{'N' if lat >= 0 else 'S'}, " @@ -226,6 +230,12 @@ def apply_region(self, radius_deg, center): point_ids = [ self.col_ids.GetId(i) for i in range(self.col_ids.GetNumberOfIds()) ] + # Order by (lat, lon) with the column closest to the center first + point_ids.sort(key=lambda pid: (points[pid][1], normalize_lon(points[pid][0]))) + center_id = self.locator.FindClosestPoint(center) + if center_id in point_ids: + point_ids.remove(center_id) + point_ids.insert(0, center_id) selected_ids = [int(col_id[pid]) for pid in point_ids] self.col_items = [ {"title": format_lat_lon(*points[pid][:2]), "value": int(col_id[pid])} From e1bf4b8f21e15dec695c8bd3cb6cbdf3ace3f23c Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 17:40:20 -0600 Subject: [PATCH 18/19] feat(find data): add find data with formula --- src/e3sm_siteview/analysis/viz3d.py | 61 +++++++++++++++++++++++- src/e3sm_siteview/components/controls.py | 6 ++- src/e3sm_siteview/data_models.py | 5 ++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py index 0c1fd39..fa4b3dd 100644 --- a/src/e3sm_siteview/analysis/viz3d.py +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -14,6 +14,7 @@ from vtkmodules.vtkFiltersCore import ( vtk3DLinearGridCrinkleExtractor, vtkAppendPolyData, + vtkArrayCalculator, vtkFeatureEdges, vtkPolyDataToUnstructuredGrid, vtkThreshold, @@ -75,6 +76,9 @@ plotly.colors.hex_to_rgb(c) for c in plotly.colors.qualitative.Plotly ] +# Find data: cells where the formula evaluates to true (1) +FIND_DATA_ARRAY = "__find_data__" + def _rotate_camera(camera, angle, axis, center): """Rotate camera around an axis going through center""" @@ -154,6 +158,7 @@ def __init__(self, server, column_reader): super().__init__(server) self._id = next(ANALYSIS_ID) self.view_handler = None + self._find_data_valid = False self._projections = [] self._subscriptions = [] @@ -330,6 +335,37 @@ def _setup_vtk(self): ) self.colormap_config.register_mapper(self.slice_v_mapper) + # Find data + self.find_data_calculator = vtkArrayCalculator( + result_array_name=FIND_DATA_ARRAY + ) + self.find_data_calculator.SetAttributeTypeToCellData() + self.find_data_threshold = vtkThreshold() + self.find_data_threshold.SetInputArrayToProcess( + 0, 0, 0, vtkDataObject.FIELD_ASSOCIATION_CELLS, FIND_DATA_ARRAY + ) + self.find_data_threshold.SetThresholdFunction(vtkThreshold.THRESHOLD_UPPER) + self.find_data_threshold.SetUpperThreshold(0.5) + self.find_data_mapper = vtkDataSetMapper() + self.find_data_mapper.ScalarVisibilityOn() + self.find_data_mapper.SetColorModeToMapScalars() + self.find_data_mapper.SetScalarModeToUseCellFieldData() + self.find_data_actor = vtkActor( + mapper=self.find_data_mapper, + force_opaque=1, + visibility=0, + ) + self.find_data_actor.property.edge_visibility = 1 + self.renderer.AddActor(self.find_data_actor) + ( + self.clean_volume + >> self.find_data_calculator + >> self.find_data_threshold + >> self._proj() + >> self.find_data_mapper + ) + self.colormap_config.register_mapper(self.find_data_mapper) + # Level cylinder self.level_cylinder = EAMLevelCylinder() self.level_cylinder.SetCenter(*self.ctx.setup.center[:2]) @@ -695,13 +731,15 @@ def _on_visibility_change(self, active_viz): has_volume = "volume" in active_viz has_slice = "hslice" in active_viz or "vslice" in active_viz has_cloud = "cloud" in active_viz - has_inside = has_slice or has_cloud + has_find = "find_data" in active_viz and self._find_data_valid + has_inside = has_slice or has_cloud or has_find self.slice_h_actor.visibility = 0 self.slice_v_actor.visibility = 0 self.outline_actor.visibility = 0 self.volume_actor.visibility = 0 self.threshold_actor.visibility = 1 if has_cloud else 0 + self.find_data_actor.visibility = 1 if has_find else 0 if has_volume: self.volume_actor.visibility = 1 @@ -768,6 +806,27 @@ def _on_cloud_change(self, threshold_by, threshold_value, opacity): self.threshold_actor.property.opacity = opacity self.view_handler.update() + @controller.add("apply_find_data") + def _apply_find_data(self): + formula = self.ctx.setup.find_data.formula.strip() + self._find_data_valid = False + + if formula: + self.clean_volume.Update() + calculator = self.find_data_calculator + calculator.RemoveAllVariables() + for name in self.clean_volume.GetOutputDataObject(0).cell_data: + calculator.AddScalarArrayName(name) + calculator.function = formula + calculator.Update() + ds = calculator.GetOutputDataObject(0) + if ds.GetCellData().GetArray(FIND_DATA_ARRAY) is None: + self.ctx.setup.find_data.error = "Invalid formula" + else: + self._find_data_valid = True + + self._on_visibility_change(self.ctx.setup.active_viz) + def _build_ui(self): with DivLayout(self.server, self.name, classes="h-100") as self.ui: with html.Div( diff --git a/src/e3sm_siteview/components/controls.py b/src/e3sm_siteview/components/controls.py index 7656842..82406bd 100644 --- a/src/e3sm_siteview/components/controls.py +++ b/src/e3sm_siteview/components/controls.py @@ -269,18 +269,20 @@ def __init__(self): v3.VTextField( v_show="controls.find_data.show", v_model="controls.find_data.formula", - hide_details=True, + hide_details="auto", density="compact", append_inner_icon="mdi-view-grid-plus-outline", style="width: 370px", placeholder="T >= 27", variant="flat", classes="border-e-thin border-s-thin", + error_messages=("controls.find_data.error",), click_appendInner=self._apply_find_data, + keydown_enter=self._apply_find_data, ) def _apply_find_data(self): - print("Perform a selection...") + self.server.controller.apply_find_data() class CropColumn(BaseToolbar): diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py index 6557306..07df94e 100644 --- a/src/e3sm_siteview/data_models.py +++ b/src/e3sm_siteview/data_models.py @@ -74,6 +74,11 @@ class HSliceControls(dataclass.StateDataModel): class FindDataControls(dataclass.StateDataModel): show = dataclass.Sync(bool, True) formula = dataclass.Sync(str, "") + error = dataclass.Sync(str, "") + + @dataclass.watch("formula") + def _on_formula_change(self, _): + self.error = "" class ColumnControls(dataclass.StateDataModel): From bf7b470f28e2e3bf572f67363d286181fa802fdd Mon Sep 17 00:00:00 2001 From: Sebastien Jourdain Date: Fri, 2 Oct 2026 17:52:22 -0600 Subject: [PATCH 19/19] feat(histogram): add data distribution feedback --- src/e3sm_siteview/analysis/viz3d.py | 107 +++++++++++++++++++++++ src/e3sm_siteview/components/controls.py | 50 +++++++++++ src/e3sm_siteview/components/footer.py | 3 + src/e3sm_siteview/data_models.py | 9 ++ 4 files changed, 169 insertions(+) diff --git a/src/e3sm_siteview/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py index fa4b3dd..415b251 100644 --- a/src/e3sm_siteview/analysis/viz3d.py +++ b/src/e3sm_siteview/analysis/viz3d.py @@ -1,12 +1,16 @@ import math +import numpy as np import plotly.colors +import plotly.graph_objects as go import vtkmodules.vtkRenderingOpenGL2 # noqa: F401 from trame.app import TrameComponent from trame.dataclasses.colormaps import ColormapConfig from trame.decorators import controller from trame.ui.html import DivLayout from trame.widgets import colormaps, html, rca +from trame_client.encoders.numpy import encode +from vtkmodules.util import numpy_support from vtkmodules.vtkCommonCore import vtkMath from vtkmodules.vtkCommonDataModel import vtkDataObject, vtkPlane from vtkmodules.vtkCommonMath import vtkMatrix4x4 @@ -159,6 +163,7 @@ def __init__(self, server, column_reader): self._id = next(ANALYSIS_ID) self.view_handler = None self._find_data_valid = False + self._histogram_key = None self._projections = [] self._subscriptions = [] @@ -211,8 +216,98 @@ def _proj(self): return proj def refresh_data(self): + self._update_histogram() self._need_render() + def _visible_data_array(self): + """Cell array of the color_by field for what is shown in the 3D view""" + color_by = self.ctx.setup.volume.color_by + if not color_by: + return None + + if self.find_data_actor.visibility: + self.find_data_threshold.Update() + ds = self.find_data_threshold.GetOutputDataObject(0) + return ds.GetCellData().GetArray(color_by) + + return self.get_data_array() + + def _update_histogram(self, *_): + if "histogram" not in self.ctx.setup.active_viz: + self._histogram_key = None + return + + array = self._visible_data_array() + bins = self.ctx.setup.histogram.bins + discard = self.ctx.setup.histogram.discard + color_by = self.ctx.setup.volume.color_by + key = ( + color_by, + bins, + tuple(sorted(discard)), + None if array is None else (id(array), array.GetMTime()), + ) + if key == self._histogram_key: + return + self._histogram_key = key + + values = np.array([]) + if array is not None: + values = numpy_support.vtk_to_numpy(array).ravel() + values = values[np.isfinite(values)] + + if values.size == 0: + self.ctx.setup.histogram.figure = {} + return + + counts, edges = np.histogram(values, bins=bins) + start = 1 if "min" in discard else 0 + end = bins - 1 if "max" in discard else bins + counts = counts[start:end] + edges = edges[start : end + 1] + if counts.size == 0: + self.ctx.setup.histogram.figure = {} + return + + units = next( + (v.units for v in self.ctx.setup.variables_3d if v.name == color_by), + None, + ) + units = f" {units}" if units else "" + percents = 100 * counts / values.size + fig = go.Figure( + data=go.Bar( + x=(edges[:-1] + edges[1:]) / 2, + y=counts, + width=np.diff(edges), + marker_color="#1f77b4", + customdata=np.column_stack((edges[:-1], edges[1:], percents)), + hovertemplate=( + f"{color_by}: [%{{customdata[0]:.4g}}, " + f"%{{customdata[1]:.4g}}]{units}
" + "Cells: %{y:,} (%{customdata[2]:.1f}%)" + f"
Total: {values.size:,} cells" + "" + ), + hoverlabel={"font": {"size": 11}}, + ) + ) + fig.update_layout( + title={ + "text": f"{color_by} ({counts.sum()} cells)", + "x": 0.5, + "xanchor": "center", + "font": {"size": 12}, + }, + bargap=0, + showlegend=False, + margin={"b": 30, "l": 40, "r": 10, "t": 30}, + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + font={"size": 10}, + ) + self.ctx.setup.histogram.figure = encode(fig.to_plotly_json()) + def _data_anchor(self): """Point on the earth surface at the bottom center of the data""" x_rad = math.radians(self.ctx.setup.center[0]) @@ -680,6 +775,12 @@ def bind_reactivity(self): self._on_column_markers_change, eager=True, ) + self._subscribe( + self.ctx.setup.histogram, + ["bins", "discard"], + self._update_histogram, + eager=True, + ) self._subscribe(self.colormap_config, ["mapper_change"], self._need_render) self.ctrl.update_color_range.add(self.colormap_config.update_color_range) @@ -754,11 +855,13 @@ def _on_visibility_change(self, active_viz): self.volume_actor.visibility = 0 self.ctrl.update_color_range.enable_empty()() + self._update_histogram() self.view_handler.update() def _on_volume_color_by_change(self, color_by): if color_by: self.colormap_config.set_data_array(color_by, self.get_data_array, "cell") + self._update_histogram() self.view_handler.update() def _on_column_height_change(self, altitude_range): @@ -771,11 +874,13 @@ def _on_column_height_change(self, altitude_range): self.ctx.setup.hslice.altitude, altitude_range[1] ) self.ctrl.update_color_range() + self._update_histogram() self.view_handler.update() def _on_column_slice_change(self, level): self.horizontal_slice.SetLevelRange(level, level) self.ctrl.update_color_range.enable_empty()() + self._update_histogram() self.view_handler.update() def _on_orientation_slice_change(self, heading): @@ -788,6 +893,7 @@ def _on_orientation_slice_change(self, heading): 0, ) self.slice_v_plane.normal = (nx, ny, 0) + self._update_histogram() self.view_handler.update() def _on_cloud_change(self, threshold_by, threshold_value, opacity): @@ -850,6 +956,7 @@ def _build_ui(self): controls.HorizontalSlice() controls.VerticalSlice() controls.FindData() + controls.Histogram() controls.CropColumn() with controls.BottomCenterFloatControls(): diff --git a/src/e3sm_siteview/components/controls.py b/src/e3sm_siteview/components/controls.py index 82406bd..4e43f99 100644 --- a/src/e3sm_siteview/components/controls.py +++ b/src/e3sm_siteview/components/controls.py @@ -285,6 +285,56 @@ def _apply_find_data(self): self.server.controller.apply_find_data() +class Histogram(BaseToolbar): + def __init__(self): + super().__init__(keys=["histogram"], icon="mdi-chart-histogram") + with self: + with v3.VCol( + v_show="controls.histogram.show", + classes="border-s pa-0", + style="width: 370px", + ) as container: + container.add_child( + '' + ) + with html.Div(classes="d-flex align-center pr-2 pb-2"): + v3.VSlider( + v_model="controls.histogram.bins", + min=5, + max=200, + step=5, + density="compact", + hide_details=True, + prepend_icon="mdi-chart-bar", + thumb_label=True, + classes="px-2", + ) + with v3.VBtnToggle( + v_model="controls.histogram.discard", + multiple=True, + density="compact", + variant="outlined", + divided=True, + color="primary", + ): + v3.VBtn( + "Min", + value="min", + size="small", + v_tooltip_bottom="'Discard min bin'", + ) + v3.VBtn( + "Max", + value="max", + size="small", + v_tooltip_bottom="'Discard max bin'", + ) + + class CropColumn(BaseToolbar): def __init__(self): super().__init__(keys=["column"], icon="mdi-sort") diff --git a/src/e3sm_siteview/components/footer.py b/src/e3sm_siteview/components/footer.py index 83aaee1..d6217e3 100644 --- a/src/e3sm_siteview/components/footer.py +++ b/src/e3sm_siteview/components/footer.py @@ -85,6 +85,9 @@ def __init__(self): TooltipButton( "'Find data'", icon="mdi-magnify-scan", value="find_data" ) + TooltipButton( + "'Histogram'", icon="mdi-chart-histogram", value="histogram" + ) TooltipButton("'Column Cropping'", icon="mdi-sort", value="column") TooltipButton( "'Probes'", icon="mdi-map-marker-plus", value="probes" diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py index 07df94e..2060b9e 100644 --- a/src/e3sm_siteview/data_models.py +++ b/src/e3sm_siteview/data_models.py @@ -81,6 +81,13 @@ def _on_formula_change(self, _): self.error = "" +class HistogramControls(dataclass.StateDataModel): + show = dataclass.Sync(bool, True) + bins = dataclass.Sync(int, 50) + discard = dataclass.Sync(list[str], list) # "min", "max" bins to hide + figure = dataclass.Sync(dict, dict) + + class ColumnControls(dataclass.StateDataModel): show = dataclass.Sync(bool, True) altitude_range = dataclass.Sync( @@ -141,6 +148,7 @@ class GlobalParameters(dataclass.StateDataModel): vslice = dataclass.Sync(VSliceControls, has_dataclass=True) hslice = dataclass.Sync(HSliceControls, has_dataclass=True) find_data = dataclass.Sync(FindDataControls, has_dataclass=True) + histogram = dataclass.Sync(HistogramControls, has_dataclass=True) column = dataclass.Sync(ColumnControls, has_dataclass=True) zscale = dataclass.Sync(ZScaleControls, has_dataclass=True) # Heatmap Chart controls @@ -166,6 +174,7 @@ def __init__(self, server, **defaults): self.vslice = VSliceControls(server) self.hslice = HSliceControls(server) self.find_data = FindDataControls(server) + self.histogram = HistogramControls(server) self.column = ColumnControls(server) self.surface_chart = DrappedChart(server) self.line_chart = CellTimeCharts(server)