diff --git a/src/e3sm_quickview/components/file_browser.py b/src/e3sm_quickview/components/file_browser.py index fac717a..a80772e 100644 --- a/src/e3sm_quickview/components/file_browser.py +++ b/src/e3sm_quickview/components/file_browser.py @@ -283,18 +283,24 @@ def ui(self): with v3.VCard(rounded="lg"): with v3.VCardTitle("File loading", classes="d-flex align-center px-3"): v3.VSpacer() - v3.VBtn( - icon="mdi-home", - variant="flat", - size="small", - click=self.goto_home, - ) - v3.VBtn( - icon="mdi-folder-upload-outline", - variant="flat", - size="small", - click=self.goto_parent, - ) + with v3.VTooltip(text="Go to launched directory"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + icon="mdi-home", + variant="flat", + size="small", + click=self.goto_home, + ) + with v3.VTooltip(text="Go up a directory"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + icon="mdi-folder-upload-outline", + variant="flat", + size="small", + click=self.goto_parent, + ) v3.VTextField( v_model=self.name("filter"), hide_details=True, @@ -386,59 +392,77 @@ def ui(self): v3.VDivider() with v3.VCardActions(classes="pa-3"): - v3.VBtn( - classes="text-none", - variant="tonal", - text="Simulation", - prepend_icon="mdi-database-plus", - disabled=( - f"{self.name('listing')}[{self.name('active')}]?.type !== 'file'", - ), - click=self.set_data_simulation, - ) - v3.VBtn( - classes="text-none", - text="Connectivity", - variant="tonal", - prepend_icon="mdi-vector-polyline-plus", - disabled=( - f"{self.name('listing')}[{self.name('active')}]?.type !== 'file'", - ), - click=self.set_data_connectivity, - ) - v3.VBtn( - classes="text-none", - text="Reset", - variant="tonal", - prepend_icon="mdi-close-octagon-outline", - click=f"{self.name('data_connectivity')}='';{self.name('data_simulation')}='';{self.name('error')}=false", - ) + with v3.VTooltip(text="Set selected file as simulation file"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + classes="text-none", + variant="tonal", + text="Simulation", + prepend_icon="mdi-database-plus", + disabled=( + f"{self.name('listing')}[{self.name('active')}]?.type !== 'file'", + ), + click=self.set_data_simulation, + ) + with v3.VTooltip(text="Set selected file as connectivity file"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + classes="text-none", + text="Connectivity", + variant="tonal", + prepend_icon="mdi-vector-polyline-plus", + disabled=( + f"{self.name('listing')}[{self.name('active')}]?.type !== 'file'", + ), + click=self.set_data_connectivity, + ) + with v3.VTooltip(text="Clear selected files"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + classes="text-none", + text="Reset", + variant="tonal", + prepend_icon="mdi-close-octagon-outline", + click=f"{self.name('data_connectivity')}='';{self.name('data_simulation')}='';{self.name('error')}=false", + ) v3.VSpacer() - v3.VBtn( - border=True, - classes="text-none", - color="surface", - text="Cancel", - variant="flat", - click=self.cancel, - ) - v3.VBtn( - disabled=(f"!{self.name('is_state_file')}",), - loading=(self.name("state_loading"), False), - classes="text-none", - color="primary", - text="Import state file", - variant="flat", - click=self.import_state_file, - ) - v3.VBtn( - classes="text-none", - color=(f"{self.name('error')} ? 'error' : 'primary'",), - text="Load files", - variant="flat", - disabled=( - f"!{self.name('data_simulation')} || !{self.name('data_connectivity')} || {self.name('error')}", - ), - loading=(self.name("loading"), False), - click=self.load_data_files, - ) + with v3.VTooltip(text="Cancel file loading"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + border=True, + classes="text-none", + color="surface", + text="Cancel", + variant="flat", + click=self.cancel, + ) + with v3.VTooltip(text="Import previous state file"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + disabled=(f"!{self.name('is_state_file')}",), + loading=(self.name("state_loading"), False), + classes="text-none", + color="primary", + text="Import state file", + variant="flat", + click=self.import_state_file, + ) + with v3.VTooltip(text="Load simulation and connectivity files"): + with v3.Template(v_slot_activator="{ props }"): + v3.VBtn( + v_bind="props", + classes="text-none", + color=(f"{self.name('error')} ? 'error' : 'primary'",), + text="Load files", + variant="flat", + disabled=( + f"!{self.name('data_simulation')} || !{self.name('data_connectivity')} || {self.name('error')}", + ), + loading=(self.name("loading"), False), + click=self.load_data_files, + ) diff --git a/src/e3sm_quickview/components/toolbars.py b/src/e3sm_quickview/components/toolbars.py index abb6745..8e21fbc 100644 --- a/src/e3sm_quickview/components/toolbars.py +++ b/src/e3sm_quickview/components/toolbars.py @@ -191,11 +191,18 @@ def __init__(self): super().__init__(**to_kwargs("adjust-databounds")) with self: - v3.VIcon( - "mdi-web", - classes="pl-6 opacity-50", - click="crop_slider_edit = !crop_slider_edit", - ) + with v3.VTooltip( + text=( + "crop_slider_edit ? 'Toggle to text edit' : 'Toggle to slider edit'", + ), + ): + with v3.Template(v_slot_activator="{ props }"): + v3.VIcon( + "mdi-web", + v_bind="props", + classes="pl-6 opacity-50", + click="crop_slider_edit = !crop_slider_edit", + ) with v3.VRow( classes="ma-0 px-2 align-center", v_if=("crop_slider_edit", True) ): @@ -318,9 +325,24 @@ def __init__(self): super().__init__(**style) with self: - v3.VIcon("mdi-tune-variant", classes="ml-3 mr-2 opacity-50") + with v3.VTooltip( + text=( + "slice_slider_edit ? 'Toggle to text edit' : 'Toggle to slider edit'", + ), + ): + with v3.Template(v_slot_activator="{ props }"): + v3.VIcon( + "mdi-tune-variant", + v_bind="props", + classes="ml-3 mr-2 opacity-50", + click="slice_slider_edit = !slice_slider_edit", + ) - with v3.VRow(classes="ma-0 pr-2 flex-wrap flex-grow-1", dense=True): + with v3.VRow( + classes="ma-0 pr-2 flex-wrap flex-grow-1", + dense=True, + v_if=("slice_slider_edit", True), + ): # Debug: Show animation_tracks array # html.Div( # "Animation Tracks: {{ JSON.stringify(available_animation_tracks) }}", @@ -344,7 +366,7 @@ def __init__(self): ) v3.VSpacer() v3.VLabel( - "{{ parseFloat(t_values[t_idx]).toFixed(2) }} hPa (k={{ t_idx }})", + "{{ t_values ? parseFloat(t_values[t_idx]).toFixed(2) : '' }} hPa (k={{ t_idx }})", classes="text-body-2", ) v3.VSlider( @@ -360,10 +382,49 @@ def __init__(self): density="compact", hide_details=True, ) + with v3.VRow( + classes="ma-0 pl-6 pr-2 align-center ga-4", + v_if="!slice_slider_edit", + ): + with v3.VCol( + v_for="(track, idx) in available_animation_tracks", + key="idx", + ): + with client.Getter(name=("track",), value_name="t_values"): + with client.Getter( + name=("track + '_idx'",), value_name="t_idx" + ): + with v3.VRow(classes="ma-0 align-center", dense=True): + v3.VNumberInput( + model_value=("Number(t_idx)",), + update_modelValue=( + self.on_update_slider, + "[track, Number($event)]", + ), + key=("track + '_' + t_idx",), + min=0, + max=("t_values ? t_values.length - 1 : 0",), + step=[1], + hide_details=True, + density="comfortable", + variant="plain", + flat=True, + control_variant="stacked", + style="max-width: 100px;", + reverse=True, + ) + v3.VLabel( + "{{track}}", + classes="text-subtitle-2 ml-2 mt-1", + ) + v3.VLabel( + "{{ t_values ? parseFloat(t_values[Number(t_idx)]).toFixed(2) : '' }} hPa", + classes="text-body-2 text-no-wrap ml-2 mt-1", + ) def on_update_slider(self, dimension, index, *_, **__): with self.state: - self.state[f"{dimension}_idx"] = index + self.state[f"{dimension}_idx"] = int(index) class Animation(v3.VToolbar): diff --git a/src/e3sm_quickview/components/tools.py b/src/e3sm_quickview/components/tools.py index 5ed51f7..5beb2f8 100644 --- a/src/e3sm_quickview/components/tools.py +++ b/src/e3sm_quickview/components/tools.py @@ -68,7 +68,7 @@ def __init__(self, compact, title, icon, click, keybinding=None): keys=keybinding, variant="contained", inline=True, - classes="mt-n2", + classes="mt-n2 border-md border-grey-darken-1 border-opacity-100 rounded-lg", ) @@ -111,6 +111,7 @@ def __init__(self, compact, title, icon, value, disabled=None, keybinding=None): prepend_icon=icon, value=value, title=(f"{compact} ? null : '{title}'",), + active_class="border-primary border-md border-primary border-opacity-100", **add_on, ): if keybinding: @@ -119,7 +120,7 @@ def __init__(self, compact, title, icon, value, disabled=None, keybinding=None): keys=keybinding, variant="contained", inline=True, - classes="mt-n2", + classes="mt-n2 border-md border-grey-darken-1 border-opacity-100 rounded-lg", ) diff --git a/src/e3sm_quickview/components/view.py b/src/e3sm_quickview/components/view.py index 89cbfeb..c53b641 100644 --- a/src/e3sm_quickview/components/view.py +++ b/src/e3sm_quickview/components/view.py @@ -126,52 +126,58 @@ def create_bottom_bar(config, update_color_preset): with html.Div(classes="d-flex align-center"): v3.VIconBtn( raw_attrs=[ - '''v-tooltip:bottom="config.color_blind ? 'Colorblind safe presets' : 'All color presets'"''' + '''v-tooltip:bottom="config.color_blind ? 'Toggle for all color presets' : 'Toggle for colorblind safe color presets'"''' ], icon=( "config.color_blind ? 'mdi-shield-check-outline' : 'mdi-palette'", ), click="config.color_blind = !config.color_blind", size="small", - text="Colorblind safe", + text=( + "config.color_blind ? 'Colorblind Safe' : 'All Colors'", + ), variant="text", ) v3.VIconBtn( raw_attrs=[ - '''v-tooltip:bottom="config.invert ? 'Inverted preset' : 'Normal preset'"''' + '''v-tooltip:bottom="config.invert ? 'Toggle to normal preset' : 'Toggle to inverted preset'"''' ], icon=( "config.invert ? 'mdi-invert-colors' : 'mdi-invert-colors-off'", ), click="config.invert = !config.invert", size="small", - text="Invert", + text=( + "config.invert ? 'Inverted Preset' : 'Normal Preset'", + ), variant="text", ) v3.VIconBtn( raw_attrs=[ - '''v-tooltip:bottom="config.use_log_scale ? 'Use log scale' : 'Use linear scale'"''' + '''v-tooltip:bottom="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-sine-wave mdi-rotate-330' : '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", ) v3.VIconBtn( raw_attrs=[ - '''v-tooltip:bottom="config.override_range ? 'Use custom range' : 'Use data range'"''' + '''v-tooltip:bottom="config.override_range ? 'Toggle to use data range' : 'Toggle to use custom range'"''' ], icon=( "config.override_range ? 'mdi-arrow-expand-horizontal' : 'mdi-pencil'", ), click="config.override_range = !config.override_range", size="small", - text="Use data range", + text=( + "config.override_range ? 'Custom Range' : 'Data Range'", + ), variant="text", ) @@ -251,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) }}", + "{{ 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) }}", + "{{ 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/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/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 3dd8c20..aa79bd0 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 @@ -11,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): @@ -50,7 +53,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) @@ -66,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): @@ -152,22 +157,123 @@ 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 + # 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() + elif log_scale == "symlog": + 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): + """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): + """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, linthresh=None, linscale=1.0, base=10, n_samples=256 + ): + """Transform the already-prepared LUT to 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. + """ + # 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) + # 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(safe_abs / 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 +321,36 @@ 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 _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( diff --git a/src/e3sm_quickview/view_manager2.py b/src/e3sm_quickview/view_manager2.py index 4fe4296..27d3ea7 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 @@ -26,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): @@ -65,7 +68,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) @@ -81,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): @@ -169,21 +174,123 @@ 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 + # 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() + elif log_scale == "symlog": + 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): + """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): + """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, linthresh=None, linscale=1.0, base=10, n_samples=256 + ): + """Transform the already-prepared LUT to 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. + """ + # 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) + # 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(safe_abs / 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 +339,35 @@ 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 _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(