Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 12 additions & 13 deletions src/e3sm_quickview/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from e3sm_quickview.components import css, dialogs, doc, drawers, file_browser, toolbars
from e3sm_quickview.pipeline import EAMVisSource
from e3sm_quickview.utils import cli, compute, perf
from e3sm_quickview.utils.colors import get_type_color
from e3sm_quickview.view_manager import ViewManager

v3.enable_lab()
Expand Down Expand Up @@ -52,7 +53,7 @@ def __init__(self, server=None):
"variables_selected": [],
# Control 'Load Variables' button availability
"variables_loaded": False,
# Dynamic type-color mapping (populated when data loads)
# Dimension type → Vuetify color mapping via utils/colors.py
"variable_types": [],
# Dimension arrays (will be populated dynamically)
"midpoints": [],
Expand Down Expand Up @@ -338,23 +339,22 @@ def download_state(self):
"type": view_type,
"name": var_name,
"config": {
# lut
# colormaps module
"preset": config.preset,
"invert": config.invert,
"color_blind": config.color_blind,
"use_log_scale": config.use_log_scale,
"discrete_log": config.discrete_log,
"n_discrete_colors": config.n_discrete_colors,
# layout
"order": config.order,
"size": config.size,
"offset": config.offset,
"break_row": config.break_row,
# color range
"override_range": config.override_range,
"color_range": config.color_range,
"color_value_min": config.color_value_min,
"color_value_max": config.color_value_max,
# view layout
"order": config.order,
"size": config.size,
"offset": config.offset,
"break_row": config.break_row,
},
}
)
Expand Down Expand Up @@ -409,6 +409,7 @@ async def _import_state(self, state_content):
view_type = view_state["type"]
var_name = view_state["name"]
cfg = view_state["config"]
# colormaps module: JSON deserializes tuples as lists
if "color_range" in cfg and isinstance(cfg["color_range"], list):
cfg["color_range"] = tuple(cfg["color_range"])
config = self.view_manager.get_view(var_name, view_type).config
Expand Down Expand Up @@ -470,9 +471,7 @@ async def data_loading_open(self, simulation, connectivity):
),
]

# Build dynamic type-color mapping
from e3sm_quickview.utils.colors import get_type_color

# Dimension type → Vuetify color mapping via utils/colors.py
dim_types = sorted(
set(
", ".join(var.dimensions)
Expand Down Expand Up @@ -618,7 +617,7 @@ def _on_slicing_change(self, var, ind_var, **_):
self.source.UpdatePipeline()

with perf.timed("tick.color_range"):
self.view_manager.update_color_range()
self.view_manager.update_color_range() # colormaps module
with perf.timed("tick.render"):
self.view_manager.render()

Expand Down Expand Up @@ -656,7 +655,7 @@ def _on_downstream_change(
self.source.UpdatePipeline()

with perf.timed("downstream_change.color_range"):
self.view_manager.update_color_range()
self.view_manager.update_color_range() # colormaps module
with perf.timed("downstream_change.render"):
self.view_manager.render()

Expand Down
223 changes: 223 additions & 0 deletions src/e3sm_quickview/colormaps/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# colormaps

Self-contained colormap module for managing ParaView/VTK color transfer
functions, colorbar rendering, and interactive preset controls in Trame apps.

Designed to be extracted as a standalone `trame-colormap` package in the future.

## Public API

Three symbols are exported from `colormaps/__init__.py`:

| Symbol | Source | Purpose |
|--------|--------|---------|
| `ColormapController` | `controller.py` | Per-view LUT manager: creates LUT, wires mapper, manages presets/range/ticks |
| `ColormapConfig` | `state.py` | Reactive state model with all colormap fields (standalone use) |
| `ColormapInitialize` | `server.py` | One-time server setup: populates `luts_normal`/`luts_inverted` on server state |

Additionally, `widgets/` provides:

| Symbol | Source | Purpose |
|--------|--------|---------|
| `ColormapColorbar` | `widgets/colorbar.py` | Colorbar strip + popup control panel (imported as alias in `view_manager.py`) |

## Dependencies

| Package | Used in | Purpose |
|---------|---------|---------|
| **paraview** | `core/presets.py`, `controller.py` | LUT proxy creation, preset discovery/application, `servermanager` access |
| **vtk** (`vtkmodules`) | `core/presets.py`, `core/transforms.py`, `controller.py` | `vtkColorTransferFunction` for color sampling, `vtkPNGWriter`/`vtkImageData` for colorbar image generation, `vtk_to_numpy` for data array conversion |
| **numpy** | `core/ticks.py`, `core/transforms.py` | See below |
| **trame** | `state.py`, `widgets/` | `StateDataModel` for reactive config, Vuetify 3 widgets for UI |

### Why numpy?

Numpy is used in two core modules for operations that would be verbose,
slower, or less correct with pure Python:

**`core/ticks.py`** — tick computation:
- `np.nanmin`/`np.nanmax` with `where=` for masked reduction without copies
(2–3 orders of magnitude faster than Python loops on large arrays)
- `np.finfo` for dtype-aware floating-point limits
- `np.linspace`/`np.geomspace` for uniform tick spacing
- `np.log10`, `np.sign`, `np.abs` for vectorized log/symlog math
- `np.isclose` for robust floating-point comparison
- `np.unique`/`np.sort` for deduplication

**`core/transforms.py`** — LUT transforms:
- `np.log10`, `np.sign`, `np.abs`, `np.asarray` for symlog transform math
- `np.linspace` for uniform sampling in log/symlog space
- `np.floor`/`np.ceil` for decade boundary computation

Numpy is already a transitive dependency of both ParaView and VTK, so it
adds no new install burden.

## Module Structure

```
colormaps/
├── __init__.py # Re-exports: ColormapController, ColormapConfig, ColormapInitialize
├── README.md # This file
├── server.py # ColormapInitialize() — Trame server setup (depends on server.state)
├── state.py # ColormapConfig(StateDataModel) — reactive color state
├── controller.py # ColormapController — owns LUT, wires mapper, manages presets/range/ticks
├── core/
│ ├── presets.py # Preset discovery, COLORBAR_CACHE, lut_to_img()
│ ├── ticks.py # Tick computation (linear, log, symlog)
│ └── transforms.py # LUT transforms (linear, log, symlog, discrete variants)
└── widgets/
├── __init__.py # Re-exports: create_colorbar, create_control_panel
├── colorbar.py # Colorbar strip with tick marks and range labels
└── control_panel.py # Preset picker, scale mode, range, discrete settings
```

## Layer Separation

| Layer | Modules | Dependencies |
|-------|---------|-------------|
| **Core** (pure VTK/numpy) | `core/presets.py`, `core/ticks.py`, `core/transforms.py` | ParaView, VTK, numpy |
| **Server** (Trame init) | `server.py` | Core + Trame server.state |
| **State** (Trame reactive model) | `state.py` | trame |
| **Controller** (orchestration) | `controller.py` | Core + State + ParaView |
| **Widgets** (UI) | `widgets/colorbar.py`, `widgets/control_panel.py` | trame (Vuetify 3) |

The core layer has zero Trame dependency and can be used independently
for headless colormap operations. `server.py` is the only file outside
`widgets/` and `state.py` that touches Trame's server API.

## Widget Structure

`ColormapColorbar` (`create_colorbar`) produces the following DOM tree:

```
html.Div (top-level — flexbox row, bg-blue-grey-darken-2, 1rem tall)
├── v3.VMenu (activator="parent" — click anywhere on the bar to open)
│ └── create_control_panel → v3.VCard (360px popup)
│ ├── VCardItem: toggle buttons (color-blind, invert, scale, range, discrete)
│ ├── VCardItem: discrete color count input (v-show when discrete)
│ ├── VCardItem: min/max text fields (v-show when override_range)
│ ├── VDivider
│ └── VList: searchable preset list with thumbnail images
├── html.Div (min range label)
├── html.Div (colorbar image container, position:relative)
│ ├── html.Img (LUT image, full width)
│ └── html.Div (tick overlay, position:absolute, pointer-events:none)
│ └── html.Div v-for="tick in color_ticks"
│ ├── html.Div (top tick line)
│ ├── html.Span (tick label)
│ └── html.Div (bottom tick line)
└── html.Div (max range label)
```

The control panel reads `luts_normal` / `luts_inverted` from server
state (populated by `ColormapInitialize`). All template bindings
reference `config.*` via `config.provide_as("config")` called in
`create_colorbar`.

## Config Fields

The `config` object passed to `ColormapController` and `ColormapColorbar`
must be a `trame.app.dataclass.StateDataModel` (or subclass) with the
following fields. All fields are required. `ColormapConfig` in `state.py`
provides the canonical definition with defaults.

| Field | Type | Default | Role |
|-------|------|---------|------|
| **User-settable (read by controller, bound to UI)** ||||
| `preset` | `str` | `"BuGnYl"` | Active color preset name |
| `invert` | `bool` | `False` | Invert the transfer function |
| `color_blind` | `bool` | `False` | Filter preset list to color-blind safe |
| `use_log_scale` | `str` | `"linear"` | Scale mode: `"linear"`, `"log"`, `"symlog"` |
| `discrete_log` | `bool` | `False` | Enable discrete banding |
| `n_discrete_colors` | `int` | `4` | Number of discrete sub-bands (1–20) |
| `color_value_min` | `str` | `"0"` | Manual range min (string for text field) |
| `color_value_max` | `str` | `"1"` | Manual range max (string for text field) |
| `override_range` | `bool` | `False` | Use manual range instead of data range |
| **Derived (written by controller, read by widgets)** ||||
| `color_range` | `tuple[float, float]` | `(0, 1)` | Active min/max color range |
| `color_value_min_valid` | `bool` | `True` | Whether `color_value_min` parses as a valid float |
| `color_value_max_valid` | `bool` | `True` | Whether `color_value_max` parses as a valid float |
| `n_colors` | `int` | `255` | Number of LUT samples |
| `lut_img` | `str` | `""` | Base64 PNG data URI of the colorbar image |
| `color_ticks` | `list` | `[]` | Tick marks: `[{position, label, color}, ...]` |
| `effective_color_range` | `tuple[float, float]` | `(0, 1)` | Actual CTF range after transforms |
| **UI widget state (used by control panel / colorbar)** ||||
| `menu` | `bool` | `False` | Whether the control panel popup is open |
| `search` | `str \| None` | `None` | Preset search filter text |

When composing into a larger config (like `ViewConfiguration`), include
all fields above alongside your app-specific fields. The controller reads
and writes them by name — no inheritance required.

## Usage

### Integrated (current — within QuickView)

The controller operates on an existing `ViewConfiguration` state model:

```python
from e3sm_quickview.colormaps import ColormapController, ColormapInitialize
from e3sm_quickview.colormaps.widgets import create_colorbar as ColormapColorbar

# One-time server initialization (populates preset lists)
ColormapInitialize(server)

# Per-view: controller creates LUT, wires mapper, sets up reactive watchers
colormap = ColormapController(
server=server,
variable_name="Temperature",
mapper=my_mapper,
data_array_fn=lambda: get_data_array(),
render_fn=render,
config=existing_config, # uses fields from a larger config object
)

# In UI building:
ColormapColorbar(config, colormap.update_color_preset)
```

### Updating color range after data changes

When the underlying data changes (e.g. slice selection, pipeline update),
call `update_color_range()` on each view's controller to recompute the
range, re-apply transforms, and regenerate ticks:

```python
for view in views:
view.colormap.update_color_range()
```

This is done in `ViewManager.update_color_range()`, called from `app.py`
after slicing or downstream pipeline changes.

### State export / import

Colormap fields live on the same config as layout fields, so they are
serialized alongside them in `download_state()` / `_import_state()`.
One caveat: JSON deserializes tuples as lists, so `color_range` must
be converted back:

```python
if isinstance(cfg["color_range"], list):
cfg["color_range"] = tuple(cfg["color_range"])
```

### Standalone (future trame-colormap package)

When no external config is provided, the controller creates its own
`ColormapConfig`:

```python
from colormaps import ColormapController, ColormapInitialize

ColormapInitialize(server)

colormap = ColormapController(
server=server,
variable_name="Pressure",
mapper=my_mapper,
data_array_fn=lambda: get_data_array(),
render_fn=render,
# config=None → creates a ColormapConfig automatically
)
```
5 changes: 5 additions & 0 deletions src/e3sm_quickview/colormaps/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from e3sm_quickview.colormaps.controller import ColormapController
from e3sm_quickview.colormaps.server import ColormapInitialize
from e3sm_quickview.colormaps.state import ColormapConfig

__all__ = ["ColormapController", "ColormapConfig", "ColormapInitialize"]
Loading
Loading