diff --git a/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-axes-duplicate-ticks-visually-looks-correct-1-snap.png b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-axes-duplicate-ticks-visually-looks-correct-1-snap.png new file mode 100644 index 0000000000..2d503fd420 Binary files /dev/null and b/integration/tests/__image_snapshots__/all-test-ts-baseline-visual-tests-for-all-stories-axes-duplicate-ticks-visually-looks-correct-1-snap.png differ diff --git a/src/chart_types/xy_chart/state/utils.ts b/src/chart_types/xy_chart/state/utils.ts index a6d23e07d7..383f242d10 100644 --- a/src/chart_types/xy_chart/state/utils.ts +++ b/src/chart_types/xy_chart/state/utils.ts @@ -295,9 +295,14 @@ export function computeSeriesGeometries( // compute how many series are clustered const { stackedBarsInCluster, totalBarsInCluster } = countBarsInCluster(stacked, nonStacked); - // compute scales - const xScale = computeXScale({ xDomain, totalBarsInCluster, range: [0, width], barsPadding, enableHistogramMode }); + const xScale = computeXScale({ + xDomain, + totalBarsInCluster, + range: [0, width], + barsPadding, + enableHistogramMode, + }); const yScales = computeYScales({ yDomains: yDomain, range: [height, 0] }); // compute colors diff --git a/src/chart_types/xy_chart/utils/axis_utils.test.ts b/src/chart_types/xy_chart/utils/axis_utils.test.ts index b736577218..60beaf4710 100644 --- a/src/chart_types/xy_chart/utils/axis_utils.test.ts +++ b/src/chart_types/xy_chart/utils/axis_utils.test.ts @@ -22,7 +22,7 @@ import { AxisSpec, DomainRange, AxisStyle } from './specs'; import { Position } from '../../../utils/commons'; import { LIGHT_THEME } from '../../../utils/themes/light_theme'; import { AxisId, GroupId } from '../../../utils/ids'; -import { ScaleType } from '../../../scales'; +import { ScaleType, Scale } from '../../../scales'; import { AxisTick, AxisTicksDimensions, @@ -48,6 +48,7 @@ import { getAxisTickLabelPadding, isVerticalGrid, isHorizontalGrid, + enableDuplicatedTicks, } from './axis_utils'; import { CanvasTextBBoxCalculator } from '../../../utils/bbox/canvas_text_bbox_calculator'; import { SvgTextBBoxCalculator } from '../../../utils/bbox/svg_text_bbox_calculator'; @@ -55,6 +56,9 @@ import { niceTimeFormatter } from '../../../utils/data/formatters'; import { mergeYCustomDomainsByGroupId } from '../state/selectors/merge_y_custom_domains'; import { ChartTypes } from '../..'; import { SpecTypes } from '../../../specs/settings'; +import { DateTime } from 'luxon'; +import { computeXScale } from './scales'; +import moment from 'moment-timezone'; describe('Axis computational utils', () => { const mockedRect = { @@ -202,7 +206,7 @@ describe('Axis computational utils', () => { expect(axisDimensions).toEqual(axis1Dims); const computeScalelessSpec = () => { - computeAxisTicksDimensions(ungroupedAxisSpec, xDomain, [yDomain], 1, bboxCalculator, 0, axes); + computeAxisTicksDimensions(ungroupedAxisSpec, xDomain, [yDomain], 1, bboxCalculator, 0, axes, undefined, false); }; const ungroupedAxisSpec = { ...verticalAxisSpec, groupId: 'foo' }; @@ -1436,4 +1440,88 @@ describe('Axis computational utils', () => { expect(getAxisTickLabelPadding(axisConfigTickLabelPadding, axisSpecStyle)).toEqual(2); }); + test('should show unique tick labels if duplicateTicks is set to false', () => { + const now = DateTime.fromISO('2019-01-11T00:00:00.000') + .setZone('utc+1') + .toMillis(); + const oneDay = moment.duration(1, 'day'); + const formatter = niceTimeFormatter([now, oneDay.add(now).asMilliseconds() * 31]); + const axisSpec: AxisSpec = { + id: 'bottom', + position: 'bottom', + showDuplicatedTicks: false, + chartType: 'xy_axis', + specType: 'axis', + groupId: '__global__', + hide: false, + showOverlappingLabels: false, + showOverlappingTicks: false, + tickSize: 10, + tickPadding: 10, + tickLabelRotation: 0, + tickFormat: formatter, + }; + const xDomainTime: XDomain = { + type: 'xDomain', + isBandScale: false, + domain: [1547190000000, 1547622000000], + minInterval: 86400000, + scaleType: ScaleType.Time, + }; + const scale: Scale = computeXScale({ xDomain: xDomainTime, totalBarsInCluster: 0, range: [0, 603.5] }); + const offset = 0; + const tickFormatOption = { timeZone: 'utc+1' }; + expect(enableDuplicatedTicks(axisSpec, scale, offset, tickFormatOption)).toEqual([ + { value: 1547208000000, label: '2019-01-11', position: 25.145833333333332 }, + { value: 1547251200000, label: '2019-01-12', position: 85.49583333333334 }, + { value: 1547337600000, label: '2019-01-13', position: 206.19583333333333 }, + { value: 1547424000000, label: '2019-01-14', position: 326.8958333333333 }, + { value: 1547510400000, label: '2019-01-15', position: 447.59583333333336 }, + { value: 1547596800000, label: '2019-01-16', position: 568.2958333333333 }, + ]); + }); + test('should show duplicate tick labels if duplicateTicks is set to true', () => { + const now = DateTime.fromISO('2019-01-11T00:00:00.000') + .setZone('utc+1') + .toMillis(); + const oneDay = moment.duration(1, 'day'); + const formatter = niceTimeFormatter([now, oneDay.add(now).asMilliseconds() * 31]); + const axisSpec: AxisSpec = { + id: 'bottom', + position: 'bottom', + showDuplicatedTicks: true, + chartType: 'xy_axis', + specType: 'axis', + groupId: '__global__', + hide: false, + showOverlappingLabels: false, + showOverlappingTicks: false, + tickSize: 10, + tickPadding: 10, + tickLabelRotation: 0, + tickFormat: formatter, + }; + const xDomainTime: XDomain = { + type: 'xDomain', + isBandScale: false, + domain: [1547190000000, 1547622000000], + minInterval: 86400000, + scaleType: ScaleType.Time, + }; + const scale: Scale = computeXScale({ xDomain: xDomainTime, totalBarsInCluster: 0, range: [0, 603.5] }); + const offset = 0; + const tickFormatOption = { timeZone: 'utc+1' }; + expect(enableDuplicatedTicks(axisSpec, scale, offset, tickFormatOption)).toEqual([ + { value: 1547208000000, label: '2019-01-11', position: 25.145833333333332 }, + { value: 1547251200000, label: '2019-01-12', position: 85.49583333333334 }, + { value: 1547294400000, label: '2019-01-12', position: 145.84583333333333 }, + { value: 1547337600000, label: '2019-01-13', position: 206.19583333333333 }, + { value: 1547380800000, label: '2019-01-13', position: 266.54583333333335 }, + { value: 1547424000000, label: '2019-01-14', position: 326.8958333333333 }, + { value: 1547467200000, label: '2019-01-14', position: 387.24583333333334 }, + { value: 1547510400000, label: '2019-01-15', position: 447.59583333333336 }, + { value: 1547553600000, label: '2019-01-15', position: 507.9458333333333 }, + { value: 1547596800000, label: '2019-01-16', position: 568.2958333333333 }, + ]); + }); }); diff --git a/src/chart_types/xy_chart/utils/axis_utils.ts b/src/chart_types/xy_chart/utils/axis_utils.ts index eb3a9a4267..d14fc274e2 100644 --- a/src/chart_types/xy_chart/utils/axis_utils.ts +++ b/src/chart_types/xy_chart/utils/axis_utils.ts @@ -29,7 +29,7 @@ import { AxisStyle, TickFormatterOptions, } from './specs'; -import { Position, Rotation } from '../../../utils/commons'; +import { Position, Rotation, getUniqueValues } from '../../../utils/commons'; import { AxisConfig, Theme } from '../../../utils/themes/theme'; import { Dimensions, Margins } from '../../../utils/dimensions'; import { AxisId } from '../../../utils/ids'; @@ -87,7 +87,6 @@ export function computeAxisTicksDimensions( if (axisSpec.hide) { return null; } - const scale = getScaleForAxisSpec( axisSpec, xDomain, @@ -238,11 +237,9 @@ function computeTickDimensions( const tickLabels = tickValues.map((d) => { return tickFormat(d, tickFormatOptions); }); - const { tickLabelStyle: { fontFamily, fontSize }, } = axisConfig; - const { maxLabelBboxWidth, maxLabelBboxHeight, @@ -252,7 +249,6 @@ function computeTickDimensions( getMaxBboxDimensions(bboxCalculator, fontSize, fontFamily, tickLabelRotation, tickLabelPadding), { maxLabelBboxWidth: 0, maxLabelBboxHeight: 0, maxLabelTextWidth: 0, maxLabelTextHeight: 0 }, ); - return { tickValues, tickLabels, @@ -444,14 +440,31 @@ export function getAvailableTicks( return [firstTick, lastTick]; } - return ticks.map((tick) => { + return enableDuplicatedTicks(axisSpec, scale, offset, tickFormatOptions); +} + +/** @internal */ +export function enableDuplicatedTicks( + axisSpec: AxisSpec, + scale: Scale, + offset: number, + tickFormatOptions?: TickFormatterOptions, +) { + const ticks = scale.ticks(); + const allTicks: AxisTick[] = ticks.map((tick) => { return { value: tick, label: axisSpec.tickFormat(tick, tickFormatOptions), position: scale.scale(tick) + offset, }; }); + + if (axisSpec.showDuplicatedTicks === true) { + return allTicks; + } + return getUniqueValues(allTicks, 'label'); } + export function getVisibleTicks(allTicks: AxisTick[], axisSpec: AxisSpec, axisDim: AxisTicksDimensions): AxisTick[] { // We sort the ticks by position so that we can incrementally compute previousOccupiedSpace allTicks.sort((a: AxisTick, b: AxisTick) => a.position - b.position); diff --git a/src/chart_types/xy_chart/utils/specs.ts b/src/chart_types/xy_chart/utils/specs.ts index dd91b2f78f..5c8ce8ba3c 100644 --- a/src/chart_types/xy_chart/utils/specs.ts +++ b/src/chart_types/xy_chart/utils/specs.ts @@ -525,11 +525,14 @@ export interface AxisSpec extends Spec { style?: AxisStyle; /** Show only integar values **/ integersOnly?: boolean; + /** Remove duplicate ticks, default is false*/ + showDuplicatedTicks?: boolean; } export type TickFormatterOptions = { timeZone?: string; }; + export type TickFormatter = (value: any, options?: TickFormatterOptions) => string; export interface AxisStyle { diff --git a/src/utils/commons.ts b/src/utils/commons.ts index 4917523f65..e1bdeca373 100644 --- a/src/utils/commons.ts +++ b/src/utils/commons.ts @@ -191,6 +191,28 @@ export function isNumberArray(value: unknown): value is number[] { return Array.isArray(value) && value.every((element) => typeof element === 'number'); } +/** @internal */ +export function getUniqueValues(fullArray: T[], uniqueProperty: keyof T): T[] { + return fullArray.reduce<{ + filtered: T[]; + uniqueValues: Set; + }>( + (acc, currentValue) => { + const uniqueValue = currentValue[uniqueProperty]; + if (acc.uniqueValues.has(uniqueValue)) { + return acc; + } + acc.uniqueValues.add(uniqueValue); + acc.filtered.push(currentValue); + return acc; + }, + { + filtered: [], + uniqueValues: new Set(), + }, + ).filtered; +} + export type ValueFormatter = (value: number) => string; export type ValueAccessor = (d: Datum) => number; export type LabelAccessor = (value: PrimitiveValue) => string; diff --git a/stories/axes/12_duplicate_ticks.tsx b/stories/axes/12_duplicate_ticks.tsx new file mode 100644 index 0000000000..d5acbc0228 --- /dev/null +++ b/stories/axes/12_duplicate_ticks.tsx @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ + +import React from 'react'; +import { Axis, Chart, LineSeries, Position, ScaleType, niceTimeFormatter } from '../../src'; +import { KIBANA_METRICS } from '../../src/utils/data_samples/test_dataset_kibana'; +import { boolean } from '@storybook/addon-knobs'; +import { DateTime } from 'luxon'; +import moment from 'moment-timezone'; + +export const example = () => { + const now = DateTime.fromISO('2019-01-11T00:00:00.000') + .setZone('utc+1') + .toMillis(); + const oneDay = moment.duration(1, 'd'); + const twoDays = moment.duration(2, 'd'); + const oneMonth = moment.duration(31, 'd'); + const threeDays = moment.duration(3, 'd'); + const fourDays = moment.duration(4, 'd'); + const fiveDays = moment.duration(5, 'd'); + const formatter = niceTimeFormatter([now, oneMonth.add(now).asMilliseconds()]); + const duplicateTicksInAxis = boolean('Show duplicate ticks in x axis', false); + return ( + + + `${Number(d).toFixed(1)}`} + /> + + + ); +}; diff --git a/stories/axes/axes.stories.tsx b/stories/axes/axes.stories.tsx index 91ee8795b3..17ea145000 100644 --- a/stories/axes/axes.stories.tsx +++ b/stories/axes/axes.stories.tsx @@ -36,3 +36,4 @@ export { example as customDomain } from './8_custom_domain'; export { example as customMixed } from './9_custom_mixed_domain'; export { example as oneDomainBound } from './10_one_domain_bound'; export { example as fitDomain } from './11_fit_domain_extent'; +export { example as duplicateTicks } from './12_duplicate_ticks'; diff --git a/stories/interactions/11_brush_time.tsx b/stories/interactions/11_brush_time.tsx index 2eb74384ae..e2b72cc470 100644 --- a/stories/interactions/11_brush_time.tsx +++ b/stories/interactions/11_brush_time.tsx @@ -23,13 +23,17 @@ import { Axis, BarSeries, Chart, LineSeries, niceTimeFormatter, Position, ScaleT import { boolean } from '@storybook/addon-knobs'; import { DateTime } from 'luxon'; import { getChartRotationKnob } from '../utils/knobs'; +import moment from 'moment-timezone'; export const example = () => { const now = DateTime.fromISO('2019-01-11T00:00:00.000') .setZone('utc+1') .toMillis(); const oneDay = 1000 * 60 * 60 * 24; - const formatter = niceTimeFormatter([now, now + oneDay * 5]); + const oneDays = moment.duration(1, 'd'); + const twoDays = moment.duration(2, 'd'); + const fiveDays = moment.duration(5, 'd'); + const formatter = niceTimeFormatter([now, fiveDays.add(now).asMilliseconds()]); return ( { timeZone="Europe/Rome" data={[ { x: now, y: 2 }, - { x: now + oneDay, y: 7 }, - { x: now + oneDay * 2, y: 3 }, + { x: oneDays.add(now).asMilliseconds(), y: 7 }, + { x: twoDays.add(now).asMilliseconds(), y: 3 }, { x: now + oneDay * 5, y: 6 }, ]} />