Skip to content

Commit

Permalink
feat: add support for start day of week on MLT axis (#2362)
Browse files Browse the repository at this point in the history
  • Loading branch information
nickofthyme committed Mar 27, 2024
1 parent 081953a commit 3aac1f0
Show file tree
Hide file tree
Showing 15 changed files with 166 additions and 12 deletions.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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';

0 comments on commit 3aac1f0

Please sign in to comment.