Skip to content

Commit

Permalink
fix(axes): start of week label on multilayer time axis (#2035)
Browse files Browse the repository at this point in the history
  • Loading branch information
nickofthyme committed May 23, 2023
1 parent e6042f3 commit 9711233
Show file tree
Hide file tree
Showing 11 changed files with 119 additions and 18 deletions.
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.
13 changes: 13 additions & 0 deletions e2e/tests/axis_stories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,17 @@ test.describe('Axis stories', () => {
},
);
});

test.describe('Timeslip/Multilayer time axis', () => {
test('should show start of week label on last month when tick is inside extents', async ({ page }) => {
await common.expectChartAtUrlToMatchScreenshot(page)(
'http://localhost:9001/?path=/story/area-chart--timeslip&globals=theme:light&knob-Bin width in ms (0: none specifed)=0&knob-Minor grid lines=true&knob-Shift time=0.3&knob-Stretch time=8.6&knob-Time zoom=78&knob-layerCount=2&knob-Show legend=&knob-Horizontal axis title=&knob-Top X axis=&knob-showOverlappingTicks time axis=&knob-showOverlappingLabels time axis=',
);
});
test('should shide start of week label on last month when tick is outside extents', async ({ page }) => {
await common.expectChartAtUrlToMatchScreenshot(page)(
'http://localhost:9001/?path=/story/area-chart--timeslip&globals=theme:light&knob-Bin width in ms (0: none specifed)=0&knob-Minor grid lines=true&knob-Shift time=0.25&knob-Stretch time=8.6&knob-Time zoom=78&knob-layerCount=2&knob-Show legend=&knob-Horizontal axis title=&knob-Top X axis=&knob-showOverlappingTicks time axis=&knob-showOverlappingLabels time axis=',
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ export const updateDataState = (
export const getNullDataState = (): DataState => ({
valid: false,
pending: false,
lo: { minimum: Infinity, supremum: Infinity },
hi: { minimum: -Infinity, supremum: -Infinity },
lo: { minimum: Infinity, supremum: Infinity, labelSupremum: Infinity },
hi: { minimum: -Infinity, supremum: -Infinity, labelSupremum: -Infinity },
binUnit: 'year',
binUnitCount: NaN,
dataResponse: { stats: { minValue: NaN, maxValue: NaN }, rows: [] },
Expand Down
27 changes: 27 additions & 0 deletions packages/charts/src/chart_types/xy_chart/axes/timeslip/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Timeslip / Multilayer time Axis

The timeslip axes rasters a continuos time range into discrete time unit layers.

## Usages

There are currently two usages of the `continuousTimeRasters`. The first is in the `timeslip` chart type here...

https://github.com/elastic/elastic-charts/blob/045fb037a97db7fcad0c3d0af2b31f7a4260149d/packages/charts/src/chart_types/timeslip/timeslip/timeslip_render.ts#L117-L118

The second is in the `xy_chart` via the multilayer ticks here...

https://github.com/elastic/elastic-charts/blob/045fb037a97db7fcad0c3d0af2b31f7a4260149d/packages/charts/src/chart_types/xy_chart/axes/timeslip/multilayer_ticks.ts#L54-L57

## Logical structure

The `continuousTimeRasters` function contains a lot of definitions before finally exporting a final function to return the required `layers` given a `filter` predicate, see the [`notTooDense`](https://github.com/elastic/elastic-charts/blob/045fb037a97db7fcad0c3d0af2b31f7a4260149d/packages/charts/src/chart_types/xy_chart/axes/timeslip/multilayer_ticks.ts#L30-L43) predicate for an example.

> The `notTooDense` filter uses the `minimumTickPixelDistance` value to determine when that layer is suitable for display. This value is static and calibrated manually.
The important definitions are the `AxisLayer` that each define a unique discrete time unit raster layer. These raster layers range from very fine (i.e. milliseconds) to very course (i.e. decades). Generally, these raster layers define the constraints, style and intervals of each raster layer.

The `allRasters` array lists the `AxisLayer`s in order from coarsest to finest.

Last of the definitions is the `replacements`, these are used to replace any number of raster layers when a given layer is present. For example, say one of the final layers is `days`, in such case we would also have `daysUnlabelled` layer because it has the same spacing constraints, thus it's best to remove the `daysUnlabelled` layer because it would be a duplication. These replacements are executed in order so best to order them from coarsest to finest as we do the raster layers.

For `labeled` raster layers, the `detailedLabelFormat` is used only if the raster layer is the last/bottom layer, `minorTickLabelFormat` is used otherwise.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const propsFromCalendarObj = (calendarObj: CalendarObject, timeZone: stri
export const epochInSecondsToYear = (timeZone: string, seconds: number) =>
timeObjToYear(timeObjFromEpochSeconds(timeZone, seconds));

/** @internal */
export const epochDaysInMonth = (timeZone: string, seconds: number) =>
timeObjFromEpochSeconds(timeZone, seconds).daysInMonth;

/** @internal */
export const addTime = (calendarObj: CalendarObject, timeZone: string, unit: CalendarUnit, count: number) =>
timeObjToSeconds(addTimeToObj(timeObjFromCalendarObj(calendarObj, timeZone), unit, count));
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
/* eslint-disable @typescript-eslint/unbound-method */

import { cachedTimeDelta, cachedZonedDateTimeFrom, TimeProp } from './chrono/cached_chrono';
import { epochInSecondsToYear } from './chrono/chrono';
import { epochDaysInMonth, epochInSecondsToYear } from './chrono/chrono';
import { LOCALE_TRANSLATIONS } from './locale_translations';

/** @public */
Expand Down Expand Up @@ -38,8 +38,18 @@ export const unitIntervalWidth: Record<BinUnit, number> = {
* @public
*/
export interface Interval {
/**
* Lower bound of interval (included)
*/
minimum: number;
/**
* Upper bound of interval (excluded)
*/
supremum: number;
/**
* Upper bound of interval to stick text label
*/
labelSupremum: number;
}

type IntervalIterableMaker<T extends Interval> = (domainFrom: number, domainTo: number) => Iterable<T>;
Expand Down Expand Up @@ -78,9 +88,11 @@ const millisecondIntervals = (rasterMs: number): IntervalIterableMaker<Interval>
function* (domainFrom, domainTo) {
for (let t = Math.floor((domainFrom * 1000) / rasterMs); t < Math.ceil((domainTo * 1000) / rasterMs); t++) {
const minimum = (t * rasterMs) / 1000;
const supremum = minimum + rasterMs / 1000;
yield {
minimum,
supremum: minimum + rasterMs / 1000,
supremum,
labelSupremum: supremum,
};
}
};
Expand All @@ -89,7 +101,7 @@ const monthBasedIntervals = (
years: YearsAxisLayer,
timeZone: string,
unitMultiplier: number,
): IntervalIterableMaker<Interval & { year: number; month: number }> =>
): IntervalIterableMaker<Interval & { year: number; month: number; days: number }> =>
function* (domainFrom, domainTo) {
for (const { year } of years.intervals(domainFrom, domainTo)) {
for (let month = 1; month <= 12; month += unitMultiplier) {
Expand All @@ -101,7 +113,15 @@ const monthBasedIntervals = (
month: ((month + unitMultiplier - 1) % 12) + 1,
day: 1,
})[TimeProp.EpochSeconds];
yield { year, month, minimum: binStart, supremum: binEnd };
const days = epochDaysInMonth(timeZone, binStart);
yield {
year,
month,
days,
minimum: binStart,
supremum: binEnd,
labelSupremum: binEnd,
};
}
}
};
Expand Down Expand Up @@ -179,7 +199,12 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast
month: 1,
day: 1,
})[TimeProp.EpochSeconds];
yield { year, minimum: binStart, supremum: binEnd };
yield {
year,
minimum: binStart,
supremum: binEnd,
labelSupremum: binEnd,
};
}
},
detailedLabelFormat: new Intl.DateTimeFormat(locale, { year: 'numeric', timeZone }).format,
Expand Down Expand Up @@ -208,7 +233,12 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast
month: 1,
day: 1,
})[TimeProp.EpochSeconds];
yield { year, minimum: binStart, supremum: binEnd };
yield {
year,
minimum: binStart,
supremum: binEnd,
labelSupremum: binEnd,
};
}
},
detailedLabelFormat: new Intl.DateTimeFormat(locale, { year: 'numeric', timeZone }).format,
Expand All @@ -219,7 +249,7 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast
labeled: false,
minimumTickPixelDistance: 1, // it should change if we ever add centuries and millennia
};
const months: AxisLayer<Interval & { year: number; month: number }> = {
const months: AxisLayer<Interval & { year: number; month: number; days: number }> = {
unit: 'month',
unitMultiplier: 1,
labeled: true,
Expand Down Expand Up @@ -269,6 +299,7 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast
dayOfWeek,
minimum: binStart,
supremum: binEnd,
labelSupremum: binEnd,
};
}
}
Expand All @@ -282,15 +313,23 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast
labeled: true,
minimumTickPixelDistance: minimumTickPixelDistance * 1.5,
intervals: function* (domainFrom, domainTo) {
for (const { year, month } of months.intervals(domainFrom, domainTo)) {
for (const { year, month, days: daysInMonth } of months.intervals(domainFrom, domainTo)) {
for (let dayOfMonth = 1; dayOfMonth <= 31; dayOfMonth++) {
const temporalArgs = { timeZone, year, month, day: dayOfMonth };
const timePoint = cachedZonedDateTimeFrom(temporalArgs);
const dayOfWeek = timePoint[TimeProp.DayOfWeek];
if (dayOfWeek !== 1) continue;
const binStart = timePoint[TimeProp.EpochSeconds];
if (Number.isFinite(binStart)) {
yield { dayOfMonth, minimum: binStart, supremum: cachedTimeDelta(temporalArgs, 'days', 7) };
const daysFromEnd = daysInMonth - dayOfMonth + 1;
const supremum = cachedTimeDelta(temporalArgs, 'days', 7);

yield {
dayOfMonth,
minimum: binStart,
supremum,
labelSupremum: daysFromEnd < 7 ? cachedTimeDelta(temporalArgs, 'days', daysFromEnd) : supremum,
};
}
}
}
Expand Down Expand Up @@ -350,6 +389,8 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast
};
const timePoint = cachedZonedDateTimeFrom(temporalArgs);
const binStart = timePoint[TimeProp.EpochSeconds];
const supremum = binStart + 6 * 60 * 60; // fixme this is not correct in case the day is 23hrs long due to winter->summer time switch

return Number.isNaN(binStart)
? []
: {
Expand All @@ -360,7 +401,8 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast
year,
month,
minimum: binStart,
supremum: binStart + 6 * 60 * 60, // fixme this is not correct in case the day is 23hrs long due to winter->summer time switch
supremum,
labelSupremum: supremum,
};
}),
) as Array<Interval & YearToHour>
Expand Down Expand Up @@ -676,7 +718,9 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast
}

replacements.forEach(([key, ruleMap]) => {
if (layers.has(key)) layers = new Set([...layers].flatMap((l) => ruleMap.get(l) ?? l));
if (layers.has(key)) {
layers = new Set([...layers].flatMap((l) => ruleMap.get(l) ?? l));
}
});

return [...layers].reverse(); // while we iterated from coarse to dense, the result follows the axis layer order: finer toward coarser
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,16 @@ export function multilayerAxisEntry(
if (l.labeled) layerIndex++; // we want three (or however many) _labeled_ axis layers; others are useful for minor ticks/gridlines, and for giving coarser structure eg. stronger gridline for every 6th hour of the day
if (layerIndex >= timeAxisLayerCount) return combinedEntry;
const timeTicks = [...l.intervals(binStartsFrom, binStartsTo)]
.filter((b) => b.supremum > domainFromS && b.minimum <= domainToS)
.filter((b) => {
if (b.labelSupremum !== b.supremum && b.minimum < domainFromS) return false;
return b.supremum > domainFromS && b.minimum <= domainToS;
})
.map((b) => 1000 * b.minimum);

if (timeTicks.length === 0) {
return combinedEntry;
}

const { entry } = fillLayerTimeslip(
layerIndex,
detailedLayerIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@ export const numericalRasters = ({ minimumTickPixelDistance, locale }: RasterCon
labeled: i === 0,
minimumTickPixelDistance,
intervals: (domainFrom, domainTo) =>
getDecimalTicks(domainFrom, domainTo, i === 0 ? 20 : 5, oneFive).map((d, i, a) => ({
minimum: d,
supremum: i < a.length - 1 ? a[i + 1] ?? NaN : d + (d - (a[i - 1] ?? NaN)),
})),
getDecimalTicks(domainFrom, domainTo, i === 0 ? 20 : 5, oneFive).map((d, i, a) => {
const supremum = i < a.length - 1 ? a[i + 1] ?? NaN : d + (d - (a[i - 1] ?? NaN));

return {
minimum: d,
supremum,
labelSupremum: supremum,
};
}),
detailedLabelFormat: (n: number) => format((n - 1300000000000) / 1e6),
minorTickLabelFormat: (n: number) => format((n - 1300000000000) / 1e6),
}),
Expand Down

0 comments on commit 9711233

Please sign in to comment.