Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for start day of week on MLT axis #2362

Merged
merged 13 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
8 changes: 8 additions & 0 deletions e2e/tests/test_cases_stories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
});
12 changes: 12 additions & 0 deletions e2e_server/server/mocks/@storybook/addon-knobs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* Side Public License, v 1.
*/

import moment from 'moment';

function getParams() {
return new URL(window.location.toString()).searchParams;
}
Expand All @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/charts/api/charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2708,7 +2708,7 @@ export const Settings: (props: SFProps<SettingsSpec, keyof (typeof settingsBuild
// Warning: (ae-forgotten-export) The symbol "BuildProps" needs to be exported by the entry point index.d.ts
//
// @public (undocumented)
export const settingsBuildProps: BuildProps<SettingsSpec, "id" | "chartType" | "specType", "debug" | "locale" | "rotation" | "ariaLabelHeadingLevel" | "ariaUseDefaultSummary" | "legendPosition" | "flatLegend" | "legendMaxDepth" | "legendSize" | "showLegend" | "showLegendExtra" | "baseTheme" | "rendering" | "animateData" | "externalPointerEvents" | "pointBuffer" | "pointerUpdateTrigger" | "brushAxis" | "minBrushDelta" | "allowBrushingLastHistogramBin", "ariaDescription" | "ariaLabel" | "xDomain" | "ariaDescribedBy" | "ariaLabelledBy" | "ariaTableCaption" | "theme" | "legendAction" | "legendColorPicker" | "legendStrategy" | "onLegendItemClick" | "customLegend" | "onLegendItemMinusClick" | "onLegendItemOut" | "onLegendItemOver" | "onLegendItemPlusClick" | "orderOrdinalBinsBy" | "debugState" | "onProjectionClick" | "onElementClick" | "onElementOver" | "onElementOut" | "onBrushEnd" | "onPointerUpdate" | "onResize" | "onRenderChange" | "onWillRender" | "onProjectionAreaChange" | "onAnnotationClick" | "resizeDebounce" | "pointerUpdateDebounce" | "roundHistogramBrushValues" | "noResults" | "legendSort", never>;
export const settingsBuildProps: BuildProps<SettingsSpec, "id" | "chartType" | "specType", "debug" | "locale" | "rotation" | "baseTheme" | "rendering" | "animateData" | "externalPointerEvents" | "pointBuffer" | "pointerUpdateTrigger" | "brushAxis" | "minBrushDelta" | "allowBrushingLastHistogramBin" | "ariaLabelHeadingLevel" | "ariaUseDefaultSummary" | "dow" | "showLegend" | "legendPosition" | "showLegendExtra" | "legendMaxDepth" | "legendSize" | "flatLegend", "ariaDescription" | "ariaLabel" | "xDomain" | "theme" | "debugState" | "onProjectionClick" | "onElementClick" | "onElementOver" | "onElementOut" | "onBrushEnd" | "onPointerUpdate" | "onResize" | "onRenderChange" | "onWillRender" | "onProjectionAreaChange" | "onAnnotationClick" | "resizeDebounce" | "pointerUpdateDebounce" | "roundHistogramBrushValues" | "orderOrdinalBinsBy" | "noResults" | "ariaLabelledBy" | "ariaDescribedBy" | "ariaTableCaption" | "legendStrategy" | "onLegendItemOver" | "onLegendItemOut" | "onLegendItemClick" | "onLegendItemPlusClick" | "onLegendItemMinusClick" | "legendAction" | "legendColorPicker" | "legendSort" | "customLegend", never>;

// @public (undocumented)
export type SettingsProps = ComponentProps<typeof Settings>;
Expand All @@ -2730,6 +2730,7 @@ export interface SettingsSpec extends Spec, LegendSpec {
debug: boolean;
// @alpha
debugState?: boolean;
dow: number;
// @alpha
externalPointerEvents: ExternalPointerEventsSettings;
locale: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* Side Public License, v 1.
*/

// eslint-disable-next-line import/no-extraneous-dependencies
import { DateTime } from 'luxon';

/** @internal */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export interface AxisLayer<T extends Interval> {
export interface RasterConfig {
minimumTickPixelDistance: number;
locale: string;
dow: number;
}

const millisecondIntervals = (rasterMs: number): IntervalIterableMaker<Interval> =>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export const getVisibleTickSetsSelector = createCustomCachedSelector(
);

function getVisibleTickSets(
{ rotation: chartRotation, locale }: Pick<SettingsSpec, 'rotation' | 'locale'>,
{ rotation: chartRotation, locale, dow }: Pick<SettingsSpec, 'rotation' | 'locale' | 'dow'>,
joinedAxesData: Map<AxisId, JoinedAxisData>,
{ xDomain, yDomains }: Pick<SeriesDomainsAndData, 'xDomain' | 'yDomains'>,
smScales: SmallMultipleScales,
Expand Down Expand Up @@ -342,6 +342,7 @@ function getVisibleTickSets(
scale,
getMeasuredTicks,
locale,
dow,
),
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/charts/src/specs/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export const settingsBuildProps = buildSFProps<SettingsSpec>()(
pointBuffer: 10,
...DEFAULT_LEGEND_CONFIG,
locale: 'en-US',
dow: 1,
},
);

Expand Down
9 changes: 9 additions & 0 deletions packages/charts/src/specs/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
21 changes: 15 additions & 6 deletions packages/charts/src/state/selectors/get_settings_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,13 +26,21 @@ export const getSettingsSpecSelector = createCustomCachedSelector([getSpecs], ge
function getSettingsSpec(specs: SpecList): SettingsSpec {
const settingsSpecs = getSpecsFromStore<SettingsSpec>(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;
}
105 changes: 105 additions & 0 deletions storybook/stories/test_cases/11_start_day_of_week.story.tsx
Original file line number Diff line number Diff line change
@@ -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<moment.unitOfTime.Base>(
'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 (
<>
<Chart title={title} description={description}>
<Settings dow={startDow} />
<Axis id="y" title="Count" position={Position.Left} />
<Axis
id="x"
title="Time"
position={Position.Bottom}
timeAxisLayerCount={2}
tickFormat={(d) => 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 },
},
}}
/>
<BarSeries
enableHistogramMode
id="bars"
name="amount"
xScaleType={ScaleType.Time}
xAccessor="x"
yAccessors={['y']}
data={data}
/>
</Chart>
<span style={{ padding: 10 }}>
<b>dow:</b> {startDow}
{/* @ts-ignore - mapping constrained */}
&nbsp;({dayMapping[startDow]!})
</span>
<span style={{ padding: 10 }}>
<b>Start:</b> {start.format('llll')}
</span>
</>
);
};

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.`,
};
1 change: 1 addition & 0 deletions storybook/stories/test_cases/test_cases.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';