-
Notifications
You must be signed in to change notification settings - Fork 11
Improve color legend tick marks in ColorLegendsContainer
#1169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| export interface AxisTickOptions { | ||
| /** The minimum pixel distance between tick marks to prevent overlap */ | ||
| minTickSpacing?: number; | ||
| /** The available length/width in pixels for the axis */ | ||
| availableSpace?: number; | ||
| /** Whether to prioritize boundaries (always include min/max) */ | ||
| prioritizeBoundaries?: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Generates tick values within a given range. | ||
| * This function calculates appropriate step sizes and positions tick marks at | ||
| * round numbers like 0, 0.5, 1, 2, 5, 10, etc. rather than arbitrary decimals. | ||
| * | ||
| * When spacing options are provided, it ensures ticks don't overlap in limited space. | ||
| * | ||
| * @param min The minimum value of the range | ||
| * @param max The maximum value of the range | ||
| * @param maxTicks The maximum number of tick marks to generate | ||
| * @param options Optional spacing and layout options | ||
| * @returns An array of nicely positioned tick values | ||
| * | ||
| * @example | ||
| * // Basic usage: | ||
| * generateNiceAxisTicks(2220.458, 3645.571, 5) | ||
| * // Returns: [2220.458, 2500, 3000, 3500, 3645.571] | ||
| * | ||
| * @example | ||
| * // With spacing constraints: | ||
| * generateNiceAxisTicks(0, 100, 10, { minTickSpacing: 30, availableSpace: 200 }) | ||
| * // Returns fewer ticks to prevent overlap | ||
| */ | ||
|
|
||
| export function generateNiceAxisTicks(min: number, max: number, maxTicks: number, options?: AxisTickOptions): number[] { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As discussed, might be slightly too aggressive on the computation; these should be able to fit 3/5 ticks
For instance, with the range I sketched up a very quick test in python, I think something like this should work? You'd still need to figure out how to best decide what multiple to round to real_start = -178.726
real_end = 762.265
ticks = 5
rounding_digits = -1
# Round to the limits outside the
round_start = round(real_start, rounding_digits) # Round to tens
round_end = round(real_end, rounding_digits) + 10**(-rounding_digits) # Round to tens
distance = round_end - round_start
stepSize = round(distance / (ticks - 1), rounding_digits) # stepSize should be the same rounding
rounded_ticks = [(round_start + t * stepSize) for t in range(1, ticks - 1)]
rounded_ticks.insert(0, real_start)
rounded_ticks.append(real_end) |
||
| if (min === max) return [min]; | ||
|
|
||
| // Handle reversed min/max by swapping them | ||
| const actualMin = Math.min(min, max); | ||
| const actualMax = Math.max(min, max); | ||
| const range = actualMax - actualMin; | ||
|
|
||
| // Apply spacing constraints if provided | ||
| let effectiveMaxTicks = maxTicks; | ||
| if (options?.minTickSpacing && options?.availableSpace) { | ||
| // Calculate maximum ticks that can fit with minimum spacing | ||
| const maxTicksFromSpacing = Math.floor(options.availableSpace / options.minTickSpacing) + 1; | ||
| effectiveMaxTicks = Math.min(maxTicks, maxTicksFromSpacing); | ||
|
|
||
| // Ensure at least 2 ticks (min and max) if space allows | ||
| if (effectiveMaxTicks < 2 && options.availableSpace >= options.minTickSpacing) { | ||
| effectiveMaxTicks = 2; | ||
| } | ||
|
||
| } | ||
|
|
||
| // Calculate nice step size using effective max ticks | ||
| const rawStep = range / (effectiveMaxTicks - 1); | ||
| const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); | ||
| const normalizedStep = rawStep / magnitude; | ||
|
|
||
| let niceStep: number; | ||
| if (normalizedStep <= 1) { | ||
| niceStep = 1 * magnitude; | ||
| } else if (normalizedStep <= 2) { | ||
| niceStep = 2 * magnitude; | ||
| } else if (normalizedStep <= 5) { | ||
| niceStep = 5 * magnitude; | ||
| } else { | ||
| niceStep = 10 * magnitude; | ||
| } | ||
|
|
||
| // Calculate nice min and max | ||
| const niceMin = Math.floor(actualMin / niceStep) * niceStep; | ||
| const niceMax = Math.ceil(actualMax / niceStep) * niceStep; | ||
|
|
||
| // Generate tick values using index-based approach to avoid floating-point accumulation errors | ||
| let ticks: number[] = []; | ||
| const numSteps = Math.round((niceMax - niceMin) / niceStep); | ||
|
|
||
| for (let i = 0; i <= numSteps; i++) { | ||
| // Calculate tick value directly from index to avoid accumulation errors | ||
| const tick = niceMin + i * niceStep; | ||
|
|
||
| // Only include ticks within the actual data range | ||
| if (tick >= actualMin && tick <= actualMax) { | ||
| ticks.push(tick); | ||
| } | ||
| } | ||
|
|
||
| // Handle boundary inclusion based on options | ||
| const ticksSet = new Set(ticks); | ||
| const shouldPrioritizeBoundaries = options?.prioritizeBoundaries !== false; | ||
|
|
||
| if (shouldPrioritizeBoundaries) { | ||
| // Always include boundaries when prioritizing them | ||
| if (ticksSet.size === 0 || !ticksSet.has(actualMin)) { | ||
| ticks.push(actualMin); | ||
| } | ||
| if (!ticksSet.has(actualMax)) { | ||
| ticks.push(actualMax); | ||
| } | ||
| } | ||
|
|
||
| // Sort the ticks in ascending order | ||
| ticks.sort((a, b) => a - b); | ||
|
|
||
| // If we have spacing constraints, remove ticks that are too close | ||
| if (options?.minTickSpacing && options?.availableSpace && ticks.length > 1) { | ||
| ticks = filterTicksBySpacing(ticks, actualMin, actualMax, options); | ||
| } | ||
|
|
||
| return ticks; | ||
| } | ||
|
|
||
| /** | ||
| * Filters ticks to ensure minimum spacing requirements are met. | ||
| * Prioritizes keeping boundary values when possible. | ||
| */ | ||
| function filterTicksBySpacing(ticks: number[], min: number, max: number, options: AxisTickOptions): number[] { | ||
| if (!options.minTickSpacing || !options.availableSpace || ticks.length <= 1) { | ||
| return ticks; | ||
| } | ||
|
|
||
| const range = max - min; | ||
| const pixelPerUnit = options.availableSpace / range; | ||
|
|
||
| const filteredTicks: number[] = []; | ||
| let lastTickPosition = -Infinity; | ||
|
|
||
| for (const tick of ticks) { | ||
| const currentPixelPosition = (tick - min) * pixelPerUnit; | ||
|
|
||
| // Always include the first tick, or if it's far enough from the last one | ||
| if (filteredTicks.length === 0 || currentPixelPosition - lastTickPosition >= options.minTickSpacing) { | ||
| filteredTicks.push(tick); | ||
| lastTickPosition = currentPixelPosition; | ||
| } | ||
| // Special case: always try to include the max boundary if it's the last tick | ||
| else if (tick === max && ticks[ticks.length - 1] === tick) { | ||
| // Check if we can fit the max by removing the last added tick | ||
| if (filteredTicks.length > 1) { | ||
| const secondLastPosition = (filteredTicks[filteredTicks.length - 2] - min) * pixelPerUnit; | ||
| if (currentPixelPosition - secondLastPosition >= options.minTickSpacing) { | ||
| filteredTicks[filteredTicks.length - 1] = tick; | ||
| lastTickPosition = currentPixelPosition; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return filteredTicks; | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -35,3 +35,18 @@ export function formatNumber(value: number, maxNumDecimalPlaces: number = 3): st | |||||||||||||||
| // Omit decimals for integers | ||||||||||||||||
| return Number.isInteger(value) ? value.toString() : fixed; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /** | ||||||||||||||||
| * Formats a number and removes trailing zeros from decimals | ||||||||||||||||
| */ | ||||||||||||||||
| export function formatNumberWithoutTrailingZeros(value: number): string { | ||||||||||||||||
| // Handle values that are essentially zero due to floating point precision | ||||||||||||||||
| if (Math.abs(value) < 1e-10) { | ||||||||||||||||
| return "0"; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| const formatted = formatNumber(value); | ||||||||||||||||
| // Remove trailing zeros after decimal point, and remove decimal point if no digits follow | ||||||||||||||||
| // This handles cases like: "1.000" -> "1", "1.200" -> "1.2", "1.20K" -> "1.2K" | ||||||||||||||||
| return formatted.replace(/(\.\d*?)0+(\D|$)/, "$1$2").replace(/\.$/, ""); | ||||||||||||||||
|
Comment on lines
+49
to
+51
|
||||||||||||||||
| // Remove trailing zeros after decimal point, and remove decimal point if no digits follow | |
| // This handles cases like: "1.000" -> "1", "1.200" -> "1.2", "1.20K" -> "1.2K" | |
| return formatted.replace(/(\.\d*?)0+(\D|$)/, "$1$2").replace(/\.$/, ""); | |
| // Remove trailing zeros from the decimal part (e.g., "1.200" -> "1.2", "1.20K" -> "1.2K") | |
| const noTrailingZeros = formatted.replace(/(\.\d*?[1-9])0+(\D|$)/, "$1$2"); | |
| // Remove dangling decimal point if no digits follow (e.g., "1." -> "1") | |
| return noTrailingZeros.replace(/\.([^\d]|$)/, "$1"); |

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like "nice" is a bit too vague, consider something like
generateRoundedAxisTicks