Skip to content

Commit 598333a

Browse files
Merge pull request #14 from Kitware/feature/nan-color-dropdown
feat: add NaN color dropdown to toolbar
2 parents 8283d09 + 2d4f9e3 commit 598333a

5 files changed

Lines changed: 238 additions & 12 deletions

File tree

README.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,10 @@ The control panel has three sections, top to bottom:
113113

114114
The toolbar has three areas, left to right:
115115

116-
- **Icon buttons**Seven buttons separated into three groups by vertical
116+
- **Icon buttons**Eight buttons separated into three groups by vertical
117117
dividers. Active toggles show a primary-colored square outline; the icon
118-
itself stays black. Details on each button below.
118+
itself stays black. The NaN color button opens a dropdown instead of
119+
toggling. Details on each button below.
119120

120121
- **Search / preset name** — Shows the name of the currently active preset.
121122
Click into it to type and filter the preset list by name. Use the clear
@@ -135,9 +136,10 @@ The toolbar has three areas, left to right:
135136
| 3 | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/palette.svg" width="24"> || Category | Opens a dropdown to select one preset category (Sequential, Multi-Sequential, Diverging, Cyclic). Default is Sequential. Disabled in Δ mode. |
136137
| 4 | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/blinds.svg" width="24"> | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/blinds.svg" width="24" style="border: 2px solid #1867C0; border-radius: 4px; padding: 2px;"> | Colorblind Safe | Limits the *Preset list* to colorblind-safe presets within the active category. |
137138
| 5 | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/invert-colors.svg" width="24"> | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/invert-colors.svg" width="24" style="border: 2px solid #1867C0; border-radius: 4px; padding: 2px;"> | Invert | Reverses the colormap direction (shown in the *Colorbar* and *Preset list*). |
139+
| 6 | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/crosshairs-question.svg" width="24"> || NaN Color | Opens a dropdown to select the color used for NaN/missing data. Default is transparent. Shows a color swatch and situation label for each option. |
138140
| | | | | |
139-
| 6 | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/gradient-horizontal.svg" width="24"> | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/gradient-horizontal.svg" width="24" style="border: 2px solid #1867C0; border-radius: 4px; padding: 2px;"> | Discrete | Switches between continuous gradient and discrete color banding. Exposes band count in *Settings panel*. |
140-
| 7 | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/pencil.svg" width="24"> | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/pencil.svg" width="24" style="border: 2px solid #1867C0; border-radius: 4px; padding: 2px;"> | Custom Range | Toggles between data-driven range and manual Min/Max inputs. Disabled in Δ mode. Cannot be active at the same time as Δ Difference. |
141+
| 7 | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/gradient-horizontal.svg" width="24"> | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/gradient-horizontal.svg" width="24" style="border: 2px solid #1867C0; border-radius: 4px; padding: 2px;"> | Discrete | Switches between continuous gradient and discrete color banding. Exposes band count in *Settings panel*. |
142+
| 8 | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/pencil.svg" width="24"> | <img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/pencil.svg" width="24" style="border: 2px solid #1867C0; border-radius: 4px; padding: 2px;"> | Custom Range | Toggles between data-driven range and manual Min/Max inputs. Disabled in Δ mode. Cannot be active at the same time as Δ Difference. |
141143

142144
Empty rows in the table indicate the vertical separator dividers between
143145
button groups.
@@ -150,6 +152,23 @@ button groups.
150152
- **Category dropdown** is disabled when Δ Difference is on (presets forced
151153
to Diverging). When Δ is turned off, category resets to Sequential.
152154

155+
#### NaN Color Dropdown
156+
157+
The NaN Color button (<img src="https://cdn.jsdelivr.net/npm/@mdi/svg/svg/crosshairs-question.svg" width="16">)
158+
opens a scrollable dropdown with 19 preset options. Each row shows a color
159+
swatch circle and a situation label. Options are grouped:
160+
161+
| Group | Options |
162+
|-------|---------|
163+
| **Default** | transparent, general |
164+
| **Colormap types** | sequential maps, diverging maps, categorical maps, grayscale maps, bright maps, dark maps, hot maps, terrain final |
165+
| **Data quality** | error, warning, suspect data, masked data, debugging |
166+
| **Background/context** | light background, dark background, publication light, publication dark |
167+
168+
The default is **transparent** (`rgba(0,0,0,0)`), meaning NaN cells are
169+
invisible. The selected color is applied via `vtkColorTransferFunction.SetNanColorRGBA()`
170+
on both the main CTF and any render CTF (e.g. symlog).
171+
153172
#### Scale Modes
154173

155174
<table>
@@ -213,6 +232,7 @@ from trame.widgets.colormaps import HorizontalScalarBar, VerticalScalarBar, Colo
213232
| `VerticalScalarBar` | `trame_colormaps.widgets` | Vertical colorbar widget with built-in control panel |
214233
| `ColorMapEditor` | `trame_colormaps.widgets` | Preset picker / control panel popup (used internally by scalar bars) |
215234
| `buttons` | `trame_colormaps.widgets` | Returns button config dicts for the control panel toolbar |
235+
| `NAN_COLOR_OPTIONS` | `trame_colormaps.widgets` | List of 19 NaN color presets with RGBA values and situation labels |
216236

217237
## Preset Data Sources
218238

@@ -501,6 +521,7 @@ subclass. Fields fall into three groups:
501521
| `color_value_min` | `str` | `"0"` | Manual range min (string for text field) |
502522
| `color_value_max` | `str` | `"1"` | Manual range max (string for text field) |
503523
| `override_range` | `bool` | `False` | Use manual range instead of data range |
524+
| `nan_color` | `list[float]` | `[0,0,0,0]` | RGBA color for NaN/missing values (transparent by default) |
504525
| **Derived (computed internally, read by UI)** ||||
505526
| `color_range` | `tuple[float, float]` | `(0, 1)` | Active min/max color range |
506527
| `color_value_min_valid` | `bool` | `True` | Whether `color_value_min` parses as a valid float |
@@ -516,6 +537,7 @@ subclass. Fields fall into three groups:
516537
| `menu` | `bool` | `False` | Whether the control panel popup is open |
517538
| `search` | `str \| None` | `None` | Preset search filter text |
518539
| `orientation` | `str` | `"horizontal"` | Colorbar orientation |
540+
| `show_nan_menu` | `bool` | `False` | Whether the NaN color dropdown is open |
519541
| `mapper_change` | `int` | `0` | Server-only counter incremented on each mapper update |
520542

521543
### ColormapConfig Methods

src/trame_colormaps/dataclasses.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,17 +131,22 @@ class ColormapConfig(StateDataModel):
131131
luts_normal: list = Sync(list, list)
132132
luts_inverted: list = Sync(list, list)
133133

134+
# --- NaN color ---
135+
nan_color: list[float] = Sync(list, [0.0, 0.0, 0.0, 0.0])
136+
134137
# --- UI widget state (control panel popup) ---
135138
menu: bool = Sync(bool, False)
136139
search: str | None = Sync(str)
137140
orientation: str = Sync(str, "horizontal")
138141
mapper_change: int = ServerOnly(int, 0)
139142
show_categories: bool = Sync(bool, False)
140143
selected_categories: str = Sync(str, "sequential")
144+
show_nan_menu: bool = Sync(bool, False)
141145

142146
def __init__(self, *args, mapper=None, data_array_fn=None, **kwargs):
143147
# Create and own the CTF
144148
self._ctf = vtkColorTransferFunction()
149+
self._ctf.SetNanColorRGBA(0.0, 0.0, 0.0, 0.0)
145150
self._mapper = mapper
146151
self._get_data_array = data_array_fn
147152

@@ -788,5 +793,22 @@ def update_color_preset(
788793
self._mapper.SetLookupTable(self._ctf)
789794

790795
self._mapper.SetScalarRange(self.color_range)
796+
self._apply_nan_color()
797+
798+
self.mapper_change += 1
791799

800+
@watch("nan_color", eager=True)
801+
def _on_nan_color_change(self, nan_color):
802+
"""Apply NaN color to the active CTF(s) whenever it changes."""
803+
self._apply_nan_color()
792804
self.mapper_change += 1
805+
806+
def _apply_nan_color(self):
807+
"""Set NaN color (RGBA) on all active CTFs."""
808+
c = self.nan_color
809+
if not c or len(c) < 4:
810+
c = [0.0, 0.0, 0.0, 0.0]
811+
r, g, b, a = float(c[0]), float(c[1]), float(c[2]), float(c[3])
812+
self._ctf.SetNanColorRGBA(r, g, b, a)
813+
if hasattr(self, "_symlog_ctf") and self._symlog_ctf:
814+
self._symlog_ctf.SetNanColorRGBA(r, g, b, a)

src/trame_colormaps/widgets.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,32 @@
1010
"VerticalScalarBar",
1111
]
1212

13+
NAN_COLOR_OPTIONS = [
14+
# Default
15+
{"color": [0.0, 0.0, 0.0, 0.0], "situation_preset_type": "transparent"},
16+
{"color": [0.75, 0.75, 0.75, 1.0], "situation_preset_type": "general"},
17+
# Colormap types
18+
{"color": [0.85, 0.85, 0.85, 1.0], "situation_preset_type": "sequential maps"},
19+
{"color": [0.60, 0.60, 0.60, 1.0], "situation_preset_type": "diverging maps"},
20+
{"color": [0.80, 0.80, 0.80, 1.0], "situation_preset_type": "categorical maps"},
21+
{"color": [0.22, 0.49, 0.72, 1.0], "situation_preset_type": "grayscale maps"},
22+
{"color": [0.0, 0.0, 0.0, 1.0], "situation_preset_type": "bright maps"},
23+
{"color": [1.0, 1.0, 1.0, 1.0], "situation_preset_type": "dark maps"},
24+
{"color": [0.0, 1.0, 1.0, 1.0], "situation_preset_type": "hot maps"},
25+
{"color": [0.74, 0.74, 0.74, 1.0], "situation_preset_type": "terrain final"},
26+
# Data quality
27+
{"color": [0.89, 0.10, 0.11, 1.0], "situation_preset_type": "error"},
28+
{"color": [1.0, 1.0, 0.20, 1.0], "situation_preset_type": "warning"},
29+
{"color": [1.0, 0.50, 0.0, 1.0], "situation_preset_type": "suspect data"},
30+
{"color": [0.30, 0.69, 0.29, 1.0], "situation_preset_type": "masked data"},
31+
{"color": [1.0, 0.0, 1.0, 1.0], "situation_preset_type": "debugging"},
32+
# Background/context
33+
{"color": [0.55, 0.55, 0.55, 1.0], "situation_preset_type": "light background"},
34+
{"color": [0.33, 0.33, 0.33, 1.0], "situation_preset_type": "dark background"},
35+
{"color": [0.94, 0.94, 0.94, 1.0], "situation_preset_type": "publication light"},
36+
{"color": [0.15, 0.15, 0.15, 1.0], "situation_preset_type": "publication dark"},
37+
]
38+
1339

1440
def buttons(name):
1541
return [
@@ -80,6 +106,12 @@ def buttons(name):
80106
"tip": (f"'Toggle to ' + ({name}.invert ? 'Normal Preset' : 'Invert Preset')"),
81107
"active": f"{name}.invert",
82108
},
109+
{
110+
"icon": "mdi-crosshairs-question",
111+
"tip": "'NaN Color'",
112+
"active": "false",
113+
"nan_menu": True,
114+
},
83115
{"separator": True},
84116
{
85117
"icon": "mdi-gradient-horizontal",
@@ -176,6 +208,62 @@ def __init__(self, name):
176208
activator="parent",
177209
location="bottom",
178210
)
211+
elif b.get("nan_menu"):
212+
with html.Div():
213+
with v3.VMenu(
214+
v_model=f"{name}.show_nan_menu",
215+
close_on_content_click=True,
216+
location="bottom",
217+
):
218+
with html.Template(v_slot_activator="{ props: nanProps }"):
219+
btn_kwargs["v_bind"] = "nanProps"
220+
btn_kwargs["variant"] = "'text'"
221+
btn_kwargs["color"] = "undefined"
222+
v3.VBtn(**btn_kwargs)
223+
with v3.VList(density="compact", max_height="300"):
224+
for nc in NAN_COLOR_OPTIONS:
225+
rgba = nc["color"]
226+
label = nc["situation_preset_type"]
227+
r255 = int(rgba[0] * 255)
228+
g255 = int(rgba[1] * 255)
229+
b255 = int(rgba[2] * 255)
230+
a_val = rgba[3]
231+
if a_val == 0:
232+
swatch_style = (
233+
"width:16px; height:16px; "
234+
"border-radius:50%; "
235+
"border: 1px solid #999; "
236+
"margin-right: 8px; "
237+
"background: repeating-conic-gradient("
238+
"#ccc 0% 25%, #fff 0% 50%) "
239+
"50%/8px 8px;"
240+
)
241+
else:
242+
swatch_style = (
243+
f"width:16px; height:16px; "
244+
f"border-radius:50%; "
245+
f"border: 1px solid #999; "
246+
f"margin-right: 8px; "
247+
f"background: rgb({r255},{g255},{b255});"
248+
)
249+
color_json = (
250+
f"[{rgba[0]},{rgba[1]},{rgba[2]},{rgba[3]}]"
251+
)
252+
with v3.VListItem(
253+
click=(f"{name}.nan_color = {color_json}"),
254+
):
255+
with html.Template(
256+
v_slot_prepend=True,
257+
):
258+
html.Span(
259+
style=swatch_style,
260+
)
261+
v3.VListItemTitle(label)
262+
v3.VTooltip(
263+
text=(b["tip"],),
264+
activator="parent",
265+
location="bottom",
266+
)
179267
else:
180268
btn_kwargs["click"] = b["click"]
181269
with html.Div():

tests/test_dataclasses.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ def test_validation_defaults(self, config):
128128
def test_active_presets_default_matches(self, config):
129129
assert config.active_presets == DEFAULT_PRESETS
130130

131+
def test_nan_color_default(self, config):
132+
assert config.nan_color == [0.0, 0.0, 0.0, 0.0]
133+
134+
def test_show_nan_menu_default(self, config):
135+
assert config.show_nan_menu is False
136+
137+
def test_nan_color_applied_to_ctf_at_init(self, config):
138+
assert config._ctf.GetNanColor() == (0.0, 0.0, 0.0)
139+
assert config._ctf.GetNanOpacity() == 0.0
140+
131141

132142
# =====================================================================
133143
# Mutation
@@ -568,3 +578,43 @@ def test_no_overlap_sequential_multi_sequential(self):
568578

569579
def test_no_overlap_diverging_cyclic(self):
570580
assert not (DIVERGING_PRESETS & CYCLIC_PRESETS)
581+
582+
583+
# =====================================================================
584+
# NaN Color
585+
# =====================================================================
586+
587+
588+
class TestNanColor:
589+
def test_apply_nan_color_sets_ctf(self, config):
590+
config.nan_color = [1.0, 0.0, 1.0, 1.0]
591+
config._apply_nan_color()
592+
assert config._ctf.GetNanColor() == pytest.approx((1.0, 0.0, 1.0))
593+
assert config._ctf.GetNanOpacity() == 1.0
594+
595+
def test_apply_nan_color_transparent(self, config):
596+
config.nan_color = [0.0, 0.0, 0.0, 0.0]
597+
config._apply_nan_color()
598+
assert config._ctf.GetNanColor() == (0.0, 0.0, 0.0)
599+
assert config._ctf.GetNanOpacity() == 0.0
600+
601+
def test_apply_nan_color_propagates_to_symlog_ctf(self, config):
602+
from vtkmodules.vtkRenderingCore import vtkColorTransferFunction
603+
604+
config._symlog_ctf = vtkColorTransferFunction()
605+
config.nan_color = [0.89, 0.10, 0.11, 1.0]
606+
config._apply_nan_color()
607+
assert config._symlog_ctf.GetNanColor() == pytest.approx((0.89, 0.10, 0.11))
608+
assert config._symlog_ctf.GetNanOpacity() == 1.0
609+
610+
def test_apply_nan_color_handles_short_list(self, config):
611+
config.nan_color = [1.0, 0.0]
612+
config._apply_nan_color()
613+
assert config._ctf.GetNanColor() == (0.0, 0.0, 0.0)
614+
assert config._ctf.GetNanOpacity() == 0.0
615+
616+
def test_apply_nan_color_handles_none(self, config):
617+
config.nan_color = None
618+
config._apply_nan_color()
619+
assert config._ctf.GetNanColor() == (0.0, 0.0, 0.0)
620+
assert config._ctf.GetNanOpacity() == 0.0

0 commit comments

Comments
 (0)