diff --git a/api/charts.api.md b/api/charts.api.md index 7b550e9f07..180b6c91b9 100644 --- a/api/charts.api.md +++ b/api/charts.api.md @@ -674,6 +674,16 @@ export type DisplayValueStyle = Omit & { }; }; +// @public +export const DomainPaddingUnit: Readonly<{ + Domain: "domain"; + Pixel: "pixel"; + DomainRatio: "domainRatio"; +}>; + +// @public +export type DomainPaddingUnit = $Values; + // @public (undocumented) export type DomainRange = LowerBoundedDomain | UpperBoundedDomain | CompleteBoundedDomain | UnboundedDomainWithInterval; @@ -2263,7 +2273,8 @@ export interface XYChartSeriesIdentifier extends SeriesIdentifier { export interface YDomainBase { constrainPadding?: boolean; fit?: boolean; - padding?: number | string; + padding?: number; + paddingUnit?: DomainPaddingUnit; } // @public (undocumented) diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-allow-domain-ratio-padding-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-allow-domain-ratio-padding-1-snap.png new file mode 100644 index 0000000000..241a8f5e74 Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-allow-domain-ratio-padding-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-allow-domain-space-padding-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-allow-domain-space-padding-1-snap.png new file mode 100644 index 0000000000..fbf367457a Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-allow-domain-space-padding-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-allow-pixel-space-padding-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-allow-pixel-space-padding-1-snap.png new file mode 100644 index 0000000000..5f9bc031b1 Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-allow-pixel-space-padding-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-apply-padding-with-positive-and-negative-values-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-apply-padding-with-positive-and-negative-values-1-snap.png new file mode 100644 index 0000000000..11a3ceb0e2 Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-apply-padding-with-positive-and-negative-values-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-apply-padding-within-intersept-negative-values-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-apply-padding-within-intersept-negative-values-1-snap.png new file mode 100644 index 0000000000..c5e18032fa Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-apply-padding-within-intersept-negative-values-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-apply-padding-within-intersept-positive-values-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-apply-padding-within-intersept-positive-values-1-snap.png new file mode 100644 index 0000000000..d9266cc06a Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-apply-padding-within-intersept-positive-values-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-constrain-padding-to-intersept-negative-values-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-constrain-padding-to-intersept-negative-values-1-snap.png new file mode 100644 index 0000000000..74f3b009aa Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-constrain-padding-to-intersept-negative-values-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-constrain-padding-to-intersept-positive-values-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-constrain-padding-to-intersept-positive-values-1-snap.png new file mode 100644 index 0000000000..9e028482ac Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-constrain-padding-to-intersept-positive-values-1-snap.png differ diff --git a/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-nice-ticks-after-domain-padding-is-applied-1-snap.png b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-nice-ticks-after-domain-padding-is-applied-1-snap.png new file mode 100644 index 0000000000..7695b7f5ba Binary files /dev/null and b/integration/tests/__image_snapshots__/bar-stories-test-ts-bar-series-stories-domain-padding-should-nice-ticks-after-domain-padding-is-applied-1-snap.png differ diff --git a/integration/tests/bar_stories.test.ts b/integration/tests/bar_stories.test.ts index d7c9576ec6..1e582901f0 100644 --- a/integration/tests/bar_stories.test.ts +++ b/integration/tests/bar_stories.test.ts @@ -178,4 +178,52 @@ describe('Bar series stories', () => { ); }); }); + + describe('domain padding', () => { + it('should allow domain space padding', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--scale-to-extent&knob-yScaleDataToExtent=&knob-fit Y domain to data=true&knob-constrain padding=true&knob-domain padding=5&knob-Domain padding unit=domain&knob-data=all negative&knob-console log domains=true&knob-nice ticks=', + ); + }); + it('should allow pixel space padding', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--scale-to-extent&knob-yScaleDataToExtent=&knob-fit Y domain to data=true&knob-constrain padding=true&knob-domain padding=100&knob-Domain padding unit=pixel&knob-data=all negative&knob-console log domains=true&knob-nice ticks=', + ); + }); + it('should apply padding with positive and negative values', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--scale-to-extent&knob-fit Y domain to data=true&knob-constrain padding=true&knob-nice ticks=&knob-domain padding=50&knob-Domain padding unit=pixel&knob-data=mixed&knob-SeriesType=bar&knob-console log domains=true', + ); + }); + it('should apply padding within intersept - positive values', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--scale-to-extent&knob-fit Y domain to data=true&knob-constrain padding=true&knob-nice ticks=&knob-domain padding=50&knob-Domain padding unit=pixel&knob-data=all positive&knob-SeriesType=line&knob-console log domains=true', + ); + }); + it('should constrain padding to intersept - positive values', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--scale-to-extent&knob-fit Y domain to data=true&knob-constrain padding=true&knob-nice ticks=&knob-domain padding=100&knob-Domain padding unit=pixel&knob-data=all positive&knob-SeriesType=line&knob-console log domains=true', + ); + }); + it('should apply padding within intersept - negative values', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--scale-to-extent&knob-fit Y domain to data=true&knob-constrain padding=true&knob-nice ticks=&knob-domain padding=50&knob-Domain padding unit=pixel&knob-data=all negative&knob-SeriesType=line&knob-console log domains=true', + ); + }); + it('should constrain padding to intersept - negative values', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--scale-to-extent&knob-fit Y domain to data=true&knob-constrain padding=true&knob-nice ticks=&knob-domain padding=100&knob-Domain padding unit=pixel&knob-data=all negative&knob-SeriesType=line&knob-console log domains=true', + ); + }); + it('should allow domain ratio padding', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--scale-to-extent&knob-yScaleDataToExtent=&knob-fit Y domain to data=true&knob-constrain padding=true&knob-domain padding=0.5&knob-Domain padding unit=domainRatio&knob-data=all negative&knob-console log domains=true&knob-nice ticks=', + ); + }); + it('should nice ticks after domain padding is applied', async () => { + await common.expectChartAtUrlToMatchScreenshot( + 'http://localhost:9001/?path=/story/bar-chart--scale-to-extent&knob-yScaleDataToExtent=&knob-fit Y domain to data=true&knob-constrain padding=&knob-domain padding=100&knob-Domain padding unit=pixel&knob-data=all negative&knob-console log domains=true&knob-nice ticks=true', + ); + }); + }); }); diff --git a/package.json b/package.json index 7abab62381..674d217e17 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "test:integration:local": "LOCAL_VRT_SERVER=true PORT=9001 yarn test:integration", "test:integration:debug": "DEBUG=true yarn test:integration:local", "test:integration:legacy": "LEGACY_VRT_SERVER=true yarn test:integration", - "test:integration:local:legacy": "LOCAL_VRT_SERVER=true PORT=9002 test:integration:legacy", + "test:integration:local:legacy": "LOCAL_VRT_SERVER=true PORT=9002 yarn test:integration:legacy", "test:integration:generate": "yarn test:integration:generate:examples && yarn test:integration:generate:page", "test:integration:generate:examples": "./scripts/extract_examples.sh", "test:integration:generate:page": "./scripts/compile_vrt_page.sh", diff --git a/src/chart_types/xy_chart/domains/types.ts b/src/chart_types/xy_chart/domains/types.ts index 0819ab61ae..05e8580560 100644 --- a/src/chart_types/xy_chart/domains/types.ts +++ b/src/chart_types/xy_chart/domains/types.ts @@ -45,4 +45,6 @@ export type YDomain = LogScaleOptions & { groupId: GroupId; domain: ContinuousDomain; desiredTickCount: number; + domainPixelPadding?: number; + constrainDomainPadding?: boolean; }; diff --git a/src/chart_types/xy_chart/domains/y_domain.ts b/src/chart_types/xy_chart/domains/y_domain.ts index 2b05e1c8bd..24d1d564c8 100644 --- a/src/chart_types/xy_chart/domains/y_domain.ts +++ b/src/chart_types/xy_chart/domains/y_domain.ts @@ -28,7 +28,7 @@ import { getSpecDomainGroupId } from '../state/utils/spec'; import { isCompleteBound, isLowerBound, isUpperBound } from '../utils/axis_type_utils'; import { groupBy } from '../utils/group_data_series'; import { DataSeries } from '../utils/series'; -import { BasicSeriesSpec, YDomainRange, SeriesType, StackMode } from '../utils/specs'; +import { BasicSeriesSpec, YDomainRange, SeriesType, StackMode, DomainPaddingUnit } from '../utils/specs'; import { areAllNiceDomain } from './nice'; import { YDomain } from './types'; @@ -70,13 +70,12 @@ function mergeYDomainForGroup( const [{ stackMode, spec }] = dataSeries; const groupId = getSpecDomainGroupId(spec); const { customDomain, type, nice, desiredTickCount } = yScaleConfig[groupId]; + const newCustomDomain = customDomain ? { ...customDomain } : {}; let domain: ContinuousDomain; if (stackMode === StackMode.Percentage) { domain = computeContinuousDataDomain([0, 1], identity, type, customDomain); } else { - const newCustomDomain = customDomain ? { ...customDomain } : {}; - // compute stacked domain const stackedDomain = computeYDomain(stacked, hasZeroBaselineSpecs, type, newCustomDomain); @@ -116,6 +115,8 @@ function mergeYDomainForGroup( logBase: customDomain?.logBase, logMinLimit: customDomain?.logMinLimit, desiredTickCount, + domainPixelPadding: newCustomDomain.paddingUnit === DomainPaddingUnit.Pixel ? newCustomDomain.padding : 0, + constrainDomainPadding: newCustomDomain.constrainPadding, }; } diff --git a/src/chart_types/xy_chart/scales/scale_defaults.ts b/src/chart_types/xy_chart/scales/scale_defaults.ts index 7eb54d56c2..7783e69853 100644 --- a/src/chart_types/xy_chart/scales/scale_defaults.ts +++ b/src/chart_types/xy_chart/scales/scale_defaults.ts @@ -31,4 +31,8 @@ export const Y_SCALE_DEFAULT = { type: ScaleType.Linear, nice: false, desiredTickCount: 10, + constrainDomainPadding: undefined, + domainPixelPadding: 0, + logBase: undefined, + logMinLimit: undefined, }; diff --git a/src/chart_types/xy_chart/utils/scales.ts b/src/chart_types/xy_chart/utils/scales.ts index 3c9125e89d..98243d0712 100644 --- a/src/chart_types/xy_chart/utils/scales.ts +++ b/src/chart_types/xy_chart/utils/scales.ts @@ -123,22 +123,40 @@ interface YScaleOptions { */ export function computeYScales(options: YScaleOptions): Map { const { yDomains, range, integersOnly } = options; - return yDomains.reduce((yScales, { type, nice, desiredTickCount, domain, groupId, logBase, logMinLimit }) => { - const yScale = new ScaleContinuous( + return yDomains.reduce( + ( + yScales, { type, - domain, - range, nice, - }, - { desiredTickCount, - integersOnly, + domain, + groupId, logBase, logMinLimit, + domainPixelPadding, + constrainDomainPadding, }, - ); - yScales.set(groupId, yScale); - return yScales; - }, new Map()); + ) => { + const yScale = new ScaleContinuous( + { + type, + domain, + range, + nice, + }, + { + desiredTickCount, + integersOnly, + logBase, + logMinLimit, + domainPixelPadding, + constrainDomainPadding, + }, + ); + yScales.set(groupId, yScale); + return yScales; + }, + new Map(), + ); } diff --git a/src/chart_types/xy_chart/utils/specs.ts b/src/chart_types/xy_chart/utils/specs.ts index 4ee93c475e..12ca9dcf32 100644 --- a/src/chart_types/xy_chart/utils/specs.ts +++ b/src/chart_types/xy_chart/utils/specs.ts @@ -280,6 +280,43 @@ interface DomainBase { minInterval?: number; } +/** + * Padding unit for domain + * @public + */ +export const DomainPaddingUnit = Object.freeze({ + /** + * Raw value in the domain space. + * + * Example: + * + * If your domain is `[20, 40]` and your padding value is `10`. + * The resulting domain would be `[10, 50]` + */ + Domain: 'domain' as const, + /** + * Spatial pixel value (aka screenspace) not dependent on domain. + * + * @alpha + */ + Pixel: 'pixel' as const, + /** + * Ratio of total domain relative to domain space + * + * Example: + * + * If your domain is `[20, 40]` and your padding value is `0.1`. + * The resulting padding would be 2 (i.e. `0.1 * (40 - 20)`) + * resulting in a domain of `[18, 42]` + */ + DomainRatio: 'domainRatio' as const, +}); +/** + * Padding unit + * @public + */ +export type DomainPaddingUnit = $Values; + /** * Domain option that **only** apply to `yDomains`. * @public @@ -293,11 +330,18 @@ export interface YDomainBase { */ fit?: boolean; /** - * Padding for computed domain. Positive pixel number or percent string. + * Padding for computed domain as positive number. + * Applied to domain __before__ nicing * * Setting `max` or `min` will override this functionality. */ - padding?: number | string; + padding?: number; + /** + * Unit of padding dimension + * + * @defaultValue 'domain' + */ + paddingUnit?: DomainPaddingUnit; /** * Constrains padded domain to the zero baseline. * diff --git a/src/scales/scale_continuous.test.ts b/src/scales/scale_continuous.test.ts index 1c66b51ed3..e687f6957f 100644 --- a/src/scales/scale_continuous.test.ts +++ b/src/scales/scale_continuous.test.ts @@ -431,6 +431,59 @@ describe('Scale Continuous', () => { }); }); }); + + describe('Domain pixel padding', () => { + const scaleOptions = Object.freeze({ + type: ScaleType.Linear, + range: [0, 100] as Range, + domain: [10, 60], + }); + + it('should add pixel padding to domain', () => { + const scale = new ScaleContinuous(scaleOptions, { domainPixelPadding: 10 }); + expect(scale.domain).toEqual([3.75, 66.25]); + }); + + it('should handle inverted domain pixel padding', () => { + const scale = new ScaleContinuous({ ...scaleOptions, domain: [60, 10] }, { domainPixelPadding: 10 }); + expect(scale.domain).toEqual([66.25, 3.75]); + }); + + it('should handle negative domain pixel padding', () => { + const scale = new ScaleContinuous({ ...scaleOptions, domain: [-60, -20] }, { domainPixelPadding: 10 }); + expect(scale.domain).toEqual([-65, -15]); + }); + + it('should handle negative inverted domain pixel padding', () => { + const scale = new ScaleContinuous({ ...scaleOptions, domain: [-20, -60] }, { domainPixelPadding: 10 }); + expect(scale.domain).toEqual([-15, -65]); + }); + + it('should constrain pixel padding to zero', () => { + const scale = new ScaleContinuous(scaleOptions, { domainPixelPadding: 20 }); + expect(scale.domain).toEqual([0, 75]); + }); + + it('should not constrain pixel padding to zero', () => { + const scale = new ScaleContinuous(scaleOptions, { domainPixelPadding: 18, constrainDomainPadding: false }); + expect(scale.domain).toEqual([-4.0625, 74.0625]); + }); + + it('should nice domain after pixel padding is applied', () => { + const scale = new ScaleContinuous( + { ...scaleOptions, nice: true }, + { domainPixelPadding: 18, constrainDomainPadding: false }, + ); + expect(scale.domain).toEqual([-10, 80]); + }); + + it('should not handle pixel padding when pixel is greater than half the total range', () => { + const criticalPadding = Math.abs(scaleOptions.range[0] - scaleOptions.range[1]) / 2; + const scale = new ScaleContinuous(scaleOptions, { domainPixelPadding: criticalPadding }); + expect(scale.domain).toEqual(scaleOptions.domain); + }); + }); + describe('ticks as integers or floats', () => { const domain: ContinuousDomain = [0, 7]; const minRange = 0; diff --git a/src/scales/scale_continuous.ts b/src/scales/scale_continuous.ts index c265ee150e..6b19770dfb 100644 --- a/src/scales/scale_continuous.ts +++ b/src/scales/scale_continuous.ts @@ -33,6 +33,7 @@ import { $Values, Required } from 'utility-types'; import { ScaleContinuousType, Scale } from '.'; import { PrimitiveValue } from '../chart_types/partition_chart/layout/utils/group_by_rollup'; +import { screenspaceMarkerScaleCompressor } from '../solvers/screenspace_marker_scale_compressor'; import { maxValueWithUpperLimit, mergePartial } from '../utils/common'; import { getMomentWithTz } from '../utils/data/date_time'; import { ContinuousDomain, Range } from '../utils/domain'; @@ -55,6 +56,8 @@ const SCALES = { [ScaleType.Time]: scaleUtc, }; +const isUnitRange = ([r1, r2]: Range) => r1 === 0 && r2 === 1; + /** * As log(0) = -Infinite, a log scale domain must be strictly-positive * or strictly-negative; the domain must not include or cross zero value. @@ -118,6 +121,46 @@ export function limitLogScaleDomain([min, max]: ContinuousDomain, logMinLimit?: return [min, max]; } +function getPixelPaddedDomain( + chartHeight: number, + domain: [number, number], + desiredPixelPadding: number, + constrainDomainPadding?: boolean, + intercept = 0, +) { + const inverted = domain[1] < domain[0]; + const orderedDomain: [number, number] = inverted ? (domain.slice().reverse() as [number, number]) : domain; + const { scaleMultiplier } = screenspaceMarkerScaleCompressor( + orderedDomain, + [2 * desiredPixelPadding, 2 * desiredPixelPadding], + chartHeight, + ); + let paddedDomainLo = orderedDomain[0] - desiredPixelPadding / scaleMultiplier; + let paddedDomainHigh = orderedDomain[1] + desiredPixelPadding / scaleMultiplier; + + if (constrainDomainPadding) { + if (paddedDomainLo < intercept && orderedDomain[0] >= intercept) { + const { scaleMultiplier } = screenspaceMarkerScaleCompressor( + [intercept, orderedDomain[1]], + [0, 2 * desiredPixelPadding], + chartHeight, + ); + paddedDomainLo = intercept; + paddedDomainHigh = orderedDomain[1] + desiredPixelPadding / scaleMultiplier; + } else if (paddedDomainHigh > 0 && orderedDomain[1] <= 0) { + const { scaleMultiplier } = screenspaceMarkerScaleCompressor( + [orderedDomain[0], intercept], + [2 * desiredPixelPadding, 0], + chartHeight, + ); + paddedDomainLo = orderedDomain[0] - desiredPixelPadding / scaleMultiplier; + paddedDomainHigh = intercept; + } + } + + return inverted ? [paddedDomainHigh, paddedDomainLo] : [paddedDomainLo, paddedDomainHigh]; +} + /** @public */ export const LogBase = Object.freeze({ /** @@ -205,6 +248,18 @@ type ScaleOptions = Required & { * @defaultValue 0 */ barsPadding: number; + /** + * Pixel value to extend the domain. Applied __before__ nicing. + * + * Does not apply to time scales + * @defaultValue 0 + */ + domainPixelPadding: number; + /** + * Constrains domain pixel padding to the zero baseline + * Does not apply to time scales + */ + constrainDomainPadding?: boolean; /** * The approximated number of ticks. * @defaultValue 10 @@ -226,6 +281,8 @@ const defaultScaleOptions: ScaleOptions = { timeZone: 'utc', totalBarsInCluster: 1, barsPadding: 0, + constrainDomainPadding: true, + domainPixelPadding: 0, desiredTickCount: 10, isSingleValueHistogram: false, integersOnly: false, @@ -251,7 +308,7 @@ export class ScaleContinuous implements Scale { readonly domain: any[]; - readonly range: number[]; + readonly range: Range; readonly isInverted: boolean; @@ -280,6 +337,8 @@ export class ScaleContinuous implements Scale { integersOnly, logBase, logMinLimit, + domainPixelPadding, + constrainDomainPadding, } = mergePartial(defaultScaleOptions, options, { mergeOptionalPartialValues: true }); this.d3Scale = SCALES[type](); @@ -291,6 +350,7 @@ export class ScaleContinuous implements Scale { } this.d3Scale.domain(this.domain); + if (nice && type !== ScaleType.Time) { (this.d3Scale as ScaleContinuousNumeric).domain(this.domain).nice(desiredTickCount); this.domain = this.d3Scale.domain(); @@ -310,6 +370,26 @@ export class ScaleContinuous implements Scale { this.totalBarsInCluster = totalBarsInCluster; this.isSingleValueHistogram = isSingleValueHistogram; + const [r1, r2] = this.range; + const totalRange = Math.abs(r1 - r2); + + if (type !== ScaleType.Time && domainPixelPadding && !isUnitRange(range) && domainPixelPadding * 2 < totalRange) { + const newDomain = getPixelPaddedDomain( + totalRange, + this.domain as [number, number], + domainPixelPadding, + constrainDomainPadding, + ); + + if (nice) { + (this.d3Scale as ScaleContinuousNumeric).domain(newDomain).nice(desiredTickCount); + this.domain = this.d3Scale.domain(); + } else { + this.domain = newDomain; + this.d3Scale.domain(newDomain); + } + } + if (type === ScaleType.Time) { const startDomain = getMomentWithTz(this.domain[0], this.timeZone); const endDomain = getMomentWithTz(this.domain[1], this.timeZone); @@ -466,6 +546,8 @@ export class ScaleContinuous implements Scale { isValueInDomain(value: number) { return value >= this.domain[0] && value <= this.domain[1]; } + + handleDomainPadding() {} } /** @internal */ diff --git a/src/utils/domain.test.ts b/src/utils/domain.test.ts index 47993ee653..c19a121547 100644 --- a/src/utils/domain.test.ts +++ b/src/utils/domain.test.ts @@ -18,6 +18,7 @@ */ import { ScaleType } from '../scales/constants'; +import { DomainPaddingUnit } from '../specs'; import { AccessorFn } from './accessor'; import { identity } from './common'; import { computeContinuousDataDomain, computeDomainExtent, computeOrdinalDataDomain } from './domain'; @@ -214,5 +215,30 @@ describe('utils/domain', () => { }); }); }); + + describe('padding units', () => { + // Note: domain pixel padding computed in continuous scale + it('should not change domain when using Pixel padding unit', () => { + expect(computeDomainExtent([5, 65], { fit: true, padding: 15, paddingUnit: DomainPaddingUnit.Pixel })).toEqual([ + 5, + 65, + ]); + }); + it('should handle DomainRatio padding unit', () => { + expect( + computeDomainExtent([50, 60], { fit: true, padding: 0.5, paddingUnit: DomainPaddingUnit.DomainRatio }), + ).toEqual([45, 65]); + }); + it('should handle negative inverted DomainRatio padding unit', () => { + expect( + computeDomainExtent([-50, -60], { fit: true, padding: 0.5, paddingUnit: DomainPaddingUnit.DomainRatio }), + ).toEqual([-45, -65]); + }); + it('should handle negative inverted Domain padding unit', () => { + expect( + computeDomainExtent([-50, -60], { fit: true, padding: 10, paddingUnit: DomainPaddingUnit.Domain }), + ).toEqual([-40, -70]); + }); + }); }); }); diff --git a/src/utils/domain.ts b/src/utils/domain.ts index 8d88d0a7e2..e71a255c13 100644 --- a/src/utils/domain.ts +++ b/src/utils/domain.ts @@ -20,9 +20,8 @@ import { extent } from 'd3-array'; import { ScaleType } from '../scales/constants'; -import { YDomainRange } from '../specs'; +import { DomainPaddingUnit, YDomainRange } from '../specs'; import { AccessorFn } from './accessor'; -import { getPercentageValue } from './common'; /** @public */ export type OrdinalDomain = (number | string)[]; @@ -31,6 +30,27 @@ export type ContinuousDomain = [min: number, max: number]; /** @public */ export type Range = [min: number, max: number]; +/** + * Returns padded domain given constrain + * @internal */ +export function constrainPadding( + start: number, + end: number, + newStart: number, + newEnd: number, + constrain: boolean = true, +): [number, number] { + if (constrain) { + if (start < end) { + return [start >= 0 && newStart < 0 ? 0 : newStart, end <= 0 && newEnd > 0 ? 0 : newEnd]; + } + + return [end >= 0 && newEnd < 0 ? 0 : newEnd, start <= 0 && newStart > 0 ? 0 : newStart]; + } + + return [newStart, newEnd]; +} + /** @internal */ export function computeOrdinalDataDomain( data: any[], @@ -48,15 +68,14 @@ export function computeOrdinalDataDomain( return sorted ? uniqueValues.sort((a, b) => `${a}`.localeCompare(`${b}`)) : uniqueValues; } -function getPaddedRange(start: number, end: number, domainOptions?: YDomainRange): [number, number] { - if (!domainOptions?.padding) { +function getPaddedDomain(start: number, end: number, domainOptions?: YDomainRange): [number, number] { + if (!domainOptions || !domainOptions.padding || domainOptions.paddingUnit === DomainPaddingUnit.Pixel) { return [start, end]; } - let computedPadding = 0; - - const delta = Math.abs(end - start); - computedPadding = getPercentageValue(domainOptions.padding, delta, 0); + const { padding, paddingUnit = DomainPaddingUnit.Domain } = domainOptions; + const absPadding = Math.abs(padding); + const computedPadding = paddingUnit === DomainPaddingUnit.Domain ? absPadding : absPadding * Math.abs(end - start); if (computedPadding === 0) { return [start, end]; @@ -65,20 +84,19 @@ function getPaddedRange(start: number, end: number, domainOptions?: YDomainRange const newStart = start - computedPadding; const newEnd = end + computedPadding; - if (domainOptions.constrainPadding ?? true) { - return [start >= 0 && newStart < 0 ? 0 : newStart, end <= 0 && newEnd > 0 ? 0 : newEnd]; - } - - return [newStart, newEnd]; + return constrainPadding(start, end, newStart, newEnd, domainOptions.constrainPadding); } /** @internal */ export function computeDomainExtent( - [start, end]: [number, number] | [undefined, undefined], + domain: [number, number] | [undefined, undefined], domainOptions?: YDomainRange, ): [number, number] { - if (start != null && end != null) { - const [paddedStart, paddedEnd] = getPaddedRange(start, end, domainOptions); + if (domain[0] == null || domain[1] == null) return [0, 0]; + + const inverted = domain[0] > domain[1]; + const paddedDomain = (([start, end]: Range): Range => { + const [paddedStart, paddedEnd] = getPaddedDomain(start, end, domainOptions); if (paddedStart >= 0 && paddedEnd >= 0) { return domainOptions?.fit ? [paddedStart, paddedEnd] : [0, paddedEnd]; @@ -88,10 +106,9 @@ export function computeDomainExtent( } return [paddedStart, paddedEnd]; - } + })(inverted ? (domain.slice().reverse() as Range) : domain); - // if either start or end are null - return [0, 0]; + return inverted ? (paddedDomain.slice().reverse() as Range) : paddedDomain; } /** diff --git a/stories/axes/11_fit_domain_extent.tsx b/stories/axes/11_fit_domain_extent.tsx index fa6cc5a82e..8ffe7a8cbd 100644 --- a/stories/axes/11_fit_domain_extent.tsx +++ b/stories/axes/11_fit_domain_extent.tsx @@ -17,11 +17,12 @@ * under the License. */ -import { boolean, select, text } from '@storybook/addon-knobs'; +import { boolean, number, select } from '@storybook/addon-knobs'; import React from 'react'; -import { Axis, Chart, LineSeries, Position, ScaleType } from '../../src'; +import { Axis, Chart, DomainPaddingUnit, LineSeries, Position, ScaleType } from '../../src'; import { SeededDataGenerator } from '../../src/mocks/utils'; +import { getKnobsFromEnum } from '../utils/knobs'; export const Example = () => { const dg = new SeededDataGenerator(); @@ -48,13 +49,18 @@ export const Example = () => { const dataset = dataTypes[dataKey]; const fit = boolean('fit Y domain to data', true); const constrainPadding = boolean('constrain padding', true); - const padding = text('domain padding', '10%'); + const padding = number('domain padding', 0.1); + const paddingUnit = getKnobsFromEnum( + 'Domain padding unit', + DomainPaddingUnit, + DomainPaddingUnit.DomainRatio as DomainPaddingUnit, + ); return ( { @@ -38,7 +39,13 @@ const logDomains = (data: any[], customDomain: any) => { export const Example = () => { const fit = boolean('fit Y domain to data', true); const constrainPadding = boolean('constrain padding', true); - const padding = text('domain padding', '0'); + const nice = boolean('nice ticks', false); + const padding = number('domain padding', 0); + const paddingUnit = getKnobsFromEnum( + 'Domain padding unit', + DomainPaddingUnit, + DomainPaddingUnit.Domain as DomainPaddingUnit, + ); const mixed = [ { x: 0, y: -4 }, { x: 1, y: -3 }, @@ -58,6 +65,7 @@ export const Example = () => { }, 'all negative', ); + const SeriesType = getXYSeriesTypeKnob(); const shouldLogDomains = boolean('console log domains', true); let data; @@ -71,7 +79,7 @@ export const Example = () => { default: data = mixed; } - const customDomain = { fit, padding, constrainPadding }; + const customDomain = { fit, padding, paddingUnit, constrainPadding, nice }; if (shouldLogDomains) { logDomains(data, customDomain); @@ -81,15 +89,16 @@ export const Example = () => { Number(d).toFixed(2)} /> - undefined, group, ) || undefined; + +const seriesTypeMap = { + [SeriesType.Bar]: BarSeries, + [SeriesType.Line]: LineSeries, + [SeriesType.Area]: AreaSeries, + [SeriesType.Bubble]: BubbleSeries, +}; +export const getXYSeriesTypeKnob = (group?: string, ignore: SeriesType[] = []) => { + const spectType = select( + 'SeriesType', + Object.fromEntries(Object.entries(SeriesType).filter(([, type]) => !ignore.includes(type))), + SeriesType.Bar, + group, + ); + + return seriesTypeMap[spectType]; +};