From 444af6e02c6aa6a07303a725902d820397899abc Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Wed, 8 Jan 2020 09:51:15 -0600 Subject: [PATCH] feat: add domain fitting (#510) * Add option to fit y domain to data --- src/chart_types/xy_chart/domains/y_domain.ts | 21 ++++--- src/chart_types/xy_chart/utils/specs.ts | 27 ++++++--- src/utils/data_generators/data_generator.ts | 9 +++ src/utils/domain.test.ts | 62 ++++++++++++++++---- src/utils/domain.ts | 31 ++++++++-- stories/axis.tsx | 49 +++++++++++++++- 6 files changed, 166 insertions(+), 33 deletions(-) diff --git a/src/chart_types/xy_chart/domains/y_domain.ts b/src/chart_types/xy_chart/domains/y_domain.ts index 945bfce29a..5756acea0d 100644 --- a/src/chart_types/xy_chart/domains/y_domain.ts +++ b/src/chart_types/xy_chart/domains/y_domain.ts @@ -72,30 +72,32 @@ function mergeYDomainForGroup( ): YDomain { const groupYScaleType = coerceYScaleTypes([...groupSpecs.stacked, ...groupSpecs.nonStacked]); const { isPercentageStack } = groupSpecs; + const fitToExtent = customDomain && customDomain.fit; let domain: number[]; if (isPercentageStack) { - domain = computeContinuousDataDomain([0, 1], identity); + domain = computeContinuousDataDomain([0, 1], identity, fitToExtent); } else { // compute stacked domain const isStackedScaleToExtent = groupSpecs.stacked.some((spec) => { return spec.yScaleToDataExtent; }); const stackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.stacked); - const stackedDomain = computeYStackedDomain(stackedDataSeries, isStackedScaleToExtent); + const stackedDomain = computeYStackedDomain(stackedDataSeries, isStackedScaleToExtent, fitToExtent); // compute non stacked domain const isNonStackedScaleToExtent = groupSpecs.nonStacked.some((spec) => { return spec.yScaleToDataExtent; }); const nonStackedDataSeries = getDataSeriesOnGroup(dataSeries, groupSpecs.nonStacked); - const nonStackedDomain = computeYNonStackedDomain(nonStackedDataSeries, isNonStackedScaleToExtent); + const nonStackedDomain = computeYNonStackedDomain(nonStackedDataSeries, isNonStackedScaleToExtent, fitToExtent); // merge stacked and non stacked domain together domain = computeContinuousDataDomain( [...stackedDomain, ...nonStackedDomain], identity, isStackedScaleToExtent || isNonStackedScaleToExtent, + fitToExtent, ); const [computedDomainMin, computedDomainMax] = domain; @@ -139,7 +141,11 @@ export function getDataSeriesOnGroup( ); } -function computeYStackedDomain(dataseries: RawDataSeries[], scaleToExtent: boolean): number[] { +function computeYStackedDomain( + dataseries: RawDataSeries[], + scaleToExtent: boolean, + fitToExtent: boolean = false, +): number[] { const stackMap = new Map(); dataseries.forEach((ds, index) => { ds.data.forEach((datum) => { @@ -158,9 +164,10 @@ function computeYStackedDomain(dataseries: RawDataSeries[], scaleToExtent: boole if (dataValues.length === 0) { return []; } - return computeContinuousDataDomain(dataValues, identity, scaleToExtent); + return computeContinuousDataDomain(dataValues, identity, scaleToExtent, fitToExtent); } -function computeYNonStackedDomain(dataseries: RawDataSeries[], scaleToExtent: boolean) { + +function computeYNonStackedDomain(dataseries: RawDataSeries[], scaleToExtent: boolean, fitToExtent: boolean = false) { const yValues = new Set(); dataseries.forEach((ds) => { ds.data.forEach((datum) => { @@ -173,7 +180,7 @@ function computeYNonStackedDomain(dataseries: RawDataSeries[], scaleToExtent: bo if (yValues.size === 0) { return []; } - return computeContinuousDataDomain([...yValues.values()], identity, scaleToExtent); + return computeContinuousDataDomain([...yValues.values()], identity, scaleToExtent, fitToExtent); } export function splitSpecsByGroupId(specs: YBasicSeriesSpec[]) { const specsByGroupIds = new Map< diff --git a/src/chart_types/xy_chart/utils/specs.ts b/src/chart_types/xy_chart/utils/specs.ts index eee569dfaa..b37c4861dd 100644 --- a/src/chart_types/xy_chart/utils/specs.ts +++ b/src/chart_types/xy_chart/utils/specs.ts @@ -42,8 +42,9 @@ export type BarStyleAccessor = (datum: RawDataSeriesDatum, geometryId: GeometryI export type PointStyleAccessor = (datum: RawDataSeriesDatum, geometryId: GeometryId) => PointStyleOverride; export const DEFAULT_GLOBAL_ID = '__global__'; -interface DomainMinInterval { - /** Custom minInterval for the domain which will affect data bucket size. +interface DomainBase { + /** + * Custom minInterval for the domain which will affect data bucket size. * The minInterval cannot be greater than the computed minimum interval between any two adjacent data points. * Further, if you specify a custom numeric minInterval for a timeseries, please note that due to the restriction * above, the specified numeric minInterval will be interpreted as a fixed interval. @@ -52,22 +53,32 @@ interface DomainMinInterval { * be a valid interval because it is greater than the computed minInterval of 365 days betwen the other years. */ minInterval?: number; + /** + * Whether to fit the domain to the data. ONLY applies to `yDomains` + * + * Setting `max` or `min` will override this functionality. + */ + fit?: boolean; } interface LowerBound { - /** Lower bound of domain range */ + /** + * Lower bound of domain range + */ min: number; } interface UpperBound { - /** Upper bound of domain range */ + /** + * Upper bound of domain range + */ max: number; } -export type LowerBoundedDomain = DomainMinInterval & LowerBound; -export type UpperBoundedDomain = DomainMinInterval & UpperBound; -export type CompleteBoundedDomain = DomainMinInterval & LowerBound & UpperBound; -export type UnboundedDomainWithInterval = DomainMinInterval; +export type LowerBoundedDomain = DomainBase & LowerBound; +export type UpperBoundedDomain = DomainBase & UpperBound; +export type CompleteBoundedDomain = DomainBase & LowerBound & UpperBound; +export type UnboundedDomainWithInterval = DomainBase; export type DomainRange = LowerBoundedDomain | UpperBoundedDomain | CompleteBoundedDomain | UnboundedDomainWithInterval; diff --git a/src/utils/data_generators/data_generator.ts b/src/utils/data_generators/data_generator.ts index a58c841303..b5b74e19d8 100644 --- a/src/utils/data_generators/data_generator.ts +++ b/src/utils/data_generators/data_generator.ts @@ -7,6 +7,15 @@ export class DataGenerator { this.generator = new Simple1DNoise(randomNumberGenerator); this.frequency = frequency; } + generateBasicSeries(totalPoints = 50, offset = 0, amplitude = 1) { + const dataPoints = new Array(totalPoints).fill(0).map((_, i) => { + return { + x: i, + y: (this.generator.getValue(i) + offset) * amplitude, + }; + }); + return dataPoints; + } generateSimpleSeries(totalPoints = 50, group = 1, groupPrefix = '') { const dataPoints = new Array(totalPoints).fill(0).map((_, i) => { return { diff --git a/src/utils/domain.test.ts b/src/utils/domain.test.ts index b301a21c8a..7a28920aea 100644 --- a/src/utils/domain.test.ts +++ b/src/utils/domain.test.ts @@ -118,17 +118,55 @@ describe('utils/domain', () => { expect(stackedDataDomain).toEqual(expectedStackedDomain); }); - test('should compute domain based on scaleToExtent', () => { - // start & end are positive - expect(computeDomainExtent([5, 10], true)).toEqual([5, 10]); - expect(computeDomainExtent([5, 10], false)).toEqual([0, 10]); - - // start & end are negative - expect(computeDomainExtent([-15, -10], true)).toEqual([-15, -10]); - expect(computeDomainExtent([-15, -10], false)).toEqual([-15, 0]); - - // start is negative, end is positive - expect(computeDomainExtent([-15, 10], true)).toEqual([-15, 10]); - expect(computeDomainExtent([-15, 10], false)).toEqual([-15, 10]); + describe('scaleToExtent', () => { + describe('true', () => { + it('should find domain when start & end are positive', () => { + expect(computeDomainExtent([5, 10], true)).toEqual([5, 10]); + }); + it('should find domain when start & end are negative', () => { + expect(computeDomainExtent([-15, -10], true)).toEqual([-15, -10]); + }); + it('should find domain when start is negative, end is positive', () => { + expect(computeDomainExtent([-15, 10], true)).toEqual([-15, 10]); + }); + }); + describe('false', () => { + it('should find domain when start & end are positive', () => { + expect(computeDomainExtent([5, 10], false)).toEqual([0, 10]); + }); + it('should find domain when start & end are negative', () => { + expect(computeDomainExtent([-15, -10], false)).toEqual([-15, 0]); + }); + it('should find domain when start is negative, end is positive', () => { + expect(computeDomainExtent([-15, 10], false)).toEqual([-15, 10]); + }); + }); + }); + + describe('fitToExtent', () => { + it('should not effect domain when scaleToExtent is true', () => { + expect(computeDomainExtent([5, 10], true)).toEqual([5, 10]); + }); + + describe('baseline far from zero', () => { + it('should get domain from positive domain', () => { + expect(computeDomainExtent([10, 70], false, true)).toEqual([5, 75]); + }); + it('should get domain from positive & negative domain', () => { + expect(computeDomainExtent([-30, 30], false, true)).toEqual([-35, 35]); + }); + it('should get domain from negative domain', () => { + expect(computeDomainExtent([-70, -10], false, true)).toEqual([-75, -5]); + }); + }); + + describe('baseline near zero', () => { + it('should set min baseline as 0 if original domain is less than zero', () => { + expect(computeDomainExtent([5, 65], false, true)).toEqual([0, 70]); + }); + it('should set max baseline as 0 if original domain is less than zero', () => { + expect(computeDomainExtent([-65, -5], false, true)).toEqual([-70, 0]); + }); + }); }); }); diff --git a/src/utils/domain.ts b/src/utils/domain.ts index 1194d39db7..9d15e2f834 100644 --- a/src/utils/domain.ts +++ b/src/utils/domain.ts @@ -45,17 +45,31 @@ export function computeOrdinalDataDomain( : uniqueValues; } +function computeFittedDomain(start?: number, end?: number) { + if (start === undefined || end === undefined) { + return [start, end]; + } + + const delta = Math.abs(end - start); + const padding = (delta === 0 ? end - 0 : delta) / 12; + const newStart = start - padding; + const newEnd = end + padding; + + return [start >= 0 && newStart < 0 ? 0 : newStart, end <= 0 && newEnd > 0 ? 0 : newEnd]; +} + export function computeDomainExtent( computedDomain: [number, number] | [undefined, undefined], scaleToExtent: boolean, + fitToExtent: boolean = false, ): [number, number] { - const [start, end] = computedDomain; + const [start, end] = fitToExtent && !scaleToExtent ? computeFittedDomain(...computedDomain) : computedDomain; if (start != null && end != null) { if (start >= 0 && end >= 0) { - return scaleToExtent ? [start, end] : [0, end]; + return scaleToExtent || fitToExtent ? [start, end] : [0, end]; } else if (start < 0 && end < 0) { - return scaleToExtent ? [start, end] : [start, 0]; + return scaleToExtent || fitToExtent ? [start, end] : [start, 0]; } return [start, end]; } @@ -64,11 +78,18 @@ export function computeDomainExtent( return [0, 0]; } -export function computeContinuousDataDomain(data: any[], accessor: AccessorFn, scaleToExtent = false): number[] { +export function computeContinuousDataDomain( + data: any[], + accessor: AccessorFn, + scaleToExtent = false, + fitToExtent = false, +): number[] { const range = extent(data, accessor); - return computeDomainExtent(range, scaleToExtent); + + return computeDomainExtent(range, scaleToExtent, fitToExtent); } +// TODO: remove or incorporate this function export function computeStackedContinuousDomain( data: any[], xAccessor: AccessorFn, diff --git a/stories/axis.tsx b/stories/axis.tsx index a5a0c1bedd..1a95f5d319 100644 --- a/stories/axis.tsx +++ b/stories/axis.tsx @@ -1,4 +1,4 @@ -import { array, boolean, number } from '@storybook/addon-knobs'; +import { array, boolean, number, select } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import React from 'react'; @@ -553,4 +553,51 @@ storiesOf('Axis', module) /> ); + }) + .add('fit domain to extent in y axis', () => { + const dg = new SeededDataGenerator(); + const base = dg.generateBasicSeries(100, 0, 50); + const positive = base.map(({ x, y }) => ({ x, y: y + 1000 })); + const both = base.map(({ x, y }) => ({ x, y: y - 100 })); + const negative = base.map(({ x, y }) => ({ x, y: y - 1000 })); + + const dataTypes = { + positive, + both, + negative, + }; + const dataKey = select( + 'dataset', + { + 'Positive values only': 'positive', + 'Positive and negative': 'both', + 'Negtive values only': 'negative', + }, + 'both', + ); + // @ts-ignore + const dataset = dataTypes[dataKey]; + const fit = boolean('fit domain to data', true); + + return ( + + + Number(d).toFixed(2)} + /> + + + + ); });