diff --git a/src/e3sm_siteview/analysis/cell_timechart.py b/src/e3sm_siteview/analysis/cell_timechart.py
index 40e8ad1..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):
@@ -39,6 +41,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))
@@ -71,7 +76,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)
@@ -83,16 +88,23 @@ def _compute_line_plots(self, *_):
n_array[idx][altitude_idx]
)
- t_array = np.array(range(self.ctx.setup.time_index_max))
+ 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():
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(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",
+ line={"color": COLORS[idx % len(COLORS)]},
+ )
)
y0, y1 = min_max(array, y0, y1)
@@ -156,7 +168,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",
@@ -170,7 +182,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]",),
diff --git a/src/e3sm_siteview/analysis/column_heatmap.py b/src/e3sm_siteview/analysis/column_heatmap.py
index b92c179..b926535 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
@@ -25,7 +26,7 @@ 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)
+ self._select_center_column()
self._build_ui()
self.bind_reactivity()
@@ -40,17 +41,25 @@ 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
)
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
@@ -70,12 +79,21 @@ 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
+ 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 or col_id is None:
+ self.ctx.setup.surface_chart.results = []
return
col = self.single_column_reader
@@ -83,80 +101,104 @@ 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},
+ col_label = next(
+ (
+ item["title"]
+ for item in self.ctx.setup.col_items
+ if item["value"] == col_id
+ ),
+ str(col_id),
)
- with self.state:
- self.update_figure(fig)
+ 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": f"{field} ({col_label})", "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
+
+ 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
+ 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)
with DivLayout(self.server, self.name, classes="h-100") as self.ui:
with (
self.ctx.setup.provide_as("global"),
@@ -179,33 +221,83 @@ def _build_ui(self):
variant="flat",
classes="w-100",
)
- 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 html.Div(classes="flex-fill pa-2 border-thin"):
- self.update_figure = plotly.Figure(
- display_mode_bar=("false",)
- ).update
+ 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(
+ 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/analysis/viz3d.py b/src/e3sm_siteview/analysis/viz3d.py
index 084221a..415b251 100644
--- a/src/e3sm_siteview/analysis/viz3d.py
+++ b/src/e3sm_siteview/analysis/viz3d.py
@@ -1,31 +1,50 @@
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.widgets import vuetify3 as v3
+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
+from vtkmodules.vtkCommonTransforms import vtkTransform
from vtkmodules.vtkFiltersCore import (
vtk3DLinearGridCrinkleExtractor,
+ vtkAppendPolyData,
+ vtkArrayCalculator,
vtkFeatureEdges,
vtkPolyDataToUnstructuredGrid,
vtkThreshold,
)
from vtkmodules.vtkFiltersGeneral import vtkCleanUnstructuredGrid
from vtkmodules.vtkFiltersGeometry import vtkGeometryFilter
-from vtkmodules.vtkFiltersSources import vtkSphereSource
-from vtkmodules.vtkInteractionStyle import vtkInteractorStyleSwitch # noqa: F401
+from vtkmodules.vtkFiltersSources import (
+ vtkConeSource,
+ vtkCylinderSource,
+ vtkSphereSource,
+ vtkTexturedSphereSource,
+)
+from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera
+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
@@ -33,8 +52,11 @@
from e3sm_siteview.io import (
CONTINENT_PATH,
EARTH_RADIUS,
+ EARTH_TEXTURE_PATH,
+ EAMColumnMarkers,
EAMColumnVolume,
EAMGridLines,
+ EAMLevelCylinder,
EAMProject,
)
@@ -42,12 +64,106 @@
CAMERA = vtkCamera()
+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
+
+# 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
+]
+
+# 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"""
+ 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):
super().__init__(server)
self._id = next(ANALYSIS_ID)
self.view_handler = None
+ self._find_data_valid = False
+ self._histogram_key = None
self._projections = []
self._subscriptions = []
@@ -99,18 +215,129 @@ def _proj(self):
self._projections.append(proj)
return proj
- @controller.add("reset_camera")
- def _reset_camera(self):
+ 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])
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()
@@ -123,7 +350,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
@@ -202,6 +430,89 @@ 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])
+ 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)
+
+ # 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()
@@ -252,8 +563,157 @@ 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)
+ # 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()
+ 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)
+
+ # 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,
+ interactor=renderWindowInteractor,
+ viewport=(0.85, 0.85, 1, 1),
+ )
+ self.orientation_widget.EnabledOn()
+ self.orientation_widget.InteractiveOff()
+
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)
+ 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 _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))
@@ -290,9 +750,37 @@ 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
)
+ 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.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)
@@ -300,7 +788,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):
@@ -309,17 +797,50 @@ 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()
+
+ 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):
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
@@ -334,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):
@@ -351,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):
@@ -368,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):
@@ -386,6 +912,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(
@@ -398,14 +945,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()
@@ -415,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/app.py b/src/e3sm_siteview/app.py
index 95ac6a0..be54a58 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, welcome
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,73 @@ 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,
+ scrollable=True,
+ max_width="640",
+ ):
+ welcome.Welcome("controls.active_page = 'site'")
+
+ with v3.VDialog(
+ absolute=True,
+ model_value=("controls.active_page === 'site'",),
+ persistent=True,
+ scrollable=True,
+ ):
+ with v3.VCard(rounded="lg"):
+ 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'",),
+ scrollable=True,
+ ):
+ with v3.VCard(rounded="lg"):
+ 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/controls.py b/src/e3sm_siteview/components/controls.py
index 2db376e..4e43f99 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__(
@@ -230,18 +269,70 @@ 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 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):
diff --git a/src/e3sm_siteview/components/footer.py b/src/e3sm_siteview/components/footer.py
index 317d9fe..d6217e3 100644
--- a/src/e3sm_siteview/components/footer.py
+++ b/src/e3sm_siteview/components/footer.py
@@ -1,88 +1,145 @@
+from trame.widgets import html
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)
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,
classes="mr-4",
mandatory=True,
):
- v3.VBtn(
- v_for="v, i in controls.available_analysis",
- key="i",
- icon=("v[1]",),
- value=("v[0]",),
+ TooltipButton(
+ "'Site selection'",
+ icon="mdi-map-marker-radius",
+ value="site",
+ )
+ TooltipButton(
+ "'Fields selection'",
+ icon="mdi-format-list-checks",
+ value="fields",
+ )
+ TooltipButton(
+ "'Visualization'",
+ icon="mdi-lightbulb-on-outline",
+ value="viz",
)
- with v3.VBtnToggle(
- v_model="controls.active_viz",
- color="primary",
- multiple=True,
- density="comfortable",
- border=True,
- divided=True,
- classes="mr-4",
+ with html.Div(
+ v_if="controls.data_loaded", classes="d-flex flex-fill align-center"
):
- 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(
+ v_model="controls.active_analysis",
+ color="primary",
+ multiple=True,
+ density="comfortable",
+ border=True,
+ divided=True,
+ classes="mr-4",
+ mandatory=True,
+ ):
+ TooltipButton(
+ "v[2]",
+ v_for="v, i in controls.available_analysis",
+ key="i",
+ icon=("v[1]",),
+ value=("v[0]",),
+ )
- 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++",
+ with v3.VBtnToggle(
+ v_model="controls.active_viz",
+ color="primary",
+ multiple=True,
+ density="comfortable",
+ border=True,
+ divided=True,
+ classes="mr-4",
+ ):
+ 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(
+ "'Histogram'", icon="mdi-chart-histogram", value="histogram"
+ )
+ 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,
+ ):
+ TooltipButton(
+ "'First time step'",
+ icon="mdi-step-backward-2",
+ click="controls.time_index = 0",
+ )
+ TooltipButton(
+ "'Previous time step'",
+ icon="mdi-step-backward",
+ click="controls.time_index > 0 && controls.time_index--",
+ )
+ TooltipButton(
+ "'Stop animation'",
+ icon="mdi-stop",
+ v_if="controls.time_animating",
+ click="controls.time_animating = false",
+ )
+ TooltipButton(
+ "'Play animation'",
+ icon="mdi-play",
+ v_else=True,
+ click="controls.time_animating = true",
+ )
+ TooltipButton(
+ "'Next time step'",
+ icon="mdi-step-forward",
+ click="controls.time_index < controls.time_index_max && controls.time_index++",
+ )
+ TooltipButton(
+ "'Last time step'",
+ 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/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")
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,
+ )
diff --git a/src/e3sm_siteview/data_models.py b/src/e3sm_siteview/data_models.py
index 9fbe063..2060b9e 100644
--- a/src/e3sm_siteview/data_models.py
+++ b/src/e3sm_siteview/data_models.py
@@ -12,6 +12,19 @@
from e3sm_siteview.constants import FIELDS_METADATA
+def normalize_lon(lon):
+ return (float(lon) + 180) % 360 - 180
+
+
+def format_lat_lon(lon, lat):
+ lon = normalize_lon(lon)
+ 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)
@@ -61,6 +74,18 @@ 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 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):
@@ -83,6 +108,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):
@@ -96,6 +123,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)
@@ -106,8 +136,11 @@ 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
+
active_viz = dataclass.Sync(list[str], ["volume"])
cloud = dataclass.Sync(CloudControls, has_dataclass=True)
surface = dataclass.Sync(ColorByControls, has_dataclass=True)
@@ -115,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
@@ -126,9 +160,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"),
],
)
@@ -140,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)
@@ -203,12 +238,22 @@ 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())
+ ]
+ # 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])}
+ for pid in point_ids
]
self.col_ids_str = json.dumps(selected_ids)
self.n_cols = len(selected_ids)
diff --git a/src/e3sm_siteview/io/__init__.py b/src/e3sm_siteview/io/__init__.py
index 2892415..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
+from .eam_filters import EAMColumnMarkers, 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", "EAMMeshSource", "EAMProject", "CONTINENT_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 3130766..f957cc8 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,277 @@ 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
+
+
+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
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):
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
-
-
-
-
-
-
-
- {{ field.name }}
- ({{ field.units }})
-
-
- {{ field.description }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Back
-
-
Theme
-
-
-
-
-
- {{ formatCoords(latitude, longitude) }} · {{ radiusValue }}
- {{ radiusUnit === 'km' ? 'km' : 'deg' }} radius
-
-
-
- {{ selectedFieldCount }} field{{ selectedFieldCount === 1 ?
- '' : 's' }} selected
-
-
-
-
- Next: Select Fields
-
-
-
-
-
-
- View request
-
-
- Continue
-
-
-
-
-
-
-
-
-
-
-
-
- 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()
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()