From c2bcbd6b314d6cb3431cb64a6e03a84afc48bc10 Mon Sep 17 00:00:00 2001 From: Patrick O'Leary Date: Wed, 15 Apr 2026 07:42:13 -0500 Subject: [PATCH 1/2] Add symlog scale toggle with 3-state cycle and update colorbar formatting --- src/e3sm_quickview/components/view.py | 12 +-- src/e3sm_quickview/module/serve/utils.js | 15 ++- src/e3sm_quickview/view_manager.py | 112 +++++++++++++++++++++-- src/e3sm_quickview/view_manager2.py | 111 ++++++++++++++++++++-- 4 files changed, 224 insertions(+), 26 deletions(-) diff --git a/src/e3sm_quickview/components/view.py b/src/e3sm_quickview/components/view.py index 98fbd90..12260d9 100644 --- a/src/e3sm_quickview/components/view.py +++ b/src/e3sm_quickview/components/view.py @@ -154,15 +154,15 @@ def create_bottom_bar(config, update_color_preset): ) v3.VIconBtn( v_tooltip_bottom=( - "config.use_log_scale ? 'Toggle to linear scale' : 'Toggle to log scale'" + "config.use_log_scale === 'linear' ? 'Toggle to log scale' : config.use_log_scale === 'log' ? 'Toggle to symlog scale' : 'Toggle to linear scale'", ), icon=( - "config.use_log_scale ? 'mdi-math-log' : 'mdi-stairs'", + "config.use_log_scale === 'log' ? 'mdi-math-log' : config.use_log_scale === 'symlog' ? 'mdi-chart-bell-curve-cumulative' : 'mdi-stairs'", ), - click="config.use_log_scale = !config.use_log_scale", + click="config.use_log_scale = config.use_log_scale === 'linear' ? 'log' : config.use_log_scale === 'log' ? 'symlog' : 'linear'", size="small", text=( - "config.use_log_scale ? 'Log scale' : 'Linear scale'", + "config.use_log_scale === 'log' ? 'Log' : config.use_log_scale === 'symlog' ? 'SymLog' : 'Linear'", ), variant="text", ) @@ -257,7 +257,7 @@ def create_bottom_bar(config, update_color_preset): classes="rounded", ) html.Div( - "{{ utils.quickview.formatRange(config.color_range?.[0], config.use_log_scale) }}", + "{{ utils.quickview.formatRange(config.color_range?.[0], config.use_log_scale, config.color_range?.[0], config.color_range?.[1]) }}", classes="text-caption px-2 text-no-wrap", ) with html.Div(classes="overflow-hidden rounded w-100", style="height:70%;"): @@ -267,6 +267,6 @@ def create_bottom_bar(config, update_color_preset): draggable=False, ) html.Div( - "{{ utils.quickview.formatRange(config.color_range?.[1], config.use_log_scale) }}", + "{{ utils.quickview.formatRange(config.color_range?.[1], config.use_log_scale, config.color_range?.[0], config.color_range?.[1]) }}", classes="text-caption px-2 text-no-wrap", ) diff --git a/src/e3sm_quickview/module/serve/utils.js b/src/e3sm_quickview/module/serve/utils.js index 1432700..c96f499 100644 --- a/src/e3sm_quickview/module/serve/utils.js +++ b/src/e3sm_quickview/module/serve/utils.js @@ -1,11 +1,22 @@ window.trame.utils.quickview = { - formatRange(value, useLog) { + formatRange(value, useLog, rangeMin, rangeMax) { if (value === null || value === undefined || isNaN(value)) { return "Auto"; } - if (useLog && value > 0) { + if (useLog === "log" && value > 0) { return `10^(${Math.log10(value).toFixed(1)})`; } + if (useLog === "symlog") { + if (value === 0) return "0"; + const linthresh = + Math.max(Math.abs(rangeMin), Math.abs(rangeMax)) * 1e-2 || 1.0; + const absVal = Math.abs(value); + if (absVal <= linthresh) { + return value.toExponential(1); + } + const sign = value < 0 ? "-" : ""; + return `${sign}10^(${Math.log10(absVal).toFixed(1)})`; + } const nSignDigit = Math.log10(Math.abs(value)); if (Math.abs(nSignDigit) < 6 || value === 0) { if (nSignDigit > 0 && nSignDigit < 3) { diff --git a/src/e3sm_quickview/view_manager.py b/src/e3sm_quickview/view_manager.py index 3dd8c20..deac4c3 100644 --- a/src/e3sm_quickview/view_manager.py +++ b/src/e3sm_quickview/view_manager.py @@ -1,5 +1,7 @@ import math +import numpy as np + from paraview import simple from trame.app import TrameComponent, dataclass from trame.decorators import controller @@ -50,7 +52,7 @@ class ViewConfiguration(dataclass.StateDataModel): preset: str = dataclass.Sync(str, "BuGnYl") invert: bool = dataclass.Sync(bool, False) color_blind: bool = dataclass.Sync(bool, False) - use_log_scale: bool = dataclass.Sync(bool, False) + use_log_scale: str = dataclass.Sync(str, "linear") color_value_min: str = dataclass.Sync(str, "0") color_value_max: str = dataclass.Sync(str, "1") color_value_min_valid: bool = dataclass.Sync(bool, True) @@ -152,14 +154,13 @@ def reset_camera(self): def update_color_preset(self, name, invert, log_scale, n_colors=255): self.config.preset = name - self.lut.UseLogScale = 0 - self.lut.ApplyPreset(self.config.preset, True) - if invert: - self.lut.InvertTransferFunction() - if log_scale: - self.lut.MapControlPointsToLogSpace() - self.lut.UseLogScale = 1 + if log_scale == "log": + self._apply_log_to_lut(invert) + elif log_scale == "symlog": + self._apply_symlog_to_lut(invert) + else: + self._apply_linear_to_lut(invert) if n_colors is not None: self.lut.NumberOfTableValues = n_colors @@ -168,6 +169,93 @@ def update_color_preset(self, name, invert, log_scale, n_colors=255): self.render() + def _apply_linear_to_lut(self, invert=False): + """Apply preset with linear scale.""" + self.lut.UseLogScale = 0 + self.lut.ApplyPreset(self.config.preset, True) + if invert: + self.lut.InvertTransferFunction() + + def _apply_log_to_lut(self, invert=False): + """Apply preset with log scale.""" + self.lut.UseLogScale = 0 + self.lut.ApplyPreset(self.config.preset, True) + if invert: + self.lut.InvertTransferFunction() + self.lut.MapControlPointsToLogSpace() + self.lut.UseLogScale = 1 + + def _apply_symlog_to_lut( + self, invert=False, linthresh=None, linscale=1.0, base=10, n_samples=256 + ): + """Apply preset with symmetric log scale. + + Uses: + - Linear for |x| <= linthresh + - Logarithmic for |x| > linthresh + with continuity at the boundary. + + Samples colors from the linear preset and redistributes them + across the data range using symlog spacing. + """ + self.lut.UseLogScale = 0 + self.lut.ApplyPreset(self.config.preset, True) + if invert: + self.lut.InvertTransferFunction() + + # Get the current data range from the LUT + ctf = self.lut.GetClientSideObject() + x_min, x_max = ctf.GetRange() + data_range = x_max - x_min + if data_range == 0: + return + + if linthresh is None: + linthresh = max(abs(x_min), abs(x_max)) * 1e-2 + if linthresh == 0: + linthresh = 1.0 + + log_base = np.log(base) + linscale_adj = linscale / (1.0 - base**-1) + + def symlog(x): + abs_x = np.abs(x) + out = np.where( + abs_x <= linthresh, + x * linscale_adj, + np.sign(x) + * linthresh + * (linscale_adj + np.log(abs_x / linthresh) / log_base), + ) + return out + + # Sample colors from the linear LUT at uniform positions + rgb = [0.0, 0.0, 0.0] + s_min = symlog(x_min) + s_max = symlog(x_max) + s_range = s_max - s_min + if s_range == 0: + return + + new_rgb_points = [] + for i in range(n_samples): + # Uniform position in data space + t = i / (n_samples - 1) + x_data = x_min + t * data_range + + # Map x_data through symlog, normalize to [0,1], then look up + # the color at the corresponding linear position + s_val = symlog(x_data) + s_t = (s_val - s_min) / s_range + x_lookup = x_min + s_t * data_range + ctf.GetColor(x_lookup, rgb) + new_rgb_points.extend( + [float(x_data), float(rgb[0]), float(rgb[1]), float(rgb[2])] + ) + + # Write back through the proxy so state stays in sync + self.lut.RGBPoints = new_rgb_points + def color_range_str_to_float(self, color_value_min, color_value_max): try: min_value = float(color_value_min) @@ -215,7 +303,13 @@ def update_color_range(self, *_): self.config.color_value_min_valid = True self.config.color_value_max_valid = True self.lut.RescaleTransferFunction(*data_range) - self.render() + + self.update_color_preset( + self.config.preset, + self.config.invert, + self.config.use_log_scale, + self.config.n_colors, + ) def _build_ui(self): with DivLayout( diff --git a/src/e3sm_quickview/view_manager2.py b/src/e3sm_quickview/view_manager2.py index 4fe4296..0701c7a 100644 --- a/src/e3sm_quickview/view_manager2.py +++ b/src/e3sm_quickview/view_manager2.py @@ -1,6 +1,8 @@ import asyncio import math +import numpy as np + # Rendering Factory import vtkmodules.vtkRenderingOpenGL2 # noqa: F401 from paraview import simple @@ -65,7 +67,7 @@ class ViewConfiguration(dataclass.StateDataModel): preset: str = dataclass.Sync(str, "BuGnYl") invert: bool = dataclass.Sync(bool, False) color_blind: bool = dataclass.Sync(bool, False) - use_log_scale: bool = dataclass.Sync(bool, False) + use_log_scale: str = dataclass.Sync(str, "linear") color_value_min: str = dataclass.Sync(str, "0") color_value_max: str = dataclass.Sync(str, "1") color_value_min_valid: bool = dataclass.Sync(bool, True) @@ -169,14 +171,13 @@ def render(self): def update_color_preset(self, name, invert, log_scale, n_colors=255): self.config.preset = name - self.lut.UseLogScale = 0 - self.lut.ApplyPreset(self.config.preset, True) - if invert: - self.lut.InvertTransferFunction() - if log_scale: - self.lut.MapControlPointsToLogSpace() - self.lut.UseLogScale = 1 + if log_scale == "log": + self._apply_log_to_lut(invert) + elif log_scale == "symlog": + self._apply_symlog_to_lut(invert) + else: + self._apply_linear_to_lut(invert) if n_colors is not None: self.lut.NumberOfTableValues = n_colors @@ -184,6 +185,93 @@ def update_color_preset(self, name, invert, log_scale, n_colors=255): self.config.lut_img = lut_to_img(self.lut) self.render() + def _apply_linear_to_lut(self, invert=False): + """Apply preset with linear scale.""" + self.lut.UseLogScale = 0 + self.lut.ApplyPreset(self.config.preset, True) + if invert: + self.lut.InvertTransferFunction() + + def _apply_log_to_lut(self, invert=False): + """Apply preset with log scale.""" + self.lut.UseLogScale = 0 + self.lut.ApplyPreset(self.config.preset, True) + if invert: + self.lut.InvertTransferFunction() + self.lut.MapControlPointsToLogSpace() + self.lut.UseLogScale = 1 + + def _apply_symlog_to_lut( + self, invert=False, linthresh=None, linscale=1.0, base=10, n_samples=256 + ): + """Apply preset with symmetric log scale. + + Uses: + - Linear for |x| <= linthresh + - Logarithmic for |x| > linthresh + with continuity at the boundary. + + Samples colors from the linear preset and redistributes them + across the data range using symlog spacing. + """ + self.lut.UseLogScale = 0 + self.lut.ApplyPreset(self.config.preset, True) + if invert: + self.lut.InvertTransferFunction() + + # Get the current data range from the LUT + ctf = self.lut.GetClientSideObject() + x_min, x_max = ctf.GetRange() + data_range = x_max - x_min + if data_range == 0: + return + + if linthresh is None: + linthresh = max(abs(x_min), abs(x_max)) * 1e-2 + if linthresh == 0: + linthresh = 1.0 + + log_base = np.log(base) + linscale_adj = linscale / (1.0 - base**-1) + + def symlog(x): + abs_x = np.abs(x) + out = np.where( + abs_x <= linthresh, + x * linscale_adj, + np.sign(x) + * linthresh + * (linscale_adj + np.log(abs_x / linthresh) / log_base), + ) + return out + + # Sample colors from the linear LUT at uniform positions + rgb = [0.0, 0.0, 0.0] + s_min = symlog(x_min) + s_max = symlog(x_max) + s_range = s_max - s_min + if s_range == 0: + return + + new_rgb_points = [] + for i in range(n_samples): + # Uniform position in data space + t = i / (n_samples - 1) + x_data = x_min + t * data_range + + # Map x_data through symlog, normalize to [0,1], then look up + # the color at the corresponding linear position + s_val = symlog(x_data) + s_t = (s_val - s_min) / s_range + x_lookup = x_min + s_t * data_range + ctf.GetColor(x_lookup, rgb) + new_rgb_points.extend( + [float(x_data), float(rgb[0]), float(rgb[1]), float(rgb[2])] + ) + + # Write back through the proxy so state stays in sync + self.lut.RGBPoints = new_rgb_points + def color_range_str_to_float(self, color_value_min, color_value_max): try: min_value = float(color_value_min) @@ -232,7 +320,12 @@ def update_color_range(self, *_): self.config.color_value_max_valid = True self.lut.RescaleTransferFunction(*data_range) - self.render() + self.update_color_preset( + self.config.preset, + self.config.invert, + self.config.use_log_scale, + self.config.n_colors, + ) def _build_ui(self): with DivLayout( From d370e18dae496e9ccbac0a7f7e3e9257ee88251d Mon Sep 17 00:00:00 2001 From: Patrick O'Leary Date: Wed, 15 Apr 2026 11:55:55 -0500 Subject: [PATCH 2/2] Add colorbar tick marks with contrast-aware labels and fix log/symlog LUT preset application order --- src/e3sm_quickview/components/view.py | 35 ++++- src/e3sm_quickview/utils/math.py | 182 ++++++++++++++++++++++++++ src/e3sm_quickview/view_manager.py | 77 ++++++++--- src/e3sm_quickview/view_manager2.py | 78 ++++++++--- 4 files changed, 332 insertions(+), 40 deletions(-) diff --git a/src/e3sm_quickview/components/view.py b/src/e3sm_quickview/components/view.py index 12260d9..6774c9d 100644 --- a/src/e3sm_quickview/components/view.py +++ b/src/e3sm_quickview/components/view.py @@ -157,7 +157,7 @@ def create_bottom_bar(config, update_color_preset): "config.use_log_scale === 'linear' ? 'Toggle to log scale' : config.use_log_scale === 'log' ? 'Toggle to symlog scale' : 'Toggle to linear scale'", ), icon=( - "config.use_log_scale === 'log' ? 'mdi-math-log' : config.use_log_scale === 'symlog' ? 'mdi-chart-bell-curve-cumulative' : 'mdi-stairs'", + "config.use_log_scale === 'log' ? 'mdi-math-log' : config.use_log_scale === 'symlog' ? 'mdi-sine-wave mdi-rotate-330' : 'mdi-stairs'", ), click="config.use_log_scale = config.use_log_scale === 'linear' ? 'log' : config.use_log_scale === 'log' ? 'symlog' : 'linear'", size="small", @@ -257,16 +257,43 @@ def create_bottom_bar(config, update_color_preset): classes="rounded", ) html.Div( - "{{ utils.quickview.formatRange(config.color_range?.[0], config.use_log_scale, config.color_range?.[0], config.color_range?.[1]) }}", + "{{ utils.quickview.formatRange(config.effective_color_range?.[0], config.use_log_scale, config.effective_color_range?.[0], config.effective_color_range?.[1]) }}", classes="text-caption px-2 text-no-wrap", ) - with html.Div(classes="overflow-hidden rounded w-100", style="height:70%;"): + with html.Div( + classes="rounded w-100", + style="height:70%;position:relative;", + ): html.Img( src=("config.lut_img",), style="width:100%;height:2rem;", draggable=False, ) + with html.Div( + style="position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;", + ): + with html.Div( + v_for="(tick, i) in config.color_ticks", + key="i", + style=( + "`position:absolute;left:${tick.position}%;top:0;height:100%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;`", + ), + ): + html.Div( + style=( + "`width:1.5px;height:30%;background:${tick.color};`", + ), + ) + html.Span( + "{{ tick.label }}", + style=( + "`font-size:0.5rem;line-height:1;white-space:nowrap;color:${tick.color};`", + ), + ) + html.Div( + style=("`width:1.5px;flex:1;background:${tick.color};`",), + ) html.Div( - "{{ utils.quickview.formatRange(config.color_range?.[1], config.use_log_scale, config.color_range?.[0], config.color_range?.[1]) }}", + "{{ utils.quickview.formatRange(config.effective_color_range?.[1], config.use_log_scale, config.effective_color_range?.[0], config.effective_color_range?.[1]) }}", classes="text-caption px-2 text-no-wrap", ) diff --git a/src/e3sm_quickview/utils/math.py b/src/e3sm_quickview/utils/math.py index b4e80f9..617654f 100644 --- a/src/e3sm_quickview/utils/math.py +++ b/src/e3sm_quickview/utils/math.py @@ -133,3 +133,185 @@ def normalize_range( normalized = (value - old_min) / (old_max - old_min) return new_min + normalized * (new_max - new_min) + + +def get_nice_ticks(vmin, vmax, n, scale="linear"): + """Compute nicely spaced tick values for a given range and scale. + + Args: + vmin: Minimum data value + vmax: Maximum data value + n: Desired number of ticks + scale: One of 'linear', 'log', or 'symlog' + + Returns: + Sorted array of unique, snapped tick values. + """ + + def snap(val): + if np.isclose(val, 0, atol=1e-12): + return 0.0 + sign = np.sign(val) + val_abs = abs(val) + mag = 10 ** np.floor(np.log10(val_abs)) + residual = val_abs / mag + nice_steps = np.array([1.0, 2.0, 5.0, 10.0]) + best_step = nice_steps[np.abs(nice_steps - residual).argmin()] + return sign * best_step * mag + + if scale == "linear": + raw_ticks = np.linspace(vmin, vmax, n) + elif scale == "log": + # Use integer powers of 10 that fall strictly inside [vmin, vmax] + safe_vmin = max(vmin, 1e-15) + safe_vmax = max(vmax, 1e-14) + start_exp = int(np.floor(np.log10(safe_vmin))) + stop_exp = int(np.ceil(np.log10(safe_vmax))) + powers = [ + 10.0**e + for e in range(start_exp, stop_exp + 1) + if safe_vmin <= 10.0**e <= safe_vmax + ] + # Fall back to log-spaced ticks when no powers of 10 are interior + if len(powers) < 2: + raw_ticks = np.geomspace(safe_vmin, safe_vmax, n) + else: + raw_ticks = np.array(powers) + elif scale == "symlog": + + def transform(x, th): + return np.sign(x) * np.log10(np.abs(x) / th + 1) + + def inverse(y, th): + return np.sign(y) * th * (10 ** np.abs(y) - 1) + + linthresh = max(abs(vmin), abs(vmax)) * 1e-2 + if linthresh == 0: + linthresh = 1.0 + t_min, t_max = transform(vmin, linthresh), transform(vmax, linthresh) + t_ticks = np.linspace(t_min, t_max, n) + raw_ticks = inverse(t_ticks, linthresh) + else: + raw_ticks = np.linspace(vmin, vmax, n) + + nice_ticks = np.array([snap(t) for t in raw_ticks]) + + # Force 0 for non-log scales if it's within range + if vmin <= 0 <= vmax and scale != "log": + idx = np.abs(nice_ticks).argmin() + nice_ticks[idx] = 0.0 + + return np.unique(np.sort(nice_ticks)) + + +def format_tick(val): + """Format a tick value as a concise human-readable string. + + Returns a string suitable for display on a colorbar. Powers of 10 are + shown as '10^N', very large/small values use scientific notation, and + intermediate values use fixed-point. + """ + if np.isclose(val, 0, atol=1e-12): + return "0" + + val_abs = abs(val) + log10 = np.log10(val_abs) + + if np.isclose(log10, np.round(log10), atol=1e-12): + exponent = int(np.round(log10)) + sign = "-" if val < 0 else "" + if exponent == 0: + return f"{sign}1" + if exponent == 1: + return f"{sign}10" + return f"{sign}10^{exponent}" + + if val_abs >= 1000 or val_abs <= 0.01: + return f"{val:.1e}" + return f"{int(val) if val == int(val) else val:.1f}" + + +def tick_contrast_color(r, g, b): + """Return '#fff' or '#000' for best contrast against the given RGB color. + + Uses the W3C relative luminance formula to decide. RGB values are + expected in [0, 1] range. + """ + luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b + return "#000" if luminance > 0.45 else "#fff" + + +def compute_color_ticks(vmin, vmax, scale="linear", n=5, min_gap=7, edge_margin=3): + """Compute tick marks for a colorbar. + + Tick positions are always linear in data space since the colorbar image + is sampled linearly (lut_to_img uses uniform steps from vmin to vmax). + + Args: + vmin: Minimum color range value + vmax: Maximum color range value + scale: One of 'linear', 'log', or 'symlog' + n: Desired number of ticks + min_gap: Minimum gap between ticks in percentage points + edge_margin: Minimum distance from edges (0% and 100%) in percentage points + + Returns: + List of dicts with 'position' (0-100 percentage) and 'label' keys. + """ + if vmin >= vmax: + return [] + + raw_n = n if scale == "linear" else n * 2 + ticks = get_nice_ticks(vmin, vmax, raw_n, scale) + data_range = vmax - vmin + + # Build candidate list with position in linear data space + candidates = [] + has_zero = False + for t in ticks: + val = float(t) + pos = (val - vmin) / data_range * 100 + if edge_margin <= pos <= (100 - edge_margin): + is_zero = np.isclose(val, 0, atol=1e-12) + if is_zero: + has_zero = True + candidates.append( + { + "position": round(pos, 2), + "label": format_tick(val), + "priority": is_zero, + } + ) + + # Always include 0 when it falls within the range (for any scale) + if not has_zero and scale != "log": + zero_pos = (0.0 - vmin) / data_range * 100 + if 0 <= zero_pos <= 100: + tick = {"position": round(zero_pos, 2), "label": "0", "priority": True} + # Insert in sorted order + inserted = False + for i, c in enumerate(candidates): + if tick["position"] <= c["position"]: + candidates.insert(i, tick) + inserted = True + break + if not inserted: + candidates.append(tick) + + # Filter out ticks that are too close together, but never remove priority ticks + result = [] + for tick in candidates: + is_priority = tick.get("priority", False) + if is_priority: + if result and (tick["position"] - result[-1]["position"]) < min_gap: + if not result[-1].get("priority", False): + result.pop() + result.append(tick) + elif not result or (tick["position"] - result[-1]["position"]) >= min_gap: + # Also check distance to next priority tick (look-ahead) + result.append(tick) + + # Clean up internal flags before returning + for tick in result: + tick.pop("priority", None) + return result diff --git a/src/e3sm_quickview/view_manager.py b/src/e3sm_quickview/view_manager.py index deac4c3..aa79bd0 100644 --- a/src/e3sm_quickview/view_manager.py +++ b/src/e3sm_quickview/view_manager.py @@ -13,6 +13,7 @@ from e3sm_quickview.components import view as tview from e3sm_quickview.presets import COLOR_BLIND_SAFE from e3sm_quickview.utils.color import COLORBAR_CACHE, lut_to_img +from e3sm_quickview.utils.math import compute_color_ticks, tick_contrast_color def auto_size_to_col(size): @@ -68,6 +69,8 @@ class ViewConfiguration(dataclass.StateDataModel): search: str | None = dataclass.Sync(str) n_colors: int = dataclass.Sync(int, 255) lut_img: str = dataclass.Sync(str) + color_ticks: list = dataclass.Sync(list, list) + effective_color_range: list[float] = dataclass.Sync(tuple[float, float], (0, 1)) class VariableView(TrameComponent): @@ -155,17 +158,29 @@ def reset_camera(self): def update_color_preset(self, name, invert, log_scale, n_colors=255): self.config.preset = name + # ApplyPreset resets range to [0,1], so always apply the linear + # preset first, rescale to the current range, then apply transforms + self._apply_linear_to_lut(invert) + self.lut.RescaleTransferFunction(*self.config.color_range) + if log_scale == "log": - self._apply_log_to_lut(invert) + self._apply_log_to_lut() elif log_scale == "symlog": - self._apply_symlog_to_lut(invert) - else: - self._apply_linear_to_lut(invert) + self._apply_symlog_to_lut() if n_colors is not None: self.lut.NumberOfTableValues = n_colors + # Read the actual LUT range (may differ from color_range for log scale) + ctf = self.lut.GetClientSideObject() + self.config.effective_color_range = ctf.GetRange() + self.config.lut_img = lut_to_img(self.lut) + self._compute_ticks() + + # Force mapper to pick up LUT changes + self.mapper.SetLookupTable(ctf) + self.mapper.Modified() self.render() @@ -176,19 +191,25 @@ def _apply_linear_to_lut(self, invert=False): if invert: self.lut.InvertTransferFunction() - def _apply_log_to_lut(self, invert=False): - """Apply preset with log scale.""" - self.lut.UseLogScale = 0 - self.lut.ApplyPreset(self.config.preset, True) - if invert: - self.lut.InvertTransferFunction() + def _apply_log_to_lut(self): + """Transform the already-prepared LUT to log scale. + + Log scale requires all positive values, so clamp the range if needed. + """ + ctf = self.lut.GetClientSideObject() + x_min, x_max = ctf.GetRange() + if x_max <= 0: + return + if x_min <= 0: + x_min = x_max * 1e-6 + self.lut.RescaleTransferFunction(x_min, x_max) self.lut.MapControlPointsToLogSpace() self.lut.UseLogScale = 1 def _apply_symlog_to_lut( - self, invert=False, linthresh=None, linscale=1.0, base=10, n_samples=256 + self, linthresh=None, linscale=1.0, base=10, n_samples=256 ): - """Apply preset with symmetric log scale. + """Transform the already-prepared LUT to symmetric log scale. Uses: - Linear for |x| <= linthresh @@ -198,11 +219,6 @@ def _apply_symlog_to_lut( Samples colors from the linear preset and redistributes them across the data range using symlog spacing. """ - self.lut.UseLogScale = 0 - self.lut.ApplyPreset(self.config.preset, True) - if invert: - self.lut.InvertTransferFunction() - # Get the current data range from the LUT ctf = self.lut.GetClientSideObject() x_min, x_max = ctf.GetRange() @@ -220,12 +236,14 @@ def _apply_symlog_to_lut( def symlog(x): abs_x = np.abs(x) + # Clip to avoid log(0); values <= linthresh use linear branch anyway + safe_abs = np.maximum(abs_x, linthresh) out = np.where( abs_x <= linthresh, x * linscale_adj, np.sign(x) * linthresh - * (linscale_adj + np.log(abs_x / linthresh) / log_base), + * (linscale_adj + np.log(safe_abs / linthresh) / log_base), ) return out @@ -311,6 +329,29 @@ def update_color_range(self, *_): self.config.n_colors, ) + def _compute_ticks(self): + vmin, vmax = self.config.effective_color_range + ticks = compute_color_ticks(vmin, vmax, scale=self.config.use_log_scale, n=5) + # Sample colors exactly as lut_to_img does: use RGBPoints range + rgb_points = self.lut.RGBPoints + if len(rgb_points) < 4: + self.config.color_ticks = [] + return + ctf = self.lut.GetClientSideObject() + rgb = [0.0, 0.0, 0.0] + img_min = rgb_points[0] + img_max = rgb_points[-4] + img_range = img_max - img_min + if img_range == 0: + self.config.color_ticks = [] + return + for tick in ticks: + t = tick["position"] / 100.0 + value = img_min + t * img_range + ctf.GetColor(value, rgb) + tick["color"] = tick_contrast_color(rgb[0], rgb[1], rgb[2]) + self.config.color_ticks = ticks + def _build_ui(self): with DivLayout( self.server, template_name=self.name, connect_parent=False, classes="h-100" diff --git a/src/e3sm_quickview/view_manager2.py b/src/e3sm_quickview/view_manager2.py index 0701c7a..27d3ea7 100644 --- a/src/e3sm_quickview/view_manager2.py +++ b/src/e3sm_quickview/view_manager2.py @@ -28,6 +28,7 @@ from e3sm_quickview.components import view as tview from e3sm_quickview.presets import COLOR_BLIND_SAFE from e3sm_quickview.utils.color import COLORBAR_CACHE, lut_to_img +from e3sm_quickview.utils.math import compute_color_ticks, tick_contrast_color def auto_size_to_col(size): @@ -83,6 +84,8 @@ class ViewConfiguration(dataclass.StateDataModel): search: str | None = dataclass.Sync(str) n_colors: int = dataclass.Sync(int, 255) lut_img: str = dataclass.Sync(str) + color_ticks: list = dataclass.Sync(list, list) + effective_color_range: list[float] = dataclass.Sync(tuple[float, float], (0, 1)) class VariableView(TrameComponent): @@ -172,17 +175,30 @@ def render(self): def update_color_preset(self, name, invert, log_scale, n_colors=255): self.config.preset = name + # ApplyPreset resets range to [0,1], so always apply the linear + # preset first, rescale to the current range, then apply transforms + self._apply_linear_to_lut(invert) + self.lut.RescaleTransferFunction(*self.config.color_range) + if log_scale == "log": - self._apply_log_to_lut(invert) + self._apply_log_to_lut() elif log_scale == "symlog": - self._apply_symlog_to_lut(invert) - else: - self._apply_linear_to_lut(invert) + self._apply_symlog_to_lut() if n_colors is not None: self.lut.NumberOfTableValues = n_colors + # Read the actual LUT range (may differ from color_range for log scale) + ctf = self.lut.GetClientSideObject() + self.config.effective_color_range = ctf.GetRange() + self.config.lut_img = lut_to_img(self.lut) + self._compute_ticks() + + # Force mapper to pick up LUT changes + self.mapper.SetLookupTable(ctf) + self.mapper.Modified() + self.render() def _apply_linear_to_lut(self, invert=False): @@ -192,19 +208,25 @@ def _apply_linear_to_lut(self, invert=False): if invert: self.lut.InvertTransferFunction() - def _apply_log_to_lut(self, invert=False): - """Apply preset with log scale.""" - self.lut.UseLogScale = 0 - self.lut.ApplyPreset(self.config.preset, True) - if invert: - self.lut.InvertTransferFunction() + def _apply_log_to_lut(self): + """Transform the already-prepared LUT to log scale. + + Log scale requires all positive values, so clamp the range if needed. + """ + ctf = self.lut.GetClientSideObject() + x_min, x_max = ctf.GetRange() + if x_max <= 0: + return + if x_min <= 0: + x_min = x_max * 1e-6 + self.lut.RescaleTransferFunction(x_min, x_max) self.lut.MapControlPointsToLogSpace() self.lut.UseLogScale = 1 def _apply_symlog_to_lut( - self, invert=False, linthresh=None, linscale=1.0, base=10, n_samples=256 + self, linthresh=None, linscale=1.0, base=10, n_samples=256 ): - """Apply preset with symmetric log scale. + """Transform the already-prepared LUT to symmetric log scale. Uses: - Linear for |x| <= linthresh @@ -214,11 +236,6 @@ def _apply_symlog_to_lut( Samples colors from the linear preset and redistributes them across the data range using symlog spacing. """ - self.lut.UseLogScale = 0 - self.lut.ApplyPreset(self.config.preset, True) - if invert: - self.lut.InvertTransferFunction() - # Get the current data range from the LUT ctf = self.lut.GetClientSideObject() x_min, x_max = ctf.GetRange() @@ -236,12 +253,14 @@ def _apply_symlog_to_lut( def symlog(x): abs_x = np.abs(x) + # Clip to avoid log(0); values <= linthresh use linear branch anyway + safe_abs = np.maximum(abs_x, linthresh) out = np.where( abs_x <= linthresh, x * linscale_adj, np.sign(x) * linthresh - * (linscale_adj + np.log(abs_x / linthresh) / log_base), + * (linscale_adj + np.log(safe_abs / linthresh) / log_base), ) return out @@ -327,6 +346,29 @@ def update_color_range(self, *_): self.config.n_colors, ) + def _compute_ticks(self): + vmin, vmax = self.config.effective_color_range + ticks = compute_color_ticks(vmin, vmax, scale=self.config.use_log_scale, n=5) + # Sample colors exactly as lut_to_img does: use RGBPoints range + rgb_points = self.lut.RGBPoints + if len(rgb_points) < 4: + self.config.color_ticks = [] + return + ctf = self.lut.GetClientSideObject() + rgb = [0.0, 0.0, 0.0] + img_min = rgb_points[0] + img_max = rgb_points[-4] + img_range = img_max - img_min + if img_range == 0: + self.config.color_ticks = [] + return + for tick in ticks: + t = tick["position"] / 100.0 + value = img_min + t * img_range + ctf.GetColor(value, rgb) + tick["color"] = tick_contrast_color(rgb[0], rgb[1], rgb[2]) + self.config.color_ticks = ticks + def _build_ui(self): with DivLayout( self.server, template_name=self.name, connect_parent=False, classes="h-100"