Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/e3sm_quickview/components/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
)
54 changes: 48 additions & 6 deletions src/e3sm_quickview/utils/math.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down
33 changes: 18 additions & 15 deletions src/e3sm_quickview/view_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
33 changes: 18 additions & 15 deletions src/e3sm_quickview/view_manager2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading