Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import React from "react";
import type { ColorScale } from "@lib/utils/ColorScale";
import { ColorScaleGradientType, ColorScaleType } from "@lib/utils/ColorScale";
import { resolveClassNames } from "@lib/utils/resolveClassNames";
import { formatNumber } from "@modules/_shared/utils/numberFormatting";
import { generateNiceAxisTicks, type AxisTickOptions } from "@modules/_shared/utils/axisUtils";
import { formatNumberWithoutTrailingZeros } from "@modules/_shared/utils/numberFormatting";
import type { ColorScaleWithName } from "@modules_shared/utils/ColorScaleWithName";

import type { ColorScaleWithId } from "./colorScaleWithId";
Expand Down Expand Up @@ -39,77 +40,90 @@ function makeMarkers(
barHeight: number,
): React.ReactNode[] {
const sectionHeight = Math.abs(sectionBottom - sectionTop);

const minMarkerHeight = STYLE_CONSTANTS.fontSize + 4 * STYLE_CONSTANTS.textGap;
const maxNumMarkers = Math.floor(sectionHeight / minMarkerHeight);

// Odd number of markers makes sure the midpoint in each section is shown which is preferable
const numMarkers = maxNumMarkers % 2 === 0 ? maxNumMarkers - 1 : maxNumMarkers;
const markerDistance = sectionHeight / (numMarkers + 1);
// Calculate the value range for this section
const sectionRelTop = (sectionTop - barTop) / barHeight;
const sectionRelBottom = (sectionBottom - barTop) / barHeight;
const sectionMinValue = colorScale.getMin() + (colorScale.getMax() - colorScale.getMin()) * (1 - sectionRelBottom);
const sectionMaxValue = colorScale.getMin() + (colorScale.getMax() - colorScale.getMin()) * (1 - sectionRelTop);

// Get nice tick values for this section with spacing constraints
const minTickSpacing = STYLE_CONSTANTS.fontSize + STYLE_CONSTANTS.textGap;
const options: AxisTickOptions = {
minTickSpacing,
availableSpace: sectionHeight,
prioritizeBoundaries: true,
};
const tickValues = generateNiceAxisTicks(sectionMinValue, sectionMaxValue, maxNumMarkers, options);

const markers: React.ReactNode[] = [];

let currentLocalY = sectionTop - barTop + markerDistance;
for (let i = 0; i < numMarkers; i++) {
const relValue = 1 - currentLocalY / barHeight;
const value = colorScale.getMin() + (colorScale.getMax() - colorScale.getMin()) * relValue;
for (let i = 0; i < tickValues.length; i++) {
const value = tickValues[i];

const globalY = barTop + currentLocalY;
// Calculate position based on value
const relValue = (value - colorScale.getMin()) / (colorScale.getMax() - colorScale.getMin());
const currentLocalY = barHeight * (1 - relValue);

markers.push(
<line
key={`${sectionTop}-${i}-marker`}
x1={left}
y1={globalY + 1}
x2={left + STYLE_CONSTANTS.lineWidth}
y2={globalY + 1}
stroke={STYLE_CONSTANTS.lineColor}
strokeWidth="1"
/>,
);
markers.push(
<text
key={`${sectionTop}-${i}-text`}
x={left + STYLE_CONSTANTS.lineWidth + STYLE_CONSTANTS.textGap}
y={globalY + 4}
fontSize="10"
style={TEXT_STYLE}
>
{formatNumber(value)}
</text>,
);
// Only draw markers that are within the section bounds
if (currentLocalY >= sectionTop - barTop && currentLocalY <= sectionBottom - barTop) {
const globalY = barTop + currentLocalY;

currentLocalY += markerDistance;
markers.push(
<line
key={`${sectionTop}-${value}-marker`}
x1={left}
y1={globalY + 1}
x2={left + STYLE_CONSTANTS.lineWidth}
y2={globalY + 1}
stroke={STYLE_CONSTANTS.lineColor}
strokeWidth="1"
/>,
);
markers.push(
<text
key={`${sectionTop}-${value}-text`}
x={left + STYLE_CONSTANTS.lineWidth + STYLE_CONSTANTS.textGap}
y={globalY + 4}
fontSize="10"
style={TEXT_STYLE}
>
{formatNumberWithoutTrailingZeros(value)}
</text>,
);
}
}
return markers;
}

function makeDiscreteMarkers(colorScale: ColorScale, left: number, top: number, barHeight: number): React.ReactNode[] {
const minMarkerHeight = STYLE_CONSTANTS.fontSize + 2 * STYLE_CONSTANTS.textGap;
const maxNumMarkers = Math.floor(barHeight / minMarkerHeight);

const numSteps = colorScale.getNumSteps();
let markerDistance = barHeight / numSteps;

while (markerDistance < minMarkerHeight) {
markerDistance += barHeight / numSteps;
}

let steps = Math.floor(barHeight / markerDistance);
if (Math.abs(barHeight - steps * markerDistance) < minMarkerHeight) {
steps--;
}
// Get nice tick values within the color scale range with spacing constraints
const minTickSpacing = STYLE_CONSTANTS.fontSize + STYLE_CONSTANTS.textGap;
const options: AxisTickOptions = {
minTickSpacing,
availableSpace: barHeight,
prioritizeBoundaries: true,
};
const tickValues = generateNiceAxisTicks(colorScale.getMin(), colorScale.getMax(), maxNumMarkers, options);

const markers: React.ReactNode[] = [];
let currentLocalY = markerDistance;
for (let i = 0; i < steps; i++) {
const relValue = 1 - currentLocalY / barHeight;
const value = colorScale.getMin() + (colorScale.getMax() - colorScale.getMin()) * relValue;

for (let i = 0; i < tickValues.length; i++) {
const value = tickValues[i];

// Calculate position based on value
const relValue = (value - colorScale.getMin()) / (colorScale.getMax() - colorScale.getMin());
const currentLocalY = barHeight * (1 - relValue);
const globalY = top + currentLocalY;

markers.push(
<line
key={`${top}-${i}-marker`}
key={`${top}-${value}-marker`}
x1={left}
y1={globalY + 1}
x2={left + STYLE_CONSTANTS.lineWidth}
Expand All @@ -120,17 +134,15 @@ function makeDiscreteMarkers(colorScale: ColorScale, left: number, top: number,
);
markers.push(
<text
key={`${top}-${i}-text`}
key={`${top}-${value}-text`}
x={left + STYLE_CONSTANTS.lineWidth + STYLE_CONSTANTS.textGap}
y={globalY + 4}
fontSize="10"
style={TEXT_STYLE}
>
{formatNumber(value)}
{formatNumberWithoutTrailingZeros(value)}
</text>,
);

currentLocalY += markerDistance;
}

return markers;
Expand Down Expand Up @@ -174,7 +186,7 @@ function ColorLegend(props: ColorLegendProps): React.ReactNode {
fontSize="10"
style={TEXT_STYLE}
>
{formatNumber(props.colorScale.getMax())}
{formatNumberWithoutTrailingZeros(props.colorScale.getMax())}
</text>,
);

Expand Down Expand Up @@ -231,7 +243,7 @@ function ColorLegend(props: ColorLegendProps): React.ReactNode {
fontSize="10"
style={TEXT_STYLE}
>
{formatNumber(props.colorScale.getDivMidPoint())}
{formatNumberWithoutTrailingZeros(props.colorScale.getDivMidPoint())}
</text>,
);

Expand Down Expand Up @@ -278,7 +290,7 @@ function ColorLegend(props: ColorLegendProps): React.ReactNode {
fontSize="10"
style={TEXT_STYLE}
>
{formatNumber(props.colorScale.getMin())}
{formatNumberWithoutTrailingZeros(props.colorScale.getMin())}
</text>,
);

Expand Down
150 changes: 150 additions & 0 deletions frontend/src/modules/_shared/utils/axisUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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[] {
Copy link
Collaborator

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

Copy link
Collaborator

Choose a reason for hiding this comment

The 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

Image

For instance, with the range (-178.726, 762.265), if we consider tens to be a round number, we could have

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)
Rounded limits:    -180.0 , 770.0
Step size:         240.0
Rounded ticks:     [-178.726, 60.0, 300.0, 540.0, 762.265]

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;
const MIN_TICK_WITH_BOUNDARIES = 2; // Minimum ticks to ensure min/max is included
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should write constants like this at the top level of the file

// 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);

if (effectiveMaxTicks < MIN_TICK_WITH_BOUNDARIES && options.availableSpace >= options.minTickSpacing) {
effectiveMaxTicks = MIN_TICK_WITH_BOUNDARIES;
}
}

// 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;
}
15 changes: 15 additions & 0 deletions frontend/src/modules/_shared/utils/numberFormatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The regex pattern (\.\d*?)0+(\D|$) could be clearer. Consider breaking this into separate steps or adding a comment explaining the pattern matches trailing zeros followed by non-digit characters or end of string.

Suggested change
// 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");

Copilot uses AI. Check for mistakes.
}
Loading
Loading