Skip to content

Commit eeb8a85

Browse files
olearypatrickjourdain
authored andcommitted
Add colorbar tick marks with contrast-aware labels and fix log/symlog LUT preset application order
1 parent e99f6ab commit eeb8a85

4 files changed

Lines changed: 332 additions & 40 deletions

File tree

src/e3sm_quickview/components/view.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ def create_bottom_bar(config, update_color_preset):
157157
"config.use_log_scale === 'linear' ? 'Toggle to log scale' : config.use_log_scale === 'log' ? 'Toggle to symlog scale' : 'Toggle to linear scale'",
158158
),
159159
icon=(
160-
"config.use_log_scale === 'log' ? 'mdi-math-log' : config.use_log_scale === 'symlog' ? 'mdi-chart-bell-curve-cumulative' : 'mdi-stairs'",
160+
"config.use_log_scale === 'log' ? 'mdi-math-log' : config.use_log_scale === 'symlog' ? 'mdi-sine-wave mdi-rotate-330' : 'mdi-stairs'",
161161
),
162162
click="config.use_log_scale = config.use_log_scale === 'linear' ? 'log' : config.use_log_scale === 'log' ? 'symlog' : 'linear'",
163163
size="small",
@@ -257,16 +257,43 @@ def create_bottom_bar(config, update_color_preset):
257257
classes="rounded",
258258
)
259259
html.Div(
260-
"{{ utils.quickview.formatRange(config.color_range?.[0], config.use_log_scale, config.color_range?.[0], config.color_range?.[1]) }}",
260+
"{{ utils.quickview.formatRange(config.effective_color_range?.[0], config.use_log_scale, config.effective_color_range?.[0], config.effective_color_range?.[1]) }}",
261261
classes="text-caption px-2 text-no-wrap",
262262
)
263-
with html.Div(classes="overflow-hidden rounded w-100", style="height:70%;"):
263+
with html.Div(
264+
classes="rounded w-100",
265+
style="height:70%;position:relative;",
266+
):
264267
html.Img(
265268
src=("config.lut_img",),
266269
style="width:100%;height:2rem;",
267270
draggable=False,
268271
)
272+
with html.Div(
273+
style="position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;",
274+
):
275+
with html.Div(
276+
v_for="(tick, i) in config.color_ticks",
277+
key="i",
278+
style=(
279+
"`position:absolute;left:${tick.position}%;top:0;height:100%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;`",
280+
),
281+
):
282+
html.Div(
283+
style=(
284+
"`width:1.5px;height:30%;background:${tick.color};`",
285+
),
286+
)
287+
html.Span(
288+
"{{ tick.label }}",
289+
style=(
290+
"`font-size:0.5rem;line-height:1;white-space:nowrap;color:${tick.color};`",
291+
),
292+
)
293+
html.Div(
294+
style=("`width:1.5px;flex:1;background:${tick.color};`",),
295+
)
269296
html.Div(
270-
"{{ utils.quickview.formatRange(config.color_range?.[1], config.use_log_scale, config.color_range?.[0], config.color_range?.[1]) }}",
297+
"{{ utils.quickview.formatRange(config.effective_color_range?.[1], config.use_log_scale, config.effective_color_range?.[0], config.effective_color_range?.[1]) }}",
271298
classes="text-caption px-2 text-no-wrap",
272299
)

src/e3sm_quickview/utils/math.py

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,3 +133,185 @@ def normalize_range(
133133

134134
normalized = (value - old_min) / (old_max - old_min)
135135
return new_min + normalized * (new_max - new_min)
136+
137+
138+
def get_nice_ticks(vmin, vmax, n, scale="linear"):
139+
"""Compute nicely spaced tick values for a given range and scale.
140+
141+
Args:
142+
vmin: Minimum data value
143+
vmax: Maximum data value
144+
n: Desired number of ticks
145+
scale: One of 'linear', 'log', or 'symlog'
146+
147+
Returns:
148+
Sorted array of unique, snapped tick values.
149+
"""
150+
151+
def snap(val):
152+
if np.isclose(val, 0, atol=1e-12):
153+
return 0.0
154+
sign = np.sign(val)
155+
val_abs = abs(val)
156+
mag = 10 ** np.floor(np.log10(val_abs))
157+
residual = val_abs / mag
158+
nice_steps = np.array([1.0, 2.0, 5.0, 10.0])
159+
best_step = nice_steps[np.abs(nice_steps - residual).argmin()]
160+
return sign * best_step * mag
161+
162+
if scale == "linear":
163+
raw_ticks = np.linspace(vmin, vmax, n)
164+
elif scale == "log":
165+
# Use integer powers of 10 that fall strictly inside [vmin, vmax]
166+
safe_vmin = max(vmin, 1e-15)
167+
safe_vmax = max(vmax, 1e-14)
168+
start_exp = int(np.floor(np.log10(safe_vmin)))
169+
stop_exp = int(np.ceil(np.log10(safe_vmax)))
170+
powers = [
171+
10.0**e
172+
for e in range(start_exp, stop_exp + 1)
173+
if safe_vmin <= 10.0**e <= safe_vmax
174+
]
175+
# Fall back to log-spaced ticks when no powers of 10 are interior
176+
if len(powers) < 2:
177+
raw_ticks = np.geomspace(safe_vmin, safe_vmax, n)
178+
else:
179+
raw_ticks = np.array(powers)
180+
elif scale == "symlog":
181+
182+
def transform(x, th):
183+
return np.sign(x) * np.log10(np.abs(x) / th + 1)
184+
185+
def inverse(y, th):
186+
return np.sign(y) * th * (10 ** np.abs(y) - 1)
187+
188+
linthresh = max(abs(vmin), abs(vmax)) * 1e-2
189+
if linthresh == 0:
190+
linthresh = 1.0
191+
t_min, t_max = transform(vmin, linthresh), transform(vmax, linthresh)
192+
t_ticks = np.linspace(t_min, t_max, n)
193+
raw_ticks = inverse(t_ticks, linthresh)
194+
else:
195+
raw_ticks = np.linspace(vmin, vmax, n)
196+
197+
nice_ticks = np.array([snap(t) for t in raw_ticks])
198+
199+
# Force 0 for non-log scales if it's within range
200+
if vmin <= 0 <= vmax and scale != "log":
201+
idx = np.abs(nice_ticks).argmin()
202+
nice_ticks[idx] = 0.0
203+
204+
return np.unique(np.sort(nice_ticks))
205+
206+
207+
def format_tick(val):
208+
"""Format a tick value as a concise human-readable string.
209+
210+
Returns a string suitable for display on a colorbar. Powers of 10 are
211+
shown as '10^N', very large/small values use scientific notation, and
212+
intermediate values use fixed-point.
213+
"""
214+
if np.isclose(val, 0, atol=1e-12):
215+
return "0"
216+
217+
val_abs = abs(val)
218+
log10 = np.log10(val_abs)
219+
220+
if np.isclose(log10, np.round(log10), atol=1e-12):
221+
exponent = int(np.round(log10))
222+
sign = "-" if val < 0 else ""
223+
if exponent == 0:
224+
return f"{sign}1"
225+
if exponent == 1:
226+
return f"{sign}10"
227+
return f"{sign}10^{exponent}"
228+
229+
if val_abs >= 1000 or val_abs <= 0.01:
230+
return f"{val:.1e}"
231+
return f"{int(val) if val == int(val) else val:.1f}"
232+
233+
234+
def tick_contrast_color(r, g, b):
235+
"""Return '#fff' or '#000' for best contrast against the given RGB color.
236+
237+
Uses the W3C relative luminance formula to decide. RGB values are
238+
expected in [0, 1] range.
239+
"""
240+
luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b
241+
return "#000" if luminance > 0.45 else "#fff"
242+
243+
244+
def compute_color_ticks(vmin, vmax, scale="linear", n=5, min_gap=7, edge_margin=3):
245+
"""Compute tick marks for a colorbar.
246+
247+
Tick positions are always linear in data space since the colorbar image
248+
is sampled linearly (lut_to_img uses uniform steps from vmin to vmax).
249+
250+
Args:
251+
vmin: Minimum color range value
252+
vmax: Maximum color range value
253+
scale: One of 'linear', 'log', or 'symlog'
254+
n: Desired number of ticks
255+
min_gap: Minimum gap between ticks in percentage points
256+
edge_margin: Minimum distance from edges (0% and 100%) in percentage points
257+
258+
Returns:
259+
List of dicts with 'position' (0-100 percentage) and 'label' keys.
260+
"""
261+
if vmin >= vmax:
262+
return []
263+
264+
raw_n = n if scale == "linear" else n * 2
265+
ticks = get_nice_ticks(vmin, vmax, raw_n, scale)
266+
data_range = vmax - vmin
267+
268+
# Build candidate list with position in linear data space
269+
candidates = []
270+
has_zero = False
271+
for t in ticks:
272+
val = float(t)
273+
pos = (val - vmin) / data_range * 100
274+
if edge_margin <= pos <= (100 - edge_margin):
275+
is_zero = np.isclose(val, 0, atol=1e-12)
276+
if is_zero:
277+
has_zero = True
278+
candidates.append(
279+
{
280+
"position": round(pos, 2),
281+
"label": format_tick(val),
282+
"priority": is_zero,
283+
}
284+
)
285+
286+
# Always include 0 when it falls within the range (for any scale)
287+
if not has_zero and scale != "log":
288+
zero_pos = (0.0 - vmin) / data_range * 100
289+
if 0 <= zero_pos <= 100:
290+
tick = {"position": round(zero_pos, 2), "label": "0", "priority": True}
291+
# Insert in sorted order
292+
inserted = False
293+
for i, c in enumerate(candidates):
294+
if tick["position"] <= c["position"]:
295+
candidates.insert(i, tick)
296+
inserted = True
297+
break
298+
if not inserted:
299+
candidates.append(tick)
300+
301+
# Filter out ticks that are too close together, but never remove priority ticks
302+
result = []
303+
for tick in candidates:
304+
is_priority = tick.get("priority", False)
305+
if is_priority:
306+
if result and (tick["position"] - result[-1]["position"]) < min_gap:
307+
if not result[-1].get("priority", False):
308+
result.pop()
309+
result.append(tick)
310+
elif not result or (tick["position"] - result[-1]["position"]) >= min_gap:
311+
# Also check distance to next priority tick (look-ahead)
312+
result.append(tick)
313+
314+
# Clean up internal flags before returning
315+
for tick in result:
316+
tick.pop("priority", None)
317+
return result

src/e3sm_quickview/view_manager.py

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from e3sm_quickview.components import view as tview
1414
from e3sm_quickview.presets import COLOR_BLIND_SAFE
1515
from e3sm_quickview.utils.color import COLORBAR_CACHE, lut_to_img
16+
from e3sm_quickview.utils.math import compute_color_ticks, tick_contrast_color
1617

1718

1819
def auto_size_to_col(size):
@@ -68,6 +69,8 @@ class ViewConfiguration(dataclass.StateDataModel):
6869
search: str | None = dataclass.Sync(str)
6970
n_colors: int = dataclass.Sync(int, 255)
7071
lut_img: str = dataclass.Sync(str)
72+
color_ticks: list = dataclass.Sync(list, list)
73+
effective_color_range: list[float] = dataclass.Sync(tuple[float, float], (0, 1))
7174

7275

7376
class VariableView(TrameComponent):
@@ -155,17 +158,29 @@ def reset_camera(self):
155158
def update_color_preset(self, name, invert, log_scale, n_colors=255):
156159
self.config.preset = name
157160

161+
# ApplyPreset resets range to [0,1], so always apply the linear
162+
# preset first, rescale to the current range, then apply transforms
163+
self._apply_linear_to_lut(invert)
164+
self.lut.RescaleTransferFunction(*self.config.color_range)
165+
158166
if log_scale == "log":
159-
self._apply_log_to_lut(invert)
167+
self._apply_log_to_lut()
160168
elif log_scale == "symlog":
161-
self._apply_symlog_to_lut(invert)
162-
else:
163-
self._apply_linear_to_lut(invert)
169+
self._apply_symlog_to_lut()
164170

165171
if n_colors is not None:
166172
self.lut.NumberOfTableValues = n_colors
167173

174+
# Read the actual LUT range (may differ from color_range for log scale)
175+
ctf = self.lut.GetClientSideObject()
176+
self.config.effective_color_range = ctf.GetRange()
177+
168178
self.config.lut_img = lut_to_img(self.lut)
179+
self._compute_ticks()
180+
181+
# Force mapper to pick up LUT changes
182+
self.mapper.SetLookupTable(ctf)
183+
self.mapper.Modified()
169184

170185
self.render()
171186

@@ -176,19 +191,25 @@ def _apply_linear_to_lut(self, invert=False):
176191
if invert:
177192
self.lut.InvertTransferFunction()
178193

179-
def _apply_log_to_lut(self, invert=False):
180-
"""Apply preset with log scale."""
181-
self.lut.UseLogScale = 0
182-
self.lut.ApplyPreset(self.config.preset, True)
183-
if invert:
184-
self.lut.InvertTransferFunction()
194+
def _apply_log_to_lut(self):
195+
"""Transform the already-prepared LUT to log scale.
196+
197+
Log scale requires all positive values, so clamp the range if needed.
198+
"""
199+
ctf = self.lut.GetClientSideObject()
200+
x_min, x_max = ctf.GetRange()
201+
if x_max <= 0:
202+
return
203+
if x_min <= 0:
204+
x_min = x_max * 1e-6
205+
self.lut.RescaleTransferFunction(x_min, x_max)
185206
self.lut.MapControlPointsToLogSpace()
186207
self.lut.UseLogScale = 1
187208

188209
def _apply_symlog_to_lut(
189-
self, invert=False, linthresh=None, linscale=1.0, base=10, n_samples=256
210+
self, linthresh=None, linscale=1.0, base=10, n_samples=256
190211
):
191-
"""Apply preset with symmetric log scale.
212+
"""Transform the already-prepared LUT to symmetric log scale.
192213
193214
Uses:
194215
- Linear for |x| <= linthresh
@@ -198,11 +219,6 @@ def _apply_symlog_to_lut(
198219
Samples colors from the linear preset and redistributes them
199220
across the data range using symlog spacing.
200221
"""
201-
self.lut.UseLogScale = 0
202-
self.lut.ApplyPreset(self.config.preset, True)
203-
if invert:
204-
self.lut.InvertTransferFunction()
205-
206222
# Get the current data range from the LUT
207223
ctf = self.lut.GetClientSideObject()
208224
x_min, x_max = ctf.GetRange()
@@ -220,12 +236,14 @@ def _apply_symlog_to_lut(
220236

221237
def symlog(x):
222238
abs_x = np.abs(x)
239+
# Clip to avoid log(0); values <= linthresh use linear branch anyway
240+
safe_abs = np.maximum(abs_x, linthresh)
223241
out = np.where(
224242
abs_x <= linthresh,
225243
x * linscale_adj,
226244
np.sign(x)
227245
* linthresh
228-
* (linscale_adj + np.log(abs_x / linthresh) / log_base),
246+
* (linscale_adj + np.log(safe_abs / linthresh) / log_base),
229247
)
230248
return out
231249

@@ -311,6 +329,29 @@ def update_color_range(self, *_):
311329
self.config.n_colors,
312330
)
313331

332+
def _compute_ticks(self):
333+
vmin, vmax = self.config.effective_color_range
334+
ticks = compute_color_ticks(vmin, vmax, scale=self.config.use_log_scale, n=5)
335+
# Sample colors exactly as lut_to_img does: use RGBPoints range
336+
rgb_points = self.lut.RGBPoints
337+
if len(rgb_points) < 4:
338+
self.config.color_ticks = []
339+
return
340+
ctf = self.lut.GetClientSideObject()
341+
rgb = [0.0, 0.0, 0.0]
342+
img_min = rgb_points[0]
343+
img_max = rgb_points[-4]
344+
img_range = img_max - img_min
345+
if img_range == 0:
346+
self.config.color_ticks = []
347+
return
348+
for tick in ticks:
349+
t = tick["position"] / 100.0
350+
value = img_min + t * img_range
351+
ctf.GetColor(value, rgb)
352+
tick["color"] = tick_contrast_color(rgb[0], rgb[1], rgb[2])
353+
self.config.color_ticks = ticks
354+
314355
def _build_ui(self):
315356
with DivLayout(
316357
self.server, template_name=self.name, connect_parent=False, classes="h-100"

0 commit comments

Comments
 (0)