Skip to content

Commit

Permalink
CartesianAxis: Improve interval option 'equidistantPreserveStart' (#3768
Browse files Browse the repository at this point in the history
)

<!--- Provide a general summary of your changes in the Title above -->

## Description

This PR overhauls the existing interval option
`equidistantPreserveStart`

In this variant, we always show the first tick, and starting from the
first, we find the smallest N, for which all n-th ticks can be shown
without collision.

A collision is defined as two overlapping ticks, taking into account
custom tick formatting, a unit, the fontsize, the angle, et cetera (in
alignment with the other existing methods). An overlap is identified if
the end of the previous tick overlaps with the start of the next one.

Downside of this method: 
- When the first tick is very long, hardly any other ticks will be
shown.
- Performance: Instead of iterating over the ticks once, as all other
methods do, this method iterates at most M times, M being the total
amount of available ticks (i.e. for 1000 datapoints, 1000 times). All
other methods are thus more performant. This is a good candidate to
discuss in review or to iterate upon.

<!--- Describe your changes in detail -->

## Related Issue
#3305

<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an
issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps
to reproduce -->
<!--- Please link to the issue here: -->

## How Has This Been Tested?

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran
to -->
<!--- see how your change affects other areas of the code, etc. -->

## Screenshots (if appropriate):

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes
that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're
here to help! -->

- [ ] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [ ] I have added tests to cover my changes.
- [ ] I have added a storybook story or extended an existing story to
show my changes
- [ ] All new and existing tests passed.

---------

Co-authored-by: “Nikolas <“nikolas@rieble.com“>
  • Loading branch information
Nikolas Rieble and “Nikolas committed Sep 22, 2023
1 parent 9f91761 commit 1a70268
Show file tree
Hide file tree
Showing 8 changed files with 110 additions and 78 deletions.
55 changes: 55 additions & 0 deletions src/cartesian/getEquidistantTicks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { isVisible } from '../util/TickUtils';
import { CartesianTickItem } from '../util/types';
import { getEveryNthWithCondition } from '../util/getEveryNthWithCondition';
import { Sign } from './getTicks';

export function getEquidistantTicks(
sign: Sign,
boundaries: { start: number; end: number },
getTickSize: (tick: CartesianTickItem, index: number) => number,
ticks: CartesianTickItem[],
minTickGap: number,
): CartesianTickItem[] {
const result = (ticks || []).slice();

const { start: initialStart, end } = boundaries;
let index = 0;
// Premature optimisation idea 1: Estimate a lower bound, and start from there.
// For now, start from every tick
let stepsize = 1;
let start = initialStart;

while (stepsize <= result.length) {
// Given stepsize, evaluate whether every stepsize-th tick can be shown.
// If it can not, then increase the stepsize by 1, and try again.

const entry = ticks?.[index];

// Break condition - If we have evaluate all the ticks, then we are done.
if (entry === undefined) {
return getEveryNthWithCondition(ticks, stepsize);
}

// Check if the element collides with the next element
const size = getTickSize(entry, index);

const tickCoord = entry.coordinate;
// We will always show the first tick.
const isShow = index === 0 || isVisible(sign, tickCoord, size, start, end);

if (!isShow) {
// Start all over with a larger stepsize
index = 0;
start = initialStart;
stepsize += 1;
}

if (isShow) {
// If it can be shown, update the start
start = tickCoord + sign * (size / 2 + minTickGap);
index += stepsize;
}
}

return [];
}
15 changes: 4 additions & 11 deletions src/cartesian/getTicks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,10 @@ import { mathSign, isNumber } from '../util/DataUtils';
import { getStringSize } from '../util/DOMUtils';
import { Props as CartesianAxisProps } from './CartesianAxis';
import { Global } from '../util/Global';
import {
isVisible,
getEveryNThTick,
getTickBoundaries,
getNumberIntervalTicks,
getAngledTickWidth,
} from '../util/TickUtils';
import { isVisible, getTickBoundaries, getNumberIntervalTicks, getAngledTickWidth } from '../util/TickUtils';
import { getEquidistantTicks } from './getEquidistantTicks';

type Sign = 0 | 1 | -1;
export type Sign = 0 | 1 | -1;

function getTicksEnd(
sign: Sign,
Expand Down Expand Up @@ -137,9 +132,7 @@ export function getTicks(props: CartesianAxisProps, fontSize?: string, letterSpa
const boundaries = getTickBoundaries(viewBox, sign, sizeKey);

if (interval === 'equidistantPreserveStart') {
candidates = getTicksStart(sign, boundaries, getTickSize, ticks, minTickGap);

return getEveryNThTick(candidates);
return getEquidistantTicks(sign, boundaries, getTickSize, ticks, minTickGap);
}

if (interval === 'preserveStart' || interval === 'preserveStartEnd') {
Expand Down
21 changes: 0 additions & 21 deletions src/util/TickUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,27 +27,6 @@ export function isVisible(sign: number, tickPosition: number, size: number, star
return sign * (tickPosition - (sign * size) / 2 - start) >= 0 && sign * (tickPosition + (sign * size) / 2 - end) <= 0;
}

/**
* Given an array of ticks, find N, the lowest possible number for which every
* nTH tick in the ticks array isShow == true and return the array of every nTh tick.
* @param {CartesianTickItem[]} ticks An array of CartesianTickItem with the
* information whether they can be shown without overlapping with their neighbour isShow.
* @returns {CartesianTickItem[]} Every nTh tick in an array.
*/
export function getEveryNThTick(ticks: CartesianTickItem[]) {
let N = 1;
let previous = getEveryNthWithCondition(ticks, N, tickItem => tickItem.isShow);
while (N <= ticks.length) {
if (previous !== undefined) {
return previous;
}
N++;
previous = getEveryNthWithCondition(ticks, N, tickItem => tickItem.isShow);
}

return ticks.slice(0, 1);
}

export function getNumberIntervalTicks(ticks: CartesianTickItem[], interval: number) {
return getEveryNthWithCondition(ticks, interval + 1);
}
3 changes: 1 addition & 2 deletions src/util/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1118,8 +1118,7 @@ export interface BaseAxisProps {
* 'preserveStart' keeps the left tick on collision and ensures that the first tick is always shown.
* 'preserveEnd' keeps the right tick on collision and ensures that the last tick is always shown.
* 'preserveStartEnd' keeps the left tick on collision and ensures that the first and last ticks are always shown.
* 'equidistantPreserveStart' computes which ticks are shown according to the 'preserveStart' strategy and
* then selects a number N such that every nTh tick will be shown.
* 'equidistantPreserveStart' selects a number N such that every nTh tick will be shown without collision.
*/
export type AxisInterval = number | 'preserveStart' | 'preserveEnd' | 'preserveStartEnd' | 'equidistantPreserveStart';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@ export const TickPositioning = {

return (
<ResponsiveContainer>
<LineChart data={ticks}>
<LineChart
data={ticks}
// Margins are neccessary to show ticks that extend beyond the chart (i.e. last and first tick).
margin={{
top: 20,
right: 30,
left: 20,
bottom: 20,
}}
>
<Line dataKey="coordinate" />
{intervalOptions.map((intervalOption, index) => (
<XAxis
Expand Down
40 changes: 40 additions & 0 deletions test/cartesian/getEquidistantTicks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// unit test for getEquidisantTicks

import { getEquidistantTicks } from '../../src/cartesian/getEquidistantTicks';
import { CartesianTickItem } from '../../src/util/types';

describe('getEquidistantTicks', () => {
// We mock getting the tick width by simply using the value of the tick.
// This makes testing rather easy and intuitive.
const getTickSize = (tick: CartesianTickItem) => {
return tick.value;
};

it('should return empty array if no ticks are passed', () => {
const result = getEquidistantTicks(1, { start: 0, end: 100 }, getTickSize, [], 0);
expect(result).toEqual([]);
});

test.each([
// If all ticks fit, we show all ticks
{ ticksThatFit: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], resultingTicks: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] },
{ ticksThatFit: [0, 1, 2, 3, 4, 5, 6, 7, 8], resultingTicks: [0, 2, 4, 6, 8] },
{ ticksThatFit: [0, 1, 2, 3, 4, 5, 6, 7], resultingTicks: [0, 5] },
{ ticksThatFit: [0, 1, 2, 3, 4, 5, 6], resultingTicks: [0, 5] },
{ ticksThatFit: [0, 1, 2, 3, 4, 5], resultingTicks: [0, 5] },
{ ticksThatFit: [0, 1, 2, 3, 4], resultingTicks: [0] },
{ ticksThatFit: [0, 1, 2, 3], resultingTicks: [0] },
{ ticksThatFit: [0, 1, 2], resultingTicks: [0] },
{ ticksThatFit: [0, 1], resultingTicks: [0] },
{ ticksThatFit: [0], resultingTicks: [0] },
// We always show the first tick, even if it does not fit
{ ticksThatFit: [1, 2, 3, 4, 5, 6, 7, 8, 9], resultingTicks: [0] },
])('Show only every n-th tick that fits, but always show the first.', ({ ticksThatFit, resultingTicks }) => {
const ticks = [] as CartesianTickItem[];
for (let index = 0; index < 10; index++) {
ticks.push({ value: ticksThatFit.includes(index) ? 10 : 1000, coordinate: index * 50 });
}
const result = getEquidistantTicks(1, { start: 0, end: 10 * 50 }, getTickSize, ticks, 0);
expect(result).toEqual(resultingTicks.map(index => ticks[index]));
});
});
1 change: 0 additions & 1 deletion test/cartesian/getTicks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ describe('getTicks', () => {
[
{
coordinate: 50,
tickCoord: 50,
value: '10',
},
],
Expand Down
42 changes: 0 additions & 42 deletions test/util/TickUtils.spec.ts

This file was deleted.

0 comments on commit 1a70268

Please sign in to comment.