Skip to content

Commit cc66b4c

Browse files
committed
refactor: extract self-contained colormaps module
1 parent fbe14eb commit cc66b4c

15 files changed

Lines changed: 1844 additions & 1051 deletions

File tree

src/e3sm_quickview/app.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from e3sm_quickview.components import css, dialogs, doc, drawers, file_browser, toolbars
1919
from e3sm_quickview.pipeline import EAMVisSource
2020
from e3sm_quickview.utils import cli, compute, perf
21+
from e3sm_quickview.utils.colors import get_type_color
2122
from e3sm_quickview.view_manager import ViewManager
2223

2324
v3.enable_lab()
@@ -52,7 +53,7 @@ def __init__(self, server=None):
5253
"variables_selected": [],
5354
# Control 'Load Variables' button availability
5455
"variables_loaded": False,
55-
# Dynamic type-color mapping (populated when data loads)
56+
# Dimension type → Vuetify color mapping via utils/colors.py
5657
"variable_types": [],
5758
# Dimension arrays (will be populated dynamically)
5859
"midpoints": [],
@@ -338,23 +339,22 @@ def download_state(self):
338339
"type": view_type,
339340
"name": var_name,
340341
"config": {
341-
# lut
342+
# colormaps module
342343
"preset": config.preset,
343344
"invert": config.invert,
344345
"color_blind": config.color_blind,
345346
"use_log_scale": config.use_log_scale,
346347
"discrete_log": config.discrete_log,
347348
"n_discrete_colors": config.n_discrete_colors,
348-
# layout
349-
"order": config.order,
350-
"size": config.size,
351-
"offset": config.offset,
352-
"break_row": config.break_row,
353-
# color range
354349
"override_range": config.override_range,
355350
"color_range": config.color_range,
356351
"color_value_min": config.color_value_min,
357352
"color_value_max": config.color_value_max,
353+
# view layout
354+
"order": config.order,
355+
"size": config.size,
356+
"offset": config.offset,
357+
"break_row": config.break_row,
358358
},
359359
}
360360
)
@@ -409,6 +409,7 @@ async def _import_state(self, state_content):
409409
view_type = view_state["type"]
410410
var_name = view_state["name"]
411411
cfg = view_state["config"]
412+
# colormaps module: JSON deserializes tuples as lists
412413
if "color_range" in cfg and isinstance(cfg["color_range"], list):
413414
cfg["color_range"] = tuple(cfg["color_range"])
414415
config = self.view_manager.get_view(var_name, view_type).config
@@ -470,9 +471,7 @@ async def data_loading_open(self, simulation, connectivity):
470471
),
471472
]
472473

473-
# Build dynamic type-color mapping
474-
from e3sm_quickview.utils.colors import get_type_color
475-
474+
# Dimension type → Vuetify color mapping via utils/colors.py
476475
dim_types = sorted(
477476
set(
478477
", ".join(var.dimensions)
@@ -618,7 +617,7 @@ def _on_slicing_change(self, var, ind_var, **_):
618617
self.source.UpdatePipeline()
619618

620619
with perf.timed("tick.color_range"):
621-
self.view_manager.update_color_range()
620+
self.view_manager.update_color_range() # colormaps module
622621
with perf.timed("tick.render"):
623622
self.view_manager.render()
624623

@@ -656,7 +655,7 @@ def _on_downstream_change(
656655
self.source.UpdatePipeline()
657656

658657
with perf.timed("downstream_change.color_range"):
659-
self.view_manager.update_color_range()
658+
self.view_manager.update_color_range() # colormaps module
660659
with perf.timed("downstream_change.render"):
661660
self.view_manager.render()
662661

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# colormaps
2+
3+
Self-contained colormap module for managing ParaView/VTK color transfer
4+
functions, colorbar rendering, and interactive preset controls in Trame apps.
5+
6+
Designed to be extracted as a standalone `trame-colormap` package in the future.
7+
8+
## Public API
9+
10+
Three symbols are exported from `colormaps/__init__.py`:
11+
12+
| Symbol | Source | Purpose |
13+
|--------|--------|---------|
14+
| `ColormapController` | `controller.py` | Per-view LUT manager: creates LUT, wires mapper, manages presets/range/ticks |
15+
| `ColormapConfig` | `state.py` | Reactive state model with all colormap fields (standalone use) |
16+
| `ColormapInitialize` | `server.py` | One-time server setup: populates `luts_normal`/`luts_inverted` on server state |
17+
18+
Additionally, `widgets/` provides:
19+
20+
| Symbol | Source | Purpose |
21+
|--------|--------|---------|
22+
| `ColormapColorbar` | `widgets/colorbar.py` | Colorbar strip + popup control panel (imported as alias in `view_manager.py`) |
23+
24+
## Dependencies
25+
26+
| Package | Used in | Purpose |
27+
|---------|---------|---------|
28+
| **paraview** | `core/presets.py`, `controller.py` | LUT proxy creation, preset discovery/application, `servermanager` access |
29+
| **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 |
30+
| **numpy** | `core/ticks.py`, `core/transforms.py` | See below |
31+
| **trame** | `state.py`, `widgets/` | `StateDataModel` for reactive config, Vuetify 3 widgets for UI |
32+
33+
### Why numpy?
34+
35+
Numpy is used in two core modules for operations that would be verbose,
36+
slower, or less correct with pure Python:
37+
38+
**`core/ticks.py`** — tick computation:
39+
- `np.nanmin`/`np.nanmax` with `where=` for masked reduction without copies
40+
(2–3 orders of magnitude faster than Python loops on large arrays)
41+
- `np.finfo` for dtype-aware floating-point limits
42+
- `np.linspace`/`np.geomspace` for uniform tick spacing
43+
- `np.log10`, `np.sign`, `np.abs` for vectorized log/symlog math
44+
- `np.isclose` for robust floating-point comparison
45+
- `np.unique`/`np.sort` for deduplication
46+
47+
**`core/transforms.py`** — LUT transforms:
48+
- `np.log10`, `np.sign`, `np.abs`, `np.asarray` for symlog transform math
49+
- `np.linspace` for uniform sampling in log/symlog space
50+
- `np.floor`/`np.ceil` for decade boundary computation
51+
52+
Numpy is already a transitive dependency of both ParaView and VTK, so it
53+
adds no new install burden.
54+
55+
## Module Structure
56+
57+
```
58+
colormaps/
59+
├── __init__.py # Re-exports: ColormapController, ColormapConfig, ColormapInitialize
60+
├── README.md # This file
61+
├── server.py # ColormapInitialize() — Trame server setup (depends on server.state)
62+
├── state.py # ColormapConfig(StateDataModel) — reactive color state
63+
├── controller.py # ColormapController — owns LUT, wires mapper, manages presets/range/ticks
64+
├── core/
65+
│ ├── presets.py # Preset discovery, COLORBAR_CACHE, lut_to_img()
66+
│ ├── ticks.py # Tick computation (linear, log, symlog)
67+
│ └── transforms.py # LUT transforms (linear, log, symlog, discrete variants)
68+
└── widgets/
69+
├── __init__.py # Re-exports: create_colorbar, create_control_panel
70+
├── colorbar.py # Colorbar strip with tick marks and range labels
71+
└── control_panel.py # Preset picker, scale mode, range, discrete settings
72+
```
73+
74+
## Layer Separation
75+
76+
| Layer | Modules | Dependencies |
77+
|-------|---------|-------------|
78+
| **Core** (pure VTK/numpy) | `core/presets.py`, `core/ticks.py`, `core/transforms.py` | ParaView, VTK, numpy |
79+
| **Server** (Trame init) | `server.py` | Core + Trame server.state |
80+
| **State** (Trame reactive model) | `state.py` | trame |
81+
| **Controller** (orchestration) | `controller.py` | Core + State + ParaView |
82+
| **Widgets** (UI) | `widgets/colorbar.py`, `widgets/control_panel.py` | trame (Vuetify 3) |
83+
84+
The core layer has zero Trame dependency and can be used independently
85+
for headless colormap operations. `server.py` is the only file outside
86+
`widgets/` and `state.py` that touches Trame's server API.
87+
88+
## Widget Structure
89+
90+
`ColormapColorbar` (`create_colorbar`) produces the following DOM tree:
91+
92+
```
93+
html.Div (top-level — flexbox row, bg-blue-grey-darken-2, 1rem tall)
94+
├── v3.VMenu (activator="parent" — click anywhere on the bar to open)
95+
│ └── create_control_panel → v3.VCard (360px popup)
96+
│ ├── VCardItem: toggle buttons (color-blind, invert, scale, range, discrete)
97+
│ ├── VCardItem: discrete color count input (v-show when discrete)
98+
│ ├── VCardItem: min/max text fields (v-show when override_range)
99+
│ ├── VDivider
100+
│ └── VList: searchable preset list with thumbnail images
101+
├── html.Div (min range label)
102+
├── html.Div (colorbar image container, position:relative)
103+
│ ├── html.Img (LUT image, full width)
104+
│ └── html.Div (tick overlay, position:absolute, pointer-events:none)
105+
│ └── html.Div v-for="tick in color_ticks"
106+
│ ├── html.Div (top tick line)
107+
│ ├── html.Span (tick label)
108+
│ └── html.Div (bottom tick line)
109+
└── html.Div (max range label)
110+
```
111+
112+
The control panel reads `luts_normal` / `luts_inverted` from server
113+
state (populated by `ColormapInitialize`). All template bindings
114+
reference `config.*` via `config.provide_as("config")` called in
115+
`create_colorbar`.
116+
117+
## Config Fields
118+
119+
The `config` object passed to `ColormapController` and `ColormapColorbar`
120+
must be a `trame.app.dataclass.StateDataModel` (or subclass) with the
121+
following fields. All fields are required. `ColormapConfig` in `state.py`
122+
provides the canonical definition with defaults.
123+
124+
| Field | Type | Default | Role |
125+
|-------|------|---------|------|
126+
| **User-settable (read by controller, bound to UI)** ||||
127+
| `preset` | `str` | `"BuGnYl"` | Active color preset name |
128+
| `invert` | `bool` | `False` | Invert the transfer function |
129+
| `color_blind` | `bool` | `False` | Filter preset list to color-blind safe |
130+
| `use_log_scale` | `str` | `"linear"` | Scale mode: `"linear"`, `"log"`, `"symlog"` |
131+
| `discrete_log` | `bool` | `False` | Enable discrete banding |
132+
| `n_discrete_colors` | `int` | `4` | Number of discrete sub-bands (1–20) |
133+
| `color_value_min` | `str` | `"0"` | Manual range min (string for text field) |
134+
| `color_value_max` | `str` | `"1"` | Manual range max (string for text field) |
135+
| `override_range` | `bool` | `False` | Use manual range instead of data range |
136+
| **Derived (written by controller, read by widgets)** ||||
137+
| `color_range` | `tuple[float, float]` | `(0, 1)` | Active min/max color range |
138+
| `color_value_min_valid` | `bool` | `True` | Whether `color_value_min` parses as a valid float |
139+
| `color_value_max_valid` | `bool` | `True` | Whether `color_value_max` parses as a valid float |
140+
| `n_colors` | `int` | `255` | Number of LUT samples |
141+
| `lut_img` | `str` | `""` | Base64 PNG data URI of the colorbar image |
142+
| `color_ticks` | `list` | `[]` | Tick marks: `[{position, label, color}, ...]` |
143+
| `effective_color_range` | `tuple[float, float]` | `(0, 1)` | Actual CTF range after transforms |
144+
| **UI widget state (used by control panel / colorbar)** ||||
145+
| `menu` | `bool` | `False` | Whether the control panel popup is open |
146+
| `search` | `str \| None` | `None` | Preset search filter text |
147+
148+
When composing into a larger config (like `ViewConfiguration`), include
149+
all fields above alongside your app-specific fields. The controller reads
150+
and writes them by name — no inheritance required.
151+
152+
## Usage
153+
154+
### Integrated (current — within QuickView)
155+
156+
The controller operates on an existing `ViewConfiguration` state model:
157+
158+
```python
159+
from e3sm_quickview.colormaps import ColormapController, ColormapInitialize
160+
from e3sm_quickview.colormaps.widgets import create_colorbar as ColormapColorbar
161+
162+
# One-time server initialization (populates preset lists)
163+
ColormapInitialize(server)
164+
165+
# Per-view: controller creates LUT, wires mapper, sets up reactive watchers
166+
colormap = ColormapController(
167+
server=server,
168+
variable_name="Temperature",
169+
mapper=my_mapper,
170+
data_array_fn=lambda: get_data_array(),
171+
render_fn=render,
172+
config=existing_config, # uses fields from a larger config object
173+
)
174+
175+
# In UI building:
176+
ColormapColorbar(config, colormap.update_color_preset)
177+
```
178+
179+
### Updating color range after data changes
180+
181+
When the underlying data changes (e.g. slice selection, pipeline update),
182+
call `update_color_range()` on each view's controller to recompute the
183+
range, re-apply transforms, and regenerate ticks:
184+
185+
```python
186+
for view in views:
187+
view.colormap.update_color_range()
188+
```
189+
190+
This is done in `ViewManager.update_color_range()`, called from `app.py`
191+
after slicing or downstream pipeline changes.
192+
193+
### State export / import
194+
195+
Colormap fields live on the same config as layout fields, so they are
196+
serialized alongside them in `download_state()` / `_import_state()`.
197+
One caveat: JSON deserializes tuples as lists, so `color_range` must
198+
be converted back:
199+
200+
```python
201+
if isinstance(cfg["color_range"], list):
202+
cfg["color_range"] = tuple(cfg["color_range"])
203+
```
204+
205+
### Standalone (future trame-colormap package)
206+
207+
When no external config is provided, the controller creates its own
208+
`ColormapConfig`:
209+
210+
```python
211+
from colormaps import ColormapController, ColormapInitialize
212+
213+
ColormapInitialize(server)
214+
215+
colormap = ColormapController(
216+
server=server,
217+
variable_name="Pressure",
218+
mapper=my_mapper,
219+
data_array_fn=lambda: get_data_array(),
220+
render_fn=render,
221+
# config=None → creates a ColormapConfig automatically
222+
)
223+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from e3sm_quickview.colormaps.controller import ColormapController
2+
from e3sm_quickview.colormaps.server import ColormapInitialize
3+
from e3sm_quickview.colormaps.state import ColormapConfig
4+
5+
__all__ = ["ColormapController", "ColormapConfig", "ColormapInitialize"]

0 commit comments

Comments
 (0)