diff --git a/e2e/screenshots/all.test.ts-snapshots/baselines/test-cases/start-day-of-week-chrome-linux.png b/e2e/screenshots/all.test.ts-snapshots/baselines/test-cases/start-day-of-week-chrome-linux.png new file mode 100644 index 0000000000..aaa0fc521f Binary files /dev/null and b/e2e/screenshots/all.test.ts-snapshots/baselines/test-cases/start-day-of-week-chrome-linux.png differ diff --git a/e2e/screenshots/test_cases_stories.test.ts-snapshots/test-cases-stories/start-day-of-week/should-correctly-render-histogram-with-start-of-week-as-sunday-chrome-linux.png b/e2e/screenshots/test_cases_stories.test.ts-snapshots/test-cases-stories/start-day-of-week/should-correctly-render-histogram-with-start-of-week-as-sunday-chrome-linux.png new file mode 100644 index 0000000000..29a3bdb922 Binary files /dev/null and b/e2e/screenshots/test_cases_stories.test.ts-snapshots/test-cases-stories/start-day-of-week/should-correctly-render-histogram-with-start-of-week-as-sunday-chrome-linux.png differ diff --git a/e2e/tests/test_cases_stories.test.ts b/e2e/tests/test_cases_stories.test.ts index 46b5ac49bf..a8f4df0fe8 100644 --- a/e2e/tests/test_cases_stories.test.ts +++ b/e2e/tests/test_cases_stories.test.ts @@ -107,4 +107,12 @@ test.describe('Test cases stories', () => { ); }); }); + + test.describe('Start day of week', () => { + test('should correctly render histogram with start of week as Sunday', async ({ page }) => { + await common.expectChartAtUrlToMatchScreenshot(page)( + 'http://localhost:9001/?path=/story/test-cases--start-day-of-week&globals=toggles.showHeader:true;toggles.showChartTitle:false;toggles.showChartDescription:false;toggles.showChartBoundary:false;theme:light&knob-start date=1710796632334&knob-start dow=7&knob-data count=18&knob-data interval (amount)=1&knob-data interval (unit)=week', + ); + }); + }); }); diff --git a/e2e_server/server/mocks/@storybook/addon-knobs/index.ts b/e2e_server/server/mocks/@storybook/addon-knobs/index.ts index 4f52a18e53..24d0204545 100644 --- a/e2e_server/server/mocks/@storybook/addon-knobs/index.ts +++ b/e2e_server/server/mocks/@storybook/addon-knobs/index.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import moment from 'moment'; + function getParams() { return new URL(window.location.toString()).searchParams; } @@ -25,6 +27,16 @@ export function number(name: string, dftValue: number, options?: any, groupId?: return Number.parseFloat(params.get(key) ?? `${dftValue}`); } +export function date(name: string, dftValue: Date, groupId?: string): Date { + const params = getParams(); + const key = getKnobKey(name, groupId); + const value = params.get(key); + const numValue = parseInt(value ?? ''); + const dateValue = isNaN(numValue) ? value : numValue; + + return dateValue ? moment(dateValue).toDate() : dftValue; +} + export function radios(name: string, options: unknown, dftValue: string, groupId?: string) { return text(name, dftValue, groupId); } diff --git a/packages/charts/api/charts.api.md b/packages/charts/api/charts.api.md index dcb018bc5e..5f25b4040b 100644 --- a/packages/charts/api/charts.api.md +++ b/packages/charts/api/charts.api.md @@ -2708,7 +2708,7 @@ export const Settings: (props: SFProps; +export const settingsBuildProps: BuildProps; // @public (undocumented) export type SettingsProps = ComponentProps; @@ -2730,6 +2730,7 @@ export interface SettingsSpec extends Spec, LegendSpec { debug: boolean; // @alpha debugState?: boolean; + dow: number; // @alpha externalPointerEvents: ExternalPointerEventsSettings; locale: string; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/config.ts b/packages/charts/src/chart_types/timeslip/timeslip/config.ts index 6277d3c622..e35bd98ef5 100644 --- a/packages/charts/src/chart_types/timeslip/timeslip/config.ts +++ b/packages/charts/src/chart_types/timeslip/timeslip/config.ts @@ -90,6 +90,7 @@ export interface TimeslipConfig extends TimeslipTheme, RasterConfig { export const rasterConfig: RasterConfig = { minimumTickPixelDistance: MINIMUM_TICK_PIXEL_DISTANCE, locale: 'en-US', + dow: 1, }; /** @internal */ diff --git a/packages/charts/src/chart_types/xy_chart/axes/timeslip/chrono/chrono_luxon/chrono_luxon.ts b/packages/charts/src/chart_types/xy_chart/axes/timeslip/chrono/chrono_luxon/chrono_luxon.ts index 56dcf529de..948664993d 100644 --- a/packages/charts/src/chart_types/xy_chart/axes/timeslip/chrono/chrono_luxon/chrono_luxon.ts +++ b/packages/charts/src/chart_types/xy_chart/axes/timeslip/chrono/chrono_luxon/chrono_luxon.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { DateTime } from 'luxon'; /** @internal */ 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 5196b67256..476ad68921 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 @@ -81,6 +81,7 @@ export interface AxisLayer { export interface RasterConfig { minimumTickPixelDistance: number; locale: string; + dow: number; } const millisecondIntervals = (rasterMs: number): IntervalIterableMaker => @@ -158,7 +159,10 @@ const englishPluralRules = new Intl.PluralRules('en-US', { type: 'ordinal' }); const englishOrdinalEnding = (signedNumber: number) => englishOrdinalEndings[englishPluralRules.select(signedNumber)]; /** @internal */ -export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: RasterConfig, timeZone: string) => { +export const continuousTimeRasters = ( + { minimumTickPixelDistance, locale, dow: startDayOfWeek }: RasterConfig, + timeZone: string, +) => { const minorDayBaseFormat = new Intl.DateTimeFormat(locale, { day: 'numeric', timeZone }).format; const minorDayFormat = (d: number) => { const numberString = minorDayBaseFormat(d); @@ -317,8 +321,9 @@ export const continuousTimeRasters = ({ minimumTickPixelDistance, locale }: Rast const temporalArgs = { timeZone, year, month, day: dayOfMonth }; const timePoint = cachedZonedDateTimeFrom(temporalArgs); const dayOfWeek = timePoint[TimeProp.DayOfWeek]; - if (dayOfWeek !== 1) continue; + if (dayOfWeek !== startDayOfWeek) continue; const binStart = timePoint[TimeProp.EpochSeconds]; + if (Number.isFinite(binStart)) { const daysFromEnd = daysInMonth - dayOfMonth + 1; const supremum = cachedTimeDelta(temporalArgs, 'days', 7); 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 8192c44cc5..a4156373c8 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 @@ -48,9 +48,10 @@ export function multilayerAxisEntry( scale: ScaleContinuous, getMeasuredTicks: GetMeasuredTicks, locale: string, + dow: number, ): Projection { const rasterSelector = continuousTimeRasters( - { minimumTickPixelDistance: MINIMUM_TICK_PIXEL_DISTANCE, locale }, + { minimumTickPixelDistance: MINIMUM_TICK_PIXEL_DISTANCE, locale, dow }, xDomain.timeZone, ); const domainValues = xDomain.domain; // todo consider a property or object type rename @@ -61,6 +62,7 @@ export function multilayerAxisEntry( const domainToS = ((Number(domainValues.at(-1)) || NaN) + domainExtension) / 1000; const cartesianWidth = Math.abs(range[1] - range[0]); const layers = rasterSelector(notTooDense(domainFromS, domainToS, binWidth, cartesianWidth, MAX_TIME_TICK_COUNT)); + let layerIndex = -1; const fillLayerTimeslip = ( layer: number, diff --git a/packages/charts/src/chart_types/xy_chart/state/selectors/visible_ticks.ts b/packages/charts/src/chart_types/xy_chart/state/selectors/visible_ticks.ts index aac19c35ee..5f451c3eef 100644 --- a/packages/charts/src/chart_types/xy_chart/state/selectors/visible_ticks.ts +++ b/packages/charts/src/chart_types/xy_chart/state/selectors/visible_ticks.ts @@ -220,7 +220,7 @@ export const getVisibleTickSetsSelector = createCustomCachedSelector( ); function getVisibleTickSets( - { rotation: chartRotation, locale }: Pick, + { rotation: chartRotation, locale, dow }: Pick, joinedAxesData: Map, { xDomain, yDomains }: Pick, smScales: SmallMultipleScales, @@ -342,6 +342,7 @@ function getVisibleTickSets( scale, getMeasuredTicks, locale, + dow, ), ); } diff --git a/packages/charts/src/specs/constants.ts b/packages/charts/src/specs/constants.ts index 441745e50d..64ba2ba794 100644 --- a/packages/charts/src/specs/constants.ts +++ b/packages/charts/src/specs/constants.ts @@ -167,6 +167,7 @@ export const settingsBuildProps = buildSFProps()( pointBuffer: 10, ...DEFAULT_LEGEND_CONFIG, locale: 'en-US', + dow: 1, }, ); diff --git a/packages/charts/src/specs/settings.tsx b/packages/charts/src/specs/settings.tsx index e1e305e994..4bfff5ccc0 100644 --- a/packages/charts/src/specs/settings.tsx +++ b/packages/charts/src/specs/settings.tsx @@ -638,6 +638,15 @@ export interface SettingsSpec extends Spec, LegendSpec { * Unicode Locale Identifier, default `en` */ locale: string; + + /** + * Refers to the first day of the week as an index. + * Expressed according to [**ISO 8601**](https://en.wikipedia.org/wiki/ISO_week_date) + * where `1` is Monday, `2` is Tuesday, ..., `6` is Saturday and `7` is Sunday + * + * @defaultValue 1 (i.e. Monday) + */ + dow: number; } /** diff --git a/packages/charts/src/state/selectors/get_settings_spec.ts b/packages/charts/src/state/selectors/get_settings_spec.ts index d16a347b06..2edc9737ed 100644 --- a/packages/charts/src/state/selectors/get_settings_spec.ts +++ b/packages/charts/src/state/selectors/get_settings_spec.ts @@ -8,9 +8,10 @@ import { getSpecs } from './get_specs'; import { ChartType } from '../../chart_types'; -import { SpecType, DEFAULT_SETTINGS_SPEC } from '../../specs/constants'; +import { SpecType, DEFAULT_SETTINGS_SPEC, settingsBuildProps } from '../../specs/constants'; import { SettingsSpec } from '../../specs/settings'; import { debounce } from '../../utils/debounce'; +import { Logger } from '../../utils/logger'; import { SpecList } from '../chart_state'; import { createCustomCachedSelector } from '../create_selector'; import { getSpecsFromStore } from '../utils'; @@ -25,13 +26,21 @@ export const getSettingsSpecSelector = createCustomCachedSelector([getSpecs], ge function getSettingsSpec(specs: SpecList): SettingsSpec { const settingsSpecs = getSpecsFromStore(specs, ChartType.Global, SpecType.Settings); const spec = settingsSpecs[0]; - return spec ? handleListenerDebouncing(spec) : DEFAULT_SETTINGS_SPEC; + return spec ? validateSpec(spec) : DEFAULT_SETTINGS_SPEC; } -function handleListenerDebouncing(settings: SettingsSpec): SettingsSpec { - const delay = settings.pointerUpdateDebounce ?? DEFAULT_POINTER_UPDATE_DEBOUNCE; +function validateSpec(spec: SettingsSpec): SettingsSpec { + const delay = spec.pointerUpdateDebounce ?? DEFAULT_POINTER_UPDATE_DEBOUNCE; - if (settings.onPointerUpdate) settings.onPointerUpdate = debounce(settings.onPointerUpdate, delay); + if (spec.onPointerUpdate) { + spec.onPointerUpdate = debounce(spec.onPointerUpdate, delay); + } - return settings; + if (spec.dow < 1 || spec.dow > 7 || !Number.isInteger(spec.dow)) { + Logger.warn(`Settings.dow option must be an integer from 1 to 7, received ${spec.dow}. Using default of 1.`); + + spec.dow = settingsBuildProps.defaults.dow; + } + + return spec; } diff --git a/storybook/stories/test_cases/11_start_day_of_week.story.tsx b/storybook/stories/test_cases/11_start_day_of_week.story.tsx new file mode 100644 index 0000000000..7b4f169cca --- /dev/null +++ b/storybook/stories/test_cases/11_start_day_of_week.story.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { number, select, date } from '@storybook/addon-knobs'; +import moment from 'moment'; +import React from 'react'; + +import { Chart, Axis, BarSeries, Position, ScaleType, Settings } from '@elastic/charts'; +import { getRandomNumberGenerator } from '@elastic/charts/src/mocks/utils'; + +import { ChartsStory } from '../../types'; + +const rng = getRandomNumberGenerator('chart'); +const randomValues = Array.from({ length: 1000 }).map(() => rng(10, 100)); + +const dayMapping = { + 1: 'Monday', + 2: 'Tuesday', + 3: 'Wednesday', + 4: 'Thursday', + 5: 'Friday', + 6: 'Saturday', + 7: 'Sunday', +}; + +export const Example: ChartsStory = (_, { title, description }) => { + const startDate = date('start date', moment(1710796632334).toDate()); + const startDow = number('start dow', 1, { min: 1, max: 7, step: 1 }); + const dataCount = number('data count', 18, { min: 0, step: 1 }); + const dataIntervalAmount = number('data interval (amount)', 1, { min: 1, step: 1 }); + const dataIntervaUnit = select( + 'data interval (unit)', + ['minute', 'hour', 'day', 'week', 'month', 'year'], + 'week', + ); + + moment.updateLocale(moment.locale(), { week: { dow: startDow } }); + + const data: { x: number; y: number }[] = []; + const start = moment(startDate).startOf('w'); + + for (let i = 0; i < dataCount; i++) { + data.push({ + x: start + .clone() + .add(dataIntervalAmount * i, dataIntervaUnit) + .valueOf(), + y: randomValues[i], + }); + } + + return ( + <> + + + + moment(d).format('llll')} + style={{ + tickLine: { visible: true, padding: 0 }, + tickLabel: { + alignment: { + horizontal: Position.Left, + vertical: Position.Bottom, + }, + padding: 0, + offset: { x: 0, y: 0 }, + }, + }} + /> + + + + dow: {startDow} + {/* @ts-ignore - mapping constrained */} +  ({dayMapping[startDow]!}) + + + Start: {start.format('llll')} + + + ); +}; + +Example.parameters = { + markdown: `You can set the start day of week on the multilayer time axis by using using the \`Settings.dow\` option. + This expects a value between \`1\` (Monday) and \`7\` (Sunday) according to the [**ISO 8601**](https://en.wikipedia.org/wiki/ISO_week_date) specification.`, +}; diff --git a/storybook/stories/test_cases/test_cases.stories.tsx b/storybook/stories/test_cases/test_cases.stories.tsx index 6461d6d42e..629816de66 100644 --- a/storybook/stories/test_cases/test_cases.stories.tsx +++ b/storybook/stories/test_cases/test_cases.stories.tsx @@ -21,3 +21,4 @@ export { Example as testPointsOutsideOfDomain } from './8_test_points_outside_of export { Example as duplicateLabelsInPartitionLegend } from './9_duplicate_labels_in_partition_legend.story'; export { Example as highlighterZIndex } from './10_highlighter_z_index.story'; export { Example as domainEdges } from './21_domain_edges.story'; +export { Example as startDayOfWeek } from './11_start_day_of_week.story';