Skip to content

Commit

Permalink
AG-11430: Fix ordinal-time scale ticks (#1775)
Browse files Browse the repository at this point in the history
* AG-11429/11430: Fix ordinal-time scale ticks

* cleanup

* update snapshots

* revert some visual snapshots

* fix time format

* update snapshot
  • Loading branch information
iMoses committed Jun 18, 2024
1 parent d576c19 commit 579eb81
Show file tree
Hide file tree
Showing 13 changed files with 81 additions and 138 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@

exports[`OrdinalTimeScale should create nice ticks 1`] = `
[
2024-02-26T00:00:00.000Z,
2024-02-27T00:00:00.000Z,
2024-02-28T00:00:00.000Z,
2024-02-29T00:00:00.000Z,
2024-03-01T00:00:00.000Z,
2024-03-04T00:00:00.000Z,
2024-03-05T00:00:00.000Z,
2024-03-06T00:00:00.000Z,
]
`;

exports[`OrdinalTimeScale should create nice ticks when reversed 1`] = `
[
2024-03-06T00:00:00.000Z,
2024-03-02T00:00:00.000Z,
2024-02-29T00:00:00.000Z,
2024-02-26T00:00:00.000Z,
2024-02-27T00:00:00.000Z,
2024-02-28T00:00:00.000Z,
2024-02-29T00:00:00.000Z,
2024-03-01T00:00:00.000Z,
2024-03-04T00:00:00.000Z,
2024-03-05T00:00:00.000Z,
2024-03-06T00:00:00.000Z,
]
`;

Expand Down Expand Up @@ -176,8 +184,8 @@ exports[`OrdinalTimeScale should create ticks with configured time interval for

exports[`OrdinalTimeScale should extend the domain to create nice ticks matching the data domain 1`] = `
[
2023-01-01T00:00:00.000Z,
2024-01-01T00:00:00.000Z,
2025-01-01T00:00:00.000Z,
2023-04-04T23:00:00.000Z,
2024-04-04T23:00:00.000Z,
2025-04-04T23:00:00.000Z,
]
`;
107 changes: 10 additions & 97 deletions packages/ag-charts-community/src/scale/ordinalTimeScale.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TickIntervals, getTickInterval } from '../util/ticks';
import type { TimeInterval } from '../util/time/interval';
import { unique } from '../util/array';
import type { TimeInterval } from '../util/time';
import { buildFormatter } from '../util/timeFormat';
import { dateToNumber, defaultTimeTickFormat } from '../util/timeFormatDefaults';
import { BandScale } from './bandScale';
Expand Down Expand Up @@ -28,10 +28,6 @@ export class OrdinalTimeScale extends BandScale<Date, TimeInterval | number> {
@Invalidating
override interval?: TimeInterval | number = undefined;

protected niceStart: number = NaN;

private medianInterval?: number;

protected override _domain: Date[] = [];
protected timestamps: number[] = [];
protected sortedTimestamps: number[] = [];
Expand All @@ -45,41 +41,13 @@ export class OrdinalTimeScale extends BandScale<Date, TimeInterval | number> {
}

this._domain = values;
this.timestamps = values.map(dateToNumber);
this.timestamps = unique(values.map(dateToNumber));
this.sortedTimestamps = this.timestamps.slice().sort(compareNumbers);
this.updateIndex();
}
override get domain(): Date[] {
return this._domain;
}

private updateIndex() {
const { sortedTimestamps } = this;
const intervals: number[] = [];

let lastValue: number | undefined;
for (const value of sortedTimestamps) {
if (lastValue != null) {
intervals.push(Math.abs(value - lastValue + 1));
}
lastValue = value;
}

const medianInterval = this.getMedianInterval(intervals);
const interval = OrdinalTimeScale.getTickInterval(medianInterval);

this.medianInterval = medianInterval;
this.niceStart = Number(interval.floor(sortedTimestamps[0]));
}

private getMedianInterval(intervals: number[]) {
intervals.sort(compareNumbers);
const middleIndex = Math.floor(intervals.length / 2);
return intervals.length > 2 && intervals.length % 2 === 0
? (intervals[middleIndex - 1] + intervals[middleIndex + 1]) / 2
: intervals[middleIndex];
}

override ticks(): Date[] {
this.refresh();

Expand All @@ -89,32 +57,14 @@ export class OrdinalTimeScale extends BandScale<Date, TimeInterval | number> {
const isReversed = t0 > t1;

let ticks;
if (this.interval != null) {
if (this.interval == null) {
ticks = this.getDefaultTicks(this.maxTickCount, isReversed);
} else {
const [r0, r1] = this.range;
const availableRange = Math.abs(r1 - r0);
ticks = TimeScale.getTicksForInterval({ start, stop, interval: this.interval, availableRange }) ?? [];
}

const n = this.domain.length;
const { maxTickCount, tickCount } = this;
let { minTickCount } = this;
let medianInterval;
if (isFinite(maxTickCount) && n <= maxTickCount) {
// Produce a tick for each band using the median interval to find a default tick interval as data intervals can be irregular
minTickCount = Math.max(1, n);
medianInterval = this.medianInterval;
}

ticks ??= this.getDefaultTicks({
start,
stop,
tickCount,
minTickCount,
maxTickCount,
isReversed,
interval: medianInterval,
});

// max one tick per band
const tickPositions = new Set<number>();
return ticks.filter((tick) => {
Expand All @@ -127,62 +77,25 @@ export class OrdinalTimeScale extends BandScale<Date, TimeInterval | number> {
});
}

static getTickInterval(target: number) {
let prevInterval: (typeof TickIntervals)[number];
for (const tickInterval of TickIntervals) {
if (target <= tickInterval.duration) {
prevInterval ??= tickInterval;
break;
}
prevInterval = tickInterval;
}
const { timeInterval, step } = prevInterval!;
return timeInterval.every(step);
}

private getDefaultTicks({
start,
stop,
tickCount,
minTickCount,
maxTickCount,
isReversed,
interval,
}: {
start: number;
stop: number;
tickCount: number;
minTickCount: number;
maxTickCount: number;
isReversed: boolean;
interval?: number;
}) {
const tickInterval = getTickInterval(start, stop, tickCount, minTickCount, maxTickCount, interval);

if (!tickInterval) {
return [];
}

private getDefaultTicks(maxTickCount: number, isReversed?: boolean) {
const ticks: Date[] = [];
const count = this.timestamps.length;
const tickEvery = Math.ceil(count / maxTickCount);
for (const [index, value] of this.timestamps.entries()) {
if (tickEvery > 0 && index % tickEvery) continue;

if (isReversed) {
ticks.push(tickInterval.ceil(index + 1 === count ? this.niceStart : this.timestamps[index + 1] + 1));
ticks.push(new Date(this.timestamps[count - index - 1]));
} else {
ticks.push(tickInterval.floor(value));
ticks.push(new Date(value));
}
}

return ticks;
}

override convert(d: Date): number {
this.refresh();
const n = Number(d);
if (n < this.niceStart) {
if (n < this.sortedTimestamps[0]) {
return NaN;
}
let i = this.findInterval(n);
Expand Down
26 changes: 13 additions & 13 deletions packages/ag-charts-community/src/util/timeFormatDefaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ describe('Default Date/Time Formatting', () => {
const formatter = defaultTimeTickFormat(ticks);
expect(ticks.map((v) => formatter(v))).toMatchInlineSnapshot(`
[
":17.300",
":20.300",
":17",
":20",
]
`);
});
Expand Down Expand Up @@ -121,8 +121,8 @@ describe('Default Date/Time Formatting', () => {
const formatter = defaultTimeTickFormat(ticks);
expect(ticks.map((v) => formatter(v))).toMatchInlineSnapshot(`
[
"01 AM Tue",
"01 AM Thu",
"Tue",
"Thu",
]
`);
});
Expand All @@ -143,8 +143,8 @@ describe('Default Date/Time Formatting', () => {
const formatter = defaultTimeTickFormat(ticks);
expect(ticks.map((v) => formatter(v))).toMatchInlineSnapshot(`
[
"Sep 03",
"Oct 08",
"September",
"October",
]
`);
});
Expand All @@ -158,9 +158,9 @@ describe('Default Date/Time Formatting', () => {
const formatter = defaultTimeTickFormat(ticks);
expect(ticks.map((v) => formatter(v))).toMatchInlineSnapshot(`
[
"Sep 01",
"Oct 01",
"Nov 01",
"September",
"October",
"November",
]
`);
});
Expand All @@ -185,8 +185,8 @@ describe('Default Date/Time Formatting', () => {
const formatter = defaultTimeTickFormat(ticks);
expect(ticks.map((v) => formatter(v))).toMatchInlineSnapshot(`
[
"September 2019",
"September 2020",
"2019",
"2020",
]
`);
});
Expand All @@ -196,8 +196,8 @@ describe('Default Date/Time Formatting', () => {
const formatter = defaultTimeTickFormat(ticks);
expect(ticks.map((v) => formatter(v))).toMatchInlineSnapshot(`
[
"September 2019",
"July 2023",
"2019",
"2023",
]
`);
});
Expand Down
66 changes: 44 additions & 22 deletions packages/ag-charts-community/src/util/timeFormatDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import timeMonth from '../util/time/month';
import timeSecond from '../util/time/second';
import timeWeek from '../util/time/week';
import timeYear from '../util/time/year';
import { durationDay, durationHour, durationMinute, durationWeek, durationYear } from './time/duration';
import { durationDay, durationHour, durationMinute, durationSecond, durationWeek, durationYear } from './time/duration';
import { buildFormatter } from './timeFormat';

enum DefaultTimeFormats {
Expand All @@ -16,7 +16,6 @@ enum DefaultTimeFormats {
WEEK_DAY,
SHORT_MONTH,
MONTH,
SHORT_YEAR,
YEAR,
}

Expand All @@ -26,32 +25,43 @@ export function dateToNumber(x: any) {

export function defaultTimeTickFormat(ticks?: any[], domain?: any[], formatOffset?: number) {
const formatString = calculateDefaultTimeTickFormat(ticks, domain, formatOffset);
return (date: Date) => buildFormatter(formatString)(date);
const formatter = buildFormatter(formatString);
return (date: Date) => formatter(date);
}

export function calculateDefaultTimeTickFormat(ticks: any[] | undefined = [], domain = ticks, formatOffset = 0) {
let defaultTimeFormat = DefaultTimeFormats.YEAR;

const updateFormat = (format: DefaultTimeFormats) => {
if (format < defaultTimeFormat) {
defaultTimeFormat = format;
}
};

for (const value of ticks) {
const format = getLowestGranularityFormat(value);
updateFormat(format);
export function calculateDefaultTimeTickFormat(ticks: any[] = [], domain = ticks, formatOffset = 0) {
let minInterval: number = Infinity;
for (let i = 1; i < ticks.length; i++) {
minInterval = Math.min(minInterval, Math.abs(ticks[i] - ticks[i - 1]));
}

const startDomain = dateToNumber(domain[0]);
const endDomain = dateToNumber(domain.at(-1)!);
const startYear = new Date(startDomain).getFullYear();
const stopYear = new Date(endDomain).getFullYear();
const startYear = new Date(domain[0]).getFullYear();
const stopYear = new Date(domain.at(-1)!).getFullYear();
const yearChange = stopYear - startYear > 0;
const timeFormat = isFinite(minInterval)
? getIntervalLowestGranularityFormat(minInterval, ticks)
: getLowestGranularityFormat(ticks[0]);

defaultTimeFormat = Math.max(defaultTimeFormat - formatOffset, 0);
return formatStringBuilder(Math.max(timeFormat - formatOffset, 0), yearChange, ticks);
}

return formatStringBuilder(defaultTimeFormat, yearChange, ticks);
function getIntervalLowestGranularityFormat(value: number, ticks: any[]): DefaultTimeFormats {
if (value < durationSecond) {
return DefaultTimeFormats.MILLISECOND;
} else if (value < durationMinute) {
return DefaultTimeFormats.SECOND;
} else if (value < durationHour) {
return DefaultTimeFormats.MINUTE;
} else if (value < durationDay) {
return DefaultTimeFormats.HOUR;
} else if (value < durationWeek) {
return DefaultTimeFormats.WEEK_DAY;
} else if (value < durationDay * 28 || (value < durationDay * 31 && hasDuplicateMonth(ticks))) {
return DefaultTimeFormats.SHORT_MONTH;
} else if (value < durationYear) {
return DefaultTimeFormats.MONTH;
}
return DefaultTimeFormats.YEAR;
}

function getLowestGranularityFormat(value: Date | number): DefaultTimeFormats {
Expand All @@ -75,6 +85,18 @@ function getLowestGranularityFormat(value: Date | number): DefaultTimeFormats {
return DefaultTimeFormats.YEAR;
}

function hasDuplicateMonth(ticks: any[]) {
let prevMonth = new Date(ticks[0]).getMonth();
for (let i = 1; i < ticks.length; i++) {
const tickMonth = new Date(ticks[i]).getMonth();
if (prevMonth === tickMonth) {
return true;
}
prevMonth = tickMonth;
}
return false;
}

function formatStringBuilder(defaultTimeFormat: DefaultTimeFormats, yearChange: boolean, ticks: any[]): string {
const firstTick = dateToNumber(ticks[0]);
const lastTick = dateToNumber(ticks.at(-1)!);
Expand All @@ -89,7 +111,7 @@ function formatStringBuilder(defaultTimeFormat: DefaultTimeFormats, yearChange:
['ms', 0, 6 * durationHour, DefaultTimeFormats.MILLISECOND, '.%L'],
['am/pm', durationMinute, 6 * durationHour, DefaultTimeFormats.HOUR, '%p'],
' ',
['day', durationDay, 1 * durationWeek, DefaultTimeFormats.WEEK_DAY, '%a'],
['day', durationDay, durationWeek, DefaultTimeFormats.WEEK_DAY, '%a'],
['month', activeDate ? 0 : durationWeek, 52 * durationWeek, DefaultTimeFormats.SHORT_MONTH, '%b %d'],
['month', 5 * durationWeek, 10 * durationYear, DefaultTimeFormats.MONTH, '%B'],
' ',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 579eb81

Please sign in to comment.