diff --git a/e2e/screenshots/all.test.ts-snapshots/baselines/small-multiples-alpha/vertical-areas-chrome-linux.png b/e2e/screenshots/all.test.ts-snapshots/baselines/small-multiples-alpha/vertical-areas-chrome-linux.png index 317a2f5829..4a2bac1afd 100644 Binary files a/e2e/screenshots/all.test.ts-snapshots/baselines/small-multiples-alpha/vertical-areas-chrome-linux.png and b/e2e/screenshots/all.test.ts-snapshots/baselines/small-multiples-alpha/vertical-areas-chrome-linux.png differ diff --git a/e2e/screenshots/area_stories.test.ts-snapshots/area-series-stories/small-multiples-with-log-scale-dont-clip-chrome-linux.png b/e2e/screenshots/area_stories.test.ts-snapshots/area-series-stories/small-multiples-with-log-scale-dont-clip-chrome-linux.png index db5a900ffc..7a308a6968 100644 Binary files a/e2e/screenshots/area_stories.test.ts-snapshots/area-series-stories/small-multiples-with-log-scale-dont-clip-chrome-linux.png and b/e2e/screenshots/area_stories.test.ts-snapshots/area-series-stories/small-multiples-with-log-scale-dont-clip-chrome-linux.png differ diff --git a/e2e/screenshots/axis_stories.test.ts-snapshots/axis-stories/timeslip-multilayer-time-axis/should-shide-start-of-week-label-on-last-month-when-tick-is-outside-extents-chrome-linux.png b/e2e/screenshots/axis_stories.test.ts-snapshots/axis-stories/timeslip-multilayer-time-axis/should-shide-start-of-week-label-on-last-month-when-tick-is-outside-extents-chrome-linux.png new file mode 100644 index 0000000000..b7f289dcb8 Binary files /dev/null and b/e2e/screenshots/axis_stories.test.ts-snapshots/axis-stories/timeslip-multilayer-time-axis/should-shide-start-of-week-label-on-last-month-when-tick-is-outside-extents-chrome-linux.png differ diff --git a/e2e/screenshots/axis_stories.test.ts-snapshots/axis-stories/timeslip-multilayer-time-axis/should-show-start-of-week-label-on-last-month-when-tick-is-inside-extents-chrome-linux.png b/e2e/screenshots/axis_stories.test.ts-snapshots/axis-stories/timeslip-multilayer-time-axis/should-show-start-of-week-label-on-last-month-when-tick-is-inside-extents-chrome-linux.png new file mode 100644 index 0000000000..ae7fde5e26 Binary files /dev/null and b/e2e/screenshots/axis_stories.test.ts-snapshots/axis-stories/timeslip-multilayer-time-axis/should-show-start-of-week-label-on-last-month-when-tick-is-inside-extents-chrome-linux.png differ diff --git a/e2e/tests/axis_stories.test.ts b/e2e/tests/axis_stories.test.ts index b0bcc1768d..d5ec41e21f 100644 --- a/e2e/tests/axis_stories.test.ts +++ b/e2e/tests/axis_stories.test.ts @@ -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=', + ); + }); + }); }); diff --git a/packages/charts/src/chart_types/timeslip/timeslip/data_fetch.ts b/packages/charts/src/chart_types/timeslip/timeslip/data_fetch.ts index 9326c6cbb8..a2ee2edf42 100644 --- a/packages/charts/src/chart_types/timeslip/timeslip/data_fetch.ts +++ b/packages/charts/src/chart_types/timeslip/timeslip/data_fetch.ts @@ -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: [] }, diff --git a/packages/charts/src/chart_types/xy_chart/axes/timeslip/README.md b/packages/charts/src/chart_types/xy_chart/axes/timeslip/README.md new file mode 100644 index 0000000000..a361f72737 --- /dev/null +++ b/packages/charts/src/chart_types/xy_chart/axes/timeslip/README.md @@ -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. diff --git a/packages/charts/src/chart_types/xy_chart/axes/timeslip/chrono/chrono.ts b/packages/charts/src/chart_types/xy_chart/axes/timeslip/chrono/chrono.ts index a002d1343d..2cf913949a 100644 --- a/packages/charts/src/chart_types/xy_chart/axes/timeslip/chrono/chrono.ts +++ b/packages/charts/src/chart_types/xy_chart/axes/timeslip/chrono/chrono.ts @@ -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)); diff --git a/packages/charts/src/chart_types/xy_chart/axes/timeslip/continuous_time_rasters.ts b/packages/charts/src/chart_types/xy_chart/axes/timeslip/continuous_time_rasters.ts index 44c65c68c4..fd9625a1ad 100644 --- a/packages/charts/src/chart_types/xy_chart/axes/timeslip/continuous_time_rasters.ts +++ b/packages/charts/src/chart_types/xy_chart/axes/timeslip/continuous_time_rasters.ts @@ -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 */ @@ -38,8 +38,18 @@ export const unitIntervalWidth: Record = { * @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 = (domainFrom: number, domainTo: number) => Iterable; @@ -78,9 +88,11 @@ const millisecondIntervals = (rasterMs: number): IntervalIterableMaker 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, }; } }; @@ -89,7 +101,7 @@ const monthBasedIntervals = ( years: YearsAxisLayer, timeZone: string, unitMultiplier: number, -): IntervalIterableMaker => +): IntervalIterableMaker => function* (domainFrom, domainTo) { for (const { year } of years.intervals(domainFrom, domainTo)) { for (let month = 1; month <= 12; month += unitMultiplier) { @@ -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, + }; } } }; @@ -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, @@ -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, @@ -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 = { + const months: AxisLayer = { unit: 'month', unitMultiplier: 1, labeled: true, @@ -269,6 +299,7 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast dayOfWeek, minimum: binStart, supremum: binEnd, + labelSupremum: binEnd, }; } } @@ -282,7 +313,7 @@ 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); @@ -290,7 +321,15 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast 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, + }; } } } @@ -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) ? [] : { @@ -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 @@ -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 diff --git a/packages/charts/src/chart_types/xy_chart/axes/timeslip/multilayer_ticks.ts b/packages/charts/src/chart_types/xy_chart/axes/timeslip/multilayer_ticks.ts index 3e963a920e..7875b6a37b 100644 --- a/packages/charts/src/chart_types/xy_chart/axes/timeslip/multilayer_ticks.ts +++ b/packages/charts/src/chart_types/xy_chart/axes/timeslip/multilayer_ticks.ts @@ -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, diff --git a/packages/charts/src/chart_types/xy_chart/axes/timeslip/numerical_rasters.ts b/packages/charts/src/chart_types/xy_chart/axes/timeslip/numerical_rasters.ts index bf2b26a939..f1f54d4cff 100644 --- a/packages/charts/src/chart_types/xy_chart/axes/timeslip/numerical_rasters.ts +++ b/packages/charts/src/chart_types/xy_chart/axes/timeslip/numerical_rasters.ts @@ -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), }),