@@ -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
0 commit comments