diff --git a/src/e3sm_quickview/components/view.py b/src/e3sm_quickview/components/view.py index 6774c9d..cd9fe17 100644 --- a/src/e3sm_quickview/components/view.py +++ b/src/e3sm_quickview/components/view.py @@ -257,7 +257,7 @@ def create_bottom_bar(config, update_color_preset): classes="rounded", ) html.Div( - "{{ utils.quickview.formatRange(config.effective_color_range?.[0], config.use_log_scale, config.effective_color_range?.[0], config.effective_color_range?.[1]) }}", + "{{ 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( @@ -294,6 +294,6 @@ def create_bottom_bar(config, update_color_preset): style=("`width:1.5px;flex:1;background:${tick.color};`",), ) html.Div( - "{{ utils.quickview.formatRange(config.effective_color_range?.[1], config.use_log_scale, config.effective_color_range?.[0], config.effective_color_range?.[1]) }}", + "{{ 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/utils/math.py b/src/e3sm_quickview/utils/math.py index 617654f..08cdbf3 100644 --- a/src/e3sm_quickview/utils/math.py +++ b/src/e3sm_quickview/utils/math.py @@ -241,11 +241,54 @@ def tick_contrast_color(r, g, b): return "#000" if luminance > 0.45 else "#fff" +def _position_on_bar(val, vmin, vmax, scale): + """Map a data value to a 0-100 percentage position on a linear colorbar. + + For 'linear' scale, position is simply the linear interpolation. + For 'log' and 'symlog', the colorbar image is always rendered from the + linear LUT, so tick positions must be placed in the *transformed* space + to show where data values actually map. + """ + data_range = vmax - vmin + if data_range == 0: + return 50.0 + + if scale == "log": + safe_vmin = max(vmin, 1e-15) + safe_vmax = max(vmax, 1e-14) + safe_val = max(val, safe_vmin) + log_min = np.log10(safe_vmin) + log_max = np.log10(safe_vmax) + log_range = log_max - log_min + if log_range == 0: + return 50.0 + return (np.log10(safe_val) - log_min) / log_range * 100 + + if scale == "symlog": + linthresh = max(abs(vmin), abs(vmax)) * 1e-2 + if linthresh == 0: + linthresh = 1.0 + + def _symlog(x): + return np.sign(x) * np.log10(np.abs(x) / linthresh + 1) + + s_min = _symlog(vmin) + s_max = _symlog(vmax) + s_range = s_max - s_min + if s_range == 0: + return 50.0 + return float((_symlog(val) - s_min) / s_range * 100) + + # linear + return (val - vmin) / data_range * 100 + + 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). + The colorbar image is always rendered from the linear LUT. For log and + symlog scales, tick *positions* are placed in the transformed space so + they line up visually with the correct colours. Args: vmin: Minimum color range value @@ -263,14 +306,13 @@ def compute_color_ticks(vmin, vmax, scale="linear", n=5, min_gap=7, edge_margin= 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 + # Build candidate list with position in transformed space candidates = [] has_zero = False for t in ticks: val = float(t) - pos = (val - vmin) / data_range * 100 + pos = _position_on_bar(val, vmin, vmax, scale) if edge_margin <= pos <= (100 - edge_margin): is_zero = np.isclose(val, 0, atol=1e-12) if is_zero: @@ -285,7 +327,7 @@ def compute_color_ticks(vmin, vmax, scale="linear", n=5, min_gap=7, edge_margin= # 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 + zero_pos = _position_on_bar(0.0, vmin, vmax, scale) if 0 <= zero_pos <= 100: tick = {"position": round(zero_pos, 2), "label": "0", "priority": True} # Insert in sorted order diff --git a/src/e3sm_quickview/view_manager.py b/src/e3sm_quickview/view_manager.py index aa79bd0..55615cb 100644 --- a/src/e3sm_quickview/view_manager.py +++ b/src/e3sm_quickview/view_manager.py @@ -163,21 +163,23 @@ def update_color_preset(self, name, invert, log_scale, n_colors=255): self._apply_linear_to_lut(invert) self.lut.RescaleTransferFunction(*self.config.color_range) + if n_colors is not None: + self.lut.NumberOfTableValues = n_colors + + # Capture the colorbar image and tick marks from the LINEAR LUT + # before any log/symlog transform so the bar always looks linear. + self.config.lut_img = lut_to_img(self.lut) + self._compute_ticks() + 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() @@ -330,24 +332,25 @@ def update_color_range(self, *_): ) def _compute_ticks(self): - vmin, vmax = self.config.effective_color_range + vmin, vmax = self.config.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: + if not ticks: self.config.color_ticks = [] return + # The colorbar image is always rendered from the linear LUT, so + # sample contrast colors using the linear color_range. 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: + cr_min, cr_max = float(vmin), float(vmax) + cr_range = cr_max - cr_min + if cr_range == 0: self.config.color_ticks = [] return for tick in ticks: + # tick position is already in the correct visual space (0-100%) t = tick["position"] / 100.0 - value = img_min + t * img_range + # Map back to the linear data range to sample the color + value = cr_min + t * cr_range ctf.GetColor(value, rgb) tick["color"] = tick_contrast_color(rgb[0], rgb[1], rgb[2]) self.config.color_ticks = ticks diff --git a/src/e3sm_quickview/view_manager2.py b/src/e3sm_quickview/view_manager2.py index 27d3ea7..1e9c098 100644 --- a/src/e3sm_quickview/view_manager2.py +++ b/src/e3sm_quickview/view_manager2.py @@ -180,21 +180,23 @@ def update_color_preset(self, name, invert, log_scale, n_colors=255): self._apply_linear_to_lut(invert) self.lut.RescaleTransferFunction(*self.config.color_range) + if n_colors is not None: + self.lut.NumberOfTableValues = n_colors + + # Capture the colorbar image and tick marks from the LINEAR LUT + # before any log/symlog transform so the bar always looks linear. + self.config.lut_img = lut_to_img(self.lut) + self._compute_ticks() + 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() @@ -347,24 +349,25 @@ def update_color_range(self, *_): ) def _compute_ticks(self): - vmin, vmax = self.config.effective_color_range + vmin, vmax = self.config.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: + if not ticks: self.config.color_ticks = [] return + # The colorbar image is always rendered from the linear LUT, so + # sample contrast colors using the linear color_range. 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: + cr_min, cr_max = float(vmin), float(vmax) + cr_range = cr_max - cr_min + if cr_range == 0: self.config.color_ticks = [] return for tick in ticks: + # tick position is already in the correct visual space (0-100%) t = tick["position"] / 100.0 - value = img_min + t * img_range + # Map back to the linear data range to sample the color + value = cr_min + t * cr_range ctf.GetColor(value, rgb) tick["color"] = tick_contrast_color(rgb[0], rgb[1], rgb[2]) self.config.color_ticks = ticks